feat: new module/library for work with call graphs (#2890)

This commit is contained in:
Skylot
2026-06-17 21:34:23 +01:00
parent 59003bdb1f
commit d25eda4839
24 changed files with 706 additions and 4 deletions
+2 -1
View File
@@ -37,7 +37,8 @@ jadx-output/
*.log
*.cfg
*.orig
quark.json
*.json
*.dot
.env
+1
View File
@@ -166,6 +166,7 @@ options:
--fs-case-sensitive - treat filesystem as case sensitive, false by default
--cfg - save methods control flow graph to dot file
--raw-cfg - save methods control flow graph (use raw instructions)
--call-graph - save app call graph in format: 'dot' or 'json', default: none
-f, --fallback - set '--decompilation-mode' to 'fallback' (deprecated)
--use-dx - use dx/d8 to convert java bytecode
--comments-level - set code comments level, values: error, warn, info, debug, user-only, none, default: info
+1
View File
@@ -11,6 +11,7 @@ dependencies {
implementation(project(":jadx-core"))
implementation(project(":jadx-plugins-tools"))
implementation(project(":jadx-commons:jadx-app-commons"))
implementation(project(":jadx-commons:jadx-analysis"))
runtimeOnly(project(":jadx-plugins:jadx-dex-input"))
runtimeOnly(project(":jadx-plugins:jadx-java-input"))
@@ -1,11 +1,14 @@
package jadx.cli;
import java.nio.file.Path;
import java.util.function.Consumer;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jadx.analysis.callgraph.JadxCallGraph;
import jadx.analysis.callgraph.api.ICallGraph;
import jadx.api.JadxArgs;
import jadx.api.JadxDecompiler;
import jadx.api.impl.AnnotatedCodeWriter;
@@ -16,6 +19,7 @@ import jadx.cli.LogHelper.LogLevelEnum;
import jadx.cli.config.JadxConfigAdapter;
import jadx.cli.plugins.JadxFilesGetter;
import jadx.core.utils.exceptions.JadxArgsValidateException;
import jadx.core.utils.exceptions.JadxRuntimeException;
import jadx.plugins.tools.JadxExternalPluginsLoader;
public class JadxCLI {
@@ -73,6 +77,7 @@ public class JadxCLI {
if (checkForErrors(jadx)) {
return 2;
}
writeCallGraph(jadx, cliArgs);
if (!SingleClassMode.process(jadx, cliArgs)) {
save(jadx);
}
@@ -131,4 +136,29 @@ public class JadxCLI {
System.out.print(" \r");
}
}
private static void writeCallGraph(JadxDecompiler jadx, JadxCLIArgs cliArgs) {
JadxCLIArgs.CallGraphSaveMode mode = cliArgs.callGraphSaveMode;
if (mode == null || mode == JadxCLIArgs.CallGraphSaveMode.NONE) {
return;
}
Path outPath = jadx.getArgs().getOutDir().toPath();
ICallGraph callGraph = JadxCallGraph.builder(jadx)
.resolvedOnly(true)
.build();
Path cgPath;
switch (mode) {
case JSON:
cgPath = outPath.resolve("callgraph.json");
callGraph.writeJson(cgPath);
break;
case DOT:
cgPath = outPath.resolve("callgraph.dot");
callGraph.writeDot(cgPath);
break;
default:
throw new JadxRuntimeException("Unexpected call graph save mode: " + mode);
}
LOG.info("Call graph saved: {}", cgPath.toAbsolutePath());
}
}
@@ -283,6 +283,13 @@ public class JadxCLIArgs implements IJadxConfig {
@Parameter(names = { "--raw-cfg" }, description = "save methods control flow graph (use raw instructions)")
protected boolean rawCfgOutput = false;
@Parameter(
names = { "--call-graph" },
description = "save app call graph in format: 'dot' or 'json'",
converter = CallGraphSaveModeConverter.class
)
protected CallGraphSaveMode callGraphSaveMode = CallGraphSaveMode.NONE;
@Parameter(names = { "-f", "--fallback" }, description = "set '--decompilation-mode' to 'fallback' (deprecated)")
protected boolean fallbackMode = false;
@@ -827,6 +834,14 @@ public class JadxCLIArgs implements IJadxConfig {
this.rawCfgOutput = rawCfgOutput;
}
public CallGraphSaveMode getCallGraphSaveMode() {
return callGraphSaveMode;
}
public void setCallGraphSaveMode(CallGraphSaveMode callGraphSaveMode) {
this.callGraphSaveMode = callGraphSaveMode;
}
public boolean isReplaceConsts() {
return replaceConsts;
}
@@ -1022,6 +1037,18 @@ public class JadxCLIArgs implements IJadxConfig {
}
}
public enum CallGraphSaveMode {
NONE,
DOT,
JSON,
}
public static class CallGraphSaveModeConverter extends BaseEnumConverter<CallGraphSaveMode> {
public CallGraphSaveModeConverter() {
super(CallGraphSaveMode::valueOf, CallGraphSaveMode::values);
}
}
public abstract static class BaseEnumConverter<E extends Enum<E>> implements IStringConverter<E> {
private final Function<String, E> parse;
private final Supplier<E[]> values;
+29
View File
@@ -0,0 +1,29 @@
## jadx analysis
Various utilities for analyze and process code and related information.
### Call graph
Full app code usage/call graph.
Usage:
```java
JadxArgs args = new JadxArgs();
args.addInputFile(new File("input.apk"));
try (JadxDecompiler jadx = new JadxDecompiler(args)) {
jadx.load();
ICallGraph callGraph = JadxCallGraph.builder(jadx)
.includePackages("com.example") // filter nodes by package
.resolvedOnly(true) // add nodes only from app (exclude framework/lib calls)
.build();
for (ICallGraphEdge edge : callGraph.edges()) {
if (edge.isResolved()) {
System.out.printf("Edge from '%s' to '%s'%n", edge.from(), edge.to());
}
}
callGraph.writeDot(Path.of("test.dot")); // export to '.dot'
callGraph.writeJson(Path.of("test.json")); // export to JSON
}
```
@@ -0,0 +1,12 @@
plugins {
id("jadx-library")
}
dependencies {
implementation(project(":jadx-core"))
implementation("com.google.code.gson:gson:2.13.2")
testRuntimeOnly(project(":jadx-plugins:jadx-dex-input"))
testRuntimeOnly(project(":jadx-plugins:jadx-smali-input"))
}
@@ -0,0 +1,34 @@
package jadx.analysis.callgraph;
import java.nio.file.Path;
import java.util.List;
import jadx.analysis.callgraph.api.ICallGraph;
import jadx.analysis.callgraph.api.ICallGraphEdge;
import jadx.api.JadxArgs;
class CallGraph implements ICallGraph {
private final JadxArgs args;
private final List<ICallGraphEdge> edges;
public CallGraph(JadxArgs args, List<ICallGraphEdge> edges) {
this.args = args;
this.edges = edges;
}
@Override
public List<ICallGraphEdge> edges() {
return edges;
}
@Override
public void writeDot(Path path) {
new CallGraphExportDot(args, this).writeTo(path);
}
@Override
public void writeJson(Path path) {
new CallGraphExportJson(this).writeTo(path);
}
}
@@ -0,0 +1,6 @@
package jadx.analysis.callgraph;
import jadx.core.dex.attributes.AttrNode;
class CallGraphAttrNode extends AttrNode {
}
@@ -0,0 +1,92 @@
package jadx.analysis.callgraph;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.jetbrains.annotations.Nullable;
import jadx.analysis.callgraph.api.ICallGraph;
import jadx.analysis.callgraph.api.ICallGraphBuilder;
import jadx.analysis.callgraph.api.ICallGraphEdge;
import jadx.api.JadxDecompiler;
import jadx.core.dex.info.ClassInfo;
import jadx.core.dex.info.MethodInfo;
import jadx.core.dex.nodes.ClassNode;
import jadx.core.dex.nodes.MethodNode;
class CallGraphBuilder implements ICallGraphBuilder {
private final JadxDecompiler decompiler;
private boolean resolvedOnly = false;
private @Nullable String pkgFilter;
public CallGraphBuilder(JadxDecompiler decompiler) {
this.decompiler = decompiler;
}
@Override
public ICallGraphBuilder resolvedOnly(boolean resolved) {
this.resolvedOnly = resolved;
return this;
}
@Override
public ICallGraphBuilder includePackages(String pkgFilter) {
this.pkgFilter = pkgFilter.endsWith(".") ? pkgFilter : pkgFilter + '.';
return this;
}
@Override
public ICallGraph build() {
return new CallGraph(decompiler.getArgs(), collectEdges());
}
private List<ICallGraphEdge> collectEdges() {
AtomicInteger nodeId = new AtomicInteger();
Map<MethodInfo, CallGraphNode> nodes = new HashMap<>();
List<ICallGraphEdge> edges = new ArrayList<>();
for (ClassNode cls : decompiler.getRoot().getClasses(true)) {
if (ignorePkg(cls.getClassInfo())) {
continue;
}
for (MethodNode mth : cls.getMethods()) {
CallGraphNode thisNode = getCallGraphNode(mth, nodes, nodeId);
for (MethodNode use : mth.getUseIn()) {
if (ignorePkg(use.getDeclaringClass().getClassInfo())) {
continue;
}
CallGraphNode useInNode = getCallGraphNode(use, nodes, nodeId);
edges.add(new CallGraphEdge(useInNode, thisNode));
}
if (!resolvedOnly) {
for (MethodInfo used : mth.getUnresolvedUsed()) {
if (ignorePkg(used.getDeclClass())) {
continue;
}
CallGraphNode usedNode = getCallGraphNode(used, nodes, nodeId);
edges.add(new CallGraphEdge(thisNode, usedNode));
}
}
}
}
return edges;
}
private boolean ignorePkg(ClassInfo cls) {
if (pkgFilter == null) {
return false;
}
return !cls.getFullName().startsWith(pkgFilter);
}
private static CallGraphNode getCallGraphNode(MethodNode mth, Map<MethodInfo, CallGraphNode> nodes, AtomicInteger nodeId) {
return nodes.computeIfAbsent(mth.getMethodInfo(), i -> new CallGraphNode(nodeId.incrementAndGet(), mth));
}
private static CallGraphNode getCallGraphNode(MethodInfo mth, Map<MethodInfo, CallGraphNode> nodes, AtomicInteger nodeId) {
return nodes.computeIfAbsent(mth, i -> new CallGraphNode(nodeId.incrementAndGet(), mth));
}
}
@@ -0,0 +1,41 @@
package jadx.analysis.callgraph;
import jadx.analysis.callgraph.api.ICallGraphEdge;
import jadx.analysis.callgraph.api.ICallGraphNode;
import jadx.core.dex.attributes.IAttributeNode;
class CallGraphEdge implements ICallGraphEdge {
private final ICallGraphNode from;
private final ICallGraphNode to;
private final CallGraphAttrNode attrNode = new CallGraphAttrNode();
public CallGraphEdge(ICallGraphNode from, ICallGraphNode to) {
this.from = from;
this.to = to;
}
@Override
public ICallGraphNode from() {
return from;
}
@Override
public ICallGraphNode to() {
return to;
}
@Override
public boolean isResolved() {
return to.isResolved();
}
@Override
public IAttributeNode attributes() {
return attrNode;
}
@Override
public String toString() {
return "CallGraphEdge{from=" + from + ", to=" + to + '}';
}
}
@@ -0,0 +1,92 @@
package jadx.analysis.callgraph;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import jadx.analysis.callgraph.api.ICallGraph;
import jadx.analysis.callgraph.api.ICallGraphEdge;
import jadx.analysis.callgraph.api.ICallGraphNode;
import jadx.api.ICodeWriter;
import jadx.api.JadxArgs;
import jadx.api.impl.SimpleCodeWriter;
import jadx.core.utils.DotGraphUtils;
import jadx.core.utils.files.FileUtils;
import static java.nio.file.StandardOpenOption.CREATE;
import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;
import static java.nio.file.StandardOpenOption.WRITE;
public class CallGraphExportDot {
private final JadxArgs args;
private final ICallGraph callGraph;
public CallGraphExportDot(JadxArgs args, ICallGraph callGraph) {
this.args = args;
this.callGraph = callGraph;
}
public void writeTo(Path path) {
try {
FileUtils.makeDirsForFile(path);
Files.writeString(path, writeToString(), StandardCharsets.UTF_8,
WRITE, TRUNCATE_EXISTING, CREATE);
} catch (IOException e) {
throw new RuntimeException("Failed to save JSON file: " + path, e);
}
}
public String writeToString() {
// collect nodes
Map<Integer, Node> nodeMap = new HashMap<>();
for (ICallGraphEdge edge : callGraph.edges()) {
addNode(edge.from(), nodeMap);
addNode(edge.to(), nodeMap);
}
List<Node> nodes = new ArrayList<>(nodeMap.values());
nodes.sort(Comparator.comparingInt(o -> o.id));
SimpleCodeWriter cw = new SimpleCodeWriter(args);
cw.add("digraph CallGraph {");
for (Node node : nodes) {
cw.startLine();
addNodeName(cw, node.id);
cw.add("[shape=record,label=\"{");
cw.add(DotGraphUtils.escape(node.method));
cw.add("}\"];");
}
for (ICallGraphEdge edge : callGraph.edges()) {
cw.startLine();
addNodeName(cw, edge.from().getId());
cw.add(" -> ");
addNodeName(cw, edge.to().getId());
cw.add(';');
}
cw.startLine('}');
return cw.getCodeStr();
}
private void addNodeName(ICodeWriter cw, int id) {
cw.add('N').add(Integer.toString(id));
}
private void addNode(ICallGraphNode cgNode, Map<Integer, Node> nodeMap) {
nodeMap.computeIfAbsent(cgNode.getId(), id -> {
Node node = new Node();
node.id = id;
node.method = cgNode.getMethodInfo().getRawFullId();
return node;
});
}
static final class Node {
int id;
String method;
}
}
@@ -0,0 +1,97 @@
package jadx.analysis.callgraph;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.Strictness;
import jadx.analysis.callgraph.api.ICallGraph;
import jadx.analysis.callgraph.api.ICallGraphEdge;
import jadx.analysis.callgraph.api.ICallGraphNode;
import jadx.core.utils.files.FileUtils;
import static java.nio.file.StandardOpenOption.CREATE;
import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;
import static java.nio.file.StandardOpenOption.WRITE;
public class CallGraphExportJson {
private final ICallGraph callGraph;
private final Gson gson;
public CallGraphExportJson(ICallGraph callGraph) {
this.callGraph = callGraph;
this.gson = new GsonBuilder()
.disableJdkUnsafe()
.disableInnerClassSerialization()
.setStrictness(Strictness.STRICT)
// .setPrettyPrinting() // TODO: add option for pretty print?
.create();
}
public void writeTo(Path path) {
try {
FileUtils.makeDirsForFile(path);
Files.writeString(path, writeToString(), StandardCharsets.UTF_8,
WRITE, TRUNCATE_EXISTING, CREATE);
} catch (IOException e) {
throw new RuntimeException("Failed to save JSON file: " + path, e);
}
}
public String writeToString() {
List<Edge> edges = new ArrayList<>();
Map<Integer, Node> nodeMap = new HashMap<>();
for (ICallGraphEdge edge : callGraph.edges()) {
ICallGraphNode from = edge.from();
ICallGraphNode to = edge.to();
addNode(from, nodeMap);
addNode(to, nodeMap);
Edge jsonEdge = new Edge();
jsonEdge.from = from.getId();
jsonEdge.to = to.getId();
jsonEdge.resolved = edge.isResolved();
edges.add(jsonEdge);
}
List<Node> nodes = new ArrayList<>(nodeMap.values());
nodes.sort(Comparator.comparingInt(o -> o.id));
RootNode rootNode = new RootNode();
rootNode.nodes = nodes;
rootNode.edges = edges;
return gson.toJson(rootNode);
}
private void addNode(ICallGraphNode cgNode, Map<Integer, Node> nodeMap) {
nodeMap.computeIfAbsent(cgNode.getId(), id -> {
Node node = new Node();
node.id = id;
node.method = cgNode.getMethodInfo().getRawFullId();
return node;
});
}
static final class RootNode {
List<Node> nodes;
List<Edge> edges;
}
static final class Node {
int id;
String method;
}
static final class Edge {
int from;
int to;
boolean resolved;
}
}
@@ -0,0 +1,60 @@
package jadx.analysis.callgraph;
import org.jetbrains.annotations.Nullable;
import jadx.analysis.callgraph.api.ICallGraphNode;
import jadx.core.dex.attributes.IAttributeNode;
import jadx.core.dex.info.MethodInfo;
import jadx.core.dex.nodes.MethodNode;
class CallGraphNode implements ICallGraphNode {
private final int id;
private final MethodInfo mthInfo;
private final @Nullable MethodNode mthNode;
private final CallGraphAttrNode attrNode;
public CallGraphNode(int id, MethodInfo mthInfo) {
this(id, mthInfo, null);
}
public CallGraphNode(int id, MethodNode mthNode) {
this(id, mthNode.getMethodInfo(), mthNode);
}
public CallGraphNode(int id, MethodInfo mthInfo, @Nullable MethodNode mthNode) {
this.id = id;
this.mthInfo = mthInfo;
this.mthNode = mthNode;
this.attrNode = new CallGraphAttrNode();
}
@Override
public int getId() {
return id;
}
@Override
public MethodInfo getMethodInfo() {
return mthInfo;
}
@Override
public @Nullable MethodNode getMethodNode() {
return mthNode;
}
@Override
public boolean isResolved() {
return mthNode != null;
}
@Override
public IAttributeNode attributes() {
return attrNode;
}
@Override
public String toString() {
return mthInfo.getFullId();
}
}
@@ -0,0 +1,11 @@
package jadx.analysis.callgraph;
import jadx.analysis.callgraph.api.ICallGraphBuilder;
import jadx.api.JadxDecompiler;
public class JadxCallGraph {
public static ICallGraphBuilder builder(JadxDecompiler decompiler) {
return new CallGraphBuilder(decompiler);
}
}
@@ -0,0 +1,13 @@
package jadx.analysis.callgraph.api;
import java.nio.file.Path;
import java.util.List;
public interface ICallGraph {
List<ICallGraphEdge> edges();
void writeDot(Path path);
void writeJson(Path path);
}
@@ -0,0 +1,10 @@
package jadx.analysis.callgraph.api;
public interface ICallGraphBuilder {
ICallGraphBuilder includePackages(String pkgFilter);
ICallGraphBuilder resolvedOnly(boolean resolved);
ICallGraph build();
}
@@ -0,0 +1,14 @@
package jadx.analysis.callgraph.api;
import jadx.core.dex.attributes.IAttributeNode;
public interface ICallGraphEdge {
ICallGraphNode from();
ICallGraphNode to();
boolean isResolved();
IAttributeNode attributes();
}
@@ -0,0 +1,21 @@
package jadx.analysis.callgraph.api;
import org.jetbrains.annotations.Nullable;
import jadx.core.dex.attributes.IAttributeNode;
import jadx.core.dex.info.MethodInfo;
import jadx.core.dex.nodes.MethodNode;
public interface ICallGraphNode {
int getId();
MethodInfo getMethodInfo();
@Nullable
MethodNode getMethodNode();
boolean isResolved();
IAttributeNode attributes();
}
@@ -0,0 +1,86 @@
package jadx.analysis.callgraph.test;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import jadx.analysis.callgraph.CallGraphExportDot;
import jadx.analysis.callgraph.CallGraphExportJson;
import jadx.analysis.callgraph.JadxCallGraph;
import jadx.analysis.callgraph.api.ICallGraph;
import jadx.analysis.callgraph.api.ICallGraphEdge;
import jadx.api.JadxArgs;
import jadx.api.JadxDecompiler;
import static org.assertj.core.api.Assertions.assertThat;
class JadxCallGraphTest {
@TempDir
Path tempDir;
@SuppressWarnings("unused")
void usageExample() {
JadxArgs args = new JadxArgs();
args.addInputFile(new File("input.apk"));
try (JadxDecompiler jadx = new JadxDecompiler(args)) {
jadx.load();
ICallGraph callGraph = JadxCallGraph.builder(jadx)
.includePackages("com.example")
.resolvedOnly(false)
.build();
for (ICallGraphEdge edge : callGraph.edges()) {
if (edge.isResolved()) {
System.out.printf("Edge from '%s' to '%s'%n", edge.from(), edge.to());
}
}
callGraph.writeDot(Path.of("test.dot"));
callGraph.writeJson(Path.of("test.json"));
}
}
@Test
void simpleTest() {
JadxArgs args = new JadxArgs();
args.addInputFile(getSampleFile("simple.smali"));
try (JadxDecompiler jadx = new JadxDecompiler(args)) {
jadx.load();
ICallGraph callGraph = JadxCallGraph.builder(jadx)
.includePackages("test.pkg")
.resolvedOnly(false)
.build();
assertThat(callGraph.edges()).hasSize(1);
for (ICallGraphEdge edge : callGraph.edges()) {
System.out.println("Edge from " + edge.from() + " to " + edge.to());
}
String dotStr = new CallGraphExportDot(jadx.getArgs(), callGraph).writeToString();
System.out.println("dot: " + dotStr);
String jsonStr = new CallGraphExportJson(callGraph).writeToString();
System.out.println("json: " + jsonStr);
callGraph.writeDot(tempDir.resolve("test.dot"));
callGraph.writeJson(tempDir.resolve("test.json"));
}
}
private File getSampleFile(String sampleName) {
try {
URL resource = getClass().getResource("/samples/" + sampleName);
assertThat(resource).describedAs("Sample not found: %s", sampleName).isNotNull();
return new File(resource.toURI().toURL().getFile());
} catch (MalformedURLException | URISyntaxException e) {
throw new RuntimeException("Failed to load sample file: " + sampleName, e);
}
}
}
@@ -0,0 +1,19 @@
.class Ltest/pkg/HelloWorld;
.super Ljava/lang/Object;
.source "HelloWorld.java"
.method public static main([Ljava/lang/String;)V
.registers 2
const-string v0, "Hello, World"
invoke-static {p0, v0}, Ltest/pkg/HelloWorld;->hello(Ljava/lang/String;)V
return-void
.end method
.method public static hello(Ljava/lang/String;)V
.registers 2
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
invoke-virtual {v0, p0}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V
return-void
.end method
@@ -156,6 +156,10 @@ public class UsageInfo implements IUsageInfoData {
* Add method usage: {@code useMth} occurrence found in {@code mth} code
*/
public void unresolvedMethodUse(MethodNode mth, MethodInfo useMth) {
if (useMth.getRawFullId().equals("java.lang.Object.<init>()V")) {
// ignore default object constructor (called in every constructor)
return;
}
unresolvedMthUsage.add(mth, useMth);
}
@@ -428,18 +428,18 @@ public class DotGraphUtils {
}
}
private String escape(Object obj) {
public static String escape(Object obj) {
if (obj == null) {
return "null";
}
return escape(obj.toString());
}
private String escape(String string) {
public static String escape(String string) {
return escape(string, NLQR);
}
private String escape(String string, String newline) {
public static String escape(String string, String newline) {
return string
.replace("\\", "") // TODO replace \"
.replace("/", "\\/")
+1
View File
@@ -16,6 +16,7 @@ include("jadx-plugins-tools")
include("jadx-commons:jadx-app-commons")
include("jadx-commons:jadx-zip")
include("jadx-commons:jadx-analysis")
include("jadx-plugins:jadx-input-api")
include("jadx-plugins:jadx-dex-input")