Patching APKs: Smali, DEX, Rebuild, and Signing

A practical, in-depth tutorial for editing Android app code and producing a
working, installable APK β€” plus the theory of why the toolchain looks the way
it does.

Legal / ethical note. Only patch APKs you own, that you built yourself, or
that you have explicit permission to modify. Repackaging someone else's app
breaks its signature, its update path, and usually its terms of service, and
can be illegal depending on where you live. This document is about learning
how Android packaging works, debugging your own builds, instrumenting test
builds, and reverse engineering for interoperability.


Table of contents

  1. The 60-second mental model
  2. What is actually inside an APK
  3. DEX in depth
  4. Smali in depth
  5. DEX vs Smali: two views of the same thing
  6. Why both exist, and which is easier to understand
  7. Toolchain setup
  8. Workflow A β€” the standard apktool loop, step by step
  9. Worked example: patching a boolean check
  10. Workflow B β€” dex-only surgery (no resource rebuild)
  11. Workflow C β€” patching the DEX binary directly
  12. Signing in depth
  13. Fast paths and build modes
  14. Troubleshooting
  15. Verification checklist
  16. Limits, anti-tamper, and reality checks
  17. Command cheat sheet

1. The 60-second mental model

An APK is a ZIP file. Inside it are resources, a manifest, native libraries,
assets β€” and one or more classes*.dex files containing all the compiled
Kotlin/Java code.

Kotlin/Java source
      β”‚  kotlinc / javac
      β–Ό
JVM .class files            (stack-based bytecode, one file per class)
      β”‚  d8 (or R8 for release builds)
      β–Ό
classes.dex                 (register-based Dalvik bytecode, all classes in one file)
      β”‚  zip  + aapt2 (resources)  +  apksigner
      β–Ό
app.apk

Smali is not a compilation target. It is a textual rendering of an existing
DEX file
, produced by a disassembler (baksmali) and consumed by an assembler
(smali). Editing smali is editing the DEX, just in a form you can read.

classes.dex  ──baksmali──▢  smali/  (readable text)
     β–²                          β”‚
     └────────smaliβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   (edit in between)

apktool wraps exactly this: apktool d = unzip + decode resources + baksmali;
apktool b = smali + aapt2 + zip. That is the whole trick.


2. What is actually inside an APK

app.apk (ZIP)
β”œβ”€β”€ AndroidManifest.xml        binary XML (AXML), not text
β”œβ”€β”€ resources.arsc             compiled resource table (binary)
β”œβ”€β”€ classes.dex                code (classes2.dex, classes3.dex … for multidex)
β”œβ”€β”€ res/                       compiled resource files (layouts, drawables…)
β”œβ”€β”€ lib/<abi>/lib*.so          native libraries
β”œβ”€β”€ assets/                    raw files shipped verbatim
└── META-INF/                  v1 (JAR) signature files β€” MANIFEST.MF, *.SF, *.RSA

Two consequences that drive everything else:

  1. AndroidManifest.xml and res/ are compiled, not plain text. You cannot
    edit them with a text editor; you need a decoder (apktool) and a rebuilder
    (aapt2). That is why "just unzip and rezip" so often produces an APK that
    won't install or crashes on startup.
  2. META-INF/ is a signature over the ZIP's contents. Any change to any
    byte invalidates it. So the moment you edit a DEX, the app is unsigned from
    Android's point of view, and you must re-sign before installing.

apktool d keeps the untouched originals in a folder called original/
(notably original/META-INF and original/AndroidManifest.xml). apktool b -c
(--copy-original) can reuse them.


3. DEX in depth

3.1 What DEX is

DEX ("Dalvik Executable") is the bytecode container that Android Runtime (ART)
loads. It is a single flat file holding every class in the app, with all
strings, types, prototypes, fields, and methods interned into shared,
indexed tables. Cross-class references become 16-bit/32-bit integer indices
instead of names, which is what makes it compact.

It is an instruction set architecture, like x86 or ARM β€” not a
programming language. There is no syntax, no expressions, no scoping. There is
only a byte stream plus a documented layout.

Note: DEX is not JVM bytecode. .class files are stack-based; DEX is
register-based. d8 performs that translation. This is why a .class file and
a .dex file for the same class look nothing alike, and why you cannot just
rename one to the other.

3.2 File layout

The first 0x70 bytes are a fixed header, then six index tables, then a data
section:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ header        β”‚ 0x00 – 0x6F (112 bytes)                      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ string_ids    β”‚ offsets to MUTF-8 strings                    β”‚
β”‚ type_ids      β”‚ indices into string_ids (type descriptors)   β”‚
β”‚ proto_ids     β”‚ method signatures (return type + params)     β”‚
β”‚ field_ids     β”‚ {class, type, name} triples                  β”‚
β”‚ method_ids    β”‚ {class, proto, name} triples                 β”‚
β”‚ class_defs    β”‚ one per class: where its data lives          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ data          β”‚ string bytes, type lists, code items, debug  β”‚
β”‚               β”‚ info, annotations, static values, map_list   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Header fields worth memorizing:

Offset Field Meaning
0x00 magic dex\n035\0 … dex\n040\0 (format version)
0x08 checksum Adler-32 of bytes from 0x0C to EOF
0x0C signature SHA-1 of bytes from 0x20 to EOF
0x20 file_size total file length
0x24 header_size always 0x70
0x28 endian_tag 0x12345678 little-endian
0x34 map_off offset of the map list (section directory)
0x38–0x3F string_ids_size/off string table
0x40–0x47 type_ids_size/off type table
0x48–0x4F proto_ids_size/off prototype table
0x50–0x57 field_ids_size/off field table
0x58–0x5F method_ids_size/off method table
0x60–0x67 class_defs_size/off class table
0x68–0x6F data_size/off data section

The checksum and signature fields are why a hand-edited DEX is rejected
until you recompute them. Everything from 0x0C onward is covered by the
checksum, and everything from 0x20 onward is covered by the signature β€” and
the checksum field (at 0x08) is itself inside the signed region, so the order
matters: signature first, then checksum.

3.3 Code items

Each method body is a code_item: register count, in/out argument sizes, try
blocks, debug info offset, instruction count in 16-bit code units, and the
instruction stream. Instructions are 1–5 code units long, variable length, and
operate on registers named v0, v1, …:

const/4 v0, 0x1          # 1 code unit  (2 bytes)
invoke-virtual {v0}, Ljava/lang/Object;->toString()Ljava/lang/String;   # 3 code units

Because instruction length depends on the encoded value (const/4 vs const/16
vs const), inserting or growing code changes byte offsets everywhere after it.
That is the central difficulty of editing DEX directly, and the reason a
textual assembler exists.

3.4 Hard limits

Limit Value Consequence
Methods referenced per DEX 65,535 (method_ids are 16-bit) 64K method limit β†’ multidex
Registers per method 65,535 (16-bit count) enormous methods only
String index (non-jumbo) 65,535 assembler switches to /jumbo
Fields referenced per DEX 65,535 same class of problem
Instruction index range signed 16-bit branch offsets huge methods fail to assemble

classes.dex, classes2.dex, … each have their own 64K budget. apktool decodes
them to smali/, smali_classes2/, smali_classes3/, … and reassembles each
directory back into the matching DEX name.


4. Smali in depth

4.1 What smali is

Smali is the assembly syntax for DEX. Every DEX instruction has a smali
mnemonic with the same name (invoke-virtual, iget-object, const-string),
and every reference is written symbolically instead of as an index:

invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

Same instruction, but now you can read the class, the method, the parameter
type, and the return type. That is the entire value proposition.

Smali is lossless in the forward direction: baksmali β†’ text β†’ smali
gives you a semantically equivalent DEX. It is not guaranteed byte-identical
(the string pool and code-item ordering can be rearranged), but the class
structure and behaviour are preserved. This round-trip property is why apktool
can rebuild real apps at all.

4.2 Type descriptors

You must be able to read these; they appear everywhere:

Descriptor Type
V void
Z boolean
B byte
S short
C char
I int
J long
F float
D double
[I int[]
[[B byte[][]
Ljava/lang/String; String
Lcom/example/Foo; Foo (dots β†’ slashes, wrapped in L…;)

4.3 Anatomy of a smali file

.class public final Lcom/example/Checker;
.super Ljava/lang/Object;
.source "Checker.java"

# static field with a constant value
.field private static final TAG:Ljava/lang/String; = "Checker"

# instance field
.field private licenseKey:Ljava/lang/String;

.method public constructor <init>()V
    .registers 1
    .line 12
    invoke-direct {p0}, Ljava/lang/Object;-><init>()V
    return-void
.end method

.method public isLicensed()Z
    .registers 2
    .line 20
    iget-object v0, p0, Lcom/example/Checker;->licenseKey:Ljava/lang/String;

    if-nez v0, :cond_0
    const/4 v0, 0x0
    return v0

    :cond_0
    const/4 v0, 0x1
    return v0
.end method

.method public static log(Ljava/lang/String;)V
    .registers 2
    .param p0, "msg"    # Ljava/lang/String;
    const-string v0, "Checker"
    invoke-static {v0, p0}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
    return-void
.end method

Line by line:

  • .class / .super β€” class declaration and superclass.
  • .source β€” the original source file name from debug info (optional).
  • .field β€” field declaration; initializers use = value.
  • .method … .end method β€” one method body.
  • .registers N β€” total register slots used by the method, including the
    registers that hold the parameters.
  • .locals N β€” alternative: N locals only, with parameters named p0…pN.
    With .locals 2 and one parameter, that parameter is p0 = v2; with
    .registers 3 the same method has p0 = v2 too. p0 is this in instance
    methods.
  • .param p0, "msg" β€” parameter debug name (cosmetic, optional).
  • .line 20 β€” debug line mapping (cosmetic, optional).
  • :cond_0 β€” a label; branch targets use :name.
  • # β€” comment.

4.4 Registers and instruction selection

  • Registers hold 32 bits each. long/double occupy two consecutive
    registers (v0+v1, or a wide pair starting at an even register when an
    instruction requires it).
  • The assembler chooses encodings: const/4 (1 unit, βˆ’8..7), const/16
    (2 units), const (3 units), const-wide* for 64-bit, const-string vs
    const-string/jumbo (string index > 65,535).
  • Method calls have two forms: invoke-virtual {v0, v1, v2}, … (up to 5
    arguments in a 35c encoding) and invoke-virtual/range {v0 .. v5}, … for
    larger argument counts or contiguous ranges.
  • You may overwrite a register with a different type mid-method; DEX is not
    typed per register, but the ART verifier tracks types along each path and
    will reject code where a register is read with the wrong type. This is where
    hand-written smali usually breaks.

4.5 Control flow, payloads, exceptions

    if-eqz v0, :cond_next
    goto :goto_0

    :cond_next
    packed-switch v1, :pswitch_data_0

    :pswitch_0
    # case 0 body

    .catch Ljava/lang/Exception; {:try_start_0 .. :try_end_0} :catch_0

    :try_start_0
    invoke-static {}, Lcom/example/Native;->init()V
    :try_end_0

    goto :goto_1

    :catch_0
    move-exception v0
    invoke-static {v0}, Landroid/util/Log;->getStackTraceString(Ljava/lang/Throwable;)Ljava/lang/String;

    :goto_0
    return-void

    :pswitch_data_0
    .packed-switch 0x0
        :pswitch_0
    .end packed-switch

Notes:

  • Labels are positional; a branch may only target a label that exists.
  • Switch and array payloads live at the end of the method and are referenced by
    label (.packed-switch, .sparse-switch, .array-data).
  • .catch directives must appear before the protected region; :catch_0
    handlers receive the exception via move-exception.
  • There is no for, while, if with expressions β€” only compares, branches,
    and gotos. That is deliberate: it maps 1:1 onto the instruction stream.

4.6 Annotations, interfaces, inner classes

.annotation runtime Ljava/lang/Deprecated;
.end annotation

.annotation system Ldalvik/annotation/InnerClass;
    accessFlags = 0x8
    name = "Builder"
.end annotation

These appear as .annotation blocks before fields/methods/classes or at the end
of the class. @Override, @Nullable, and Kotlin metadata are all annotations
here β€” the Kotlin metadata blob is how decompilers recover Kotlin constructs.

4.7 What smali is not

  • Not typed-checked at assembly time beyond syntax and register arity. The
    assembler will happily emit code the verifier rejects.
  • Not a language with scoping, expressions, or types per variable.
  • Not stable as a source format β€” you cannot feed smali to a compiler to get
    Java back. jadx does that (approximately), from DEX.
  • Not the only text form: dexdump -d prints a similar listing, and
    baksmali --code-offsets annotates byte offsets, but neither reassembles.

5. DEX vs Smali: two views of the same thing

Aspect DEX Smali
Nature binary container + ISA text assembly syntax
Human-readable no (hex + spec) yes
Editable in a text editor no yes
Contains resource info no no
Reassembles to a runnable artifact n/a (it is the artifact) yes, via smali
Size compact, indexed, mmap-friendly much larger text
References numeric indices into tables symbolic names
Debug info offset tables .line, .local, .param
Produced by d8 / R8 baksmali
Consumed by ART smali (assembler)
Round-trip fidelity β€” semantic, not byte-identical
Typical tool dexdump, dexlib2, ART apktool, baksmali/smali, jadx fallback

The relationship is exactly like .o/ELF versus .s assembly: one is what the
machine runs, one is what humans and patch tools manipulate. DEX is the
canonical form; smali is a projection of it.


6. Why both exist, and which is easier to understand

Why DEX exists

  1. Density. One shared string/type/method pool for the entire app removes
    the per-class duplication that .class files have, and most cross-references
    are 16-bit indices. Result: much smaller downloads.
  2. Load speed. ART can mmap a DEX, verify it, and resolve references by
    index. No per-class file parsing, no classpath scanning.
  3. Register-based ISA. Fewer, denser instructions than a stack machine:
    add-int v0, v1, v2 instead of iload_1; iload_2; iadd; istore_0. That
    means less interpreter work and smaller code on battery-constrained devices.
  4. Verification and optimization. A single file with explicit tables is
    easy to validate ahead of time and to AOT-compile (dex2oat).

Why smali exists

  1. DEX is not editable by hand. Variable-length instructions, LEB128
    encoding, offset tables, checksums β€” changing one instruction can shift
    thousands of bytes. A textual assembler does the bookkeeping.
  2. Decompilers are lossy. Java decompilation (jadx, CFR, Procyon) guesses
    at structure. Optimized, obfuscated, or synthetic bytecode often has no valid
    Java form. Smali has a 1:1 instruction mapping, so a patch tool never has to
    guess β€” it can rebuild exactly what was there.
  3. Tooling needs a stable intermediate. apktool's whole design is
    "decode to text, let the user edit, re-encode". Smali is that text format.
  4. Debugging and teaching. Reading smali is how you learn what Kotlin
    actually compiles to (lambdas, when, coroutines, inline functions).

Which is easier to understand?

Ordered by ease for a human:

  1. Decompiled Java/Kotlin (jadx) β€” easiest to understand intent, but you
    cannot rebuild from it. It is a reading aid, not a build input.
  2. Smali β€” easiest editable form. Symbolic names, one instruction per
    line, labels instead of offsets, comments allowed. You can reason about it
    with zero knowledge of the binary layout.
  3. DEX β€” hardest. Requires the header spec, offset arithmetic, checksum
    recomputation, and constant re-parsing of index tables. Only worth touching
    for in-place same-size patches or when writing your own tool.

So: smali is the easiest thing to edit; Java is the easiest thing to read;
DEX is the only thing that actually runs.
A good workflow uses all three:
jadx to understand, smali to change, DEX (via the assembler) to ship.


7. Toolchain setup

You need, at minimum:

Tool Why Where
JDK 17+ runs apktool, apksigner, keytool apt install openjdk-17-jdk / Temurin
apktool 2.9+ (2.11.x current) decode/rebuild APKs https://apktool.org or your distro
zipalign align before signing Android SDK build-tools
apksigner v1/v2/v3/v4 signing Android SDK build-tools
keytool generate a keystore part of the JDK
adb install/test Android platform-tools
jadx / jadx-gui read decompiled Java (optional) https://github.com/skylot/jadx
baksmali / smali jars standalone DEX ↔ smali (optional) https://github.com/baksmali/smali
dexdump inspect a DEX header/instructions (optional) build-tools

Check what you have:

java -version
apktool --version
apksigner version
zipalign -h | head -3

Version gotchas:

  • apktool 2.9.0+ uses aapt2 by default; 2.7.x needs --use-aapt2
    explicitly when rebuilding modern apps. If your apktool --version says
    2.7.x, add the flag.
  • If you get UnsupportedClassVersionError, your JDK is too old for the
    apktool/smali build β€” upgrade the JDK, do not downgrade the tool.
  • Distro packages are often years behind. You can always run a pinned release
    jar instead; the CLI is identical:
    java -jar apktool_2.11.1.jar d app.apk -o decoded.
  • apksigner from build-tools 30+ supports v2/v3/v4; very old build-tools only
    do v1 through jarsigner.

Locate the SDK tools if they are not on PATH:

export ANDROID_HOME="$HOME/Android/Sdk"
export PATH="$ANDROID_HOME/build-tools/35.0.0:$ANDROID_HOME/platform-tools:$PATH"

Working directory convention used below:

mkdir -p ~/work/patch && cd ~/work/patch
cp ~/Downloads/app.apk .

8. Workflow A β€” the standard apktool loop, step by step

This is the normal, safest path. It rebuilds resources and code, and it is what
you want 90% of the time.

apktool d app.apk -o decoded      ──▢          decoded/     (edit smali in decoded/smali*)
                                                   β”‚ apktool b
                                                   β–Ό
                                            unsigned.apk
                                                   β”‚ zipalign
                                                   β–Ό
                                             aligned.apk
                                                   β”‚ apksigner
                                                   β–Ό
                                             signed.apk
                                                   β”‚ adb install
                                                   β–Ό
                                                 device

Step 0 β€” Keep a pristine copy

cp app.apk app.original.apk
sha256sum app.apk app.original.apk

You will compare against this when something breaks. Never debug on your only
copy.

Step 1 β€” Decode

apktool d app.apk -o decoded

Useful flags:

Flag Effect
-f / --force overwrite an existing output directory
-s / --no-src skip DEX decoding (resources only)
-r / --no-res skip resource decoding (code only, much faster)
-p / --frame-path where framework files live
--only-main-classes skip DEX files that are not classes*.dex

What you get:

decoded/
β”œβ”€β”€ AndroidManifest.xml      now readable XML
β”œβ”€β”€ apktool.yml              metadata: versions, DEX names, flags
β”œβ”€β”€ res/                     decoded resources
β”œβ”€β”€ smali/                   classes.dex
β”œβ”€β”€ smali_classes2/          classes2.dex
β”œβ”€β”€ smali_classes3/          classes3.dex
β”œβ”€β”€ assets/
β”œβ”€β”€ lib/
└── original/                untouched META-INF + manifest

If decoding fails on an OEM/system APK, install its framework first:

apktool if framework-res.apk

Step 2 β€” Find the code to patch

Three complementary approaches:

# 1. Search strings in smali (fast, exact)
grep -rn "isLicensed\|license" decoded/smali*/ | head -50

# 2. Search the decompiled Java for intent (jadx is read-only)
jadx -d jadx-out app.apk
grep -rn "isLicensed" jadx-out/ | head

# 3. Inspect the manifest for entry points and permissions
sed -n '1,80p' decoded/AndroidManifest.xml

Use jadx to understand, smali to locate the exact instructions. jadx-gui is
better for browsing; note that when jadx fails to decompile a method it prints
the smali instead β€” that is your bridge.

Step 3 β€” Read the smali before touching it

Open the file and find the method. Typical patterns to look for:

.method public isLicensed()Z
    .registers 2
    iget-object v0, p0, Lcom/example/Checker;->licenseKey:Ljava/lang/String;
    invoke-virtual {v0}, Ljava/lang/String;->isEmpty()Z
    move-result v0
    xor-int/lit8 v0, v0, 0x1
    return v0
.end method

Before editing, note: register count, which registers are inputs, whether there
are try/catch blocks, and whether the method returns a value or V. Write down
the original (or rely on git β€” see Step 4).

Step 4 β€” Put the decoded tree under version control

cd decoded && git init && git add -A && git commit -m "pristine decode" && cd ..

Now every patch is a reviewable diff, and reverting is trivial. This is the
single biggest quality-of-life improvement in this workflow.

Step 5 β€” Edit the smali

Minimal, surgical changes win. Common patterns:

Force a boolean true:

.method public isLicensed()Z
    .registers 2
    const/4 v0, 0x1
    return v0
.end method

Force false: identical with 0x0.

Skip a void call:

.method public onCreate(Landroid/os/Bundle;)V
    .registers 3
    invoke-super {p0, p1}, Landroid/app/Activity;->onCreate(Landroid/os/Bundle;)V
    return-void
.end method

Neutralise one instruction β€” replace with nops, matching the original
instruction's code-unit count
, or you will corrupt the following bytes when
assembling by hand (the assembler handles it, but the semantics of labels and
try ranges still matter):

# before: invoke-static {}, Lcom/example/Analytics;->ping()V   (3 code units)
nop
nop
nop

Add logging (bump .registers if you need a new register):

.method public isLicensed()Z
    .registers 2
    const-string v0, "PATCH"
    const-string v1, "isLicensed() called"
    invoke-static {v0, v1}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
    const/4 v0, 0x1
    return v0
.end method

Rules of thumb while editing:

  • Keep .registers/.locals consistent with the highest register you use.
  • Preserve the return type. A Z method must return v0 with 0/1; a V
    method must return-void.
  • Do not delete .line/.local directives you do not need to delete β€” they
    are harmless and keep diffs small.
  • If you add a register, initialise it (const/4 vX, 0x0) before a branch that
    might read it, or the verifier may complain about an uninitialised register.
  • Avoid changing method signatures or class names unless you also fix every
    caller.
  • If a method is huge or uses invoke-* on a register range, do not reorder
    registers casually.

Step 6 β€” Rebuild

apktool b decoded -o unsigned.apk

Notes:

  • With apktool 2.7.x and a modern app, add --use-aapt2.
  • If aapt2 fails on resource errors, --force-all forces a rebuild of all
    resources (slower, sometimes succeeds where incremental fails).
  • -c / --copy-original copies the original AndroidManifest.xml,
    META-INF, and resources.arsc from decoded/original/ instead of
    rebuilding them. Use it when you only changed smali and the resource
    rebuild is causing trouble.
  • The output is unsigned. Do not install it yet.

Sanity-check the rebuilt DEX:

unzip -l unsigned.apk | grep -E 'classes.*dex|resources.arsc'
unzip -p unsigned.apk classes.dex > /tmp/check.dex
dexdump -f /tmp/check.dex | head -20     # header: checksum, signature, counts

Step 7 β€” Align

zipalign -p -f -v 4 unsigned.apk aligned.apk

Why this step exists:

  • zipalign makes every uncompressed entry start at a 4-byte boundary so
    ART can mmap them directly instead of copying.
  • -p page-aligns uncompressed .so files (required for extractNativeLibs=false
    and for API 23+ in practice).
  • Android 11+ (API 30) additionally requires resources.arsc to be stored
    uncompressed and 4-byte aligned. apktool writes it stored; zipalign
    fixes the offset.
  • apksigner must run after zipalign: the v2+ signature covers the whole
    file, so any later byte move destroys it.

Verify alignment:

zipalign -c -v 4 aligned.apk | tail -5

If you ever see Failure: resources.arsc not stored uncompressed, the APK was
rezip'd with a tool that compressed it; rebuild with apktool rather than
fighting zip.

Step 8 β€” Create a signing key (once)

keytool -genkeypair -v \
  -keystore my-release.keystore \
  -alias mykey \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -storepass changeit -keypass changeit \
  -dname "CN=Test, OU=Dev, O=Personal, L=Nowhere, ST=None, C=US"

Keep this file. Every future build of "your" version of the app must use the
same key, otherwise Android treats it as a different app and refuses to update
over the installed one.

Step 9 β€” Sign

apksigner sign \
  --ks my-release.keystore \
  --ks-key-alias mykey \
  --ks-pass pass:changeit \
  --key-pass pass:changeit \
  --out signed.apk \
  aligned.apk

That default enables v1 (for API < 24 compatibility), v2, and v3 in one pass.
Explicit control, if you need it:

apksigner sign \
  --ks my-release.keystore --ks-key-alias mykey \
  --v1-signing-enabled true \
  --v2-signing-enabled true \
  --v3-signing-enabled true \
  --v4-signing-enabled true \
  --min-sdk-version 21 \
  --out signed.apk aligned.apk

Version semantics:

Scheme Covers Notes
v1 (JAR) each ZIP entry via META-INF/*.SF/*.RSA needed for API < 24; slow, per-file
v2 whole APK bytes, in the ZIP central directory area required for API 30+ installs, fast verify
v3 like v2 plus key rotation API 28+
v4 separate .idsig file incremental install (adb); not inside the APK

Do not mix jarsigner and apksigner on the same output; if you must sign
with jarsigner (very old toolchains), sign, then zipalign, and accept v1-only.

Step 10 β€” Verify the signature

apksigner verify --verbose --print-certs signed.apk

Expected output shows Verifies, the scheme list (v1, v2, v3), and your
certificate's SHA-256. If it says DOES NOT VERIFY, you signed before
aligning, edited the APK after signing, or the keystore/alias is wrong.

Step 11 β€” Install and test

adb install -r signed.apk

If it fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE or
signatures do not match previously installed version, uninstall the original
first (this wipes app data):

adb uninstall com.example.app
adb install signed.apk

Then watch the runtime:

adb logcat -c
adb shell am start -n com.example.app/.MainActivity
adb logcat -s AndroidRuntime:E ActivityManager:I

A java.lang.VerifyError or ClassNotFoundException here almost always means
your smali is structurally wrong (register mismatch, bad label, wrong return
type), not that signing failed.

Step 12 β€” Diff and iterate

cd decoded && git diff && cd ..

Keep patches small and one-concern-at-a-time so a failure is easy to bisect.


9. Worked example: patching a boolean check

Suppose jadx shows:

public boolean isPro() {
    return this.purchaseToken != null && this.purchaseToken.length() > 10;
}

and the class is com.example.billing.Entitlement.

1. Locate it in smali:

grep -rn "isPro" decoded/smali*/com/example/billing/

Open decoded/smali/com/example/billing/Entitlement.smali:

.method public isPro()Z
    .registers 2
    iget-object v0, p0, Lcom/example/billing/Entitlement;->purchaseToken:Ljava/lang/String;

    if-eqz v0, :cond_0
    invoke-virtual {v0}, Ljava/lang/String;->length()I
    move-result v0
    const/16 v1, 0xa
    if-le v0, v1, :cond_0

    const/4 v0, 0x1
    return v0

    :cond_0
    const/4 v0, 0x0
    return v0
.end method

2. Patch the branch. The cheapest change is the constant at
:cond_0:

    :cond_0
    const/4 v0, 0x1
    return v0

3. Rebuild, align, sign, verify, install:

apktool b decoded -o unsigned.apk
zipalign -p -f 4 unsigned.apk aligned.apk
apksigner sign --ks my-release.keystore --ks-key-alias mykey \
  --ks-pass pass:changeit --key-pass pass:changeit \
  --out signed.apk aligned.apk
apksigner verify --verbose --print-certs signed.apk
adb install -r signed.apk

4. Confirm at runtime. Compare with the original behaviour: launch the app,
exercise the gated feature, and check logcat. If the app still gates, either
the check is duplicated elsewhere (search for the field name, not the method
name) or the value is validated server-side (in which case client patching is
the wrong layer).

A useful debugging trick: temporarily insert a log line inside the method (see
Step 5) and confirm from logcat that your new code is actually the code being
executed. That distinguishes "patch not applied" from "patch applied but logic
elsewhere".


10. Workflow B β€” dex-only surgery (no resource rebuild)

When you only change code, rebuilding resources is unnecessary work and a common
source of breakage. Three options.

B1. apktool with --no-res

apktool d app.apk -o code_only --no-res
# edit code_only/smali*/...
apktool b code_only -o unsigned.apk -c

--no-res (decode-only flag) skips resource decoding; -c copies
original/resources.arsc and original/AndroidManifest.xml verbatim instead of
rebuilding them. Result: your resources are bit-identical to the original, so
nothing can regress there. apktool.yml remembers where the originals are β€”
keep the original/ folder.

B2. baksmali/smali directly (no apktool at all)

Useful when you want zero changes outside the DEX, or when apktool chokes on
resources.

mkdir work && cd work
unzip ../app.apk -d unpacked

# disassemble every DEX
for f in unpacked/classes*.dex; do
  baksmali d "$f" -o "smali_$(basename "$f" .dex)"
done

# ... edit smali_classes2/... as usual ...

# reassemble
smali a smali_classes  -o unpacked/classes.dex
smali a smali_classes2 -o unpacked/classes2.dex
smali a smali_classes3 -o unpacked/classes3.dex

# repack: resources.arsc MUST stay uncompressed
cd unpacked
zip -X -0 ../patched.apk resources.arsc
zip -X -r ../patched.apk . -x 'META-INF/*' 'resources.arsc'
cd ..

zipalign -p -f 4 patched.apk aligned.apk
apksigner sign --ks ~/my-release.keystore --ks-key-alias mykey \
  --out signed.apk aligned.apk

Caveats for B2:

  • zip will compress resources.arsc by default, which breaks API 30+
    installs. Repack with resources.arsc stored (zip -X -0 …), or let
    apktool b do the packaging.
  • Strip or replace META-INF/ β€” the old signature is now invalid and, worse, a
    stale v1 signature can make verification fail in confusing ways.
  • The ZIP must not contain the old classes*.dex alongside new ones; overwrite
    in place.
  • smali a (assemble) accepts a directory tree, not a single file.

B3. Programmatic rewriting (dexlib2 / R8 / d8)

If you need to do this repeatedly or structurally (rename classes, inject a new
class, rewrite call sites), use dexlib2 (the library under baksmali/smali) or
DexRewriter. It parses the DEX into an object model, lets you replace
MethodImplementations, and writes a new DEX with all offsets, string pools,
and checksums recomputed. This is the only sane way to make size-changing
edits programmatically.

For adding new Java/Kotlin code, the normal route is: compile the new class with
javac/kotlinc, convert it with d8 new.jar --output out/, and merge the
resulting classes.dex into the app (or add it as classesN.dex). apktool can
then decode it if you drop it into the APK. Remember the 64K method budget and
that new references must exist at runtime.


11. Workflow C β€” patching the DEX binary directly

Sometimes you must patch in place: a size-preserving tweak, a hex-editor-only
environment, or a tool that only accepts a DEX blob.

11.1 When it works

Only when the patch is the same number of bytes: flipping a constant that
fits the same encoding, changing a branch target within range, or swapping a
register number. Example: const/4 v0, 0x0 β†’ const/4 v0, 0x1 is one changed
nibble in a 2-byte instruction.

Find the instruction with dexdump -d (method names plus instructions) or,
better, baksmali d --code-offsets, which annotates every instruction with its
byte offset inside the DEX. Note the offset, then edit those bytes:

dexdump -d classes.dex | grep -n -A4 "isPro"
xxd -s 0x1234 -l 16 classes.dex

11.2 Fix the header or nothing will load

After any byte change:

#!/usr/bin/env python3
"""Recompute DEX checksum + signature after an in-place patch."""
import hashlib, struct, sys, zlib

path = sys.argv[1]
data = bytearray(open(path, "rb").read())
assert data[:4] == b"dex\n", "not a dex file"

# 1) SHA-1 over everything after the 32-byte magic+checksum+signature area
data[12:32] = hashlib.sha1(bytes(data[32:])).digest()

# 2) Adler-32 over everything after the checksum field
struct.pack_into("<I", data, 8, zlib.adler32(bytes(data[12:])) & 0xFFFFFFFF)

open(path, "wb").write(data)
print("fixed", path)

Then confirm:

dexdump -f classes.dex | head -8     # checksum/signature should be populated

11.3 Why this is fragile

  • Instructions are variable length. Inserting a byte shifts every subsequent
    offset, code_item.insns_size, try/catch ranges, map_list offsets, and the
    string/type tables' base addresses. A naive hex insert produces a DEX that
    parses as garbage.
  • file_size in the header must match the actual length.
  • Branch offsets are relative and range-limited; moving code can put a target
    out of range.
  • The ART verifier re-checks types, so a byte-level change that "looks right"
    can still throw VerifyError.
  • Multidex: you must patch the right classesN.dex, and references across DEX
    files are resolved at runtime.

Conclusion: direct DEX patching is a scalpel for same-size edits. For
anything else, use smali (Workflow A/B) or dexlib2.


12. Signing in depth

12.1 What a signature proves

Android does not care who signed the APK; it cares that the signature is
self-consistent and that updates use the same key as the installed app.
Consequences:

  • Any modification invalidates the signature. There is no "edit and keep the
    signature" shortcut (that would break the cryptography).
  • A repackaged app cannot be installed over the original (different key).
  • Apps that check their own signature (PackageManager.getPackageInfo with
    GET_SIGNATURES/GET_SIGNING_CERTIFICATES, Play Integrity, SafetyNet,
    certificate pinning) will notice immediately and may refuse to run. That is a
    server/attestation problem, not a packaging problem.
  • Play Store distribution requires the original upload key.

12.2 The v1/v2/v3/v4 scheme in one paragraph each

v1 (JAR signing). META-INF/MANIFEST.MF lists SHA-256 digests of every
entry; CERT.SF signs the manifest; CERT.RSA holds the signer's certificate
and signature. Verification is per-entry, which is slow and vulnerable to
"master key"-style ZIP parsing bugs. Still required for API < 24.

v2. A signature block embedded just before the ZIP central directory,
covering the entire APK (all bytes, including the central directory). Fast,
whole-file verification; detects any modification. Required for installs on
Android 11+ (targetSdk 30+) in practice.

v3. Same idea as v2 with a key-rotation proof, allowing an app to change
signing keys across versions. Android 9+.

v4. A detached .idsig file used for incremental/streaming installs via
adb; it is not part of the APK and is not required for normal distribution.

12.3 The one ordering rule

build β†’ zipalign β†’ apksigner β†’ (never modify again)

Signing hashes bytes; alignment moves bytes. Reverse the order and v2/v3 fail.
If you must re-zipalign a signed APK, re-sign it afterwards.

12.4 v1-only fallback (legacy)

jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
  -keystore my-release.keystore -storepass changeit \
  unsigned.apk mykey
zipalign -p -f 4 unsigned.apk aligned.apk   # then re-sign if you aligned after

apksigner is strictly better; keep this only for very old SDK levels or old
CI images.

12.5 Verifying what is actually in the file

apksigner verify --verbose --print-certs signed.apk
unzip -l signed.apk | grep META-INF      # v1 artifacts

apksigner verify reports which schemes verified and the certificate digest.
Compare the digest to what the app's own integrity check expects β€” if they
differ, self-checks will fail by design.


13. Fast paths and build modes

Goal Command
Full decode (resources + code) apktool d app.apk -o out
Code only, fast apktool d app.apk -o out --no-res
Resources only, fast apktool d app.apk -o out --no-src
Rebuild apktool b out -o unsigned.apk
Rebuild with aapt2 (old apktool) apktool b out -o unsigned.apk --use-aapt2
Rebuild keeping original manifest/arsc apktool b out -o unsigned.apk -c
Force full resource rebuild apktool b out -o unsigned.apk --force-all
Debug build apktool b out -o unsigned.apk -d

Practical guidance:

  • Changing one method in smali_classes2 and nothing else? --no-res + -c
    is the fastest reliable path.
  • Changing AndroidManifest.xml or res/? Full decode/rebuild; never hand-edit
    binary XML.
  • apktool output "W: Could not decode attr value" or similar warnings usually
    mean an OEM framework mismatch β€” install the device framework and retry.
  • If apktool b produces an APK that installs but crashes instantly with
    Resources$NotFoundException, the resources.arsc rebuild went wrong; try
    -c to reuse the original, and if that works, keep it.

14. Troubleshooting

Symptom Likely cause Fix
Invalid file: classes.dex during decode corrupted/odd DEX re-extract from APK; check unzip -t
brut.androlib.AndrolibException: Could not decode arsc resource table version use newer apktool; install framework
aapt2 errors on build after resource edits broken XML/attr revert resource edits, use -c, or --force-all
INSTALL_PARSE_FAILED_NO_CERTIFICATES not signed / v1 missing on old API sign with apksigner, enable v1
INSTALL_FAILED_UPDATE_INCOMPATIBLE different signing key adb uninstall first
INSTALL_FAILED_INVALID_APK / resources.arsc alignment compressed or misaligned arsc rebuild with apktool + zipalign
java.lang.VerifyError at launch bad smali (registers/types/labels) fix method; run dexdump; compare with original
ClassNotFoundException after patch added/moved a class that nothing can load check class name/descriptor and DEX placement
Build works, app behaves unchanged patched the wrong copy / dead code / server-side check grep for all occurrences; add a log line to confirm execution
apksigner verify fails after editing signed then modified, or aligned after signing rebuild and re-sign in the correct order
zipalign: Unable to open wrong path/typo use absolute paths
UnsupportedClassVersionError JDK too old for the tool upgrade JDK

15. Verification checklist

Before you call a patch "done":

  • apksigner verify --verbose reports Verifies and the schemes you expect.
  • zipalign -c -v 4 signed.apk passes.
  • unzip -l signed.apk shows the expected classes*.dex set and a stored
    resources.arsc.
  • dexdump -f on the rebuilt DEX shows a consistent header (checksum,
    signature, sizes).
  • The app installs cleanly on a clean device/emulator (no leftovers).
  • logcat shows no VerifyError/ClassNotFoundException/Resources$NotFound.
  • The specific behaviour you changed actually changed.
  • The rest of the app still works (smoke test the main flows).
  • You can revert: git diff in the decoded tree, or restore
    app.original.apk.

16. Limits, anti-tamper, and reality checks

  • Signature checks in code. GET_SIGNATURES, GET_SIGNING_CERTIFICATES,
    certificate pinning, and server-side attestation (Play Integrity/SafetyNet)
    are common. Patching them is another patch, and it escalates quickly.
    Server-side validation cannot be patched on the client at all.
  • Obfuscation. R8/ProGuard renames classes to a.a.a; smali is still
    readable as instructions but names are meaningless. Use behaviour (strings,
    API calls, control flow) rather than names.
  • Native code. Logic in lib/*/lib*.so is not smali; patching it requires
    native RE (IDA/Ghidra/radare2) and often invalidates more invariants.
  • Integrity of resources. Some apps hash their own resources or DEX at
    startup; any rebuild trips it.
  • Split APKs / App Bundles. base.apk plus split_config.*.apk. apktool
    can decode a split, but installs need all splits and matching signatures.
    Bundles must be converted (bundletool build-apks --mode=universal) before
    patching.
  • Legality and ToS. See the note at the top. Modify your own software.
  • Updates. A patched app cannot receive Play updates; the store's signature
    will not match. Keep an unmodified copy.

17. Command cheat sheet

# ---------- decode / build ----------
apktool d app.apk -o decoded
apktool d app.apk -o decoded --no-res          # code only
apktool b decoded -o unsigned.apk
apktool b decoded -o unsigned.apk --use-aapt2  # apktool < 2.9
apktool b decoded -o unsigned.apk -c           # reuse original manifest/arsc

# ---------- dex <-> smali ----------
baksmali d classes.dex -o smali_out
smali a smali_out -o classes.dex

# ---------- inspect ----------
dexdump -f classes.dex | head -20
dexdump -d classes.dex | less
unzip -l app.apk

# ---------- align / sign / verify ----------
zipalign -p -f 4 unsigned.apk aligned.apk
zipalign -c -v 4 aligned.apk
keytool -genkeypair -v -keystore my-release.keystore -alias mykey \
  -keyalg RSA -keysize 2048 -validity 10000
apksigner sign --ks my-release.keystore --ks-key-alias mykey \
  --out signed.apk aligned.apk
apksigner verify --verbose --print-certs signed.apk

# ---------- install / debug ----------
adb install -r signed.apk
adb uninstall com.example.app
adb logcat -c && adb logcat -s AndroidRuntime:E

# ---------- integrity of your patch ----------
cd decoded && git diff
sha256sum app.apk app.original.apk signed.apk

Closing summary

  • DEX is the binary, register-based bytecode ART executes; it exists for
    density, fast loading, and verifiability. It is not meant to be edited by
    hand.
  • Smali is the textual assembly view of DEX β€” symbolic, labelled,
    round-trippable. It exists because decompiled Java is lossy and binary DEX is
    not human-editable. It is the easiest editable representation.
  • apktool turns an APK into editable smali + XML/resources and back. It
    does not sign.
  • The reliable loop is: decode β†’ understand (jadx) β†’ patch (smali) β†’
    rebuild (apktool) β†’ align (zipalign) β†’ sign (apksigner) β†’ verify β†’ install β†’
    test
    .
  • Editing the DEX directly is possible but only safe for same-size patches, and
    always requires recomputing the checksum and SHA-1 signature in the header.
  • Re-signing is mandatory after every change, changes the app's identity, and
    is detected by any app that checks its own signature.

Need an Android Developer or a full-stack website developer?

I specialize in Kotlin, Jetpack Compose, and Material Design 3. For websites, I use modern web technologies to create responsive and user-friendly experiences. Check out my portfolio or get in touch to discuss your project.