Can Someone Steal Your Code From an APK? What Reverse Engineering Really Exposes

Ever wondered what someone actually gets when they download your Android app and pull it apart? The uncomfortable truth: every APK can be decompiled. The reassuring truth: that…

Ever wondered what someone actually gets when they download your Android app and pull it apart? The uncomfortable truth: every APK can be decompiled. The reassuring truth: that does not mean your source code and project are up for grabs. The gap between those two statements is where all the interesting engineering lives.

This post walks through what a reverse engineer really sees inside a release APK, what modern obfuscation does and doesn't hide, and a practical, layered checklist to make reverse engineering (RE) so expensive that nobody bothers.

First, what actually ships inside an APK?

An APK is just a ZIP file. Rename it to .zip, unzip it, and you get a predictable structure. Each piece exposes a different amount of information — and some of it is readable with zero effort.

flowchart TD
    A["app-release.apk"] -->|"unzip / apktool"| B["APK contents"]
    B --> C["AndroidManifest.xml"]
    B --> D["resources.arsc + res/"]
    B --> E["classes.dex"]
    B --> F["lib/*.so"]
    B --> G["assets/"]

    C -->|"aapt2 dump"| H["Permissions, components,<br/>package name, metadata"]
    D -->|"readable as-is"| I["Every UI string & label"]
    E -->|"jadx"| J["Decompiled Java"]
    E -->|"strings"| K["Every string literal"]

    J --> L{"Obfuscated?"}
    L -->|"Yes (R8 release build)"| M["Gibberish names —<br/>logic still readable"]
    L -->|"No (debug build)"| N["Near-original source"]

The key insight from that diagram: the manifest, the resources, and every string literal are readable no matter what. Obfuscation only touches your code (the .dex). So before we even talk about decompilers, a huge amount leaks for free.

The 30-second attack: strings and jadx

You don't need to be an expert. Two tools cover 90% of a first pass:

  • strings (or a two-line Python script) dumps every text literal in the DEX. Log messages, JSON keys, URLs, custom protocol names, error text — all of it.
  • jadx turns the DEX back into browsable Java in one click.

Here's the humbling part. This is a Kotlin data class you wrote:

data class WifiCredentials(
    val ssid: String,
    val password: String,
    val security: String,
)

Kotlin auto-generates a toString() for it. After R8 obfuscation, the class becomes d00 — but the generated toString still contains the literal field names, in plain text, in the DEX:

d00(ssid=…, password=…, security=…)

So even with aggressive obfuscation, a strings dump hands the attacker your entire data model — field by field — and any value those objects print. That's a real, common leak, and it happens without anyone opening a decompiler.

What obfuscation does hide

Android's R8 (the successor to ProGuard) is genuinely good at what it's designed for. With shrinking and obfuscation enabled, it renames, repackages, and deletes:

// build.gradle.kts
android {
    buildTypes {
        release {
            isMinifyEnabled = true      // R8: shrink + obfuscate + optimize
            isShrinkResources = true    // drop unused resources
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
            )
        }
    }
}

And a few extra rules push it further:

# Flatten every renamable class into a single unnamed package,
# so the DEX no longer reveals your feature/module structure by name.
-repackageclasses ''

# Let R8 rename across package-private boundaries too.
-allowaccessmodification

# Strip all android.util.Log calls (and their string arguments) from release.
-assumenosideeffects class android.util.Log {
    public static int v(...);
    public static int d(...);
    public static int i(...);
    public static int w(...);
    public static int e(...);
}

After this, a decompiler shows classes named a, b, d00, qr0 — the package hierarchy is gone, unused code is deleted, and your debug logs are stripped. An attacker sees the shape of the logic but has to rebuild all the meaning by hand.

What obfuscation doesn't hide

This is where most teams have a false sense of security. R8 does not:

Not protected by R8Consequence
AndroidManifest.xmlPermissions, components, package, metadata all readable
resources.arsc / res/Every UI string, label, and asset name in the clear
String literals in codeLog text, JSON keys, protocol names, endpoints readable via strings
Kotlin data class field namesLeaked through the generated toString()
Control flowDecompiled logic reads linearly once names are guessed

The headline: *plain strings can reconstruct most of your app's behaviour without any decompiling at all. Obfuscation hides names, not narrative*.

Layered defense: raise the cost, don't chase the impossible

You can't make an APK un-decompilable — it runs on a device you don't control. The realistic goal is defense in depth: stack cheap measures so the effort to understand or tamper with your app outweighs the payoff.

flowchart LR
    subgraph BUILD["Build time"]
      A["R8: rename +<br/>repackage + shrink"]
      B["String encryption"]
      C["Full v2 / v3 / v4 signing"]
    end
    subgraph RUN["Runtime"]
      D["Root / debugger /<br/>Frida detection"]
      E["Signature self-check"]
    end
    subgraph SRV["Server"]
      F["Play Integrity /<br/>remote attestation"]
    end
    A --> B --> C --> D --> E --> F --> G(["Attacker cost ↑↑↑"])

1. Sign with the full scheme set

Modern signature schemes prove the APK wasn't modified and re-signed. v3 carries a key-rotation lineage; v4 enables fast incremental installs.

signingConfigs {
    create("release") {
        // ...storeFile, passwords, alias...
        enableV1Signing = true   // legacy verifiers (pre-API 24)
        enableV2Signing = true
        enableV3Signing = true   // key-rotation lineage
        enableV4Signing = true
    }
}

2. Redact sensitive fields in toString()

Stop the free data-model leak. For any class that holds secrets, don't let the generated toString print them:

data class WifiCredentials(
    val ssid: String,
    val password: String,
) {
    // Override so the password never appears in logs, crash traces, or the DEX
    override fun toString() = "WifiCredentials(ssid=$ssid, password=***)"
}

3. Encrypt string literals

This is the single highest-impact anti-RE step, because it defeats the strings attack. Sensitive constants are stored encrypted and decoded at runtime, so a raw dump shows ciphertext instead of your protocol and log messages. Commercial tools (DexGuard, DexProtector) automate it; you can also hand-roll it for a handful of critical constants, or move them into native code.

4. Detect a hostile runtime

If the app is running under a debugger, on a rooted device, or hooked by an instrumentation framework, react to it:

fun isTampered(): Boolean {
    // Debugger attached?
    if (android.os.Debug.isDebuggerConnected()) return true

    // Common root binaries
    val su = listOf("/system/xbin/su", "/system/bin/su", "/sbin/su")
    if (su.any { java.io.File(it).exists() }) return true

    // Instrumentation frameworks (Frida, Xposed) leave traces in memory maps
    val maps = runCatching { java.io.File("/proc/self/maps").readText() }.getOrNull().orEmpty()
    if (listOf("frida", "xposed").any { it in maps }) return true

    return false
}

Keep in mind: client-side checks like this can be patched out by a determined attacker. They raise the cost; they don't guarantee safety.

5. Verify integrity on a server you control

The only tamper check an attacker can't simply delete from your APK is one that runs off-device. Play Integrity API attests the app and device from Google's servers; your backend refuses to serve if attestation fails. This is the strongest layer because the decision isn't in the attacker's hands.

The one file that undoes everything: mapping.txt

When R8 obfuscates, it writes a mapping.txt that maps d00 back to your real class names (you keep it to de-obfuscate crash reports). If that file leaks, all of your obfuscation is instantly reversed.

app/build/outputs/mapping/release/mapping.txt

Treat it like a private key: never commit it, never ship it, never put it anywhere a customer or the public can reach. This is the most common way teams quietly defeat their own hardening.

"But can they rebuild my whole project?"

Here's the reassuring part, and it's worth stating plainly. A release APK does not contain:

  • Your original Kotlin source (decompilers emit Java, not your Kotlin)
  • Comments, or real class / method / field names
  • Your Gradle build scripts and module structure
  • Editable resource source files (only the compiled resources.arsc)
  • Your signing key

So a decompiler gives an attacker obfuscated Java that won't even compile into a working app. Turning that back into a buildable project means renaming thousands of symbols by hand, fixing decompiler errors, and rebuilding the entire project scaffold — that's a rewrite using the decompiled code as a hint, not a "clone my project" button. For the vast majority of apps, nobody will bother.

The catch: this only holds for release builds. A debug (un-minified) build decompiles back to near-original names. Never distribute one.

The practical checklist

flowchart TD
    S(["Ship a release build?"]) --> A["✅ isMinifyEnabled = true"]
    A --> B["✅ v2 + v3 signing, real key"]
    B --> C["✅ Redact secrets in toString()"]
    C --> D["✅ Encrypt sensitive strings"]
    D --> E["✅ Runtime tamper checks"]
    E --> F["✅ Server-side attestation"]
    F --> G["🔒 Guard mapping.txt like a private key"]
    G --> DONE(["RE is now economically pointless"])
  • [ ] Only ever distribute the release build (isMinifyEnabled = true). Never a debug build.
  • [ ] Full signing (v2 + v3), with a real production key — not a dev/dummy keystore.
  • [ ] No secrets in the binary — no API keys, passwords, or endpoints baked into code or resources.
  • [ ] Redact toString() on any class carrying sensitive data.
  • [ ] Encrypt string literals to defeat the strings attack (highest-impact single step).
  • [ ] Runtime checks for debugger / root / hooking frameworks.
  • [ ] Server-side attestation (Play Integrity) for anything that must be trusted.
  • [ ] Guard mapping.txt — the file that undoes all your obfuscation.

Key takeaway

You cannot stop someone from decompiling an APK — anyone who promises "100% RE-proof" is selling something. What you can do is make the result worthless: obfuscated names that won't compile, encrypted strings that hide your logic, a tamper-aware runtime, and a server that refuses to trust a modified client.

Reverse engineering is a cost-vs-reward decision for the attacker. Do the cheap, layered work above, and you tip that math firmly in your favour — without ever chasing the myth of an unbreakable app.