Skip to content

Graph export and comparison

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

Graph export and comparison

GraphJson, GraphDot, and GraphDifference work on immutable CallGraphSnapshot values. They require no active Soot session and can be used after analysis closes. They complement the reachability and subgraph queries.

Export an APK's graph, then query it offline

Install the library and build the example project using Getting started. From the repository root, use the runnable ExportGraph and GraphQueries programs:

APK=/absolute/path/to/app.apk
PLATFORMS=/absolute/path/to/android-sdk/platforms

java -Xmx4g -cp 'examples/target/classes:examples/target/dependency/*' \
  com.jordansamhi.androspecter.examples.ExportGraph \
  "$APK" "$PLATFORMS" /tmp/androspecter-graph.json

java -Xmx4g -cp 'examples/target/classes:examples/target/dependency/*' \
  com.jordansamhi.androspecter.examples.GraphQueries \
  /tmp/androspecter-graph.json \
  '<com.example.app.Entry: void start()>' \
  '<com.example.app.Output: void send(java.lang.String)>' \
  /tmp/androspecter-reachable.dot

Replace the two method signatures with actual graph nodes. ExportGraph captures the complete graph and closes the session before writing JSON. GraphQueries imports that file, prints reachability and a shortest method path, and optionally writes the subgraph reachable from the source as DOT. Its fourth argument is optional. Unknown method signatures yield empty paths or closures, rather than being resolved against an APK.

Output files are replaced if present; their parent directories must already exist. On Windows, use ; instead of : in the classpath. Graphviz is not required to generate DOT. If installed separately, it can render the result, for example:

dot -Tsvg /tmp/androspecter-reachable.dot -o /tmp/androspecter-reachable.svg

Filter large graphs before rendering when you only need a particular region. Importing JSON reconstructs the graph and its indexes in memory; a large export still needs an appropriate heap budget even though Soot is not opened.

Versioned JSON API

API Behavior
GraphJson.toJson(graph) Returns a complete JSON string
GraphJson.fromJson(json) Validates and imports one JSON document
GraphJson.write(graph, writer) Writes to a caller-owned character writer
GraphJson.read(reader) Reads and validates one complete document from a caller-owned reader

The library declares Gson 2.14.0 for this API. Consumers do not need to construct a Gson instance or reflectively deserialize private snapshot fields. Use UTF-8 when opening files, for example Files.newBufferedReader(path, StandardCharsets.UTF_8).

Version 1 uses this shape:

{
  "schemaVersion": 1,
  "analysisId": "12345678-1234-1234-1234-123456789abc",
  "methods": [
    "<example.App: void a()>",
    "<example.App: void b()>"
  ],
  "edges": [
    {
      "source": "<example.App: void a()>",
      "target": "<example.App: void b()>",
      "kind": "STATIC",
      "callSite": null,
      "callSiteIndex": -1
    }
  ]
}

All displayed fields are required. Only callSite may be null. The UUID, field types, integer version, and edge positions are validated; each edge's endpoints must occur in methods. Unsupported versions, missing/null required values, duplicate required fields, invalid JSON, and trailing documents are rejected with IllegalArgumentException. Unknown extra fields are ignored. Reader/writer I/O failures propagate as IOException.

Method signatures and kinds are opaque strings: import does not resolve methods, validate their declaration in an APK, or restrict kind names to a particular Soot version. Node duplicates are normalized, while equal parallel edge entries remain separate. Explicit isolated nodes, unavailable statement values, the analysis ID, and all edge metadata survive a round trip. Output ordering is deterministic for the same snapshot.

The stream methods neither close nor flush caller-owned streams. Use try-with-resources around file streams. Writes can leave partial output on I/O failure; callers needing atomic publication should write a temporary file and move it after success.

DOT API

GraphDot.toDot(graph) returns DOT text; GraphDot.write(graph, writer) writes it to a caller-owned writer without closing or flushing it. Each method is emitted as a quoted node, including isolated nodes. Each edge occurrence is emitted separately, labelled with its kind, with optional statement text as a tooltip and its capture-time index as an attribute. Quotes, backslashes, and line breaks are escaped. NUL characters in identifiers or attributes cause IllegalArgumentException before any DOT output is written, because Graphviz cannot represent them. JSON can retain these values.

DOT is intended for visualization. It does not retain the analysis ID and there is no DOT importer. Use the versioned JSON format for graph interchange and later queries.

Compare snapshots

This complete CompareGraphs.java program accepts two exported JSON paths and compares them without opening Soot. Place it in the consumer project described in Getting started:

import com.jordansamhi.androspecter.graph.CallGraphSnapshot;
import com.jordansamhi.androspecter.graph.GraphDifference;
import com.jordansamhi.androspecter.graph.GraphJson;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

/** Compares detached graph files without creating an Android analysis session. */
public final class CompareGraphs {
    private CompareGraphs() {}

    /**
     * Prints changed nodes and edge multiplicities using source/target/kind identity.
     *
     * @param args baseline JSON path followed by the later graph's JSON path
     * @throws IllegalArgumentException if arguments or graph documents are invalid
     * @throws IOException if either file cannot be read
     */
    public static void main(String[] args) throws IOException {
        if (args.length != 2) {
            throw new IllegalArgumentException("Usage: CompareGraphs <before.json> <after.json>");
        }
        CallGraphSnapshot before;
        CallGraphSnapshot after;
        try (Reader input = Files.newBufferedReader(Path.of(args[0]), StandardCharsets.UTF_8)) {
            before = GraphJson.read(input);
        }
        try (Reader input = Files.newBufferedReader(Path.of(args[1]), StandardCharsets.UTF_8)) {
            after = GraphJson.read(input);
        }
        GraphDifference difference = GraphDifference.between(before, after);
        System.out.println("Unchanged: " + difference.isEmpty());
        System.out.println("Added nodes: " + difference.addedMethods());
        System.out.println("Removed nodes: " + difference.removedMethods());
        System.out.println("Added edge occurrences: " + difference.addedEdges());
        System.out.println("Removed edge occurrences: " + difference.removedEdges());
    }
}

addedEdges() and removedEdges() map GraphDifference.EdgeIdentity(source, target, kind) to positive occurrence-count changes. If a baseline contains two equivalent edge occurrences and the later graph contains three, the added count is 1, not 3. Changing only statement text or its position creates no difference; changing the edge kind creates a removal and an addition. Added and removed isolated nodes are reported too. Analysis IDs are ignored, so snapshots from separate sessions can be compared.

This identity deliberately avoids callSiteIndex, which is only the position in a processed body at capture time. Inserting probes or changing Jimple locals can alter indices and text without changing a source/target relationship. Conversely, unchanged graph structure does not establish unchanged program behavior. Comparisons do not infer why an edge changed or normalize obfuscated/renamed method signatures across APK versions.

The runnable InstrumentInMemory example also compares snapshots before and after applying its transformation pipeline.

Explore a graph in an offline HTML page

GraphHtml.write(graph, writer) exports a self-contained interactive viewer from the same detached snapshot. For example, with an existing CallGraphSnapshot graph:

try (java.io.Writer writer = java.nio.file.Files.newBufferedWriter(
        java.nio.file.Path.of("callgraph.html"), java.nio.charset.StandardCharsets.UTF_8)) {
    com.jordansamhi.androspecter.graph.GraphHtml.write(graph, writer);
}

Open the file directly in a browser. Search covers all exported methods; selecting a method shows its callers and callees. Directed edges retain Soot edge kinds, parallel-call counts, and call-site details. Pan, zoom, and fit controls help explore a neighborhood. The viewer caps the visible neighborhood at 60 methods and 180 grouped relationships and displays omitted counts. These rendering limits do not remove data from the file: the full selected snapshot is embedded, so large graphs still consume browser memory. Filter the snapshot before exporting when you need a smaller report.

All scripts, styles, and data are embedded; the viewer requires no server, CDN, or network access. Graph strings are escaped for HTML embedding and rendered as text. The compact HTML payload is internal to the viewer; use GraphJson for versioned interchange between tools. The write method neither closes nor flushes the caller's writer, while toHtml(graph) returns the entire document as a string.

The standalone CLI also creates these pages without writing Java:

bin/androspecter graph --input-json callgraph.json --format html -o callgraph.html

Source: GraphJson, GraphDot, GraphHtml, GraphDifference.

Clone this wiki locally