"` / `exec --file ` | **UNSAFE**: run Python in the backend process. | `--file`, same |
| `read int/string/struct [...]` | Typed reads: decode memory as an integer, C string, or defined struct. | `--size`, `--signed`, `--endian`, `--max-len`, `--encoding`, same + `--json` |
| `read_memory ` | Read raw bytes from the binary at ``. Default output is a hexdump. | `--format {hexdump,hex,raw}`, same + `--json` (base64-encoded bytes) |
+| `backend status jadx` | Check the optional Java/JADX runtime without loading an input. | `--json` |
| `install-skill` | Install this file for Claude Code or Codex. | `--agent`, `--dest`, `--force`, `--json` |
### `xref_to` vs `get_callers`
diff --git a/docs/decompiler_cli.md b/docs/decompiler_cli.md
index d3dd1121..965c00b0 100644
--- a/docs/decompiler_cli.md
+++ b/docs/decompiler_cli.md
@@ -31,6 +31,7 @@ and can be installed with `decompiler install-skill`.
- [`rename`](#rename)
- [`list_strings`](#list_strings)
- [`get_callers`](#get_callers)
+ - [`backend status`](#backend-status)
- [`install-skill`](#install-skill)
- [Server selection (`--id`, `--binary`, `--backend`)](#server-selection)
- [JSON output (`--json`, `--raw`)](#json-output)
@@ -65,6 +66,12 @@ Pick a backend you have available:
- **ghidra** — requires `GHIDRA_INSTALL_DIR` and uses PyGhidra.
- **binja** — requires a Binary Ninja license.
- **ida** — requires IDA Pro.
+- **jadx** — optional; requires the official JADX 1.5.6+ distribution and
+ Java 17+. DecLib finds it via `JADX_HOME` or `jadx` on `PATH`. Check setup
+ with `decompiler backend status jadx --json`. It uses stable JVM/Dex
+ references and has dedicated `class`, `method`, `field`, `resource`, and
+ `manifest` commands; see the
+ [JADX backend guide](./jadx.md).
---
@@ -128,7 +135,7 @@ Load a binary, starting a headless server if one isn't already running for
it.
```bash
-decompiler load [--backend {angr,ghidra,binja,ida}]
+decompiler load [--backend {angr,ghidra,binja,ida,jadx}]
[--id SERVER_ID]
[--force | --replace]
[--timeout SECONDS]
@@ -654,6 +661,19 @@ to the backend's lifted form, so both refer to the same byte instead of the
backend double-adding the image base. An address at or above the image base is
treated as absolute; a smaller one is already lifted.
+### `backend status`
+
+Check whether an optional backend runtime is installed without starting a
+decompiler server:
+
+```bash
+decompiler backend status jadx [--json]
+```
+
+The JSON response reports the bridge JAR, Java runtime, official JADX JAR and
+detected versions. It exits successfully when the backend is ready and exits
+`1` with a `reasons` list when setup is incomplete.
+
### `install-skill`
Copy the bundled Agent Skill into a supported agent skill directory so Claude
@@ -684,7 +704,7 @@ to know which one to talk to. Narrow with any combination of:
- **`--id `** — exact match.
- **`--binary `** — match by binary path (resolved to an absolute
path).
-- **`--backend `** — match by backend.
+- **`--backend `** — match by backend.
If zero servers match, the CLI errors out and tells you to run
`decompiler load`. If multiple match, it prints a disambiguation list:
diff --git a/docs/jadx.md b/docs/jadx.md
new file mode 100644
index 00000000..00b4a603
--- /dev/null
+++ b/docs/jadx.md
@@ -0,0 +1,72 @@
+# JADX backend
+
+The JADX backend analyzes APK, DEX, JAR, class, Smali, AAB, and XAPK inputs
+through `jadx-core`. It does not depend on MCP. DecLib starts a private,
+long-lived Java worker for each JADX server and exchanges structured JSON over
+the worker's standard input and output.
+
+## Setup
+
+JADX is optional and is not bundled with DecLib. Install the official JADX
+1.5.6 or newer distribution and Java 17 or newer. DecLib finds JADX through
+`JADX_HOME` or a `jadx` executable on `PATH`:
+
+```bash
+export JADX_HOME=/opt/jadx
+decompiler backend status jadx --json
+decompiler load ./challenge.apk --backend jadx
+```
+
+Released DecLib wheels contain the small Java bridge used to communicate with
+JADX. Gradle is only needed when developing DecLib from a source checkout and
+the bridge has not been built:
+
+```bash
+gradle --no-daemon -p declib/decompilers/jadx/worker test jar
+```
+
+Set `DECLIB_JADX_JAR` to select a specific official `jadx-*-all.jar`, or
+`DECLIB_JADX_WORKER` to use a completely custom worker command. Additional
+JVM arguments can be supplied through `DECLIB_JADX_WORKER_OPTS`; the default
+maximum heap is 4 GiB:
+
+```bash
+export DECLIB_JADX_WORKER_OPTS="-Xmx8g"
+```
+
+## Usage
+
+Managed-code objects use stable JVM/Dex references instead of fake native
+addresses. Always copy the complete `ref` from a list command; method
+descriptors distinguish overloads.
+
+```bash
+decompiler class list --filter 'challenge' --json
+decompiler class source 'com.example.MainActivity' --raw
+
+decompiler method list --class 'com.example.MainActivity' --json
+decompiler method source \
+ 'com.example.MainActivity->checkFlag(Ljava/lang/String;)Z' --raw
+decompiler method xrefs \
+ 'com.example.MainActivity->checkFlag(Ljava/lang/String;)Z' --json
+
+decompiler field list --class 'com.example.MainActivity' --json
+decompiler resource list --filter 'xml|json' --json
+decompiler resource get 'res/values/strings.xml' --max-chars 8000 --json
+decompiler manifest --raw
+```
+
+Class source, method source, text resources, and the manifest can be bounded
+with `--max-chars`. Binary resources are base64 encoded and bounded to 1 MiB
+by default; change that limit with `resource get --max-bytes`.
+
+## Native API differences
+
+JADX methods are intentionally not exposed through DecLib's address-keyed
+`functions` artifact dictionary. Native operations such as memory reads,
+segments, byte patches, and define/undefine have no JVM/Dex equivalent.
+
+Callers, callees, and other xrefs can trigger JADX whole-program usage
+analysis. On large applications this can take substantially more time and
+memory than listing or decompiling a single class. The 4 GiB default worker
+heap bounds this work; raise it explicitly for unusually large applications.
diff --git a/pyproject.toml b/pyproject.toml
index 6d3ea985..c7814f96 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,6 +53,13 @@ include-package-data = true
[tool.setuptools.package-data]
"declib.skills" = ["**/SKILL.md", "**/*.md"]
+"declib.decompilers.jadx.worker" = [
+ "build.gradle.kts",
+ "settings.gradle.kts",
+ "bridge/declib-jadx-worker.jar",
+ "src/main/java/**/*.java",
+ "src/main/resources/**/*",
+]
[tool.setuptools.packages]
find = {namespaces = false}
diff --git a/tests/test_jadx.py b/tests/test_jadx.py
new file mode 100644
index 00000000..51e9557d
--- /dev/null
+++ b/tests/test_jadx.py
@@ -0,0 +1,195 @@
+import os
+import shlex
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from declib.api.decompiler_interface import DecompilerInterface
+from declib.decompilers.jadx.interface import JadxInterface
+from declib.decompilers.jadx.worker_client import JadxWorkerClient
+
+
+class FakeWorker:
+ def __init__(self, **kwargs):
+ self.calls = []
+ self.closed = False
+
+ def call(self, method, params=None, timeout=None):
+ self.calls.append((method, params, timeout))
+ if method == "load":
+ return {
+ "path": params["path"],
+ "classes": 2,
+ "resources": 1,
+ "errors": 0,
+ "warnings": 0,
+ "capabilities": ["classes", "methods"],
+ }
+ if method == "list_classes":
+ return [{"kind": "class", "ref": "example.Main", "name": "Main"}]
+ if method == "method_source":
+ return {
+ "ref": params["ref"],
+ "language": "java",
+ "text": "void run() {}",
+ }
+ return {}
+
+ def close(self):
+ self.closed = True
+
+
+def test_jadx_interface_uses_opaque_managed_references(tmp_path):
+ apk = tmp_path / "sample.apk"
+ apk.write_bytes(b"not needed by fake worker")
+
+ with patch(
+ "declib.decompilers.jadx.interface.JadxWorkerClient",
+ FakeWorker,
+ ):
+ interface = JadxInterface(binary_path=apk, headless=True)
+ try:
+ assert interface.binary_base_addr == 0
+ assert interface.binary_arch == "dalvik"
+ assert interface._functions() == {}
+ assert interface.managed_list_classes()[0]["ref"] == "example.Main"
+ source = interface.managed_method_source("example.Main->run()V")
+ assert source["text"] == "void run() {}"
+ finally:
+ worker = interface._worker
+ interface.shutdown()
+ assert worker.closed
+
+
+def test_discover_constructs_jadx_backend(tmp_path):
+ apk = tmp_path / "sample.apk"
+ apk.write_bytes(b"sample")
+ sentinel = MagicMock()
+
+ with patch(
+ "declib.decompilers.jadx.interface.JadxInterface",
+ return_value=sentinel,
+ ) as constructor:
+ result = DecompilerInterface.discover(
+ force_decompiler="jadx",
+ binary_path=apk,
+ headless=True,
+ )
+
+ assert result is sentinel
+ constructor.assert_called_once_with(binary_path=apk, headless=True)
+
+
+def test_worker_client_json_protocol(tmp_path):
+ worker = tmp_path / "fake_worker.py"
+ worker.write_text(
+ """
+import json
+import sys
+for line in sys.stdin:
+ request = json.loads(line)
+ if request["method"] == "fail":
+ response = {
+ "id": request["id"],
+ "error": {"type": "IllegalArgumentException", "message": "bad input"},
+ }
+ else:
+ response = {
+ "id": request["id"],
+ "result": {"method": request["method"], "params": request["params"]},
+ }
+ print(json.dumps(response), flush=True)
+""".strip()
+ + "\n",
+ encoding="utf-8",
+ )
+
+ command = f"{shlex.quote(sys.executable)} {shlex.quote(str(worker))}"
+ with JadxWorkerClient(executable=command) as client:
+ assert client.call("echo", {"value": 7}) == {
+ "method": "echo",
+ "params": {"value": 7},
+ }
+ with pytest.raises(ValueError, match="bad input"):
+ client.call("fail")
+
+
+def test_find_official_jadx_runtime_from_home(tmp_path, monkeypatch):
+ jadx_home = tmp_path / "jadx"
+ jadx_jar = jadx_home / "lib" / "jadx-1.5.6-all.jar"
+ jadx_jar.parent.mkdir(parents=True)
+ jadx_jar.write_bytes(b"test")
+ monkeypatch.setenv("JADX_HOME", str(jadx_home))
+ monkeypatch.delenv("DECLIB_JADX_JAR", raising=False)
+ monkeypatch.setattr("shutil.which", lambda name: None)
+
+ assert JadxWorkerClient.find_jadx_runtime() == (jadx_jar, "1.5.6")
+
+
+def test_find_official_jadx_runtime_rejects_old_version(tmp_path, monkeypatch):
+ jadx_home = tmp_path / "jadx"
+ jadx_jar = jadx_home / "lib" / "jadx-1.5.5-all.jar"
+ jadx_jar.parent.mkdir(parents=True)
+ jadx_jar.write_bytes(b"test")
+ monkeypatch.setenv("JADX_HOME", str(jadx_home))
+ monkeypatch.delenv("DECLIB_JADX_JAR", raising=False)
+ monkeypatch.setattr("shutil.which", lambda name: None)
+
+ with pytest.raises(RuntimeError, match="too old"):
+ JadxWorkerClient.find_jadx_runtime()
+
+
+def test_resolve_command_uses_thin_bridge_and_official_jadx(
+ tmp_path,
+ monkeypatch,
+):
+ java = tmp_path / "java"
+ bridge = tmp_path / "declib-jadx-worker.jar"
+ jadx = tmp_path / "jadx-1.5.6-all.jar"
+ for path in (java, bridge, jadx):
+ path.write_bytes(b"test")
+
+ monkeypatch.delenv("DECLIB_JADX_WORKER", raising=False)
+ monkeypatch.setenv("DECLIB_JADX_WORKER_OPTS", "-Xmx2g")
+ with (
+ patch.object(
+ JadxWorkerClient,
+ "find_jadx_runtime",
+ return_value=(jadx, "1.5.6"),
+ ),
+ patch.object(
+ JadxWorkerClient,
+ "find_java",
+ return_value=(java, 21),
+ ),
+ patch.object(
+ JadxWorkerClient,
+ "find_bridge_jar",
+ return_value=bridge,
+ ),
+ ):
+ command = JadxWorkerClient.resolve_command(build_if_missing=False)
+
+ assert command == [
+ str(java),
+ "-Xmx2g",
+ "-cp",
+ f"{bridge}{os.pathsep}{jadx}",
+ JadxWorkerClient.WORKER_MAIN_CLASS,
+ ]
+
+
+def test_runtime_status_reports_missing_optional_runtime(monkeypatch):
+ monkeypatch.delenv("DECLIB_JADX_WORKER", raising=False)
+ with (
+ patch.object(JadxWorkerClient, "find_bridge_jar", return_value=Path("/bridge.jar")),
+ patch.object(JadxWorkerClient, "find_java", return_value=(Path("/java"), 21)),
+ patch.object(JadxWorkerClient, "find_jadx_runtime", return_value=(None, None)),
+ ):
+ status = JadxWorkerClient.runtime_status()
+
+ assert status["available"] is False
+ assert status["source"] == "official-jadx"
+ assert status["reasons"] == ["official JADX runtime was not found"]