Dart Obfuscator
A command-line tool that obfuscates Dart (including Flutter) source code by renaming declarations and their references using the Dart analyzer.
It is intended for preparing code to be shared as private packages or distributed in source form while reducing readability: classes, mixins, enums, extensions, typedefs, constructors, methods, fields, enum constants, named parameters and local variables are renamed, and comments are stripped.
The tool works on copied project sources — the originals are never touched — and produces an obfuscated copy laid out exactly like the input, so a multi-package project keeps building the way it did before.
Parts of the input can be excluded from obfuscation. Naming a package or a directory
with --clear keeps its declarations and comments as written while still rewriting
its references to everything that was renamed, which is how one package of a
workspace can be delivered readable and the rest obfuscated.
How this project is developed
This started as a hand-written project. The releases up to and including 0.0.1+3
were authored by a person.
Development from 0.1.0 onward is AI-developed, at the maintainer's direction —
that covers the rewrite of the rename engine around resolved element identity,
--clear, the merged library package, the test suite, and the fixes listed in the
changelog. The source, its comments and its tests were produced that
way.
It is worth knowing when reading the code or weighing how much to trust it. The project leans on mechanical verification rather than on authorship as evidence: the output of a run has to analyse with the same diagnostics as its input, it has to build, and the test suite asserts on the constructs the rename engine has to reason about. If you are evaluating this tool for your own sources, reproduce that check on your own project — see Best practices — rather than taking the documentation at its word.
Table of contents
Features
- Parse and resolve Dart source code using the Dart analyzer.
- Discover declarations (classes, mixins, enums, extensions, extension types, typedefs, constructors, methods, getters/setters, fields, enum constants, named parameters, local variables and local functions).
- Rename every declaration together with all of its references, including named
argument labels, initialising formals (
this.value), super parameters (super.value), import prefixes,show/hidecombinators and documentation comment references. - Keep names which are constrained to match: overrides, interface implementations, mixin applications, field/accessor pairs, redirecting factories, the variables of a logical-or pattern and the branches of conditional imports are renamed as one, and a single member declared outside of the run keeps the whole group as written.
- Strip comments from obfuscated sources, preserving
// ignore:and other analyzer and formatter directives. - Resolve transitive relative
path:dependencies, so naming one entry point package is enough to obfuscate a whole application. - Reproduce the input directory layout, including pub workspaces, assets and
partfiles, so the output builds with the same commands as the input. - Exclude declarations from obfuscation by annotation, by identifier, or by
location (
--clear). - Emit a mapping of every renamed identifier and a report of every retained one.
Quick start
1. CLI Usage
1.1 Install the package:
dart pub global activate obfuscator
1.2 Run the obfuscator:
Point --src at the package to obfuscate and --out at a directory to write to.
Relative path: dependencies are resolved and included automatically, so an
application's entry point package is usually the only one worth naming.
obfuscator --src="/path/to/app" --out="/path/to/output"
1.3 Verify the result:
cd /path/to/output/source && flutter analyze
The output has to report the same diagnostics as the input. A new error means a reference was missed — see Troubleshooting.
2. Run From Source
2.1 Git clone:
git clone https://github.com/ljmatan/obfuscator
2.2 Run the obfuscator:
dart run bin/obfuscator.dart --src="/path/to/app" --out="/path/to/output"
Usage
Run the main entrypoint bin/obfuscator.dart, or the installed obfuscator
executable. Run with --help for the generated usage text.
Required arguments
-
--src— comma-separated list of package directories to obfuscate. Each path must hold apubspec.yamlfile.--src /home/user/projects/app,/home/user/projects/shared_package -
--out— output directory. The location is emptied at the start of every run, so it must be a dedicated directory.--out /home/user/obf-output
Optional arguments
-
--pub— comma-separated annotation or declaration identifiers which keep their names. Naming a type keeps the identifiers of its members too, which is what makes the argument usable for an enum whose constant names are persisted or sent over the wire.Always includes the
NoObfuscationannotation provided by this package, along with the reserved runtime identifiers listed below.--pub NoObfuscation,CacheEntry -
--clear— comma-separated package identifiers or paths whose declarations and comments are kept as written. Paths may be absolute, relative to the working directory, or relative to the resolved source root.--clear packages/my_plugin,packages/core/lib/src/models -
--root— directory the copied layout is anchored to, so that relativepath:dependencies keep resolving. Defaults to the longest shared parent directory of all resolved packages. -
--seed— integer seed for the replacement name generator. The same input and seed produce the same output, which makes deliveries reproducible and diffable. -
--name-length— character count of the generated identifiers. Defaults to10.
Flags
Every flag may be negated with a --no- prefix.
| Flag | Default | Effect |
|---|---|---|
--follow-path-deps |
on | Include the transitive relative path: dependencies of the requested packages. |
--strip-comments |
on | Remove comments from obfuscated sources. Cleartext locations keep theirs. |
--rename-locals |
on | Also rename local variables, local functions and positional parameters. |
--format |
on | Format the rewritten sources with dart_style. |
--pub-get |
on | Resolve the copied sources' dependencies before analysis. |
--merge |
off | Additionally emit a merged library package the applications import. See below. |
Examples
Obfuscate an application and everything it depends on by relative path:
dart run bin/obfuscator.dart --src ./packages/my_app --out /tmp/obf
Obfuscate the shared packages of a workspace while delivering one plugin readable:
dart run bin/obfuscator.dart \
--src ./packages/my_app \
--out /tmp/obf \
--clear packages/my_plugin \
--pub CacheEntry \
--seed 7
Obfuscate a single package, keeping annotated declarations and its comments:
dart run bin/obfuscator.dart --src ./app --out ./out --pub NoObfuscation,Keep --no-strip-comments
Example obfuscated code for various projects can be found in the output directory:
https://github.com/ljmatan/obfuscator/tree/main/output
How it works
- Resolve: The requested packages are read, and their transitive relative
path:dependencies are followed. The longest shared parent directory becomes the source root. - Copy: Every package is copied to
<out>/source/<path relative to the source root>, preserving the original layout. Build artefacts and tooling state are skipped, symbolic links are followed and copied in place,path:dependencies are rewritten to resolve within the copy, and a workspace rootpubspec.yamlis generated for the packages declaringresolution: workspace. Directories holding no sources are cloned copy-on-write where the filesystem supports it, so a shared asset payload costs no disk space per run. - Resolve dependencies:
pub getruns for the copy, so the analyzer sees the same resolution the original project does. - Analyse: Every Dart file of every copied package is resolved with the Dart
analyzer. The files are enumerated by walking the copy, and any
analyzer: exclude:entries are lifted for the duration of the run, because a file excluded from analysis is still compiled — and a declaration left as written while its references are renamed breaks the build without any diagnostic. The manifests are restored before the run ends. - Collect: One pass over each compilation unit records every identifier occurrence together with the element it resolves to, and the subset of those occurrences which declare something renameable.
- Group: Declarations which the language requires to share a name are joined into one group — overrides, interface members, mixin members, field/accessor pairs, initialising and super formals, the named parameters of a group of executables, redirecting factory parameters, and the branches of a conditional import or export. A group containing anything declared outside of the run, marked as public API, or sitting in a cleartext location keeps its original name.
- Rename: Each remaining group is assigned a generated identifier which
preserves privacy (a
_-prefixed name stays private) and case convention, and which collides with no identifier surviving in the output. - Rewrite: All occurrences are applied per file, back to front, after verifying the file contents still match what was analysed. Comments are then stripped from obfuscated locations, and the result is formatted.
Generated outputs
<out>/source/…— the obfuscated project, laid out like the input.<out>/mappings.json— every renamed identifier, what it became, where it was declared, and how many occurrences were rewritten.<out>/retained.json— every identifier which kept its name, and why.<out>/merged/…— only with--merge; see the merged library package.
What is never renamed
- The reserved runtime identifiers
main,call,toJson,fromJson,toString,hashCode,noSuchMethodandruntimeType. Each of these may be reached without a static reference —mainby the tooling,callthrough aFunction-typed value,toJsonbydart:convert, and theObjectprotocol members throughdynamic. - Operators, which the language resolves by symbol.
- Unnamed constructors, which have no identifier to rename.
- Anything declared outside of the run, including SDK and third-party members,
along with every declaration constrained to match them. This is what keeps
build,initState,createStateand the rest of the Flutter contract intact. - Named parameters written as part of a function type — a
typedef, a function-typed field, or a closure. Those names are matched structurally against any conforming function, including ones outside of the run. - The field names of a record pattern, for the same reason: a record's fields are
matched structurally, so the name describes the type being destructured rather than
a declaration of the run. Object patterns are renamed, including the shorthand
case Foo(:final id), where the one token both names the field and declares the variable. - JSON keys, route identifiers, and every other string literal. The tool renames identifiers only; it never edits string contents. Wire formats are unaffected.
- Anything a build regenerates: a Dart file below
.dart_toolorbuild, and the localisation sources of a package declaringflutter: generate: true. A generated file committed tolib— a.g.dartfrombuild_runner, say — is a source like any other and is renamed; run the generator before obfuscating, never after. - The named parameters of a function referenced as a value, since a torn-off function is matched structurally against the function type it is assigned to.
- Declarations marked with
@NoObfuscation()or named with--pub. - Declarations in a
--clearlocation.
Keeping part of a project readable
--clear takes package identifiers and paths:
--clear packages/my_plugin # a directory and everything below it
--clear my_plugin # a package, by pubspec name
--clear core/api/lib/src/models # a subdirectory of a package
A cleartext location keeps its declarations, its comments and its formatting. Its references to obfuscated declarations are still rewritten, because the sources have to keep compiling.
Two consequences are worth knowing about:
- Cleartext code pins what it overrides. A cleartext class overriding an
obfuscated method forces that method to keep its name, in every class of the
hierarchy. Check
retained.jsonif more names survived than expected. - Cleartext code shows the obfuscated names it calls.
--clearreduces what the obfuscated packages reveal about themselves; it does not hide that they are used.
The merged library package
--merge additionally emits a single obfuscated library, which is what makes the
output distributable as one private package:
<out>/merged/
merged_app/ every obfuscated library of the run
pubspec.yaml their external dependencies, assets and fonts
lib/merged.dart all of their declarations, in one file
assets/ fonts/ the asset files they declared
<package>/ one directory per retained package
pubspec.yaml depends on ../merged_app by path
lib/ web/ … imports rewired to package:merged_app/merged.dart
Two kinds of package are not merged, and import the merged library instead:
- Applications, recognised by their
lib/main.dart. - Cleartext packages, named with
--clear. They keep their own name, assets and readable sources.
That combination is how a plugin is delivered as readable source over a merged, obfuscated framework:
dart run bin/obfuscator.dart \
--src ./packages/my_app --out /tmp/obf \
--clear packages/my_plugin --merge
merged/merged_app/ the framework — obfuscated, in one file
merged/my_plugin/ the plugin — readable, depends on merged_app
merged/my_app/ the application — depends on the plugin
Build it exactly as the original application was built:
cd <out>/merged/<application> && flutter build web
An application is deliberately not merged in: it selects platform implementations through conditional imports, and both branches of such an import declare the same identifiers — no single library can hold both. Libraries reached that way are copied beside the merged file with their selecting directive intact.
Rename merged_app with --merge-name.
Best practices / recommendations
- Analyse the output.
flutter analyze(ordart analyze) on<out>/sourcehas to report the same diagnostics as the original sources. Build the application as well: the compiler checks whole-program consistency that per-library analysis does not. - Lift the analysis excludes before believing the result. The copied
analysis_options.yamlfiles are handed back as they were written, so a project which hides its generated sources from linting hides them from your check too — while the compiler still reads them. Delete theanalyzer: exclude:entries in the output before analysing it, or a broken generated file reads as clean. - Watch for identifiers which leave the program. Renaming changes anything
derived from a declaration name at runtime —
Enum.nameused as a cache key or a request field, aType.toString()used as a map key that is also persisted. Keep those with--pubor@NoObfuscation();retained.jsonandmappings.jsonmake it easy to audit which names moved. - Annotate declarations reached without a static reference: platform channel
handlers, anything invoked on a
dynamicreceiver, anything a code generator expects by name. - Pass
--seedfor reproducible deliveries, so two runs of the same sources can be diffed. - Keep the mapping files. They are the only way back from an obfuscated stack trace, and they should not be delivered alongside the sources.
Limitations & caveats
-
Not a security barrier: source obfuscation increases the effort needed to understand the code, but it is not a substitute for licensing, access control, or binary-level protection. String literals, including user-visible text and API paths, stay as written.
-
Dynamic dispatch: a member invoked on a
dynamicreceiver cannot be resolved statically, so its call site is not rewritten. Analysis will not report this; test the obfuscated build. -
Reflection:
dart:mirrors,reflectable, and string-based lookups are not understood. Annotate the declarations they reach. -
Generated code:
partfiles are rewritten along with everything else, so generated code stays consistent — but re-running the generator against obfuscated sources will not reproduce the same output. Generate first, obfuscate after. -
Tear-offs onto external function types: assigning a renamed method to a function type declared outside of the run breaks the match. The compiler reports it; keep the method with
--pub. -
The merged library package (
--merge) requires each application to be distinguishable by itslib/main.dart; a package whose entry point sits elsewhere is merged in, and has to be named with--clearto be kept out. -
Ambiguous imports in a merged output. Separate libraries may each import a different package declaring the same name —
dart:uiandlatlong2both declarePath, for instance. That is fine in separate files and ambiguous once they share one scope. The run reports every such identifier and the libraries declaring it; keep one of the packages using them readable with--clear, or leave that input unmerged. The source tree output is unaffected.An extension declared outside of the run cannot be repaired that way: its members are reached without naming it, so no prefix reaches them. Those are reported separately, and
--clearon a package using them is the only fix. -
A prefix naming both a merged and a retained library. Importing a merged package and an external one under the same prefix leaves the references into the merged package unrepairable — the prefix has to stay for the import which survives. The run reports it; give one of the two a prefix of its own.
-
A nested package which the run does not include. An application sitting inside a library that the run pulled in is not obfuscated, so it is left out of the output rather than delivered referencing renamed identifiers. Name it with
--srcto have it obfuscated alongside. -
Comment stripping can surface
unused_import. An import whose only reference was from a documentation comment becomes genuinely unused. A warning, never an error. -
Legal: ensure you have the right to obfuscate and distribute the sources, and note that stripping comments also strips licence headers.
Security & legal considerations
- Keep obfuscation mappings confidential; they de-obfuscate the delivered sources in full.
- Obfuscation is not encryption. To protect intellectual property, also employ legal safeguards: licensing, access control, and restricted repositories.
- Verify the license compatibility of third-party code before obfuscating and redistributing.
Troubleshooting
-
A new analysis error in the output. A reference form was missed.
mappings.jsongives the original name of the identifier the error mentions; the surrounding syntax names the construct to look at. Re-run with--clearon the affected directory to unblock a delivery. -
More names retained than expected. Read
retained.json: each entry records why. The common causes are a cleartext or annotated declaration pinning the whole override group it takes part in, and a member which implements something declared by the SDK or a third-party package. -
Runtime behaviour changed but the build is clean. Look for identifiers that leave the program: enum constant names used as storage keys or request fields,
Type.toString()values compared against persisted data, and dynamic invocations. Keep them with--pub. -
pub getfailed for the copied sources. The copy resolves from scratch, so a dependency reachable only through apubspec.lockfile or a path outside the source root will not resolve. Pass--rootto widen the copied layout. -
Nothing was obfuscated. Check the package list the run prints: a package matched by
--clearis reported ascleartext.
Tests
dart test
The suite builds synthetic multi-package projects in a temporary directory, runs the
CLI against them, and asserts on the rewritten sources, the generated manifests and
the two reports. It covers the constructs the rename engine has to reason about —
override chains, field and accessor pairs, initialising formals, named parameters,
function types, enums whose constant names are persisted, operators, import prefixes,
annotations, part files and conditional imports — along with both delivery shapes
and the flags. Each fixture is also analysed with the Dart analyzer, so a missed
reference fails a test rather than a delivery.
Contributions
If you encounter a failure or incorrect obfuscation result, please file a report on the GitHub issue tracker with:
- The error or stack trace (if any)
- A short code sample reproducing the issue
- The command you used (with arguments)
Your report helps improve the reliability of future releases.
License
The project is published with MIT license.
See the LICENSE file for details.