A crash report comes in full of a.b.c.d() frames. Nobody can find the mapping file for that build. The one engineer who "owns the obfuscation config" is on leave. Obfuscation is one of those controls that quietly degrades the moment it becomes a manual step: a developer runs it locally before a release, forgets a flag, or skips it under deadline pressure, and the protection a banking or RASP-adjacent app depends on becomes inconsistent from build to build.
Working on mobile app protection at Protectt.ai, I've seen this pattern across teams of every size, including banks shipping apps to millions of users. Automating obfuscation inside CI/CD isn't just a convenience — it's what makes the protection trustworthy enough to reason about. This post is about how CodeProtectt does that: what it does to your bytecode, how it hooks into the build, and how the whole thing runs unattended in GitHub Actions and Jenkins.
What CodeProtectt does to your app
Before talking about pipelines, it's worth being precise about the passes, because they're often lumped together as "obfuscation." CodeProtectt runs three independent transformations, each scoped from the same checked-in codeprotectt.yaml.

What CodeProtectt actually does to your bytecodeThree independent passes, each scoped from the same checked-in codeprotectt.yaml and applied before R8 finishes minifying.Code obfuscationSymbol renamingGenerates a fresh renaming dictionaryevery build and feeds it to R8, whichrenames classes, methods and fields.a.b.c.d() ← LoginViewModelRandomized dictionary each build · never cachedCFOControl Flow ObfuscationAnchors injections before existingconditional branches:• genuine RASP security checks• bogus / opaque-predicate junkReal checks hide among the decoys.insertionFrequency · maxInjectionPerMethodSLOString Literal ObfuscationReplaces eligible string literals witha call to a generated decryptor (XOR,4 variants + shard vaults).LDC "key" → D.d(blob, slot, cipher)Skips reflection & format stringscodeprotectt.yamlenabled · obfuscationType · controlFlowObfuscation {inclusion / exclusion} · per-module basePackageNo seed — every build is randomizedCodeProtectt uses no seed at all. Each build gets a randomized dictionary; CFO randomizes where its bogus injections landand SLO randomizes its string encryption — so no two builds share a layout, and there is nothing to store, rotate, or leak.
The three CodeProtectt passes: code obfuscation, CFO, and SLO, all driven by codeprotectt.yaml
Code obfuscation (symbol renaming). CodeProtectt generates a different, randomized renaming dictionary for every build and hands it to R8, which renames classes, methods, and fields. This is the layer that turns LoginViewModel.validate() into a.b.c.d(). Crucially, the dictionary is randomized fresh each build and the generation task is deliberately never cached — there is no seed anywhere, so nothing to leak, reuse, or reproduce.
CFO — Control Flow Obfuscation. This is not control-flow flattening into a dispatcher. CFO anchors injections before existing conditional branches in a method and inserts two kinds of code: genuine RASP-style security checks (debugger detection, integrity checks, and similar) and bogus, opaque-predicate junk. The real checks hide among the decoys, so an attacker reading the disassembly can't cheaply tell which branch matters. It's scoped and tunable — insertionFrequency, maxInjectionPerMethod, and per-class inclusion/exclusion lists all live in the YAML.
SLO — String Literal Obfuscation. SLO removes plaintext string literals from the binary so that running strings on the APK reveals nothing useful. At build time it rewrites each eligible LDC "secret" into a call to a generated decryptor — roughly push blobId; push slot; LDC <cipher>; INVOKESTATIC decryptor.d(...), where slot indexes into the shard vault holding the keystream pieces for that blob — and emits the decryptor and shard-vault classes alongside your code. The scheme is a lightweight XOR with four keystream variants and two shards, and the encryption is randomized independently on every build, so the same literal encrypts to different bytes each time. It deliberately skips reflection strings and format strings: if code looks up a class or method by a string that SLO has encrypted, the lookup resolves the ciphertext at runtime and fails, so anything reflective has to stay on the exclusion list.
Where obfuscation sits in the pipeline
The first shift is architectural: the obfuscation policy — codeprotectt.yaml, ProGuard/R8 rules, keep-rules — needs to live in version control next to the source it protects, not on a build engineer's laptop. When the policy is code, it gets reviewed in pull requests, diffed across releases, and rolled back like any other change.
CodeProtectt follows this pattern directly. It ships as a Gradle plugin (ai.protectt.plugin.codeprotectt) that hooks into Android's own R8 task graph: any minify*WithR8 task automatically gains a dependsOn its generation task. Obfuscation runs as a first-class step of the standard release build rather than a bolt-on script.

CodeProtectt hooks into the Gradle Build ProcessAny minify*WithR8 task automatically dependsOn CodeProtectt's generation task — obfuscation is part of the standard release build, not a bolt-on script.CompilecompileReleaseKotlin→ .class bytecodegenerateCodeProtectt<Variant>CodeProtectt Gradle plugin · never cached1 · Code (symbol) obfuscationFresh renaming dictionary + ProGuard rules for R82 · CFO — Control Flow ObfuscationInject security checks + bogus predicates at branches3 · SLO — String Literal ObfuscationEncrypt literals; emit decryptor + shard classesminifyReleaseWithR8Shrinks + renames usingCodeProtectt's dictionaryPackageAPK / AABSignRelease keystoreMapping artifacts(archive, never ship)mapping.txtR8 symbol mapobfuscation-mapping.txtCodeProtectt map+ readable reportdependsOnConfig lives in a checked-in codeprotectt.yaml — reviewed in pull requests, diffed across releases, rolled back like any other change.Debug builds skip minify for readable stack traces; release & staging always run the full chain.
CodeProtectt's generation task runs between compilation and minifyReleaseWithR8, which dependsOn it; R8 then renames using CodeProtectt's dictionary and emits the mapping artifacts
That dependsOn is the whole point. Because the plugin binds itself onto the minify task, there's no code path that produces a release artifact which skipped obfuscation. A build engineer can't forget a flag under deadline pressure — the only release build that exists is the protected one. Debug builds skip minification for stack-trace friendliness; release and staging builds never do, and the branch is on build type, not a developer-toggled switch.
For banking and RASP-adjacent apps specifically, it's worth gating the whole thing behind the same approval and artifact-signing controls as the rest of the security pipeline, so an obfuscated binary can't be swapped for an unobfuscated one between the point it passes CI and the point it ships.
No seed to manage — every build is randomized
Most obfuscators rename symbols using a dictionary and a seed, which turns automation into a key-management problem: where does the seed come from, is it checked into the repo, does it change between builds, and what happens when it leaks? A fixed seed hands an attacker who reversed one release a head start on every future one; a rotated seed has to be stored, injected, and guarded like a signing key.
CodeProtectt sidesteps the whole question: there is no seed. Each of the three passes randomizes itself independently on every build.
Code obfuscation generates a different, randomized renaming dictionary for each build — no two builds share a naming scheme.
CFO builds its injection logic so the bogus, opaque-predicate code lands in different places on every build. No two builds share a control-flow decoy layout.
SLO randomizes its string encryption per build, so the same literal encrypts to different bytes each time.
The payoff is exactly what a rotated seed is supposed to buy you, without the key management: an attacker who fully maps one release gets no head start on the next, because the naming, the decoys, and the ciphertext all move. And because there is no seed or shared secret anywhere in the toolchain, there is nothing to check in, rotate, inject through CI secrets, or leak in a build log. The one thing you give up is byte-identical rebuilds — which matters less than it sounds, and is what the next section is about.
Reproducibility: knowing what shipped
Reproducibility here doesn't mean "the same build produces byte-identical output forever" — with a freshly generated dictionary each run, it won't, by design. It means you can answer, for any release already in the field, exactly what naming, control-flow, and encryption decisions were made, without reverse-engineering the binary to find out. That's what makes a crash report or a security incident on a three-month-old release actionable.
The mechanism is the mapping artifact. CodeProtectt writes an obfuscation-mapping.txt and a human-readable obfuscation report for every build, alongside R8's own mapping.txt. Treat all three the way you'd treat a signing key or a symbol-server upload: archive them per release, tie them to a build number, and keep them out of the artifact that actually ships. When a production crash needs de-obfuscating six weeks later, the mapping file — not a re-run of the build — is what makes that possible.
To keep the toolchain itself from drifting, pin the CodeProtectt plugin version, AGP, and JDK to exact versions, not ranges. A minor R8 or plugin bump can change how symbols are renamed or how SLO packs its blobs.
Running it in GitHub Actions
Here's the shape of a CodeProtectt build in GitHub Actions, close to how our own SDK repo runs it: a self-hosted runner inside a pinned builder container, with obfuscation folded into the build step rather than sitting as a skippable job.
CodeProtectt in GitHub ActionsSelf-hosted runner inside a pinned builder container. Obfuscation is a dependency of the build step — never a separate job that can be skipped.TRIGGERSpull_request → developbuild + unit testsrelease: publishedfull build + SAST + publishworkflow_dispatchmanual: Build | Security_Scanruns-on: [self-hosted, Linux, X64] · container: builder-android-global:v1.0.0Checkoutactions/checkoutsafe.directorychmod +x gradlewCache Gradle~/.gradle/caches~/.gradle/wrapper(generate task staysun-cached by design)Build./gradlewassembleReleaseminifyReleaseWithR8pulls in CodeProtectt:code obf · CFO · SLOTest:codeprotectt:testrun against theobfuscated outputSASTFortify scannersourceanalyzer→ .fpr reportPublish / Archivegradlew publish → Mavenupload-artifact:mapping.txt+ obfuscation reportretained per releaseWhy "dependsOn", not a stageBecause the plugin wires itself onto minify*WithR8, there is no way to produce a release artifact that skipped obfuscation.A build engineer can't forget a flag — the only build that exists is the protected one. Seeds & signing keys come from Actions secrets.
GitHub Actions pipeline: triggers, self-hosted runner in a builder container, then checkout, cache, build (CodeProtectt via R8), test, Fortify SAST, and archive of the mapping artifacts
name: CI - Release
on:
release:
types: [ published ] # full build only on a published release
jobs:
build:
# self-hosted runner, pinned builder image — reproducible toolchain
runs-on: [ self-hosted, Linux, X64 ]
container:
image: containers.git.protectt.ai/mobile-sdk/builder-android-global:v1.0.0
credentials:
username: ${{ github.actor }}
password: ${{ secrets.github_token }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build prerequisites
run: |
git config --global --add safe.directory '*'
chmod +x ./gradlew
# Cache Gradle deps — note CodeProtectt's generate task opts out
# of caching itself, so the dictionary is still fresh each run
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
# assembleRelease triggers minifyReleaseWithR8, which dependsOn
# the CodeProtectt generate task: code obf + CFO + SLO all run here
- name: Build protected release
run: ./gradlew assembleRelease
- name: Run tests
run: ./gradlew :app:testReleaseUnitTest
# Mapping artifacts are a release deliverable — archived, never shipped.
# retention: 400 days — see gotcha below the snippet
- name: Archive mapping + obfuscation report
uses: actions/upload-artifact@v4
with:
name: codeprotectt-mapping-${{ github.ref_name }}
path: |
app/build/outputs/mapping/release/mapping.txt
app/build/outputs/codeprotectt/**
retention-days: 400
One gotcha worth calling out: GitHub Actions caps artifact retention at 400 days, and most orgs default to 90 — raise it explicitly or your mapping files will silently age out before the release they belong to does.
Two things carry over from our real workflows and are worth keeping. First, a separate static-analysis job runs a Fortify SAST scan against codeprotectt/src and uploads the .fpr as an artifact — obfuscation protects the shipped binary, SAST protects the source. Second, pull-request builds run the unit tests on every PR into develop, while the full build-and-publish only fires on a published release. The obfuscation itself is identical across all three triggers because it's attached to the build, not the workflow.
Running it in Jenkins
Nothing about CodeProtectt is GitHub-specific. The protection contract lives in Gradle, so any orchestrator that calls assembleRelease inherits it unchanged.

The same build in a Jenkins declarative pipelineDifferent orchestrator, identical contract: ./gradlew assembleRelease runs CodeProtectt through R8, and the mapping never ships.agent { label 'android-builder' } · tools { jdk 'temurin-17' } · pinned CodeProtectt plugin versionCheckoutSCM + codeprotectt.yamlBuildgradlew assembleReleasecode obf · CFO · SLO via R8Testunit + instrumentationSASTFortify in docker agentSign & ArchivearchiveArtifacts mapping.txt+ obfuscation reportenvironment { }KEYSTORE = credentials('release-keystore')no obfuscation seed — every build is randomizedpost { }success → archive mapping + report, tie to BUILD_NUMBERfailure → surface the de-obfuscated stack trace in the logThe orchestrator is interchangeableGitHub Actions, Jenkins, GitLab CI, Bitrise — the protection contract is identical because it lives in Gradle, not the YAML:• The plugin binds to minify*WithR8, so any orchestrator that calls assembleRelease gets obfuscation for free.• Policy is versioned in codeprotectt.yaml; the plugin version is pinned; the generate task is never cached.• The mapping artifacts are archived per release and kept out of the shipped binary, wherever the CI runs.Moving CI vendors changes the wrapper, not the guarantee.
Jenkins declarative pipeline with the same stages — checkout, build, test, SAST, sign and archive — plus credentials and post blocks
pipeline {
agent { label 'android-builder' }
tools { jdk 'temurin-17' }
environment {
// pulled from Jenkins' credential store, never printed.
// note: CodeProtectt has no seed — every build randomizes itself,
// so there's no obfuscation secret to inject here, only the keystore
RELEASE_KEYSTORE = credentials('release-keystore')
}
stages {
stage('Checkout') {
steps { checkout scm } // brings codeprotectt.yaml with it
}
stage('Build') {
// same one line as GitHub Actions — CFO + SLO + code obf via R8
steps { sh './gradlew clean assembleRelease' }
}
stage('Test') {
steps { sh './gradlew :app:testReleaseUnitTest' }
}
stage('SAST') {
steps { sh './scripts/fortify-scan.sh' }
}
}
post {
success {
// archive mapping + report, tied to this build number
archiveArtifacts artifacts: 'app/build/outputs/mapping/release/mapping.txt, app/build/outputs/codeprotectt/**',
fingerprint: true
}
}
}
The stages line up one-to-one with the Actions version because they're doing the same work; only the wrapper changes. Moving CI vendors changes how you invoke Gradle and where secrets come from — it doesn't change what protection ships.
Performance gating: the honest gap
Obfuscation has a cost — SLO adds a decryption call every time an encrypted string is used, CFO inflates method size as it injects — and it creeps up quietly as rules grow, because nothing fails loudly when it does. The right answer is the one teams already apply to test coverage or bundle size: capture build time and APK size on every CI run, and fail the build (or flag the PR) when a metric regresses past a threshold, measured on the protected build.
I'll be honest about where CodeProtectt is rather than pretend it's solved: the plugin has the scaffolding to track build metrics internally, but that data isn't yet wired into a CI gate. Until it is, the cost is being absorbed silently, one release at a time, by whoever notices the build got slower. When cost does bite, the fix is scoping in codeprotectt.yaml, not turning protection off — apply SLO to sensitive strings and CFO to security-critical classes via the inclusion lists, and leave the hot paths alone.
The checklist
CodeProtectt runs as part of
minify*WithR8viadependsOn— obfuscation can't be skipped for a release buildThere is no seed anywhere — every build randomizes independently (different dictionary, different CFO injections, different SLO ciphertext); nothing to store or leak
Mapping and report artifacts are archived per release and kept out of the shipped binary
Performance gating is the known gap — track build metrics now, gate on them next
The goal of automating obfuscation isn't to remove a manual step. It's to make the protection consistent, auditable, and cheap enough to run on every single release without anyone having to think about it.