Current supported target scope:
- Flutter AOT Android ARM64 binaries
- static analysis only
Inputs:
- APK
libapp.so
If you want the shortest path:
- run without installing:
nix run - install persistently with Nix:
nix profile install - install a release binary
From GitHub:
nix run github:caverav/flutterdec -- --help
nix run github:caverav/flutterdec -- info ./sample.apk --json
nix run github:caverav/flutterdec -- decompile ./sample.apk -o ./outFrom a local checkout:
nix run . -- --help
nix run . -- info ./sample.apk --json
nix run . -- decompile ./sample.apk -o ./outInstall from GitHub:
nix profile install github:caverav/flutterdec
flutterdec --helpInstall from a local checkout:
nix profile install .
flutterdec --helpUpdate later:
nix profile upgrade flutterdecCurrent prerelease: v0.1.0-alpha.4
The archive is a prefix, not a lone binary: it holds bin/flutterdec plus the compatibility
registry, the runtime profiles and the packaged producer under share/flutterdec. The CLI finds
that data relative to its own executable, so copy bin and share to the same prefix and keep
them together. A binary moved out on its own reports which directories it looked in and stops.
Linux x64:
curl -fLO https://github.com/caverav/flutterdec/releases/download/v0.1.0-alpha.4/flutterdec-v0.1.0-alpha.4-Linux-X64.tar.gz
mkdir -p flutterdec-v0.1.0-alpha.4
tar -xzf flutterdec-v0.1.0-alpha.4-Linux-X64.tar.gz -C flutterdec-v0.1.0-alpha.4
sudo cp -R flutterdec-v0.1.0-alpha.4/bin flutterdec-v0.1.0-alpha.4/share /usr/local/
flutterdec --helpmacOS arm64:
curl -fLO https://github.com/caverav/flutterdec/releases/download/v0.1.0-alpha.4/flutterdec-v0.1.0-alpha.4-macOS-ARM64.tar.gz
mkdir -p flutterdec-v0.1.0-alpha.4
tar -xzf flutterdec-v0.1.0-alpha.4-macOS-ARM64.tar.gz -C flutterdec-v0.1.0-alpha.4
sudo cp -R flutterdec-v0.1.0-alpha.4/bin flutterdec-v0.1.0-alpha.4/share /usr/local/
flutterdec --helpOther platforms and future tags:
Install into the user Cargo bin:
nix develop -c cargo install --path crates/flutterdec-cli
~/.cargo/bin/flutterdec --helpRun from source without installing:
nix develop -c cargo run -p flutterdec-cli -- --help
nix develop -c cargo run -p flutterdec-cli -- info ./sample.apk --json
nix develop -c cargo run -p flutterdec-cli -- decompile ./sample.apk -o ./outBuild a local release binary:
nix develop -c cargo build -p flutterdec-cli --release
./target/release/flutterdec --helpflutterdec uses two directories, and neither one depends on your current directory.
Read-only package data holds the compatibility registry (adapters/registry.json), the runtime
profiles (data/*.json), and the checked-in reference producer. It is resolved from the location of
the binary itself, first match wins:
FLUTTERDEC_DATA_DIR, if set. An explicit override never silently falls back: if it holds noadapters/registry.json, the command fails and says so.<binary>/../share/flutterdec, which is where the Nix package and any normal prefix install put it.<binary>, for a flat unpacked distribution.<binary>/../.., which is wherecargo buildleaves a binary inside a checkout.
The writable adapter store holds installed adapters and the store's own state file. It is:
FLUTTERDEC_ADAPTER_STORE, if set.- otherwise
$XDG_DATA_HOME/flutterdec/adapters, or$HOME/.local/share/flutterdec/adapterswhenXDG_DATA_HOMEis unset.
The local symbol cache used by map-symbols --register-local-cache follows the same rule under
FLUTTERDEC_SYMBOL_CACHE or <data home>/flutterdec/symbols.
So a release binary works from any directory, installing an adapter never dirties a checkout, and the
same install is visible to info, decompile, diff and adapter list in the same environment. If you
want a throwaway store, point FLUTTERDEC_ADAPTER_STORE at a temporary directory.
Install once per snapshot hash, from anywhere:
flutterdec info ./sample.apk --json # read snapshot_hash
flutterdec adapter install --dart-hash <HASH>
flutterdec adapter listThe compatibility registry is the only install authority. adapter install refuses a hash it has no
record for, a record that does not serve this host, and any artifact whose bytes do not match the digest
and size the record declares. It publishes one file into the store atomically, is safe to run
concurrently, and reports already-installed when the store already holds exactly that install. A
failed install leaves nothing behind.
adapter list reports a verified state per record rather than whether a file exists:
| state | meaning |
|---|---|
verified |
installed, present, and byte-for-byte what the record declares |
missing |
the store records the install but the artifact file is gone |
corrupt |
the artifact is present but is not what the record declares |
incompatible |
no artifact variant, or no supported protocol/model major, for this host |
unavailable |
authorized by a record, not installed |
adapter list exits 2 when any entry is missing or corrupt, so a broken store is an error rather
than a line of output. Both commands take --json.
If this is your first run, this is the shortest useful path.
- Inspect the target:
flutterdec info ./sample.apk --jsonFor APK inputs, info also reports Android startup summary fields:
android_startup_presentandroid_startup_confidenceandroid_startup_entrypoint_countandroid_startup_flutter_activity_count
If adapter metadata is available, info also reports:
app_package_counts_toprequested_backend,resolved_backend,backend_fallback_reasonproducer_id,producer_trust,compatibility_record_sha256dart_aliases(provenance labels, not selectors)registry_record_present,snapshot_identity_is_exact,identity_rejectionmodel_capabilities(per-domaincomplete/partial/unavailable)compatibility_warningsadapter_containment(which execution controls were established for the adapter process, eachappliedwith its bound orunavailablewith the reason)
- Install the adapter for the detected Dart hash:
flutterdec adapter install --dart-hash <HASH>
flutterdec adapter list- Decompile:
flutterdec decompile ./sample.apk -o ./out- Start with:
out/pseudocode/*.dartpseudoout/report.jsonout/quality.json
Inspect target metadata:
flutterdec info ./sample.apk --jsonDecompile with the default app-focused scope:
flutterdec decompile ./sample.apk -o ./outDecompile with extra artifacts:
flutterdec decompile ./sample.apk -o ./out --emit-asm --emit-irCompare two builds at recovered-function level:
flutterdec diff --old ./old.apk --new ./new.apk -o ./out-diff --jsonGenerate a Ghidra import script with recovered symbols:
flutterdec decompile ./sample.apk -o ./out --emit-ghidra-scriptGenerate an IDA import script with recovered symbols:
flutterdec decompile ./sample.apk -o ./out --emit-ida-scriptDefault scope is app-focused:
app-unknown(default): app (package:*) plus unknown ownership functionsapp: only app (package:*) functionsall: include Flutter, Dart runtime, and framework internals too
Include everything:
flutterdec decompile ./sample.apk -o ./out --function-scope allLimit output to selected app packages:
flutterdec decompile ./sample.apk -o ./out \
--function-scope app-unknown \
--app-package my_appTip: if you are not sure about package names, check report.json under function_scope.app_package_counts_top.
Decompile and disassemble one specific function:
flutterdec decompile ./sample.apk -o ./out \
--target id:42 \
--emit-asmTarget by entry address:
flutterdec decompile ./sample.apk -o ./out \
--target va:0x613468 \
--emit-asm--target accepts id:<N>, va:0x<ADDR>, 0x<ADDR>, or <N>.
When <N> matches multiple functions, the command fails and asks for explicit id: or va:.
Target mode emits only the matched function artifacts and reports selection details in report.json.target_selection.
If the match is outside current scope filters, target mode can override scope automatically for that function.
decompile profile controls analysis depth vs throughput.
Profile options:
balanced(default): best readability and semantic recoverylight: reduced analysis for faster large-scale runs
Examples:
flutterdec decompile ./sample.apk -o ./out --analysis-profile balanced
flutterdec decompile ./sample.apk -o ./out --analysis-profile lightAdapter backend options:
--adapter-backend auto(default): try r2flutter, then the Blutter bridge, then the internal adapter--adapter-backend internal: force internal adapter only--adapter-backend blutter: require Blutter bridge backend with no fallback--adapter-backend r2-flutter: require the r2flutter backend with no fallback--require-snapshot-hash-match: fail unless the snapshot identity came from a real header
Requested and resolved backend are separate facts. auto may fall back, and the
result says which backend answered and why it differed; a named backend either runs or
fails, never silently substituted. Both appear in info output and in
report.json.adapter_selection.
An adapter runs as a bounded one-shot job: a private working directory with read-only
inputs, an allowlisted environment, an overall deadline, capped output, its own process
group, and CPU, file size, address space, process count and descriptor limits, plus
network isolation where the host permits it. adapter_containment in info --json and
adapter_selection.containment in report.json say which of those were actually
established; a control the host could not establish is reported unavailable with a
reason rather than claimed.
Backend choice decides how much is actually recovered. The internal adapter carves
strings and scans prologues: it recovers code ranges with no names at all, and its
object_pool is a list of carved strings in an ordinal index space, not real pool
slots. Its model_capabilities say so: function_names: unavailable,
pool_index_space: unavailable. r2flutter and blutter parse the snapshot, so they
return real Dart names, and r2flutter can return a hardware ObjectPool.
Only a hardware pool index space lets flutterdec turn a pool[N] reference in the
disassembly into a value. With an ordinal pool, references are left unresolved on
purpose, and report.json.pool_metadata.hints_suppressed_reason explains why. Check
pool_metadata.index_space_authoritative if pseudocode has fewer string literals than
you expected.
r2flutter backend environment variables:
FLUTTERDEC_R2FLUTTER_BIN: path to ther2flutterbinaryFLUTTERDEC_R2FLUTTER_CMD: full command to launch it, when a wrapper is neededFLUTTERDEC_R2FLUTTER_TIMEOUT: per-invocation timeout in seconds (default 900)- otherwise
r2flutteris resolved fromPATH
Blutter bridge environment variables:
FLUTTERDEC_BLUTTER_CMD: full command used to launch Blutter, for examplepython3 /opt/blutter/blutter.pyFLUTTERDEC_BLUTTER_PY: direct path toblutter.py
Nix setup note:
- in
nix develop,FLUTTERDEC_BLUTTER_CMDis exported automatically to a Nix-managedflutterdec-blutterwrapper - direct wrapper invocation is available with
nix run .#blutter-bridge -- --help
Per-feature toggles:
--with-canonical-model-symbols/--no-canonical-model-symbols--with-pool-value-hints/--no-pool-value-hints--with-pool-semantic-hints/--no-pool-semantic-hints--with-semantic-reporting/--no-semantic-reporting--with-bootflow-category-seeds/--no-bootflow-category-seeds--with-apk-startup-analysis/--no-apk-startup-analysis
Decompile quality checks are controlled by:
--max-placeholder-ifs--max-unresolved-cf--max-indirect-call-ratio--min-disassembly-ratio
Example for exploratory runs:
flutterdec decompile ./sample.apk -o ./out \
--max-functions 250 \
--max-placeholder-ifs 999999 \
--max-unresolved-cf 999999 \
--max-indirect-call-ratio 1.0 \
--min-disassembly-ratio 0.0Generate direct-call target mapping from a stripped/unstripped engine pair:
flutterdec map-symbols \
--stripped ./libflutter.stripped.so \
--unstripped ./libflutter.unstripped.so \
-o ./out/symbol-map \
--register-local-cacheUse the cached mapping in later APK decompile runs:
flutterdec decompile ./sample.apk -o ./out \
--extra-symbol-elf ./libflutter.unstripped.soWhen the cached build id matches the APK's embedded libflutter.so, decompile auto-loads the registered target summary and reports the match under report.json.engine_symbol_ingestion.
Main output artifacts:
pseudocode/*.dartpseudoreport.jsonquality.jsonasm/*.sif--emit-asmir/*.jsonif--emit-irghidra_apply_symbols.pyif--emit-ghidra-scriptida_apply_symbols.pyif--emit-ida-scriptdiff_report.jsonif you runflutterdec diff
Most users should start by reading:
pseudocode/*.dartpseudoreport.json.android_startupquality.json
A register whose binding the decompiler could not carry through a control-flow merge renders as a bare
regN. Where the value it held is known, flutterdec appends it as a comment rather than substituting it,
so the comment never changes what the code says. Four forms appear, and the distinction between the first two
is the one worth learning:
| form | meaning |
|---|---|
regN /* = a | b */ |
exhaustive. Every path reaching this merge contributed a value, and they are all listed. regN is one of these. |
regN /* possible (non-exhaustive): a | b */ |
incomplete. At least one incoming path contributed no usable value, so the real value may be something not listed. |
regN /* loop-entry value: a | b */ |
the value held on entry to the loop, one per entry arm - several when the loop has more than one entry arm and they disagree. The value on the back edge is never shown, and is usually different. |
regN /* value before this call: a */ |
the value held immediately before the adjacent call, which clobbered the register. Not the value after it. Always a single value. |
Expect the incomplete form to dominate, and expect the call form to be rare. Measured on LocalSend 1.17 at default scope: 917 non-exhaustive, 246 exhaustive, 148 loop-entry, 3 pre-call. Values are listed in ascending predecessor order, so the order is stable across runs.
Two properties are worth relying on:
- The annotations are comments and nothing else. Strip every one and you get byte-identical output to a build without them. They do not participate in naming, aliasing or type inference, so they cannot make the surrounding code wrong.
- A listed value was actually observed on the path it is attributed to, not guessed from context. Values the analysis could not attribute are omitted rather than approximated, which is why the non-exhaustive form exists and is the more common of the two.
Absence of an annotation means the value was not recovered, not that the register is unimportant. Most bare
regN occurrences carry no annotation: see docs/research-pseudocode-quality.md R29 for the measured
coverage per site and its limits.