τTau SolutionsOfuscación

R8 y ProGuard

Antes conviene leer «Anatomía de un APK», «Formato DEX»

referencia técnica · Actualizado el 25 de septiembre de 2026

1. Qué es y por qué existe

Casi todo el código ofuscado que se encuentra dentro de un APK no lo ofuscó nadie a propósito: lo ofuscó la cadena de compilación oficial de Android, porque el desarrollador puso minifyEnabled = true para que la aplicación ocupase menos. R8 es la herramienta de Google que hace ese trabajo, y desde el plugin de Gradle 3.4.0 viene activada por defecto.

Eso convierte a R8 en el punto de partida obligatorio de este bloque. No es un producto de seguridad ni pretende serlo: es un reductor de tamaño cuyo efecto secundario —renombrar todo lo que puede a a, b, c— resulta ser la barrera de análisis más extendida del ecosistema. Un ofuscador comercial como DexGuard («Ofuscadores comerciales») empieza donde R8 acaba.

La pieza que hace que todo esto sea reversible se llama mapping.txt. R8 la emite en cada compilación y contiene la correspondencia exacta entre el nombre original y el ofuscado. Con ella, deshacer la ofuscación es una sustitución determinista sin riesgo; sin ella, hay que inferir, y ahí el techo es bajo («Deofuscación»). Casi todo el valor práctico de este documento está en la «sección 6 · mapping.txt: la gramática exacta», que fija la gramática de ese fichero con suficiente detalle para implementar un lector.

Este documento se queda en el lado de la identificación: qué hace R8, qué formato produce y cómo se reconoce, desde un APK ya construido, que pasó por ahí. La «sección 10 · Medición propia: la fracción de identificadores cortos» publica una medición propia sobre el «muestrario».

Versiones de referencia: R8 9.2.4-dev (build-tools 37.0.0), Retrace 8.9.27, ProGuard 7.9.1 y AGP hasta la 9.3, consultadas en agosto y septiembre de 2026.

2. Historia y relevo

ProGuard lo escribió Eric Lafortune; su primer commit es del 1 de mayo de 2002 y lo describe como un proyecto personal. En diciembre de 2010 rondaba el medio millón de descargas y ya venía integrado en el SDK de Android: la decisión de Google de incluirlo en Android 2.3 Gingerbread es la que le dio escala. En 2012 nace DexGuard, su hermano comercial, y en 2014 se funda Guardsquare, la empresa belga que hoy mantiene los dos. ProGuard sigue vivo y es software libre bajo GPL-2.0 con excepciones; la versión más reciente es la v7.10.0, del 24 de agosto de 2026, y la de las ejecuciones de este documento, la v7.9.1, del 9 de abril. Sus cuatro pasos, según su propio manual, son shrinking, optimization, obfuscation y preverification.

ProGuard opera sobre bytecode de la JVM: entra .class, sale .class, lo que en la cadena antigua de Android obligaba a encadenar javac → ProGuard → dx. Google lo colapsó en dos herramientas nuevas que comparten repositorio y código:

Herramienta Entrada Salida Qué hace
D8 .class DEX Traduce y desugariza. Sustituye a dx. No renombra nada
R8 .class DEX Todo lo de D8 más shrinking, optimización y ofuscación. Sustituye a ProGuard + dx
L8 .class DEX/.class Compila la biblioteca del núcleo reescrita del core library desugaring

R8 se introdujo en el plugin de Gradle 3.3.0 y quedó activado por defecto en la 3.4.0, para módulos de aplicación y de biblioteca. La documentación de esa versión lo resume sin rodeos: antes, «ProGuard era un paso de compilación distinto del dexing y del desugaring»; ahora es uno solo.

Qué queda de ProGuard hoy, en la práctica:

  • El formato de configuración. Las keep rules de R8 son sintaxis de ProGuard, y los ficheros se siguen llamando proguard-rules.pro.
  • El formato de mapping.txt. R8 produce un superconjunto compatible («sección 6 · mapping.txt: la gramática exacta»).
  • La marca PG en el atributo SourceFile («sección 9.2 · El atributo SourceFile»), que R8 sigue reconociendo como valor centinela heredado.
  • Proyectos antiguos y builds no-Android que siguen invocando ProGuard directamente, y DexGuard, que es «compatible hacia atrás con ProGuard» y reutiliza su configuración.

En el muestrario, 69 de las 86 aplicaciones llevan un marcador ~~R8 dentro del DEX («sección 9.1 · El marcador ~~R8{…} dentro del DEX»). ProGuard puro no aparece en ninguno.

3. Los tres trabajos, que no son el mismo

minifyEnabled = true enciende tres transformaciones distintas a la vez. Se confunden constantemente, y separarlas importa porque solo una de las tres es la que mapping.txt deshace.

                 ┌──────────────────────────────────────────────┐
   .class  ────► │ 1. SHRINKING       elimina lo inalcanzable    │
   librerías     │    (tree shaking)  desde los entry points     │
   keep rules    ├──────────────────────────────────────────────┤
                 │ 2. OPTIMIZACIÓN    inlining, fusión de clases │
                 │                    propagación de constantes  │
                 ├──────────────────────────────────────────────┤
                 │ 3. OFUSCACIÓN      renombra lo que queda      │
                 │    (minification)  a a, b, c…                 │
                 └──────────────────────────────────────────────┘
                        │                          │
                        ▼                          ▼
                  classes.dex                 mapping.txt

1. Shrinking. «R8 identifica y elimina el código inalcanzable de la aplicación y de sus dependencias. Analizando los entry points de la aplicación (como las Activities o los Services declarados en el manifiesto), R8 construye un grafo del código referenciado y elimina todo lo que quede sin referenciar». Lo eliminado se lista en usage.txt.

2. Optimización. Las dos técnicas que la documentación nombra son method inlining —«R8 sustituye el punto de llamada por el cuerpo del método llamado»— y class merging —«R8 combina conjuntos de clases e interfaces en una sola clase»—. A eso se añaden propagación de constantes, eliminación de argumentos sin uso y outlining («sección 6.6 · Los comentarios de metadatos por elemento»). Desde AGP 8.12.0, R8 optimiza además los recursos, y desde AGP 9.0 lo hace por defecto (android.r8.optimizedResourceShrinking pasa a true).

3. Ofuscación. «Para reducir el tamaño del fichero DEX, R8 acorta los nombres de clases, campos y métodos (por ejemplo, com.example.MyActivity podría convertirse en a.b.a)». Es un efecto de tamaño, no de seguridad: los nombres viven en string_ids («Formato DEX → §6 · Las tablas de índices») y acortarlos ahorra bytes.

Por qué la optimización estorba al decompilador tanto o más que el renombrado. Un nombre renombrado sigue siendo un nombre: el decompilador produce código correcto y feo, y el lector puede renombrar mentalmente. La optimización destruye la correspondencia entre el fuente y el binario: un método inlineado ya no existe como método, y su cuerpo aparece repetido dentro de cada llamador; dos clases fusionadas aparecen como una, con métodos que solo tienen sentido para la mitad del objeto; una constante propagada borra el parámetro que la transportaba; y el outlining hace lo contrario, inventa un método que no estaba en el fuente y lo llama desde sitios que no tenían relación.

mapping.txt recupera los nombres y, gracias a los rangos de línea, también la pila de llamadas original de un método inlineado («sección 6.5 · Métodos inlineados»). Lo que no recupera es la forma del código: el DEX sigue teniendo el cuerpo repetido. Esa asimetría es el motivo de que retrace funcione perfectamente sobre una traza de pila y de que deofuscar un proyecto decompilado sea otro problema.

Cada pieza se puede pedir por separado: -dontobfuscate deja shrinking y optimización, -dontoptimize deja shrinking y renombrado, -dontshrink deja los otros dos. Varias aplicaciones del muestrario están en ese estado intermedio: Signal y Candy Crush llevan marcador de R8 y SourceFile de clases sintéticas de R8, pero 2.580 y 2.486 nombres de fichero fuente reales y medianas de longitud de identificador de 13 y 15. Es R8 sin ofuscación.

4. Cómo se enciende y dónde viven las reglas

La forma clásica, todavía la mayoritaria, y la DSL nueva de AGP 9.3, que enciende las dos cosas de golpe y ya incluye las reglas por defecto de la plataforma:

buildTypes { release {                       buildTypes { release {
    isMinifyEnabled = true                       optimization { enable = true }
    isShrinkResources = true                 } }
    proguardFiles(
        getDefaultProguardFile("proguard-android-optimize.txt"),
        "proguard-rules.pro")
} }

Con la DSL nueva, las reglas van en ficheros con sufijo .keep dentro del source set src/<variant>/keepRules, por ejemplo src/main/keepRules/custom-rules.keep.

Dónde caen los ficheros de salida. mapping.txt se escribe en app/build/outputs/mapping/<variante>/mapping.txt y, además, AGP lo empaqueta automáticamente dentro del AAB. La documentación advierte de lo importante: «el fichero de mapping se sobrescribe cada vez que se compila el proyecto, así que hay que guardar una copia cada vez que se publica una release». Junto a él aparecen seeds.txt —lo que las reglas impidieron eliminar—, usage.txt —lo eliminado— y configuration.txt —«todas las reglas fusionadas de cada biblioteca y módulo que se usaron para configurar R8»—.

La ruta real lleva segmento de variante: app/build/outputs/mapping/<variante>/configuration.txt, en el mismo directorio que seeds.txt y usage.txt. Lo dicen las notas de AGP 9.0.0 («typically in a path like <app_module>/build/outputs/mapping/<build_variant>/configuration.txt») y lo confirma el código de AGP: InternalArtifactType.R8_MAPPING_CONFIGURATION se declara con categoría OUTPUTS, carpeta mapping y fichero configuration.txt, y getOutputPath() intercala la variante entre la carpeta y el fichero (platform/tools/base, etiqueta studio-2026.1.2). Las otras dos páginas oficiales fallan cada una en un detalle: la de diagnóstico omite la variante (./app/build/outputs/mapping/configuration.txt) y la del APK Analyzer escribe mappings en plural.

5. Keep rules

Una keep rule le dice a R8 qué no puede tocar. Las opciones se dividen en tres ejes que se combinan: qué se conserva (la clase, sus miembros, o ambos), si además se conserva el nombre, y si se permite optimizar por dentro.

Opción Conserva la clase Conserva los miembros Se aplica cuando
-keep Sí Los que se especifiquen Siempre
-keepclassmembers No por sí sola Sí Si la clase se conserva por otra vía
-keepclasseswithmembers Sí Sí Si la clase tiene esos miembros
-keepnames Solo el nombre — La clase puede eliminarse; si sobrevive, no se renombra
-keepclassmembernames — Solo el nombre Ídem para miembros
-keepclasseswithmembernames Solo el nombre Solo el nombre La usa la regla estándar de métodos nativos

Modificadores entre corchetes tras la opción: allowshrinking, allowoptimization, allowobfuscation, allowaccessmodification, allowrepackage, includedescriptorclasses. Comodines: * (un segmento), ** (varios, incluidos puntos), ? (un carácter), *** (cualquier tipo), ... (cualquier lista de argumentos), % (cualquier primitivo). Existen reglas condicionales -if … -keep … con retrorreferencias <1>, <2>.

Un detalle que sorprende, y que depende de la versión: si una regla -keep no especifica miembros, R8 la convierte implícitamente en una regla que conserva el constructor por defecto. Así funciona R8 cuando se usa solo y con AGP hasta la 8.x. Desde AGP 9.0, android.r8.strictFullModeForKeepRules vale true y «-keep class A no longer implies -keep class A { <init>(); }» (notas de AGP 9.0.0): el constructor hay que pedirlo expresamente.

5.1 Los ficheros y su procedencia

Fichero Quién lo pone Qué contiene
proguard-android-optimize.txt AGP, vía getDefaultProguardFile() Reglas base de la plataforma
proguard-rules.pro El desarrollador Las reglas propias del módulo
consumer-rules.pro La biblioteca, vía consumerProguardFiles Reglas que la biblioteca impone a quien la usa
META-INF/proguard/* Un JAR de biblioteca Equivalente al anterior para artefactos JAR
(generado) AAPT2, o R8 con la reducción optimizada de recursos Reglas derivadas de los componentes del manifiesto

La distinción entre proguardFiles y consumerProguardFiles está bien enunciada en la documentación de Google: los primeros «se usan en tiempo de compilación para definir qué parte de tu biblioteca debe conservarse durante la compilación de la biblioteca — como mínimo, su API pública»; los segundos «se empaquetan dentro de la biblioteca para afectar a qué optimizaciones ocurren después, durante la compilación de la aplicación que la consume». Consecuencia práctica para quien analiza: buena parte de las reglas que gobiernan una aplicación no las escribió su autor, vinieron dentro de sus dependencias.

Desde AGP 9.0, publicar una biblioteca falla si sus consumer rules llevan opciones globales como -dontoptimize o -dontobfuscate, y la aplicación ignora en silencio las que lleguen dentro de un JAR o AAR ya compilado (notas de AGP 9.0.0, android.r8.globalOptionsInConsumerRules.disallowed).

El mecanismo tiene dos formas, según cómo se reduzcan los recursos. En la clásica, AAPT2 escribe las reglas al enlazar (aapt2 link --proguard) y AGP se las pasa a R8: es el aapt_rules.txt de build/intermediates/aapt_proguard_file/, que la página oficial de diagnóstico cita entre los orígenes de reglas («rules generated by AAPT») con una MainActivity conservada desde …/processReleaseResources/aapt_rules.txt:4:1. Reproducido con el aapt2 de build-tools 37.0.0 sobre un manifiesto mínimo, sale una regla por componente con su línea de origen: # Referenced at AndroidManifest.xml:5 seguido de -keep class com.ejemplo.demo.MainActivity { <init>(); }. Con la reducción optimizada de recursos —por defecto desde AGP 9.0 cuando isShrinkResources = true— esas reglas no llegan a R8: AGP deja de pasarlas («R8's optimized shrinking does not need AAPT2-generated Proguard rules», ProguardConfigurableTask.kt) y es R8 quien lee el manifiesto compilado y traza los android:name de activity, service, receiver, provider, instrumentation, process y application (ResourceShrinkerState.java).

5.2 Las reglas que AGP empaqueta de verdad

Desde AGP 2.2 los ficheros de $ANDROID_HOME «ya no se mantienen y serán ignorados»; lo que getDefaultProguardFile() desempaqueta son tres ficheros que viven en el propio plugin: proguard-header.txt (solo comentarios), proguard-optimizations.txt (una única línea, -allowaccessmodification) y proguard-common.txt, que es el interesante:

-keepattributes AnnotationDefault, EnclosingMethod, InnerClasses,
                RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations,
                RuntimeVisibleTypeAnnotations, Signature
-keepclasseswithmembernames,includedescriptorclasses class * { native <methods>; }
-keepclassmembers public class * extends android.view.View { void set*(***); *** get*(); }
-keepclassmembers class * extends android.app.Activity { public void *(android.view.View); }
-keepclassmembers enum * { public static **[] values(); public static ** valueOf(java.lang.String); }
-keepclassmembers class * implements android.os.Parcelable { public static final ** CREATOR; }
-keepclassmembers class * { @android.webkit.JavascriptInterface <methods>; }
-keep class android.support.annotation.Keep
-keep @android.support.annotation.Keep class * {*;}

Cada línea es una pista de análisis: que los set*/get* de las subclases de View conserven su nombre, que los values()/valueOf() de los enum sobrevivan, que el campo CREATOR de todo Parcelable se siga llamando CREATOR y que los métodos anotados con @JavascriptInterface mantengan el suyo son anclas fijas en un DEX por lo demás ilegible, y la base de las heurísticas de «Deofuscación».

Nota verificable: el fichero de AGP solo entiende la anotación legacy android.support.annotation.Keep, no androidx.annotation.Keep, y sigue así en el proguard-common.txt de AGP 9.4.1. La versión AndroidX trae sus reglas dentro de la propia biblioteca: androidx.annotation:annotation-jvm:1.11.0 lleva META-INF/proguard/androidx-annotations.pro, con -keep,allowobfuscation @interface androidx.annotation.Keep, -keep @androidx.annotation.Keep class * {*;} y tres -keepclasseswithmembers para métodos, campos y constructores anotados. El @Keep de AndroidX funciona como regla de biblioteca, no porque lo sepa AGP.

5.3 Qué se conserva siempre, y qué rompe

Siempre: los componentes declarados en el manifiesto, que el framework instancia por nombre; los métodos native, por la regla de arriba, «para que se puedan seguir enlazando con la biblioteca nativa»; y lo que digan las reglas de las dependencias.

Lo de los componentes vale con la configuración por defecto. R8 trae ya una poda de los componentes con android:exported="false" y sin intent-filter —no los trata como raíz—, pero apagada: solo se enciende con la propiedad com.android.tools.r8.enableManifestPruning (InternalOptions.java y ResourceShrinkerState.java, etiqueta 9.4.23).

Lo que no se conserva y rompe en ejecución es todo lo alcanzado por reflexión: «R8 no puede identificar cuándo se accede a clases, campos o métodos mediante reflexión… Podría renombrarlos o eliminarlos por completo, lo que produce una ClassNotFoundException o una NoSuchMethodException en tiempo de ejecución». Y lo mismo con JNI: «cuando código nativo llama a un método Java o Kotlin… la llamada se basa en una búsqueda dinámica por cadena del nombre del método».

6. mapping.txt: la gramática exacta

Todo lo que sigue está contrastado contra tres fuentes: la gramática publicada en el manual de ProGuard, el código de R8 (ProguardMapReader.java, ClassNamingForNameMapper.java, Range.java, ProguardMapMarkerInfo.java) y doc/retrace.md del repositorio de R8. Los ejemplos son salida real de R8 9.2.4-dev, el que viene en build-tools/37.0.0/lib/d8.jar de esta máquina; el experimento está en la «sección 11.2 · Generar un mapping.txt real (ejecutada)».

Es un fichero de texto plano en UTF-8, orientado a líneas, con indentación significativa de cuatro espacios.

6.1 El preámbulo de metadatos

Salida real:

# compiler: R8
# compiler_version: 9.2.4-dev
# min_api: 21
# compiler_hash: 084a89126ae0596c599143df57a654646af28313
# common_typos_disable
# {"id":"com.android.tools.r8.mapping","version":"2.2"}
# pg_map_id: 5d9ebd539f55ab9c5999893268740017c8d4b9b6acfe65239fb4fbcdfc906ac5
# pg_map_hash: SHA-256 5d9ebd539f55ab9c5999893268740017c8d4b9b6acfe65239fb4fbcdfc906ac5

El orden y la condicionalidad los fija ProguardMapMarkerInfo.toPreamble():

Línea ¿Siempre? Significado
# compiler: <Tool> Sí D8, L8, R8, R8Partial, Relocator, TraceReferences…
# compiler_version: <v> Sí Versión de R8
# min_api: <n> Solo si genera DEX minSdkVersion numérico
# compiler_hash: <sha> Solo en builds de desarrollo SHA del build de R8
# common_typos_disable Sí Desactiva el linting del fichero en algunos sistemas de build
# {"id":"com.android.tools.r8.mapping","version":"…"} Si la versión > none Versión del formato
# pg_map_id: <id> Sí Identificador del mapping
# pg_map_hash: SHA-256 <hash> Sí Hash del contenido

Dos trampas para un parser. min_api va antes de common_typos_disable, no al final. Y compiler_hash no aparece en releases estables: quien lo dé por obligatorio falla contra mapping.txt de producción. El prefijo SHA-256 de pg_map_hash es literal y R8 lo valida como tal (line.startsWith("# pg_map_hash: SHA-256 ")).

La versión estable del formato en R8 9.2.4-dev, y en main en agosto de 2026, es la 2.2 (MapVersion.STABLE). Existe una 2.3 en el enumerado, pero no es la estable. La regla de compatibilidad está en doc/retrace.md y es la que hace que el formato pueda crecer: «la información de versión se aplica al contenido del fichero que sigue a la posición de la entrada de versión hasta el final del fichero o hasta otra entrada de versión», y «al interpretar el fichero, cualquier información adicional correspondiente a una versión posterior debe ignorarse; dicho de otro modo, tratarse como un comentario normal». Un lector correcto avisa cuando ve una versión superior a la que soporta, y sigue.

6.2 Mapeo de clase

Empieza en columna 0 y termina en dos puntos (originalclassname -> obfuscatedclassname:). Ese : final es su discriminador. Salida real:

com.ejemplo.Calculadora -> com.ejemplo.b:
com.ejemplo.Calculadora$Auxiliar -> com.ejemplo.a:
com.ejemplo.Main -> com.ejemplo.Main:
com.ejemplo.Punto -> a:
com.ejemplo.Lambdas -> R8$$REMOVED$$CLASS$$0:

Cinco cosas de la vida real en cinco líneas: los nombres van con puntos, no con barras ni en formato descriptor; las clases internas se nombran con $ como en el DEX; una clase puede mapearse a sí misma (Main -> Main, porque una keep rule la conservó); R8 puede sacar una clase de su paquete y ponerla en el paquete raíz (Punto -> a), que es -repackageclasses y lo que explica los La;, Laai; de las aplicaciones de Google; y una clase eliminada cuyo código sobrevive inlineado en otra recibe el nombre centinela R8$$REMOVED$$CLASS$$<n>, que sostiene sus marcos inlineados. Una clase eliminada por inalcanzable no aparece en mapping.txt: solo en usage.txt.

6.3 Mapeo de campo

Cuatro espacios de indentación, sin rango de líneas: originalfieldtype originalfieldname -> obfuscatedfieldname. Salida real:

com.ejemplo.Calculadora -> com.ejemplo.b:
    int acumulador -> a
    java.lang.String etiqueta -> b

Dos campos de tipos distintos pueden mapearse al mismo nombre ofuscado, porque en el DEX un field_id lleva class_idx, type_idx y name_idx: a de tipo int y a de tipo String son campos distintos. El mapeo no es biyectivo por el nombre solo.

6.4 Mapeo de método y los rangos de línea

La forma completa, tal como la publica el manual de ProGuard:

    [startline:endline:]originalreturntype [originalclassname.]originalmethodname(args)[:origstart[:origend]] -> obfuscatedmethodname

Y así es como R8 la serializa, en ClassNamingForNameMapper.MappedRange.toString():

if (minifiedRange != null) builder.append(minifiedRange).append(':');
builder.append(signature);
if (originalRange != null && !originalRange.equals(minifiedRange))
    builder.append(":").append(originalRange);
builder.append(" -> ").append(renamedName);

Descompuesto sobre el ejemplo del enunciado clásico, 23:25:void foo():42:44 -> a:

Trozo Nombre en el código de R8 Qué es
23:25 minifiedRange Rango de líneas en el binario producido. Es lo que aparecerá en la traza de pila del APK
void foo() signature Firma original: tipo de retorno, nombre y argumentos originales
42:44 originalRange Rango de líneas en el fichero fuente
a renamedName Nombre ofuscado del método

Tres reglas que un parser tiene que respetar y que casi ninguna descripción informal recoge:

  1. R8 omite originalRange cuando es igual a minifiedRange. La condición literal es !originalRange.equals(minifiedRange). Así que 1:3:void <init>(java.lang.String):7:7 y 1:7:void main(java.lang.String[]):5:11 conviven con líneas sin la segunda mitad.
  2. Un Range «cardinal» se serializa como un solo número, no como from:to. De ahí la tercera forma que documenta el javadoc de ProguardMapReader: range COLON signature COLON number ARROW name.
  3. Puede no haber rango en absoluto, por ejemplo en un método abstracto, que no tiene código. Salida real: double area() -> a.

Salida real completa de una clase con métodos renombrados:

com.ejemplo.Calculadora -> com.ejemplo.b:
    int acumulador -> a
    java.lang.String etiqueta -> b
    1:3:void <init>(java.lang.String):7:7 -> <init>
    4:6:void <init>(java.lang.String):8:8 -> <init>
    7:9:void <init>(java.lang.String):9:9 -> <init>
    1:3:int cuentaDeOperaciones():30:30 -> a
    1:1:void registrar(java.lang.String):23:23 -> b
    2:2:void registrar(java.lang.String):25:25 -> b
    1:7:int resta(int,int):18:18 -> c
    1:7:int suma(int,int):13:13 -> d

Obsérvese que cuatro métodos originales se llaman ahora a, b, c y d y que los rangos de minifiedRange se solapan entre métodos distintos: los dos empiezan en 1. No es un error. El rango solo desambigua dentro del mismo nombre ofuscado: 1:1 -> b y 2:2 -> b son dos líneas del mismo método b, mientras que 1:7 -> c y 1:7 -> d son métodos distintos. La clave de búsqueda es el par (nombre ofuscado, línea).

El rango catch-all. Cuando basta una sola posición, R8 emite el rango completo:

0:65535:void foo():33:33 -> a

doc/retrace.md lo explica: «para asegurar la compatibilidad, R8 emite un rango catch-all 0:65535… Los rangos catch-all nunca deben usarse para sobrecargas».

6.5 Métodos inlineados

Aquí es donde el formato deja de ser una tabla y se convierte en algo con estructura. El javadoc de ProguardMapReader lo enuncia: la forma range COLON signature COLON range ARROW name significa «que signature fue inlineado desde el segundo rango a los números de línea nuevos del primero», y a continuación vienen las entradas «con la información de la traza de llamada hasta donde el miembro fue inlineado».

Salida real de una compilación con optimización, sobre el mismo código:

com.ejemplo.Main -> com.ejemplo.Main:
    1:1:void com.ejemplo.Calculadora.registrar(java.lang.String):24:24 -> main
    1:1:int com.ejemplo.Calculadora.suma(int,int):13 -> main
    1:1:void main(java.lang.String[]):6 -> main
    2:2:double com.ejemplo.Punto.distancia(com.ejemplo.Punto):8:8 -> main
    2:2:void main(java.lang.String[]):9 -> main

La lectura correcta del bloque 1:1, de arriba abajo, es de marco más interno a marco más externo:

línea 1 del método `main` del APK  ──►  Calculadora.registrar(String)  línea 24   ← el fallo
                                        llamado desde
                                        Calculadora.suma(int,int)      línea 13
                                        llamado desde
                                        Main.main(String[])            línea 6

Una sola línea de la traza de pila real se expande en tres marcos. Eso es lo que hace retrace, y es la razón por la que una traza deofuscada puede ser más larga que la original. La señal sintáctica de que una entrada es un marco inlineado venido de otra clase es que la firma lleva el nombre de clase cualificado delante (void com.ejemplo.Calculadora.registrar(...) en vez de void registrar(...)).

6.6 Los comentarios de metadatos por elemento

Además del preámbulo, R8 emite comentarios JSON pegados al elemento al que se refieren. El conjunto exacto de identificadores, extraído de las constantes ID del paquete com.android.tools.r8.naming.mappinginformation:

id Desde Claves Qué dice
com.android.tools.r8.mapping 1.0 version Versión del formato (solo en el preámbulo)
sourceFile 0 fileName Nombre del fichero fuente de la clase
com.android.tools.r8.synthesized 1.0 — Este elemento lo generó el compilador, no estaba en el fuente
com.android.tools.r8.residualsignature 2.2 signature Firma residual (la del binario) cuando difiere de la original
com.android.tools.r8.rewriteFrame 2.0 conditions, actions Reescritura condicional de marcos
com.android.tools.r8.outline 2.0 — Este método es un outline
com.android.tools.r8.outlineCallsite 2.0 positions, outline Correspondencia posición-en-el-outline → posición-en-el-llamador
com.android.tools.r8.mergedClasses — — Clases fusionadas
partitionSourceFiles — fileNameMappings Ficheros fuente en mappings particionados

Dos correcciones a lo que circula por ahí, verificadas en el código: no existe com.android.tools.r8.compilerSynthesized —la clase Java se llama CompilerSynthesizedMappingInformation pero su ID es com.android.tools.r8.synthesized— y no existe com.android.tools.r8.sourceFile: el identificador es el literal sourceFile, sin cualificar, porque, según doc/retrace.md, «es el único identificador sin cualificar permitido, ya que se introdujo antes del esquema de versionado». Los dos últimos de la tabla existen en el código pero no están documentados en doc/retrace.md.

Salida real con clases sintéticas de lambda y firma residual:

com.ejemplo.Lambdas$1 -> b:
# {"id":"sourceFile","fileName":"R8$$SyntheticClass"}
# {"id":"com.android.tools.r8.synthesized"}
    int com.ejemplo.Lambdas$$InternalSyntheticLambda$1$5e2a…$2.f$0 -> a
      # {"id":"com.android.tools.r8.synthesized"}
    1:1:boolean com.ejemplo.Lambdas.lambda$run$2(int,int,java.lang.String):19:19 -> test
      # {"id":"com.android.tools.r8.residualsignature","signature":"(Ljava/lang/Object;)Z"}

Tres cosas que enseña ese fragmento. El fileName de una clase sintética es la cadena literal R8$$SyntheticClass, que aparece tal cual dentro del DEX y es una huella excelente («sección 9.2 · El atributo SourceFile»). El nombre original de una lambda desugarizada es …$$InternalSyntheticLambda$<n>$<sha256>$<m>, con un hash de 64 hexadecimales dentro. Y residualsignature existe porque la firma del binario no se puede deducir de la original: aquí el parámetro String se convirtió en Object al implementar Predicate. Según doc/retrace.md, «no tiene efecto sobre el retrace de trazas de pila, pero es necesaria al interactuar con firmas residuales a través de la API de Retrace o al componer ficheros de mapping».

Reglas de colocación, verbatim: la información de fichero fuente «debe colocarse directamente debajo del mapeo de clase al que se refiere»; la de synthesized, «directamente debajo del mapeo de clase, campo o método al que se refiere», y «nunca debe colocarse sobre marcos inlineados».

rewriteFrame es el más raro y merece nota: su condición throws(<descriptor>) «será cierta si la excepción lanzada arriba es <descriptor>» —varias condiciones se combinan con AND, y para hacer un OR hay que duplicar la información— y su acción removeInnerFrames(<n>) «elimina ese número de marcos empezando por el más interno», y solo se aplica «si la línea que se está deofuscando está directamente debajo de la línea de la excepción». Sirve para que los NullPointerException sintéticos que R8 introduce al inlinear no salgan en la traza.

⚠️ Los ejemplos de doc/retrace.md usan comillas simples en el JSON; el fichero real que emite R8 usa comillas dobles, como se ve arriba. Un parser tolerante acepta ambas.

6.7 El algoritmo de lectura

estado: clase_actual = ninguna
para cada línea:
  1. empieza por '#', con o sin sangría
                                     → metadato
       · preámbulo                   → compilador, versión, min_api, map id
       · JSON con "id"               → asociarlo al último elemento emitido
       · versión desconocida         → avisar y tratar como comentario
  2. no empieza por espacio, acaba en ':'
                                     → clase: partir por " -> ", clase_actual = esta
  3. empieza por espacios            → miembro de clase_actual, partir por " -> "
       · empieza por dígito y ':'    → método con rango
       · contiene '('                → método sin rango
       · en otro caso                → campo

La operación inversa —la de retrace— está desarrollada en «Deofuscación → §3.1 · El algoritmo», con sus tres casos límite: entradas sin minifiedRange, rangos solapados, y la no inyectividad del mapping, que hace la deofuscación ambigua cuando la traza no trae número de línea.

7. retrace

retrace es la herramienta que aplica un mapping.txt a una traza de pila. La definición oficial: «R8 retrace es una herramienta para obtener la traza de pila original a partir de una traza ofuscada. La traza se reconstruye emparejando los nombres de clase y método de un fichero de mapping con sus definiciones originales».

retrace  <fichero-de-mapping> [<fichero-de-traza>] [opciones]

Se instala con el SDK Manager en cmdline-tools/<version>/bin/. Sin fichero de traza lee de la entrada estándar. Opciones, las mismas que lista retrace --help en Retrace 8.9.27 (cmdline-tools 19.0): --verbose («imprime más información, como los parámetros y el tipo de retorno del método»), --info, --quiet, --regex <expresión> y --verify-mapping-file-hash. El retrace.jar de ProGuard acepta la misma idea con una sintaxis de comodines propia (%c clase, %m método, %l línea, %s fichero fuente…) y exige que las expresiones usen solo grupos sin captura, (?:...).

Ejecutado de verdad sobre el mapping.txt generado en la «sección 11.2 · Generar un mapping.txt real (ejecutada)», con java -cp $BT/lib/d8.jar com.android.tools.r8.retrace.Retrace mapping2.txt traza.txt:

java.lang.IllegalStateException: demo:suma        java.lang.IllegalStateException: demo:suma
	at com.ejemplo.b.b(SourceFile:2)          →   	at com.ejemplo.Calculadora.registrar(Calculadora.java:25)
	at com.ejemplo.b.d(SourceFile:1)              	at com.ejemplo.Calculadora.suma(Calculadora.java:13)
	at com.ejemplo.Main.main(SourceFile:6)        	at com.ejemplo.Main.main(Main.java:10)

Y sobre el mapping.txt con optimización, una sola línea de entrada produce tres:

java.lang.NullPointerException                     java.lang.NullPointerException
	at com.ejemplo.Main2.main(SourceFile:4)    →   	at com.ejemplo.Lambdas.filtra(Lambdas.java:12)
                                                   	at com.ejemplo.Lambdas.run(Lambdas.java:19)
                                                   	at com.ejemplo.Main2.main(Main2.java:5)

Por qué es deofuscación determinista y sin riesgo. Porque no infiere nada. Es una búsqueda en una tabla que produjo el mismo compilador que produjo el binario: no hay heurística, no hay confianza que declarar, no hay falsos positivos posibles. O el mapping.txt corresponde al APK —y el hash pg_map_id permite comprobarlo, «sección 9.1 · El marcador ~~R8{…} dentro del DEX»— o no corresponde y la respuesta es «no lo sé». Es la razón por la que retrace es la única deofuscación que se puede ofrecer sin reservas.

8. R8 full mode

R8 tiene dos modos. El modo compatibilidad imita a ProGuard; el modo completo (full mode) hace suposiciones más fuertes sobre el código y optimiza más. android.enableR8.fullMode pasó a valer true por defecto en AGP 8.0.

Las siete diferencias, tomadas literalmente de compatibility-faq.md del repositorio de R8:

  1. El constructor por defecto <init>() no se conserva implícitamente al conservar una clase.
  2. Tampoco se conserva para tipos que solo se usan con ldc, instanceof o checkcast.
  3. Las clases que contienen campos o métodos casados por una -keepclassmembers no se consideran implícitamente instanciadas.
  4. Los métodos default no se conservan implícitamente como métodos abstractos.
  5. Los atributos (como Signature) y las anotaciones solo se conservan para las clases, métodos y campos casados por una keep rule, aunque se haya puesto -keepattributes.
  6. Al optimizar o minificar, el atributo SourceFile siempre se reescribe a SourceFile salvo que se use -renamesourcefileattribute. En el R8 9.2.4-dev de build-tools 37 el valor por defecto en full mode es r8-map-id-<hash> («sección 9.2 · El atributo SourceFile»).
  7. La modificación de acceso (-allowaccessmodification) está activada por defecto al optimizar.

Los puntos 1 a 4 son la razón de que el modo completo «rompa más código»: cada uno elimina una conservación implícita en la que muchos proyectos se apoyaban sin saberlo, y el síntoma es una ClassNotFoundException o una NoSuchMethodException en ejecución.

El punto 5 es el que más duele al analizar, no al compilar: sin Signature desaparecen los genéricos, que en el DEX viven en la anotación de sistema dalvik.annotation.Signature («Formato DEX → §9 · La sección de datos»). Un List<String> decompila como List y ya no hay forma de recuperar el parámetro de tipo.

⚠️ Discrepancia observada entre fuentes oficiales: el mismo compatibility-faq.md afirma que «el modo compatibilidad de R8 es el predeterminado en Android Studio», lo que contradice las notas de AGP 8.0. El texto de la FAQ parece desactualizado. Los datos del muestrario apoyan las notas de AGP: de los 90 marcadores con campo r8-mode encontrados, 64 dicen full y 26 dicen compatibility, y los compatibility se concentran en bibliotecas compiladas con versiones antiguas de R8 que el AAR traía ya dexificadas.

9. Cómo se detecta que una app pasó por R8

En orden de fiabilidad, de la certeza a la estadística.

9.1 El marcador ~~R8{…} dentro del DEX

R8, D8 y L8 escriben su propia firma como una cadena del DEX. Es un objeto JSON precedido de dos virgulillas, y es la huella más fuerte que existe porque no es una inferencia: es una declaración. Salida real de la compilación de la «sección 11.2 · Generar un mapping.txt real (ejecutada)»:

~~R8{"backend":"dex","compilation-mode":"release","has-checksums":false,"min-api":21,
     "pg-map-id":"5d9ebd539f55ab9c5999893268740017c8d4b9b6acfe65239fb4fbcdfc906ac5",
     "r8-mode":"full","sha-1":"084a89126ae0596c599143df57a654646af28313","version":"9.2.4-dev"}

(partido en tres líneas aquí; en el fichero es una sola cadena).

Qué se saca de ahí, de un APK cualquiera y sin herramientas: qué herramienta lo procesó, qué versión exacta, si se compiló en release o en debug, contra qué minSdkVersion, si estaba en modo full o compatibility, y el pg-map-id, que es literalmente el mismo valor que la línea # pg_map_id: del mapping.txt. Es decir: se puede verificar que un mapping.txt dado corresponde a un APK dado sin ejecutar nada.

Buscarlo es trivial:

MUESTRAS=~/muestras-apk
unzip -p $MUESTRAS/com.looker.droidify_710.apk classes.dex | strings | grep '~~'

Medido sobre el muestrario completo, extrayendo el marcador de los 323 classes*.dex de los 86 contenedores:

Observación Valor
Contenedores con al menos un marcador ~~R8 69 de 86
Contenedores sin ningún marcador ~~D8/~~R8/~~L8 5
Versiones distintas de R8/D8/L8 observadas 45
Marcadores con r8-mode 90: 64 full, 26 compatibility

Los cinco sin marcador son com.whatsapp.apks, com.supercell.clashroyale.apks, com.moonactive.coinmaster.apks, org.gnucash.android_2.4.0.apk y io.github.muntashirakon.AppManager_445.apk. La ausencia del marcador es en sí una señal: en WhatsApp, que sí está claramente ofuscado («sección 10 · Medición propia: la fracción de identificadores cortos»), significa que algo posterior a R8 reescribió el DEX y se llevó la cadena por delante.

Un matiz que evita conclusiones erróneas: una aplicación trae muchos marcadores, no uno. Twitch trae 12 con 8 pg-map-id distintos y versiones desde la 2.2.66 hasta la 8.12.x; los AAR de terceros vienen ya dexificados y con su propio marcador. El marcador del DEX que contiene el código de la aplicación es el que importa; los demás describen sus dependencias —y, de paso, delatan qué bibliotecas usa.

9.2 El atributo SourceFile

Cada class_def_item tiene un source_file_idx («Formato DEX → §10 · class_def_item y class_data_item»). En un binario compilado sin ofuscar hay un nombre de fichero distinto por clase; tras pasar por R8 hay uno solo, y su valor identifica el camino recorrido:

Valor de SourceFile Qué significa Origen
Muchos valores distintos (Foo.java, Bar.kt…) Sin renombrar —
SourceFile Valor por defecto de R8 DexItemFactory.defaultSourceFileAttributeString
PG Valor centinela heredado de ProGuard DexItemFactory.pgSourceFileAttributeString
R8$$SyntheticClass / D8$$SyntheticClass Clase sintética generada por el compilador mapping.txt, {"id":"sourceFile"}
r8-map-id-<64 hex> El pg_map_id incrustado en el SourceFile SourceFileRewriter.computeSourceFileProvider
Cadena vacía Reescrito a vacío por algo posterior —

La lógica exacta está en SourceFileRewriter.java: -renamesourcefileattribute solo se aplica si SourceFile está en -keepattributes; en modo compatibilidad, además, solo cuando se están minificando nombres; y si no está, R8 reescribe al valor por defecto, que en el R8 9.2.4-dev de build-tools 37 es "SourceFile" en modo compatibilidad y r8-map-id-<hash> en full mode, el caso que sigue.

El caso r8-map-id-… merece una nota, porque el nombre del interruptor engaña. En el fuente de R8 la rama vive en SourceFileRewriter.computeSourceFileProvider y está condicionada a options.getTestingOptions().enableMapIdInSourceFile, lo que hace pensar en una opción experimental. No lo es: el campo se declara

public boolean enableMapIdInSourceFile =
    SystemPropertyUtils.parseSystemPropertyOrDefault(
        "com.android.tools.r8.enableMapIdInSourceFile", true);

es decir, activa por defecto en cualquier R8 estándar, salvo que se desactive con esa propiedad de sistema. Comprobado con el R8 9.2.4-dev de build-tools 37: en full mode escribe r8-map-id-<hash> con -keepattributes SourceFile y sin ella, y con -Dcom.android.tools.r8.enableMapIdInSourceFile=false vuelve a SourceFile. Vivir en TestingOptions no la hace experimental. Eso explica sin misterio que aparezca en 19 de las 85 aplicaciones medidas —Twitch, Reddit, K-9 Mail, NewPipe, Wikipedia, AnkiDroid y siete aplicaciones de Fossify—. Es además la huella más útil de todas, porque el valor contiene el identificador del mapping.txt correspondiente.

Distribución del valor mayoritario de SourceFile sobre las 85 aplicaciones con DEX:

Valor mayoritario Contenedores
SourceFile 31
r8-map-id-<sha256> 19
D8$$SyntheticClass 11
R8$$SyntheticClass 9
PG 9
Cadena vacía 2
Otros (R.java, lambda, nombre de artefacto Maven) 4

Las nueve aplicaciones con PG son las nueve de Google del muestrario: Gmail, Maps, YouTube, Photos, Drive, Keep —en sus dos empaquetados— más Authenticator y Calculator. Es una huella de la cadena de compilación interna de Google, no de AGP.

Y 59 de los 85 contenedores tienen un único valor de SourceFile para todas sus clases, que es el indicador binario más barato de «esto pasó por un reescritor de atributos».

9.3 Otras huellas

  • com.google.android.gms sin renombrar junto a clases La;, Lb;: es el patrón normal de una aplicación ofuscada con dependencias de Google, cuyas consumer rules conservan buena parte de su superficie. Netflix, con DexGuard, tiene 475 clases en com/google/android/gms/internal/cast sin ofuscar al lado de 17.329 clases en un paquete llamado o.
  • Clases sintéticas con nombres reconocibles: $$ExternalSyntheticLambda<n>, $$InternalSyntheticLambda$<n>$<sha256>$<m>, $$ExternalSyntheticApiModelOutline. R8 y D8 los generan y no los renombran; su sola presencia identifica la herramienta.
  • El paquete raíz poblado, resultado de -repackageclasses/-flattenpackagehierarchy. El classes.dex de Gmail empieza literalmente por La;, Laai;, Laaj;.
  • META-INF/*.kotlin_module intacto: conservan el nombre del módulo Gradle original y R8 no los toca. En Shazam, ofuscado al máximo, siguen ahí AMSKit_release.kotlin_module y app_googleRelease.kotlin_module; en Reddit, account_impl.kotlin_module, awards_public.kotlin_module, deeplinkdispatch_release.kotlin_module.
  • Pocos debug_info_item para muchos métodos: R8 codifica las líneas por PC y hace que muchos code_item compartan el mismo debug_info_item. No es ausencia de información de depuración: en una compilación de prueba con el R8 9.2.4 de build-tools 37, los 48 code_item tienen debug_info_off != 0 y apuntan a solo 3 debug_info_item distintos, con -keepattributes LineNumberTable y sin ella. En el classes.dex de com.looker.droidify_710.apk, del muestrario, 28.006 code_item comparten 1.113 items y solo 21 tienen debug_info_off == 0. Lo que hay que contar son esos code_item, no los items; ver «Formato DEX → §12 · debug_info_item».

10. Medición propia: la fracción de identificadores cortos

10.1 Definición de la métrica

Una forma habitual de caracterizar la ofuscación es la «fracción de identificadores de uno o dos caracteres en el primer DEX». Esa métrica se ha reimplementado desde cero —un lector de DEX en Python que abre el base.apk del contenedor, lee su classes.dex y recorre class_defs, class_data_item, field_ids, method_ids y string_ids— con esta definición precisa:

  • Universo: los identificadores definidos en el primer DEX, no los referenciados. Es decir, para los miembros se recorre el class_data_item de cada class_def_item y se siguen los field_idx_diff/method_idx_diff, no se toman las tablas enteras: las tablas incluyen referencias a java.lang.String.length y a medio framework, que no ofusca nadie.
  • Clases: el nombre simple de la clase de nivel superior, deduplicado. De Lcom/foo/Bar$Baz$1; se toma Bar. Los sufijos $1, $2 de clases anónimas los pone el compilador esté ofuscado el código o no, y contarlos como identificadores cortos infla la cifra en un 20 % incluso en aplicaciones limpias.
  • Métodos: se excluyen <init> y <clinit>, que nunca se renombran.
  • Longitud: en unidades UTF-16, leída del utf16_size del string_data_item, lo que evita tener que decodificar MUTF-8 para contar.

10.2 El resultado, y por qué el umbral de dos caracteres es el instrumento equivocado

App Identificadores ≤ 1 ≤ 2 ≤ 3 Mediana SourceFile
YouTube 70.622 62,7 % 85,4 % 92,2 % 1 PG
Maps 60.397 64,2 % 85,4 % 91,0 % 1 PG
Gmail 61.604 69,4 % 83,1 % 91,3 % 1 PG
Photos 63.919 63,1 % 81,4 % 90,7 % 1 PG
Snapchat 97.035 51,2 % 81,2 % 89,7 % 1 SourceFile
Keep 63.545 64,2 % 77,6 % 89,2 % 1 PG
Drive 87.393 72,3 % 76,4 % 87,1 % 1 PG
VLC Remote 72.826 74,5 % 82,1 % 82,7 % 1 SourceFile
WhatsApp 67.796 0,0 % 0,3 % 79,8 % 3 (vacío)
Spotify 82.402 61,4 % 69,3 % 73,8 % 1 SourceFile
Shazam 88.457 50,1 % 54,3 % 70,0 % 1 SourceFile
Duolingo 90.728 45,8 % 55,8 % 64,9 % 2 SourceFile
Twitch 59.406 54,5 % 61,8 % 62,6 % 1 r8-map-id-…
Reddit 97.480 48,2 % 52,8 % 61,2 % 2 r8-map-id-…
My Talking Tom 85.947 51,6 % 55,4 % 56,5 % 1 SourceFile
Telegram 86.187 1,1 % 1,3 % 29,9 % 8 SourceFile
Netflix 80.238 16,7 % 19,2 % 22,0 % 10 (vacío)
Clash Royale 105.944 1,7 % 2,0 % 19,6 % 11 SourceFile
Coin Master 96.598 8,2 % 9,2 % 14,5 % 17 SourceFile (810 distintos)
Signal 83.604 0,3 % 0,6 % 7,4 % 13 R8$$SyntheticClass (2.580)
LinkedIn Learning 81.305 0,7 % 0,9 % 7,1 % 13 SourceFile
Candy Crush 95.783 0,7 % 0,8 % 4,0 % 15 R8$$SyntheticClass (2.486)

La fila de WhatsApp es la que enseña algo. Con el umbral de dos caracteres da 0,3 %: «sin ofuscar». Con tres da 79,8 %. Mirando los nombres se ve por qué: sus clases se llaman LX/000;, LX/001;, LX/00A; — exactamente tres caracteres, dentro de un paquete de uno. Es de las aplicaciones más ofuscadas del muestrario y el umbral de dos la declara limpia.

La causa es estructural: R8 genera nombres por orden lexicográfico creciente, a, b, …, z, aa, ab, …, aaa, así que cuando el espacio de nombres de un ámbito se llena, los nombres crecen. Una aplicación con 60.000 identificadores en el paquete raíz necesariamente tiene miles de nombres de tres y cuatro caracteres. El umbral fijo no mide «cuánto está ofuscada la app», mide «cuántos identificadores caben en el ámbito antes de desbordar», que depende del tamaño y del esquema de repaquetado.

El indicador robusto es la mediana de la longitud. La distribución es netamente bimodal: en el grupo ofuscado la mediana vale 1 o 2 —3 en WhatsApp—, y en el no ofuscado, entre 8 y 17. No hay nada en medio salvo cuatro aplicaciones con mediana 4 o 6, las de ofuscación parcial.

10.3 El grupo de control de F-Droid

Medidos 57 de los 58 APK sueltos —todos menos com.termux.styling_1000.apk, que solo define 155 identificadores—, 37 caen por debajo del 20 % de identificadores de tres o menos caracteres (mediana de la métrica: 4,4 %; medianas de longitud entre 10 y 17) y 20 caen por encima (mediana: 72,3 %; medianas de longitud de 1 a 6). De esos 57, 53 vienen de F-Droid, tres de la publicación de su propio proyecto —Bitwarden, GnuCash y Orbot— y uno de APKPure; la procedencia de cada uno está en la «sección 5 de El muestrario · De dónde salen y cuándo».

Es decir: la premisa de que los sueltos de F-Droid son un grupo de control «mayoritariamente sin ofuscar» solo es cierta para dos tercios. F-Droid compila desde el fuente con la configuración Gradle del propio proyecto, y si el proyecto pone minifyEnabled = true, entrega un APK ofuscado. Las doce aplicaciones de Fossify, AnkiDroid, Aurora Store, Bitwarden, My Expenses, Pachli, Material Files, net.typeblog.shelter e io.github.subhamtyagi.ocr —los 20 del grupo alto— están tan ofuscadas como Twitch. Como grupo de control real quedan los 37 del extremo bajo: Termux (2,3 %, mediana 14), osmdroid (2,6 %, 15), AntennaPod (2,8 %, 17), F-Droid mismo, VLC, NewPipe, Briar y SimpleX Chat, entre otros.

Eso no es un defecto del muestrario: es mejor de lo previsto, porque esos 20 ofuscados son los únicos casos en los que el código fuente está disponible, y por tanto los únicos donde se puede construir la verdad de terreno de un evaluador de deofuscación.

10.4 Qué indicador usar, y la sorpresa de Google

De la medición salen dos conclusiones que conviene dejar dichas, porque ninguna se ve de un vistazo en la tabla.

El indicador principal es la mediana de longitud, no un umbral fijo. Ya se ha visto con WhatsApp, que pasa del 0,3 % al 79,8 % entre el umbral de dos caracteres y el de tres; Telegram hace lo mismo del 1,3 % al 29,9 %. Un umbral no ordena las aplicaciones, las baraja, y cuál elegir cambia el resultado más que la ofuscación que pretende medir. La mediana las separa limpiamente —1 en las ofuscadas al máximo, de 8 a 17 en las que no lo están— y no depende del tamaño del ámbito de nombres.

Las aplicaciones de Google están entre las más ofuscadas del muestrario, no entre las menos. Es contraintuitivo, y la tabla de la «sección 10.2 · El resultado, y por qué el umbral de dos caracteres es el instrumento equivocado» lo dice sin ambigüedad: YouTube, Maps, Gmail, Photos, Keep y Drive ocupan seis de las siete primeras filas. La evidencia directa lo confirma: la primera clase de classes.dex de Gmail es La;, seguida de Laai;, Laaj;, Laax;; su SourceFile es PG para las 5.452 clases; su ≤ 3 es del 91,3 % y su mediana de longitud de identificador, 1.

Y el muestrario sirve como gradiente: va del 4,0 % de Candy Crush al 92,2 % de YouTube, con casos intermedios y con casos de ofuscación parcial. Hay dónde elegir para probar una herramienta contra grados distintos de ofuscación.

11. Recetas

11.1 Sobre un APK cualquiera (ejecutadas)

BT=~/Library/Android/sdk/build-tools/37.0.0
MUESTRAS=~/muestras-apk
Objetivo Orden
Marcador de compilador unzip -p $MUESTRAS/com.termux_1002.apk classes.dex | strings | grep '~~'
Valores de SourceFile $BT/dexdump -h classes.dex | grep source_file_idx | sort | uniq -c
Nombres de clase definidos $BT/dexdump -h classes.dex | grep 'Class descriptor'
Módulos Kotlin supervivientes unzip -l app.apk 'META-INF/*.kotlin_module'

11.2 Generar un mapping.txt real (ejecutada)

R8 y retrace están disponibles en esta máquina dentro de build-tools/37.0.0/lib/d8.jar, aunque no aparezcan como binarios sueltos. Es la forma de obtener salida real del formato sin instalar nada:

BT=~/Library/Android/sdk/build-tools/37.0.0
java -cp $BT/lib/d8.jar com.android.tools.r8.R8 --version
# R8 9.2.4-dev (build 084a89126ae0596c599143df57a654646af28313 from go/r8bot …)

javac -g -d cls $(find src -name '*.java')
printf -- '-keep class com.ejemplo.Main { public static void main(java.lang.String[]); }\n-keepattributes SourceFile,LineNumberTable\n-dontoptimize\n-dontshrink\n' > reglas.pro
mkdir -p out
java -cp $BT/lib/d8.jar com.android.tools.r8.R8 --release --min-api 21 \
  --lib ~/Library/Android/sdk/platforms/android-34/android.jar \
  --pg-conf reglas.pro --pg-map-output mapping.txt --output out cls/com/ejemplo/*.class

--output debe ser un directorio que ya exista o un .zip/.jar; si no, R8 falla con Invalid output. --pg-compat selecciona el modo compatibilidad; sin él, R8 compila en full mode, que es lo que confirma la «sección 8 · R8 full mode». Todo el mapping.txt de las «secciones 6.1 · El preámbulo de metadatos» a 6.6 y la ejecución de retrace de la «sección 7 · retrace» salen de este experimento.

11.3 retrace, y los dos programas que se llaman igual

Hay dos retrace y hacen lo mismo con implementaciones distintas: el de Guardsquare, que viene con ProGuard, y el de Google, que viene con cmdline-tools y entiende el formato extendido de R8. Si están los dos instalados colisionan en el PATH, así que conviene invocarlos por ruta absoluta. Verificado el 13 de agosto de 2026 con ProGuard 7.9.1 (Homebrew) y Retrace 8.9.27 (cmdline-tools).

Partiendo del mapping.txt del experimento de 11.2 y de esta traza ofuscada:

java.lang.ArithmeticException: divide by zero
	at com.ejemplo.Main.a(SourceFile:3)
	at com.ejemplo.Main.main(SourceFile:4)
~/Library/Android/sdk/cmdline-tools/latest/bin/retrace mapping.txt traza.txt
java.lang.ArithmeticException: divide by zero
	at com.ejemplo.Main.calcula(Main.java:3)
	at com.ejemplo.Main.main(Main.java:4)

Deshace las dos cosas a la vez: el nombre del método —a vuelve a ser calcula— y el del fichero fuente, porque R8 escribió el nombre real en el comentario # {"id":"sourceFile","fileName":"Main.java"} de la «sección 6.2 · Mapeo de clase». Para eso no hace falta -keepattributes SourceFile: desde R8 8.2 el nombre original va siempre al mapping (compatibility FAQ), y con el R8 9.2.4 de build-tools 37 el comentario sale igual con la regla y sin ella.

ProGuard independiente también se ejecuta, con la sintaxis de fichero de configuración:

proguard @config.pro
ProGuard, version 7.9.1

El mapping.txt tiene que ser el del binario del que salió la traza. Aplicando el mapa que produjo ProGuard en su propia compilación a una traza generada por el binario de R8, el retrace de Guardsquare no falla: devuelve la traza medio traducida, con el fichero resuelto y el método sin resolver.

	at com.ejemplo.Main.a(Main.java:3)

Ese a que sobrevive es la única señal de que el mapa no correspondía. No hay error, no hay código de salida distinto de cero, y una automatización que no compare el pg_map_hash de la «sección 6.1 · El preámbulo de metadatos» se lo traga entero.

Fuentes

  1. Enable app optimization with R8 — https://developer.android.com/build/shrink-code Consultado el 12 de agosto de 2026. De aquí salen la separación entre shrinking, optimización y ofuscación («sección 3 · Los tres trabajos, que no son el mismo»), las dos DSL y el source set keepRules («sección 4 · Cómo se enciende y dónde viven las reglas»), y el estado de full mode por versión de AGP («sección 8 · R8 full mode»).
  2. Troubleshoot app optimization — https://developer.android.com/topic/performance/app-optimization/troubleshoot-the-optimization Consultado el 12 de agosto de 2026. De aquí salen la ruta de mapping.txt, su empaquetado en el AAB, configuration.txt y la invocación de retrace (secciones «4» y «7»). Consultado de nuevo el 25 de septiembre de 2026: sigue dando la ruta de configuration.txt sin la variante («sección 4 · Cómo se enciende y dónde viven las reglas»).
  3. Add keep rules / Keep rules overview / Optimize your library — https://developer.android.com/topic/performance/app-optimization/add-keep-rules, .../keep-rules-overview y .../library-optimization Consultados el 12 de agosto de 2026. Del primero, la tabla de opciones, modificadores y comodines de la «sección 5 · Keep rules» y la regla del constructor por defecto implícito; del segundo, las citas sobre reflexión y JNI de la «sección 5.3 · Qué se conserva siempre, y qué rompe»; del tercero, consumerProguardFiles, META-INF/proguard/ y la distinción de la «sección 5.1 · Los ficheros y su procedencia».
  4. Analyze your build with APK Analyzer — https://developer.android.com/studio/debug/apk-analyzer Consultado el 12 de agosto de 2026. De aquí salen seeds.txt y usage.txt y la discrepancia de ruta señalada en la «sección 4 · Cómo se enciende y dónde viven las reglas». Consultado de nuevo el 25 de septiembre de 2026: sigue escribiendo mappings, en plural.
  5. Retrace — https://developer.android.com/tools/retrace Consultado el 12 de agosto de 2026. De aquí salen la definición, la sintaxis y las opciones de retrace de la «sección 7 · retrace».
  6. Android Gradle plugin 8.0.0 release notes — https://developer.android.com/build/releases/past-releases/agp-8-0-0-release-notes Consultado el 12 de agosto de 2026. De aquí sale que android.enableR8.fullMode pasa a true por defecto («sección 8 · R8 full mode»).
  7. Android Gradle plugin 3.4.0 release notes — https://developer.android.com/build/releases/past-releases/agp-3-4-0-release-notes Consultado el 12 de agosto de 2026. De aquí sale el relevo de ProGuard por R8 («sección 2 · Historia y relevo»).
  8. R8 — compatibility FAQ — https://r8.googlesource.com/r8/+/refs/heads/main/compatibility-faq.md Consultado el 12 de agosto de 2026. De aquí salen las siete diferencias del full mode y la discrepancia señalada al final de la «sección 8 · R8 full mode». Consultado de nuevo el 25 de septiembre de 2026: de aquí sale también que desde R8 8.2 no hace falta -keepattributes SourceFile para conservar el nombre del fichero fuente en el mapping («sección 11.3 · retrace, y los dos programas que se llaman igual»).
  9. R8 — doc/retrace.md — https://r8.googlesource.com/r8/+/refs/heads/main/doc/retrace.md Consultado el 12 de agosto de 2026. De aquí salen el versionado del formato, el rango catch-all 0:65535, y la semántica de sourceFile, synthesized, rewriteFrame, outline, outlineCallsite y residualsignature (secciones «6.1», «6.4» y «6.6»).
  10. R8 — código fuente — https://r8.googlesource.com/r8/ Consultado el 12 de agosto de 2026. Ficheros leídos: src/main/java/com/android/tools/r8/naming/ProguardMapReader.java (gramática del javadoc), ClassNamingForNameMapper.java y Range.java (serialización exacta de la «sección 6.4 · Mapeo de método y los rangos de línea»), ProguardMapMarkerInfo.java y MapVersion.java (preámbulo y versión estable de la «sección 6.1 · El preámbulo de metadatos»), SourceFileRewriter.java y graph/DexItemFactory.java (valores centinela SourceFile, PG y r8-map-id- de la «sección 9.2 · El atributo SourceFile»), dex/Marker.java (valores de # compiler:) y naming/mappinginformation/*.java (los id de la «sección 6.6 · Los comentarios de metadatos por elemento»).
  11. Reglas por defecto que empaqueta AGP — https://android.googlesource.com/platform/tools/base/+/refs/heads/mirror-goog-studio-main/build-system/gradle-core/src/main/resources/com/android/build/gradle/ Consultado el 12 de agosto de 2026. De aquí salen íntegros proguard-header.txt, proguard-common.txt y proguard-optimizations.txt de la «sección 5.2 · Las reglas que AGP empaqueta de verdad».
  12. ProGuard manual — Retrace — https://www.guardsquare.com/manual/tools/retrace Consultado el 12 de agosto de 2026. De aquí sale la gramática formal de la «sección 6 · mapping.txt: la gramática exacta» y la sintaxis de comodines de retrace.jar de la «sección 7 · retrace».
  13. ProGuard manual — Usage / Home — https://www.guardsquare.com/manual/configuration/usage y https://www.guardsquare.com/manual/home Consultados el 12 de agosto de 2026. De aquí salen -renamesourcefileattribute, -keepattributes, -printmapping, -applymapping, -printseeds, -printusage, y los cuatro pasos de ProGuard de la «sección 2 · Historia y relevo».
  14. The ProGuard story: 20 years of innovation — https://www.guardsquare.com/blog/the-proguard-story-20-years-of-innovation-in-java-optimization-guardsquare Consultado el 12 de agosto de 2026. De aquí salen Eric Lafortune, el primer commit del 1 de mayo de 2002, Gingerbread, 2012 y 2014 («sección 2 · Historia y relevo»).
  15. Guardsquare/proguard — https://github.com/Guardsquare/proguard Consultado el 12 de agosto de 2026. De aquí salen la licencia GPL-2.0 con excepciones y el tag v7.9.1 («sección 2 · Historia y relevo»). ⚠️ El año de esa release no es visible en la interfaz.
  16. Dalvik executable format — https://source.android.com/docs/core/runtime/dex-format Consultado el 12 de agosto de 2026. De aquí sale source_file_idx del class_def_item y el papel de dalvik.annotation.Signature (secciones «8» y «9.2»).
  17. ProGuard 7.9.1 y Retrace 8.9.27 ejecutados localmente — ProGuard instalado con Homebrew y retrace de cmdline-tools, el 13 de agosto de 2026. De aquí salen las tres ejecuciones de la «sección 11.3 · retrace, y los dos programas que se llaman igual», incluida la traducción a medias que produce aplicar un mapping.txt que no corresponde al binario.
  18. Android Gradle Plugin 9.0.0 release notes — https://developer.android.com/build/releases/agp-9-0-0-release-notes Consultado el 24 de septiembre de 2026. De aquí sale android.r8.strictFullModeForKeepRules y el fin del constructor implícito de las reglas -keep («sección 5 · Keep rules»). Consultado de nuevo el 25 de septiembre de 2026: de aquí salen también la optimización de recursos por defecto («sección 3 · Los tres trabajos, que no son el mismo»), el rechazo de opciones globales en las consumer rules («sección 5.1 · Los ficheros y su procedencia») y la ruta de configuration.txt con la variante («sección 4 · Cómo se enciende y dónde viven las reglas»).
  19. R8 9.2.4-dev de build-tools 37 ejecutado localmente — java -cp build-tools/37.0.0/lib/d8.jar com.android.tools.r8.R8, con dexdump y un lector de code_item en Python Consultado el 25 de septiembre de 2026. De aquí salen el método sin rango de la «sección 6.4 · Mapeo de método y los rangos de línea», la clase inlineada R8$$REMOVED$$CLASS$$0 frente a la inalcanzable, que solo va a usage.txt («sección 6.2 · Mapeo de clase»), los debug_info_item compartidos de la «sección 9.3 · Otras huellas» y el comentario sourceFile sin -keepattributes SourceFile de la «sección 11.3 · retrace, y los dos programas que se llaman igual».
  20. API de GitHub, repos/Guardsquare/proguard/releases — https://api.github.com/repos/Guardsquare/proguard/releases Consultado el 25 de septiembre de 2026. De aquí salen la v7.10.0, del 24 de agosto de 2026, y la fecha de la v7.9.1, el 9 de abril de 2026 («sección 2 · Historia y relevo»).
  21. retrace --help de cmdline-tools 19.0, ejecutado localmente — ~/Library/Android/sdk/cmdline-tools/latest/bin/retrace --help Consultado el 25 de septiembre de 2026. De aquí sale que Retrace 8.9.27 lista exactamente --regex, --verbose, --info, --quiet y --verify-mapping-file-hash («sección 7 · retrace»).
  22. R8 9.2.4-dev de build-tools 37, SourceFile por modo y por regla, ejecutado localmente — java -cp build-tools/37.0.0/lib/d8.jar com.android.tools.r8.R8, con y sin --pg-compat, -keepattributes SourceFile, -renamesourcefileattribute SourceFile y -Dcom.android.tools.r8.enableMapIdInSourceFile=false, leído con dexdump Consultado el 25 de septiembre de 2026. De aquí sale que en full mode el valor por defecto es r8-map-id-<hash> y en modo compatibilidad SourceFile, y que la propiedad de sistema lo devuelve a SourceFile (secciones «8» y «9.2»).
  23. Use rules to troubleshoot optimization — https://developer.android.com/topic/performance/app-optimization/troubleshooting-rules Consultado el 25 de septiembre de 2026 (actualizada el 19 de mayo de 2026). De aquí salen las «rules generated by AAPT» entre los orígenes de reglas, el ejemplo de -whyareyoukeeping con aapt_rules.txt:4:1 y el aviso de que esas reglas no existen con la reducción optimizada de recursos («sección 5.1 · Los ficheros y su procedencia»).
  24. AGP — código fuente, etiqueta studio-2026.1.2 — https://android.googlesource.com/platform/tools/base/+/refs/tags/studio-2026.1.2/build-system/gradle-core/ Consultado el 25 de septiembre de 2026. Ficheros leídos: internal/scope/InternalArtifactType.kt, ArtifactTypeUtil.kt y internal/tasks/R8Task.kt (la ruta de configuration.txt, «sección 4 · Cómo se enciende y dónde viven las reglas»); LinkApplicationAndroidResourcesTask.kt, ProguardConfigurableTask.kt, R8ResourceShrinkingParameters.kt y BooleanOption.kt (cuándo llega aapt_rules.txt a R8, «sección 5.1 · Los ficheros y su procedencia»).
  25. R8 — código fuente, etiqueta 9.4.23 — https://r8.googlesource.com/r8/+/refs/tags/9.4.23/ Consultado el 25 de septiembre de 2026. Ficheros leídos: src/resourceshrinker/java/com/android/tools/r8/resourceshrinker/ResourceShrinkerState.java (los elementos del manifiesto que traza R8 y la poda de componentes no exportados) y src/main/java/com/android/tools/r8/utils/InternalOptions.java (enableManifestPruning, apagada por defecto). Secciones «5.1» y «5.3».
  26. aapt2 link --proguard, ejecutado localmente — aapt2 2.20-15087165 de build-tools 37.0.0; opción documentada en https://developer.android.com/tools/aapt2 Consultado el 25 de septiembre de 2026. De aquí sale el aapt_rules.txt de un manifiesto mínimo, con una regla y su línea de origen por componente («sección 5.1 · Los ficheros y su procedencia»).
  27. androidx.annotation:annotation-jvm:1.11.0 y AGP 9.4.1 en Google Maven — https://dl.google.com/android/maven2/androidx/annotation/annotation-jvm/1.11.0/ y https://dl.google.com/android/maven2/com/android/tools/build/gradle/9.4.1/ Consultado el 25 de septiembre de 2026; los dos JAR coinciden con el .sha1 publicado. Del primero, META-INF/proguard/androidx-annotations.pro; del segundo, com/android/build/gradle/proguard-common.txt («sección 5.2 · Las reglas que AGP empaqueta de verdad»).