Skip to content

Call graphs and inspection

Jordan Samhi edited this page Oct 23, 2026 · 2 revisions

Call graphs and inspection

Use AndroidAnalysis.callGraph() for detached graph queries and Inspection for selected callbacks over the current Soot model. A graph captured from Soot contains methods incident to edges, while inspection can visit declared methods that have no edge. Graph factories and filtered subgraphs can also retain explicitly supplied or selected isolated nodes. Start with Analysis sessions for dependencies and lifecycle rules.

Inspect selected classes and query a graph

This complete GraphInspection.java application accepts <apk> <platforms> <package-prefix> [method-signature]. The package argument uses a package boundary: com.example includes com.example.util but not com.exampleother. The optional fourth argument is a full Jimple method signature, quoted as one shell argument.

import com.jordansamhi.androspecter.analysis.AndroidAnalysis;
import com.jordansamhi.androspecter.graph.CallGraphSnapshot;
import com.jordansamhi.androspecter.inspection.Inspection;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import soot.SootClass;

/**
 * Command-line example of scoped model inspection and detached call-graph queries.
 * The default constructor performs no analysis; invoke {@link #main(String[])} to run it.
 */
public final class GraphInspection {
    /**
     * Counts selected model elements, closes the analysis, and optionally queries a graph method.
     * The package filter applies to inspection; the retained graph includes all captured edges.
     *
     * @param args APK path, SDK platforms directory, package prefix, and optionally a full method signature
     * @throws IllegalArgumentException if the argument count, package prefix, or input paths are invalid
     * @throws RuntimeException if backend initialization, inspection, or graph capture fails
     */
    public static void main(String[] args) {
        if (args.length < 3 || args.length > 4) {
            throw new IllegalArgumentException(
                    "Usage: GraphInspection <apk> <platforms> <package-prefix> [method-signature]");
        }
        String prefix = args[2];
        if (prefix.isBlank() || prefix.endsWith(".")) {
            throw new IllegalArgumentException("Use a nonblank package without a trailing dot");
        }
        List<String> invokedSignatures = new ArrayList<>();
        CallGraphSnapshot graph;

        try (var analysis = AndroidAnalysis.open(Path.of(args[0]), Path.of(args[1]))) {
            var metrics = analysis.metrics();
            metrics.setMetric("selectedClasses", 0);
            metrics.setMetric("selectedMethods", 0);
            metrics.setMetric("selectedJimpleUnits", 0);
            analysis.inspect(Inspection.builder()
                    .classes(SootClass::isApplicationClass)
                    .classes(clazz -> clazz.getPackageName().equals(prefix)
                            || clazz.getPackageName().startsWith(prefix + "."))
                    .onClass(clazz -> metrics.incrementMetric("selectedClasses"))
                    .onMethod(method -> metrics.incrementMetric("selectedMethods"))
                    .onStatement((method, unit) -> metrics.incrementMetric("selectedJimpleUnits"))
                    .onInvoke((caller, statement, invocation) ->
                            invokedSignatures.add(invocation.getMethodRef().getSignature()))
                    .build());
            System.out.println(metrics.snapshot().metrics());
            graph = analysis.callGraph();
        }

        System.out.println("Graph incident methods: " + graph.methods().size());
        System.out.println("Graph edges: " + graph.edges().size());
        System.out.println("Selected invocation sites: " + invokedSignatures.size());
        if (args.length == 4) {
            String method = args[3];
            System.out.println("In graph: " + graph.containsMethod(method));
            System.out.println("Callers: " + graph.callersOf(method));
            System.out.println("Callees: " + graph.calleesOf(method));
            for (var edge : graph.outgoingEdges(method)) {
                System.out.println(edge.target() + " | " + edge.kind()
                        + " | unit " + edge.callSiteIndex() + " | " + edge.callSite());
            }
        }
    }
}

The inspection filters do not filter the call graph. graph still contains the full graph, including framework and generated methods. Application-class flags can include third-party code packaged with the APK. A package prefix is an explicit scope choice, not proof that all first-party code is inside that package.

The counts describe the processed Soot/FlowDroid model. Method counts include abstract/native methods selected by the filters and may include backend-generated helpers. Statement counts are Jimple units in selected concrete methods, including identities and returns; they are not Java source statements or original DEX instruction counts.

The repository also contains the runnable InspectApk example. After building the examples as described in their README, run:

java -Xmx4g -cp 'examples/target/classes:examples/target/dependency/*' \
  com.jordansamhi.androspecter.examples.InspectApk \
  "$APK" "$PLATFORMS" "$APP_PREFIX"

Graph snapshot contract

Query/value Meaning
analysisId() Session identifier attached at capture
methods() Immutable node set: edge endpoints when captured from Soot, plus any explicitly supplied or retained isolated nodes
edges() Immutable list retaining every captured edge, including parallel entries
callersOf(signature) / calleesOf(signature) Indexed sets of distinct adjacent method signatures
incomingEdges(signature) / outgoingEdges(signature) Indexed lists preserving individual edges and call sites
containsMethod(signature) Whether the signature is a node, including an explicitly retained isolated node
containsEdge(source, target) Whether any directed edge connects the two signatures, regardless of kind or site
reachableFrom(source) / reachableTo(target) Forward/reverse transitive closure, including the starting node if known
canReach(source, target) Whether a path of zero or more directed edges exists
shortestPath(source, target) Deterministic shortest node sequence, including both endpoints
subgraph(methodPredicate[, edgePredicate]) Detached filtered graph retaining selected nodes even if isolated

Unknown signatures yield empty query collections or false, even for canReach("missing", "missing"). A known isolated node reaches itself and has a one-node shortest path to itself. Null signatures are rejected. Adjacency queries use indexes; transitive queries traverse those indexes without loading Soot bodies.

Each CallGraphEdge stores source, target, kind, callSite, and callSiteIndex. callSite is processed Jimple text, or null when there is no source unit. The index is the zero-based position in the source's active body at capture time; -1 means unavailable. It is not a source line number or DEX offset. Different sites can have identical text. Parallel edges remain separate even when their detached values match; Soot context objects are not retained.

Capture does not load missing bodies. It creates detached values and indexes in addition to the live backend graph, so account for both when sizing memory for large APKs. The snapshot includes generated entry points; unlike some legacy SootUtils methods, it does not remove dummy-main nodes. Direct Soot capture excludes isolated declarations because the backend call graph is edge-based.

CallGraphSnapshot.of(id, edges) builds a snapshot from detached edges. The overload of(id, methods, edges) retains an explicit node set and rejects edges whose endpoints are missing from it. Repeated method identifiers are deduplicated; parallel edge entries are preserved. Neither factory parses method signatures or checks whether a method was actually declared in an APK.

Reachability, shortest paths, and subgraphs

The following method-body snippet assumes a CallGraphSnapshot graph already captured or imported. It works after the analysis has closed. Replace both signatures with methods from your graph:

String source = "<com.example.app.Entry: void start()>";
String target = "<com.example.app.Output: void send(java.lang.String)>";
System.out.println(graph.canReach(source, target));
var path = graph.shortestPath(source, target);
System.out.println(path);
if (!path.isEmpty()) {
    System.out.println("Edges in shortest path: " + (path.size() - 1));
}

var reachable = graph.reachableFrom(source);
var downstream = graph.subgraph(reachable::contains);
var staticCalls = graph.subgraph(method -> true, edge -> edge.kind().equals("STATIC"));
System.out.println("Reachable nodes: " + downstream.methods().size());
System.out.println("Reachable using only STATIC edges: " + staticCalls.canReach(source, target));

shortestPath minimizes edge count. Its list includes both endpoints, so a path with three nodes has two edges. Equal-length alternatives are resolved by lexicographic neighbor order, independently of edge insertion order. Cycles terminate, and parallel edges do not duplicate closure members. Closure sets are immutable and sorted by signature. There is no hidden traversal limit or silently truncated result.

These queries establish reachability in the supplied call graph. They do not prove that a path is executable or that data flows along it. Filtering can remove possible paths, so interpret a negative answer relative to the selected nodes and edge kinds.

Subgraph node predicates run once per node. Edge predicates run only for edges whose endpoints both pass the node predicate. The originating analysis ID is retained, the original graph is unchanged, and selected nodes remain present even if no edge survives. Keeping every node while filtering edges, as above, can therefore create isolated nodes.

For JSON import/export, DOT visualization, and multiplicity-aware comparisons, see Graph export and comparison. The runnable GraphQueries example performs these queries on a JSON file without reopening an APK.

Optional callbacks and filter composition

Inspection.builder() starts with no callbacks and no class or library restrictions. Register only the work you need:

  • onClass alone does not enumerate methods or load their bodies.
  • onMethod visits selected methods, including abstract/native ones, without loading bodies for that callback alone.
  • onStatement and onInvoke load selected concrete method bodies. onInvoke does not require the other callbacks and does not resolve the target method; invocation.getMethodRef().getSignature() reads the reference directly.
  • Class predicates run before method enumeration. Method predicates run before body loading. Multiple classes(...) or methods(...) calls combine with short-circuit AND, in registration order.
  • Callbacks run class, method, statement, then invocation; multiple callbacks at the same level retain registration order. A plan with no callbacks only validates its context.

LibraryFilter is an optional classification policy: true means library. packages(...) matches the listed packages and subpackages using package boundaries. knownLibraries() consults the current legacy LibrariesManager settings; it does not take a snapshot of that mutable manager. Neither policy is applied implicitly.

This method-scope snippet requires an open AndroidAnalysis analysis on its owning thread. Add these imports to the surrounding class:

import com.jordansamhi.androspecter.analysis.AndroidAnalysis;
import com.jordansamhi.androspecter.inspection.Inspection;
import com.jordansamhi.androspecter.inspection.LibraryFilter;
import soot.SootClass;
LibraryFilter excluded = LibraryFilter.packages("com.vendor.analytics")
        .or(LibraryFilter.packages("org.vendor.ads"));
analysis.inspect(Inspection.builder()
        .classes(SootClass::isApplicationClass)
        .excludeLibraries(excluded)
        .methods(method -> method.isConcrete())
        .onMethod(method -> System.out.println(method.getSignature()))
        .build());

filterA.or(filterB) classifies the union as libraries; and classifies their intersection; negate reverses classification. Two successive excludeLibraries(A).excludeLibraries(B) calls exclude the union, since both exclusion predicates must pass. LibraryFilter.none() excludes nothing.

Callbacks receive live Soot objects. Copy values to strings or your own immutable records before retaining them. Predicate, callback, and body-loading exceptions propagate. Use analysis.apply(...) for mutations that should invalidate the cached graph; inspection does not automatically track body edits. See AndroidAppInspector for the legacy adapter.

Source: Inspection, LibraryFilter, CallGraphSnapshot.

Clone this wiki locally