La tabla de recursos: resources.arsc
Todos los enteros de este documento son little-endian, con dos excepciones que se señalan
en su fila: los campos language y country de ResTable_config, cuya disposición es
big-endian con independencia de la arquitectura.
Este documento no repite el sustrato de chunks ni el formato del string pool: viven en
XML binario (AXML), secciones 2 y 3, y son literalmente las mismas
estructuras binarias.
1. Qué problema resuelve
Una aplicación tiene un botón que dice «Guardar». En español dice «Guardar», en alemán «Speichern», en japonés «保存». Su icono existe en cinco densidades de pantalla. Su margen es uno en teléfono y otro en tableta. Y el código, que está compilado, tiene que referirse a todo eso con un solo número que no cambie nunca.
Ese número es el resource ID, un uint32_t con la forma 0xPPTTEEEE. R.string.save no es
una cadena: es la constante 0x7f120075 que el compilador incrustó en el DEX. Y
resources.arsc es la tabla que, en tiempo de ejecución y para la configuración concreta del
dispositivo, convierte ese número en el valor que toca.
resources.arsc
│
R.string.app_name ──────────►│ 0x7f120076
(0x7f120076) │ ├── () → "F-Droid Basic"
│ ├── (ca) → "F-Droid Bàsic"
│ ├── (da) → "F-Droid Basis"
│ ├── (fa) → "افدروید پایه"
│ ├── (ga) → "F-Droid Bunúsach"
│ └── (ta) → "எஃப்-டிராய்டு அடிப்படை"
│
configuración del ─────────┘
dispositivo
El ejemplo es real: es string/app_name_basic de org.fdroid.fdroid_1023052.apk, tal y como lo
imprime aapt2 dump resources. En ese fichero el tipo string tiene 100 variantes de
configuración, una por cada uno de los 85 idiomas traducidos más las combinaciones de versión
de API.
La alternativa sería resolver por nombre en tiempo de ejecución con un mapa de cadenas por idioma: más lento, más grande, y sin comprobación en compilación de que el recurso existe. La tabla paga ese precio por adelantado a cambio de un índice binario, mapeable, con búsqueda por número y filtrado por configuración.
Y es el camino crítico de todo el dominio. No existe ningún escritor de este formato en Rust: arsc
está abandonado desde 2022 y resand es un proyecto personal. Sin escritor no hay editor, hay
visor, y ya existen cinco visores. Ese es el motivo de que este sea el documento más largo del
corpus.
2. El árbol de chunks
Todo el fichero es un solo chunk RES_TABLE_TYPE con un árbol dentro:
RES_TABLE_TYPE (0x0002) ── el fichero entero
│
├── RES_STRING_POOL_TYPE (0x0001) pool global de valores
│
├── RES_TABLE_PACKAGE_TYPE (0x0200) uno por paquete
│ ├── RES_STRING_POOL_TYPE typeStrings: "anim", "drawable", "string"…
│ ├── RES_STRING_POOL_TYPE keyStrings: "app_name", "abc_fade_in"…
│ │
│ ├── RES_TABLE_TYPE_SPEC_TYPE (0x0202) tipo 1: qué ejes hacen variar cada entrada
│ ├── RES_TABLE_TYPE_TYPE (0x0201) tipo 1, configuración por defecto
│ ├── RES_TABLE_TYPE_TYPE (0x0201) tipo 1, configuración "es"
│ ├── RES_TABLE_TYPE_TYPE (0x0201) tipo 1, configuración "xhdpi"
│ ├── RES_TABLE_TYPE_SPEC_TYPE (0x0202) tipo 2
│ ├── RES_TABLE_TYPE_TYPE (0x0201) tipo 2, …
│ │ …
│ ├── RES_TABLE_LIBRARY_TYPE (0x0203) opcional
│ ├── RES_TABLE_OVERLAYABLE_TYPE (0x0204) opcional
│ │ └── RES_TABLE_OVERLAYABLE_POLICY_TYPE (0x0205)
│ └── RES_TABLE_STAGED_ALIAS_TYPE (0x0206) opcional
│
└── RES_TABLE_PACKAGE_TYPE (0x0200) siguiente paquete, si lo hay
Dos reglas de orden que AOSP sí impone y conviene tener presentes:
- Un
RES_TABLE_TYPE_TYPEtiene que venir después delRES_TABLE_TYPE_SPEC_TYPEde su mismoid.LoadedArsc.cppaborta conRES_TABLE_TYPE_TYPE with ID %02x found without preceding RES_TABLE_TYPE_SPEC_TYPE. - Si aparecen dos
RES_TABLE_TYPE_SPEC_TYPEcon el mismoid, el segundo se ignora con un aviso.
Fuera de eso, el orden es el que quiera el escritor.
3. RES_TABLE_TYPE / ResTable_header
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
header |
0x00 |
8 | ResChunk_header |
type = RES_TABLE_TYPE = 0x0002; size es el fichero entero |
packageCount |
0x08 |
4 | uint32_t |
Número de chunks ResTable_package que siguen |
headerSize vale 12 en los 563 APK con resources.arsc del corpus. Un APK normal tiene
packageCount = 1 —así 557 de ellos—; el máximo observado es 6, en
org.totschnig.myexpenses_858.apk, por sus bibliotecas compartidas dinámicas (sección 12.1).
Y hay un caso límite que conviene tener presente antes de escribir la primera línea de código:
packageCount = 0 es válido. Cinco APK del corpus, todos splits de módulo de funcionalidad
del zip SAI de Netflix —split_voip.config.xhdpi.apk y cuatro más—, traen un resources.arsc
de 40 bytes exactos: los 12 de ResTable_header más los 28 de un RES_STRING_POOL_TYPE
vacío, y ningún paquete. Es la tabla de recursos mínima válida, y un lector que dé por supuesto
que hay al menos un paquete falla con ella.
4. El string pool global
Justo detrás de la cabecera viene un RES_STRING_POOL_TYPE. Su formato es exactamente el
descrito en XML binario, sección 3,
incluidas las dos codificaciones y el doble campo de longitud en UTF-8, que aquí importa más
todavía porque este pool es, con diferencia, el mayor del APK: 44.157 cadenas y 2,1 MiB en
org.fdroid.fdroid_1023052.apk.
Contiene los valores de tipo cadena de toda la tabla: los textos traducidos y las rutas de
fichero de los recursos (res/layout/main.xml, res/vp1.xml). No contiene los nombres de
los tipos ni los de las entradas: esos viven en dos pools propios de cada paquete (sección 5).
Un Res_value de tipo TYPE_STRING dentro de una entrada indexa este pool, no el del
paquete. Es un error frecuente al implementar.
Medido sobre los 58 APK standalone del corpus, hay 184 string pools en total —tres por APK más alguno extra por paquete— de los cuales 121 son UTF-8 y 63 UTF-16, y 33 llevan estilos.
5. ResTable_package
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
header |
0x00 |
8 | ResChunk_header |
type = RES_TABLE_PACKAGE_TYPE = 0x0200 |
id |
0x08 |
4 | uint32_t |
El PP del resource ID. 0 significa que el paquete hereda de otro |
name |
0x0c |
256 | uint16_t[128] |
Nombre del paquete en UTF-16, terminado en \0 y rellenado con ceros |
typeStrings |
0x10c |
4 | uint32_t |
Offset desde el inicio de este chunk al pool de nombres de tipo |
lastPublicType |
0x110 |
4 | uint32_t |
Último índice de typeStrings de uso público. 0 en todo el corpus |
keyStrings |
0x114 |
4 | uint32_t |
Offset desde el inicio de este chunk al pool de nombres de entrada |
lastPublicKey |
0x118 |
4 | uint32_t |
Último índice de keyStrings de uso público. 0 en todo el corpus |
typeIdOffset |
0x11c |
4 | uint32_t |
Desplazamiento a sumar a los id de tipo. 0 en todo el corpus |
headerSize vale 288 (0x120), que es exactamente sizeof(ResTable_package). Y en todo el
corpus typeStrings vale también 0x120: el pool de tipos empieza inmediatamente después de
la cabecera.
Los dos pools propios de cada paquete son la razón de que resolver un recurso por nombre exija tres pools distintos:
| Pool | Contiene | Indexado por |
|---|---|---|
| Global (sección 4) | Valores de tipo cadena y rutas de fichero | Res_value.data cuando dataType = TYPE_STRING |
typeStrings |
"anim", "attr", "drawable", "string", "style"… |
ResTable_type.id - 1 y ResTable_typeSpec.id - 1 |
keyStrings |
"app_name", "abc_fade_in", "actionBarDivider"… |
ResTable_entry.key |
Los índices de tipo son base 1 y los pools base 0: el tipo id = 0x01 es
typeStrings[0]. id = 0 es inválido y LoadedArsc.cpp lo rechaza.
De com.termux_1002.apk, el pool de tipos completo:
[1] anim [2] animator [3] attr [4] bool [5] color [6] dimen [7] drawable
[8] id [9] integer [10] interpolator [11] layout [12] menu [13] mipmap
[14] plurals [15] raw [16] string [17] style [18] ?18 [19] xml
El ?18 no es un error del volcador: es una cadena vacía en el pool, hueco que deja aapt2
cuando un tipo se declara y luego no se usa. Un lector tiene que tolerarlo.
6. ResTable_typeSpec
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
header |
0x00 |
8 | ResChunk_header |
type = RES_TABLE_TYPE_SPEC_TYPE = 0x0202 |
id |
0x08 |
1 | uint8_t |
Identificador de tipo, base 1. 0 es inválido |
res0 |
0x09 |
1 | uint8_t |
Debe ser 0 |
typesCount |
0x0a |
2 | uint16_t |
Antes reservado; si es > 0, número de ResTable_type de este tipo |
entryCount |
0x0c |
4 | uint32_t |
Número de máscaras uint32_t que siguen |
headerSize vale 16. Detrás vienen entryCount valores uint32_t, uno por entrada del tipo:
una máscara de bits de los ejes de configuración que hacen variar esa entrada, con las
constantes ResTable_config::CONFIG_* (sección 8.4), más dos flags propios:
| Flag | Valor | Significado |
|---|---|---|
SPEC_PUBLIC |
0x40000000 |
La entrada es pública y otras aplicaciones pueden referenciarla |
SPEC_STAGED_API |
0x20000000 |
El resource ID de esta entrada puede cambiar en una compilación futura. Implica SPEC_PUBLIC |
Para qué sirve realmente: es el índice que dice qué se puede tirar. Si la máscara de la
entrada 5 del tipo string no tiene el bit CONFIG_LOCALE, esa entrada vale igual en todos los
idiomas y un split de idioma no necesita llevarla. Es la información con la que bundletool
decide qué entra en config.es.apk y qué se queda en base.apk, y la que un merge necesita
para saber si dos entradas homónimas de dos splits distintos son la misma o compiten.
entryCount del typeSpec es el número total de entradas del tipo, que puede ser mucho
mayor que el entryCount de un ResTable_type concreto cuando este es disperso (sección 7.3).
En config.xhdpi.apk de Google Keep, el typeSpec del tipo 0x08 declara
entryCount = 810 y el ResTable_type que le sigue solo trae 42.
typesCount es el campo menos fiable de la estructura. La cabecera lo describe como «used to be
reserved»: los productores antiguos lo dejaban a cero y los modernos escriben el número de
ResTable_type del tipo. En el corpus conviven los dos comportamientos dentro del mismo
universo: de los 1.067 typeSpec de los 58 APK standalone, 172 llevan 0 y 895 un valor
real; de los 1.618 de los APK internos, 121 llevan 0 y 1.497 un valor real. Un lector no debe
apoyarse en él —es información redundante con el recuento efectivo de chunks—, pero un escritor
que aspire al round-trip tiene que reemitir el valor que leyó, incluido el cero.
7. ResTable_type
Es el chunk que de verdad contiene los datos: una configuración concreta y los valores de todas las entradas del tipo para esa configuración.
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
header |
0x00 |
8 | ResChunk_header |
type = RES_TABLE_TYPE_TYPE = 0x0201 |
id |
0x08 |
1 | uint8_t |
Identificador de tipo, base 1 |
flags |
0x09 |
1 | uint8_t |
FLAG_SPARSE = 0x01, FLAG_OFFSET16 = 0x02 |
reserved |
0x0a |
2 | uint16_t |
Debe ser 0 |
entryCount |
0x0c |
4 | uint32_t |
Número de entradas del array de offsets |
entriesStart |
0x10 |
4 | uint32_t |
Offset desde el inicio del chunk donde empiezan los ResTable_entry |
config |
0x14 |
var | ResTable_config |
La configuración. Debe ser siempre el último campo |
Ese «debe ser siempre el último campo» no es una recomendación: es un static_assert de
ResourceTypes.h, con el comentario «This poses a problem for extending ResTable_type in the
future, as ResTable_config is variable». headerSize vale
20 + config.size, que en la práctica es 20 + 64 = 84.
La disposición completa:
+0x00 ┌──────────────────────────────────────────────────────────┐
│ ResTable_type (headerSize = 20 + config.size = 84) │
+0x54 ├──────────────────────────────────────────────────────────┤
│ array de offsets: │
│ uint32_t[entryCount] si no hay flags │
│ uint16_t[entryCount] si FLAG_OFFSET16 │
│ ResTable_sparseTypeEntry[entryCount] si FLAG_SPARSE │
├──────────────────────────────────────────────────────────┤
│ relleno hasta múltiplo de 4 │
entriesStart ─────────────────────────────────────────────────────┤
│ ResTable_entry + Res_value o ResTable_map[] │
│ ResTable_entry + … │
│ … │
└──────────────────────────────────────────────────────────┘
entriesStart es relativo al inicio del chunk, y los offsets del array son relativos a
entriesStart. Confundir las dos bases es el segundo error más frecuente del formato, tras
el doble campo de longitud del string pool.
7.1 El array denso
Sin flags, el array son entryCount valores uint32_t. El valor NO_ENTRY = 0xFFFFFFFF
significa que esa entrada no está definida para esta configuración. De
com.termux_1002.apk, tipo 0x04 (bool), configuración por defecto:
array de offsets (uint32_t):
[ 0] 0x00000000
[ 1] 0x00000010
[ 2] 0x00000020
[ 3] 0x00000030
entradas:
id=0x7f040000 size=8 flags=0x0000 key=1022('abc_action_bar_embed_tabs')
value: size=8 dataType=0x12 data=0xffffffff
id=0x7f040001 size=8 flags=0x0000 key=1023('abc_config_actionMenuItemAllCaps')
value: size=8 dataType=0x12 data=0xffffffff
Y el segundo ResTable_type del mismo tipo, para sw360dp, empieza con NO_ENTRY: no redefine
la entrada 0.
7.2 FLAG_OFFSET16
Con FLAG_OFFSET16 = 0x02 el array es de uint16_t y el offset real es valor * 4; el valor
0xffff significa NO_ENTRY. La conversión está en una función de una línea de la cabecera:
static inline uint32_t offset_from16(uint16_t off16) {
return dtohs(off16) == 0xffffu ? ResTable_type::NO_ENTRY : dtohs(off16) * 4u;
}
Existe en ResourceTypes.h de la rama main y aapt2 solo lo emite cuando ya está usando
entradas compactas (FLAG_COMPACT, sección 10.2) y los offsets caben en 16 bits.
No se ha observado ni una sola vez en el corpus: 0 de 22.247 chunks ResTable_type
inspeccionados, sumando los 58 APK standalone y los 505 APK internos con resources.arsc.
7.3 FLAG_SPARSE y ResTable_sparseTypeEntry
Con FLAG_SPARSE = 0x01, entryCount deja de ser el número total de entradas del tipo y pasa
a ser el número de entradas realmente definidas. El array es de
ResTable_sparseTypeEntry, un uint32_t con dos mitades:
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
idx |
0x00 |
2 | uint16_t |
Índice de la entrada (EEEE del resource ID) |
offset |
0x02 |
2 | uint16_t |
Offset desde entriesStart, dividido por 4 |
Las entradas están ordenadas por idx y AOSP hace std::lower_bound sobre ellas. Un
escritor que no las ordene produce un fichero que se lee mal sin dar error.
Es la variante que ahorra espacio cuando un split de configuración define pocas entradas de
un tipo con muchas. Del split config.xhdpi.apk de Google Keep:
+0x000bf8 RES_TABLE_TYPE_SPEC_TYPE headerSize=16 size=3256 id=0x08 typesCount=2 entryCount=810
+0x0018b0 RES_TABLE_TYPE_TYPE headerSize=84 size=924 id=0x08 flags=0x01[FLAG_SPARSE]
entryCount=42 entriesStart=0xfc config.size=64
array de offsets (ResTable_sparseTypeEntry):
[ 0] raw=0x00000161 → idx=353 offset=0x0
[ 1] raw=0x00040166 → idx=358 offset=0x10
[ 2] raw=0x00080167 → idx=359 offset=0x20
[ 3] raw=0x000c016c → idx=364 offset=0x30
0x00040166: la mitad baja 0x0166 = 358 es el índice, la alta 0x0004 × 4 = 0x10 es el
offset. El chunk ocupa 924 bytes en vez de los 810 × 4 = 3.240 bytes que costaría solo el array
denso.
Cuándo lo emite aapt2. De TableFlattener.cpp, tres condiciones simultáneas: que se haya
pedido con --enable-sparse-encoding; que minSdkVersion >= 32 (SDK_S_V2) o que no haya
minSdk ni sdkVersion en el contexto; y que la proporción de entradas definidas esté por
debajo de kSparseEncodingThreshold = 60, o sea que menos del 60 % de las entradas del tipo
estén presentes. En el ejemplo, 42 de 810 es el 5,2 %.
El comentario de ResourceTypes.h dice que la codificación dispersa «solo está disponible en
plataformas ≥ O» —API 26— y recomienda marcar esos tipos con el calificador v26. aapt2
exige 32 por defecto. La diferencia importa a quien fusione splits: reemitir como disperso un
tipo que venía denso puede romper la instalación en dispositivos por debajo del minSdk
declarado.
Medido en el corpus: 976 de 5.917 chunks ResTable_type de los APK internos llevan
FLAG_SPARSE, y ninguno de los 16.330 de los 58 APK standalone de F-Droid. Es una marca
casi perfecta de origen: los .apks y .apkm vienen de Play y llevan dispersos; los de F-Droid
no.
8. ResTable_config
La estructura que describe una configuración de dispositivo. Es la más grande, la que más ha cambiado con las versiones de Android y la que rompe los parsers ingenuos.
8.1 La tabla completa
Offsets relativos al inicio de la estructura, con sizeof(ResTable_config) = 64 en la rama
main:
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
size |
0x00 |
4 | uint32_t |
Tamaño de esta estructura. Ver 8.2 |
mcc |
0x04 |
2 | uint16_t |
Mobile country code de la SIM. 0 = cualquiera |
mnc |
0x06 |
2 | uint16_t |
Mobile network code. 0 = cualquiera |
language |
0x08 |
2 | char[2] |
Código de idioma. Big-endian. Ver 8.3 |
country |
0x0a |
2 | char[2] |
Código de región. Big-endian. Ver 8.3 |
orientation |
0x0c |
1 | uint8_t |
ANY=0, PORT=1, LAND=2, SQUARE=3 |
touchscreen |
0x0d |
1 | uint8_t |
ANY=0, NOTOUCH=1, STYLUS=2, FINGER=3 |
density |
0x0e |
2 | uint16_t |
Densidad en dpi. Ver 8.5 |
keyboard |
0x10 |
1 | uint8_t |
ANY=0, NOKEYS=1, QWERTY=2, 12KEY=3 |
navigation |
0x11 |
1 | uint8_t |
ANY=0, NONAV=1, DPAD=2, TRACKBALL=3, WHEEL=4 |
inputFlags |
0x12 |
1 | uint8_t |
MASK_KEYSHIDDEN = 0x03, MASK_NAVHIDDEN = 0x0c (SHIFT_NAVHIDDEN = 2) |
grammaticalInflection |
0x13 |
1 | uint8_t |
Género gramatical. MASK = 0b11. Antes inputFieldPad0 |
screenWidth |
0x14 |
2 | uint16_t |
Anchura en píxeles. 0 = cualquiera |
screenHeight |
0x16 |
2 | uint16_t |
Altura en píxeles. 0 = cualquiera |
sdkVersion |
0x18 |
2 | uint16_t |
Nivel de API mínimo. 0 = cualquiera. Es el -v26 de los calificadores |
minorVersion |
0x1a |
2 | uint16_t |
Sin significado definido. Debe ser 0 |
screenLayout |
0x1c |
1 | uint8_t |
MASK_SCREENSIZE = 0x0f, MASK_SCREENLONG = 0x30, MASK_LAYOUTDIR = 0xc0 |
uiMode |
0x1d |
1 | uint8_t |
MASK_UI_MODE_TYPE = 0x0f, MASK_UI_MODE_NIGHT = 0x30 |
smallestScreenWidthDp |
0x1e |
2 | uint16_t |
El sw<N>dp del calificador |
screenWidthDp |
0x20 |
2 | uint16_t |
El w<N>dp |
screenHeightDp |
0x22 |
2 | uint16_t |
El h<N>dp |
localeScript |
0x24 |
4 | char[4] |
Código ISO-15924 del sistema de escritura (Latn, Hant) |
localeVariant |
0x28 |
8 | char[8] |
Subetiqueta de variante BCP-47, de 4 a 8 caracteres |
screenLayout2 |
0x30 |
1 | uint8_t |
MASK_SCREENROUND = 0x03 |
colorMode |
0x31 |
1 | uint8_t |
MASK_WIDE_COLOR_GAMUT = 0x03, MASK_HDR = 0x0c |
screenConfigPad2 |
0x32 |
2 | uint16_t |
Relleno reservado |
localeScriptWasComputed |
0x34 |
1 | bool |
Si es cierto, localeScript lo dedujo el sistema y no venía en el fuente |
localeNumberingSystem |
0x35 |
8 | char[8] |
Extensión BCP-47 de clave nu, de 3 a 8 caracteres |
| (relleno) | 0x3d |
3 | — | Hasta el múltiplo de 4 |
Varios de estos campos se agrupan en union con un nombre colectivo, que es el que usan las
funciones de comparación de la sección 9: imsi (mcc + mnc), locale (language +
country), screenType (orientation + touchscreen + density), input, screenSize,
version, screenConfig, screenSizeDp y screenConfig2.
8.2 El problema del size variable
Este es EL detalle que rompe los parsers ingenuos. ResTable_config ha ido creciendo con
las versiones de Android, y un APK compilado hace ocho años trae una estructura más corta que
la que espera un lector escrito hoy.
La regla, tomada literalmente de ResTable_config::copyFromDeviceNoSwap:
void ResTable_config::copyFromDeviceNoSwap(const ResTable_config& o) {
const size_t size = dtohl(o.size);
if (size >= sizeof(ResTable_config)) {
*this = o;
} else {
memcpy(this, &o, size);
memset(((uint8_t*)this)+size, 0, sizeof(ResTable_config)-size);
}
}
Es decir:
Se leen exactamente
sizebytes y el resto de la estructura se rellena a cero. Sisizees mayor que la estructura conocida, se leen los campos conocidos y se ignora el exceso.
Nunca se lee sizeof de la propia struct. Y en el otro sentido —al escribir— hay que
reemitir size bytes, ni uno más, o el round-trip deja de ser byte-exacto.
ResourceTypes.h define la constante que hace falta para leer con seguridad la cabecera de un
ResTable_type antes de saber cuánto ocupa su config:
constexpr size_t kResTableTypeMinSize =
sizeof(ResTable_type) - sizeof(ResTable_config) + sizeof(ResTable_config::size);
O sea, 20 + 4 = 24 bytes: lo justo para llegar a leer config.size.
El historial de tamaños, derivado de los campos presentes en ResourceTypes.h en cada etiqueta
de AOSP:
size |
Último campo añadido | Presente desde |
|---|---|---|
| 36 | screenSizeDp |
android-4.4_r1 |
| 48 | localeScript, localeVariant |
android-5.0.0_r1 |
| 52 | screenConfig2 |
android-6.0.0_r1 |
| 56 | localeScriptWasComputed |
android-7.1.2_r36 |
| 64 | localeNumberingSystem |
android-9.0.0_r1 y posteriores, incluida main |
⚠️ sin verificar: los valores de la columna size están calculados a partir de la lista de
campos de cada etiqueta y de la regla de alineación a 4 bytes de C, no leídos de un
static_assert de la cabecera. Los dos que sí están medidos son 56 y 64, que son los únicos que
aparecen en el corpus.
Y aparecen los dos. Sobre los 58 APK standalone: 16.028 configuraciones de 64 bytes y 302 de
56 bytes, todas las de 56 en un solo fichero, org.gnucash.android_2.4.0.apk, compilado con
una cadena de herramientas de la época de Android 7. Los 505 APK internos de los contenedores
usan 64 sin excepción.
Un lector que asuma 64 lee 8 bytes de más en cada una de las 302 configuraciones de ese fichero —invadiendo el array de offsets que viene detrás— y produce basura sin dar error.
8.3 language y country: el truco del bit alto
Los dos campos ocupan dos bytes y tienen que representar tres cosas: «cualquiera», un código
ISO-639-1 de dos letras, y un código ISO-639-2 de tres letras. La solución empaqueta tres
valores de 5 bits en los 16 disponibles y usa el bit alto como discriminador —
{1, t,t,t,t,t, s,s,s,s,s, f,f,f,f,f}, con las letras primera, segunda y tercera en los bits
0–4, 5–9 y 10–14—. «The layout is always bigendian irrespective of the runtime architecture»,
dice la cabecera. El desempaquetado real, de unpackLanguageOrRegion:
if (in[0] & 0x80) { // tres letras
const uint8_t first = in[1] & 0x1f;
const uint8_t second = ((in[1] & 0xe0) >> 5) + ((in[0] & 0x03) << 3);
const uint8_t third = (in[0] & 0x7c) >> 2;
out[0] = first + base; out[1] = second + base; out[2] = third + base;
return 3;
}
if (in[0]) { memcpy(out, in, 2); return 2; } // dos letras ASCII
return 0; // "cualquiera"
La base es 'a' para el idioma y '0' para la región, porque las regiones UN M.49 son
numéricas de tres dígitos.
Los dos casos, medidos sobre los ResTable_type del tipo string de
org.fdroid.fdroid_1023052.apk, que trae 85 idiomas:
catalán config +0x00 40 00 00 00 00 00 00 00 63 61 00 00 … language = 'c','a' en ASCII
filipino config +0x00 40 00 00 00 00 00 00 00 ad 05 00 00 … language = 0xad, 0x05
Para el segundo, 0xad & 0x80 ≠ 0, luego son tres letras:
first = 0x05 & 0x1f = 5 → 'a'+5 = 'f';
second = ((0x05 & 0xe0) >> 5) + ((0xad & 0x03) << 3) = 0 + 8 → 'a'+8 = 'i';
third = (0xad & 0x7c) >> 2 = 11 → 'a'+11 = 'l'. Resultado: fil, que es lo que
aapt2 dump resources imprime como (fil). En el mismo fichero están también empaquetados
ast y yue.
Cuando el locale necesita más que idioma y región —escritura, variante, sistema de numeración—
se usan además localeScript, localeVariant y localeNumberingSystem, y el calificador de
directorio pasa de la forma es-rES a la forma BCP-47 extendida b+es+Latn+ES.
8.4 Las banderas CONFIG_*
Son las que aparecen en las máscaras del ResTable_typeSpec (sección 6) y las que devuelve
ResTable_config::diff(). ResourceTypes.h las define por indirección a las constantes
ACONFIGURATION_* de frameworks/native/include/android/configuration.h, donde están los
valores:
| Bandera | Valor | Bandera | Valor |
|---|---|---|---|
CONFIG_MCC |
0x0001 |
CONFIG_SCREEN_SIZE |
0x0200 |
CONFIG_MNC |
0x0002 |
CONFIG_VERSION |
0x0400 |
CONFIG_LOCALE |
0x0004 |
CONFIG_SCREEN_LAYOUT |
0x0800 |
CONFIG_TOUCHSCREEN |
0x0008 |
CONFIG_UI_MODE |
0x1000 |
CONFIG_KEYBOARD |
0x0010 |
CONFIG_SMALLEST_SCREEN_SIZE |
0x2000 |
CONFIG_KEYBOARD_HIDDEN |
0x0020 |
CONFIG_LAYOUTDIR |
0x4000 |
CONFIG_NAVIGATION |
0x0040 |
CONFIG_SCREEN_ROUND |
0x8000 |
CONFIG_ORIENTATION |
0x0080 |
CONFIG_COLOR_MODE |
0x10000 |
CONFIG_DENSITY |
0x0100 |
CONFIG_GRAMMATICAL_GENDER |
0x20000 |
La cabecera advierte de que estos valores deben coincidir con los de
android.content.pm.ActivityInfo y attrs_manifest.xml. La máscara 0x4000 que imprime
aapt2 dump chunks como layout_dir en la receta 15.3 es CONFIG_LAYOUTDIR.
8.5 Densidad
density no es un enumerado cerrado: es el valor en dpi. Los nombres son atajos.
| Constante | Valor | Calificador |
|---|---|---|
DENSITY_DEFAULT |
0 | — |
DENSITY_LOW |
120 | ldpi |
DENSITY_MEDIUM |
160 | mdpi |
DENSITY_TV |
213 | tvdpi |
DENSITY_HIGH |
240 | hdpi |
DENSITY_XHIGH |
320 | xhdpi |
DENSITY_XXHIGH |
480 | xxhdpi |
DENSITY_XXXHIGH |
640 | xxxhdpi |
DENSITY_ANY |
0xfffe |
anydpi |
DENSITY_NONE |
0xffff |
nodpi |
El split config.xhdpi.apk de Google Keep trae density = 0x0140 = 320, consistente con la
tabla. Un detalle que muerde: mnc = 0 significa «cualquiera», así que la MNC real cero se
codifica con el valor centinela ACONFIGURATION_MNC_ZERO = 0xffff.
9. Cómo elige Android qué entrada usar
Dado un resource ID y la configuración del dispositivo, AssetManager recorre todos los
ResTable_type del tipo correspondiente y aplica dos filtros en cadena.
Primero match(). Descarta las configuraciones incompatibles. La regla es asimétrica:
«A default piece of data will match every request but a request for the default should not
match odd specifics». Una entrada sin idioma sirve para cualquier petición; una entrada es no
sirve para una petición de; una entrada sw600dp no sirve para un dispositivo de 480 dp, pero
sí al revés.
Después isBetterThan(). Entre las supervivientes elige la mejor, comparando eje por eje
en un orden fijo y devolviendo en el primer eje que difiera:
mcc → mnc → locale → grammaticalInflection → layoutDirection →
smallestScreenWidthDp → screenWidthDp/screenHeightDp → screenSize (small/normal/large) →
screenLong → screenRound → wideColorGamut → HDR → orientation →
uiModeType → uiModeNight → density → touchscreen →
keysHidden → navHidden → keyboard → navigation →
screenWidth/screenHeight → sdkVersion → minorVersion
La regla general, del comentario de la cabecera: si la petición se interesa por un eje y las dos
candidatas difieren, una de las dos tiene que ser genérica —porque match() ya eliminó las
incompatibles—, así que gana la que no es genérica.
Tres ejes tienen lógica propia y son los que más sorprenden:
- Densidad. No gana la más parecida sino, en igualdad, la que se pueda reducir en vez de
ampliar: si ni la propia ni la otra coinciden con la pedida, gana la mayor.
DENSITY_ANYgana siempre. Si no se pide densidad, se asumeDENSITY_MEDIUM(160). - Dimensiones en dp. Gana la que minimiza la suma de
pedido - candidataen anchura y altura, asumiendo quematch()ya descartó las mayores que lo pedido. sdkVersion. Gana la mayor, no la más cercana:return (sdkVersion > o.sdkVersion). Es lo que hace que un recurso marcado-v31sustituya al-v21en un dispositivo moderno.
El locale tiene su propia función, isLocaleBetterThan(), con las reglas de fallback de
BCP-47 —idioma, escritura, región, variante— y una puntuación de importancia
(getImportanceScoreOfLocale).
Reproducir isBetterThan exactamente no hace falta para leer ni para reescribir la tabla;
hace falta para elegir qué splits fusionar y para el modo de fusión que descarta
configuraciones. Cuando haya que reproducirlo, la única referencia válida es el código:
ResTable_config::match() y ResTable_config::isBetterThan() en
libs/androidfw/ResourceTypes.cpp.
10. ResTable_entry, ResTable_map_entry y ResTable_map
10.1 La entrada normal
ResTable_entry es una union de dos formas. La normal, Full:
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
size |
0x00 |
2 | uint16_t |
Tamaño de esta cabecera de entrada, no del valor |
flags |
0x02 |
2 | uint16_t |
Ver tabla |
key |
0x04 |
4 | ResStringPool_ref |
Índice en el keyStrings del paquete |
| Flag | Valor | Significado |
|---|---|---|
FLAG_COMPLEX |
0x0001 |
La entrada es un mapa: le siguen count ResTable_map en vez de un Res_value |
FLAG_PUBLIC |
0x0002 |
Declarada pública; otras bibliotecas pueden referenciarla |
FLAG_WEAK |
0x0004 |
Recurso débil, sustituible por uno fuerte del mismo nombre al enlazar |
FLAG_COMPACT |
0x0008 |
La entrada usa la forma compacta de 10.2 |
Si FLAG_COMPLEX no está puesto, inmediatamente después de los size bytes de la cabecera
viene un Res_value de 8 bytes (XML binario, sección 5).
size vale 8 en ese caso.
Si FLAG_COMPLEX sí está puesto, la cabecera es un ResTable_map_entry, que extiende a
Full:
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
size, flags, key |
0x00 |
8 | ResTable_entry::Full |
Igual que arriba. size vale 16 |
parent |
0x08 |
4 | ResTable_ref |
resource ID del mapa del que se hereda, o 0. Se trata siempre como TYPE_DYNAMIC_REFERENCE |
count |
0x0c |
4 | uint32_t |
Número de pares nombre-valor que siguen |
Y a continuación count estructuras ResTable_map de 12 bytes:
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
name |
0x00 |
4 | ResTable_ref |
resource ID del atributo que se define, o un valor especial ATTR_* |
value |
0x04 |
8 | Res_value |
El valor |
Los valores especiales de name se construyen con la macro
Res_MAKEINTERNAL(x) = 0x01000000 | (x & 0xFFFF):
| Nombre | Valor | Uso |
|---|---|---|
ATTR_TYPE |
0x01000000 |
Máscara de tipos admitidos por el atributo |
ATTR_MIN |
0x01000001 |
Valor mínimo para atributos enteros |
ATTR_MAX |
0x01000002 |
Valor máximo |
ATTR_L10N |
0x01000003 |
L10N_NOT_REQUIRED = 0, L10N_SUGGESTED = 1 |
ATTR_OTHER, ATTR_ZERO, ATTR_ONE, ATTR_TWO, ATTR_FEW, ATTR_MANY |
0x01000004 a 0x01000009 |
Las seis categorías de plural, en ese orden |
Y la máscara de tipos que acompaña a ATTR_TYPE:
| Nombre | Valor | Significado |
|---|---|---|
TYPE_ANY |
0x0000FFFF |
Sin restricción entre los tipos genéricos |
TYPE_REFERENCE |
1 << 0 |
Admite una referencia |
TYPE_STRING |
1 << 1 |
Admite una cadena |
TYPE_INTEGER |
1 << 2 |
Admite un entero |
TYPE_BOOLEAN |
1 << 3 |
Admite un booleano |
TYPE_COLOR |
1 << 4 |
Admite un color |
TYPE_FLOAT |
1 << 5 |
Admite un decimal |
TYPE_DIMENSION |
1 << 6 |
Admite una dimensión |
TYPE_FRACTION |
1 << 7 |
Admite una fracción |
TYPE_ENUM |
1 << 16 |
Enumerado; los valores vienen como entradas más del mapa |
TYPE_FLAGS |
1 << 17 |
Máscara de bits; los valores vienen como entradas más del mapa |
Tres ejemplos reales de com.termux_1002.apk, del tipo 0x03 (attr) y del 0x11 (style):
id=0x7f030000 size=16 flags=0x0005[COMPLEX,WEAK] key=58('actionBarDivider')
parent=0x00000000 count=1
map name=0x01000000 ATTR_TYPE value dataType=0x10 data=0x00000001 ← TYPE_REFERENCE
id=0x7f03002f key='alphabeticModifiers' parent=0x00000000 count=7
map name=0x01000000 ATTR_TYPE value dataType=0x10 data=0x00020000 ← TYPE_FLAGS (1<<17)
map name=0x7f080005 value dataType=0x11 data=0x00010000 ← un valor de la máscara
map name=0x7f080003 value dataType=0x11 data=0x00001000
id=0x7f110000 size=16 flags=0x0001[COMPLEX] key=2728('AlertDialog.AppCompat')
parent=0x7f110008 count=0 ← estilo que solo hereda
10.2 La entrada compacta: FLAG_COMPACT
Es la adición más reciente. Cuando está puesto FLAG_COMPACT = 0x0008, los 8 bytes de la
entrada contienen ya el valor y no hay Res_value detrás:
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
key |
0x00 |
2 | uint16_t |
Índice en keyStrings, en 16 bits |
flags |
0x02 |
2 | uint16_t |
Byte bajo: los flags. Byte alto: el dataType del valor |
data |
0x04 |
4 | uint32_t |
El dato |
El campo flags está deliberadamente en el mismo offset que en la forma Full, con un
static_assert que lo garantiza, para poder decidir qué forma es antes de interpretar el
resto. El size de la entrada y el del valor se dan por supuestos: 8 bytes ambos.
aapt2 solo lo emite con --enable-compact-entries y minSdkVersion > 33, es decir Android 14
en adelante, y únicamente si todas las claves del tipo caben en 16 bits. No se ha observado ni
una vez en el corpus: 0 entradas compactas en los 563 APK con resources.arsc inspeccionados.
Un lector escrito hoy tiene que soportarlo igualmente, porque los APK que lo usen empezarán a
aparecer conforme suba el minSdk obligatorio de Play.
10.3 Lo que AOSP valida en cada entrada
De VerifyResTableEntry en LoadedArsc.cpp, y merece la pena copiarlo tal cual en un lector
propio: el offset debe estar alineado a 4; no puede desbordar al sumarle entriesStart; tiene
que quedar sitio para un ResTable_entry completo; entry->size() no puede ser menor que
sizeof(ResTable_entry) ni mayor que el chunk; si no es compleja, tiene que quedar sitio para
un Res_value cuyo size sea al menos 8; y si es compleja, los ResTable_map tienen que
empezar alineados a 4 y caber count de ellos en lo que queda de chunk.
11. La estructura del resource ID: 0xPPTTEEEE
| Parte | Bits | Significado |
|---|---|---|
PP |
31–24 | Paquete. Base 1: 0 es inválido |
TT |
23–16 | Tipo dentro del paquete. Base 1: 0 es inválido |
EEEE |
15–0 | Entrada dentro del tipo. Base 0 |
Las macros de ResourceTypes.h lo dicen sin ambigüedad:
#define Res_MAKEID(package, type, entry) \
(((package+1)<<24) | (((type+1)&0xFF)<<16) | (entry&0xFFFF))
#define Res_GETPACKAGE(id) ((id>>24)-1)
#define Res_GETTYPE(id) (((id>>16)&0xFF)-1)
#define Res_GETENTRY(id) (id&0xFFFF)
Ojo: las macros toman los índices en base 0 y suman uno; los campos id de ResTable_package,
ResTable_typeSpec y ResTable_type guardan ya el valor en base 1, listo para meter en el
resource ID. Res_MAXPACKAGE y Res_MAXTYPE valen 255, y EEEE de 16 bits impone el límite
de 65.536 entradas por tipo, que LoadedArsc.cpp comprueba explícitamente.
Los valores de PP:
PP |
Quién |
|---|---|
0x00 |
Paquete sin identificador asignado: es lo que aapt2 link --shared-lib emite, y el sistema le asigna uno real al cargar |
0x01 |
kFrameworkPackageId — el framework de Android, android.R |
0x02–0x7e |
Reservado históricamente. aapt2 solo lo permite con --allow-reserved-package-id, que existe para los antiguos feature splits previos a Android 8 |
0x7f |
kAppPackageId — la aplicación. Lo normal |
0x80–0xff |
Bibliotecas compartidas dinámicas y overlays. aapt2 --package-id acepta este rango sin más |
Link.cpp lo valida así, y el mensaje de error es la definición operativa del rango:
invalid package ID 0x%02x. Must be in the range 0x7f-0xff.
con la excepción documentada de que, antes de Android 8, el runtime trataba como inválido
cualquier ID con PP > 0x7f porque el resource ID se interpretaba como entero con signo. De
ahí la existencia del rango reservado 0x02–0x7e y del FeatureSplitSymbolTableDelegate que
reescribe identificadores cuando minSdkVersion < 26.
En el corpus se ven los dos extremos. org.totschnig.myexpenses_858.apk trae seis paquetes:
0x7f para la aplicación y 0x80, 0x83, 0x84, 0x85 y 0x86 para cinco bibliotecas
dinámicas suyas.
12. Los chunks menos frecuentes
Un lector puede no entenderlos, pero tiene que conservarlos: si se pierden, la aplicación puede seguir arrancando y fallar en un caso concreto meses después.
12.1 RES_TABLE_LIBRARY_TYPE (0x0203)
Tabla de traducción de identificadores de paquete de bibliotecas compartidas dinámicas. El ID que una biblioteca tiene en tiempo de compilación no tiene por qué ser el que reciba al cargarse; este chunk permite traducirlos por nombre.
ResTable_lib_header: ResChunk_header (8 bytes) + count (uint32_t, offset 0x08).
headerSize = 12. Le siguen count estructuras ResTable_lib_entry de 260 bytes:
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
packageId |
0x00 |
4 | uint32_t |
ID asignado en tiempo de compilación. uint32_t solo por alineación |
packageName |
0x04 |
256 | uint16_t[128] |
Nombre del paquete en UTF-16, terminado en \0 |
De org.totschnig.myexpenses_858.apk, el chunk del cuarto paquete:
RES_TABLE_LIBRARY_TYPE en +0x6d1704: headerSize=12 size=792 count=3
ResTable_lib_entry packageId=0x80 packageName='org.totschnig.myexpenses.ocr'
ResTable_lib_entry packageId=0x83 packageName='org.totschnig.myexpenses.webdav'
ResTable_lib_entry packageId=0x84 packageName='org.totschnig.myexpenses.sqlcrypt'
Cada paquete declara las bibliotecas de las que depende, así que el mismo nombre aparece en varios chunks. Observado en 11 chunks del corpus.
12.2 RES_TABLE_OVERLAYABLE_TYPE (0x0204) y su política (0x0205)
Declara qué recursos pueden sobrescribir los Runtime Resource Overlays. Es lo que emite el
bloque <overlayable> del fuente.
ResTable_overlayable_header: ResChunk_header (8) + name (uint16_t[256], offset 0x08) +
actor (uint16_t[256], offset 0x208). headerSize = 1032, confirmado en el corpus.
Dentro vienen uno o más ResTable_overlayable_policy_header de headerSize 16: header
(0x00, 8 bytes), policy_flags (0x08, uint32_t) y entry_count (0x0c, uint32_t),
seguidos de entry_count estructuras ResTable_ref de 4 bytes.
Bandera de policy_flags |
Valor | Requisito que impone al overlay |
|---|---|---|
NONE |
0x00000000 |
— |
PUBLIC |
0x00000001 |
Cualquier overlay |
SYSTEM_PARTITION |
0x00000002 |
Residir en la partición system |
VENDOR_PARTITION |
0x00000004 |
Residir en vendor |
PRODUCT_PARTITION |
0x00000008 |
Residir en product |
SIGNATURE |
0x00000010 |
Estar firmado con la misma clave que el paquete objetivo |
ODM_PARTITION |
0x00000020 |
Residir en odm |
OEM_PARTITION |
0x00000040 |
Residir en oem |
ACTOR_SIGNATURE |
0x00000080 |
Estar firmado con la clave del actor declarado |
CONFIG_SIGNATURE |
0x00000100 |
Estar firmado con la clave de referencia de SystemConfig |
De com.google.android.apps.maps.apk, extraído del .apks del corpus:
RES_TABLE_OVERLAYABLE_TYPE +0x384e58 headerSize=1032 size=1056
name='AssistedDrivingBrandingResources' actor=''
RES_TABLE_OVERLAYABLE_POLICY_TYPE +0x385260 headerSize=16 size=24
policy_flags=0x0000000e entry_count=2
ResTable_ref ident=0x7f0802d0
ResTable_ref ident=0x7f14242a
0x0e = SYSTEM_PARTITION | VENDOR_PARTITION | PRODUCT_PARTITION. Observado en 34 chunks del
corpus, todos en aplicaciones de Google con integración de Android Auto.
12.3 RES_TABLE_STAGED_ALIAS_TYPE (0x0206)
Reescribe identificadores staged —no finalizados— a sus equivalentes definitivos. Es un mecanismo del ciclo de desarrollo del framework.
ResTable_staged_alias_header: ResChunk_header (8) + count (uint32_t, offset 0x08).
Le siguen count ResTable_staged_alias_entry de 8 bytes: stagedResId en 0x00 y
finalizedResId en 0x04, ambos uint32_t.
No se ha observado en el corpus: 0 ocurrencias. Aparece en el framework-res.apk de la
plataforma, no en aplicaciones.
12.4 RES_NULL_TYPE (0x0000)
Chunk de relleno. Un lector debe saltarlo por size como cualquier otro.
13. Qué entender y qué conservar en crudo
El sustrato de chunks garantiza que se puede reemitir sin pérdida lo que no se entiende: la
cabecera lleva el tamaño total, así que un chunk desconocido es un bloque opaco de size bytes
que se copia tal cual. Eso permite dividir el formato en dos:
| Se modela tipado, se puede mutar | Se conserva en crudo, se reemite byte a byte |
|---|---|
RES_TABLE_TYPE — hay que recalcular size |
RES_TABLE_LIBRARY_TYPE mientras no se remapeen paquetes |
RES_STRING_POOL_TYPE — hay que añadir y renombrar cadenas |
RES_TABLE_OVERLAYABLE_TYPE y su política |
RES_TABLE_PACKAGE_TYPE — hay que fusionar paquetes |
RES_TABLE_STAGED_ALIAS_TYPE |
RES_TABLE_TYPE_SPEC_TYPE — hay que unir máscaras al fusionar |
RES_NULL_TYPE y cualquier tipo futuro |
RES_TABLE_TYPE_TYPE — es donde viven los valores |
La lista de la izquierda no es arbitraria: son los cinco tipos que hay que tocar para fusionar dos tablas y para renombrar entradas, que son las dos mutaciones básicas. Todo lo demás se copia.
Dos matices que el reparto no debe ocultar:
- Conservar en crudo no es ignorar. Si
mergereasigna elPPde un paquete, losResTable_refque viven dentro de unRES_TABLE_OVERLAYABLE_POLICY_TYPEdejan de apuntar donde deben. La respuesta correcta no es reescribirlos a ciegas: es detectarlo y advertirlo en el informe. - La métrica de completitud es medible. El porcentaje de bytes que un fichero reemite desde el modelo tipado, frente a los que reemite en crudo, convierte «¿entendemos el formato?» en un número que se puede seguir compilación a compilación, en vez de en una apuesta.
14. Recursos ofuscados
Hay dos cosas distintas que se llaman «ofuscación de recursos», y solo una de ellas es frecuente.
El acortamiento de rutas. Es lo que hace la optimización de recursos del Android Gradle
Plugin: res/layout/activity_main.xml pasa a llamarse res/vp1.xml dentro del ZIP, y la cadena
del pool global cambia en consecuencia. El nombre de la entrada —la key— no se toca.
El renombrado de claves. Sustituir app_name por a en el pool keyStrings. Es lo que
hacen herramientas como AndResGuard, y es mucho más destructivo porque no queda de dónde
recuperar el nombre.
Medido sobre el corpus, la diferencia entre ambos es abrumadora:
| Fichero | Claves | % de claves ≤ 3 car. | Rutas res/ |
% de rutas ≤ 3 car. |
|---|---|---|---|---|
com.termux_1002.apk |
3.507 | 0,5 % | 637 | 84,8 % |
org.fdroid.fdroid_1023052.apk |
6.632 | 0,5 % | 1.067 | 78,5 % |
org.videolan.vlc_13070108.apk |
10.145 | 0,3 % | 2.424 | 89,2 % |
Google Keep, base.apk |
6.895 | 0,2 % | 954 | 74,7 % |
WhatsApp, base.apk |
17.250 | 0,2 % | 8.753 | 100,0 % |
Netflix, base.apk |
14.046 | 0,0 % | 4.656 | 100,0 % |
Twitch, base.apk |
19.893 | 0,2 % | 3.830 | 0,2 % |
Shazam, base.apk |
7.555 | 0,3 % | 1.203 | 0,0 % |
El acortamiento de rutas está en casi todas partes; el renombrado de claves, en ninguna de
las ocho aplicaciones medidas, incluidas las que tienen el DEX ofuscado al 99,9 % o pasan por
DexGuard. Es un hallazgo con consecuencias directas: la información que hace falta para
deshacer la ofuscación de recursos sigue en el fichero.
El caso, tal como se ve en el split config.xhdpi.apk de Google Keep:
[ResTable_type] id: 0x08 name: drawable flags: 0x01 (SPARSE) entryCount: 42 config: xhdpi
[ResTable_entry] id: 0x0161 name: abc_ab_share_pack_mtrl_alpha keyIndex: 0 size: 8
[Res_value] size: 8 dataType: 0x03 data: 0x0000000b ((file) res/JJ.9.png type=PNG)
[ResTable_entry] id: 0x0166 name: abc_btn_check_to_on_mtrl_000 keyIndex: 1 size: 8
[Res_value] size: 8 dataType: 0x03 data: 0x00000000 ((file) res/-B.png type=PNG)
El fichero se llama res/JJ.9.png, pero la entrada sigue diciendo abc_ab_share_pack_mtrl_alpha
y el tipo sigue diciendo drawable. La ruta legible es reconstruible de forma determinista:
res/<tipo><calificadores>/<key>.<extensión>
│ │ │ └── del contenido, o del sufijo .9.png que sí se conserva
│ │ └── ResTable_entry.key → keyStrings
│ └── ResTable_type.config → calificador
└── ResTable_type.id → typeStrings
En el ejemplo: res/drawable-xhdpi/abc_ab_share_pack_mtrl_alpha.9.png. Reconstruirla es por
tanto deofuscación determinista y sin riesgo, al mismo nivel que aplicar un mapping.txt.
Las dos operaciones que exige son renombrar la entrada del ZIP y actualizar la cadena
correspondiente del pool global —no la del keyStrings, que ya está bien—, más los
AndroidManifest.xml y los res/*.xml que referencien la ruta por texto. Ninguna toca la
estructura de la tabla.
⚠️ sin verificar: el corpus no contiene ninguna muestra con keyStrings ofuscado, así que no se
ha podido observar qué aspecto tiene ni comprobar si hay señales que permitan detectarlo con
fiabilidad. La cifra más alta medida es el 0,5 % de claves de tres caracteres o menos de F-Droid
y Termux, que corresponde a nombres legítimamente cortos (id, tag, top).
15. Recetas
Máquina de referencia: macOS, aapt2 2.20-15087165 de las build-tools 37.0.0.
15.1 aapt2 dump resources
BT=~/Library/Android/sdk/build-tools/37.0.0
CORPUS=~/corpus-apk
$BT/aapt2 dump resources $CORPUS/com.termux_1002.apk | head -6
Binary APK
Package name=com.termux id=7f
type anim id=01 entryCount=32
resource 0x7f010000 anim/abc_fade_in
() (file) res/vp1.xml type=XML
resource 0x7f010001 anim/abc_fade_out
El () es la configuración por defecto. Con un recurso traducido se ve la lista entera; de
org.fdroid.fdroid_1023052.apk:
resource 0x7f120076 string/app_name_basic
() "F-Droid Basic"
(fil) "F-Droid Basic"
(ca) "F-Droid Bàsic"
(da) "F-Droid Basis"
(fa) "افدروید پایه"
(ga) "F-Droid Bunúsach"
15.2 aapt2 dump configurations
Lista las configuraciones de la tabla, ya traducidas a calificadores de directorio.
$BT/aapt2 dump configurations $CORPUS/org.fdroid.fdroid_1023052.apk | head -8
h720dp
sw360dp
sw480dp
sw600dp
sw720dp
watch
night
La primera línea vacía es la configuración por defecto. El fichero tiene 141 configuraciones distintas.
15.3 aapt2 dump chunks
Es el volcador oficial del formato y el mejor oráculo diferencial para un lector propio.
$BT/aapt2 dump chunks /tmp/keep/config.xhdpi.apk | sed -n '113,122p'
[RES_TABLE_TYPE_SPEC_TYPE] chunkSize: 3256 headerSize: 16 id: 0x08 types: 2 entry configs: 810
Entry qualifier masks:
#0x19e = 0x4000: layout_dir
[ResTable_type] chunkSize: 924 headerSize: 84 id: 0x08 name: drawable flags: 0x01 (SPARSE) entryCount: 42 entryStart: 252 config: xhdpi
[ResTable_entry] id: 0x0161 name: abc_ab_share_pack_mtrl_alpha keyIndex: 0 size: 8 flags: 0x0000
[Res_value] size: 8 dataType: 0x03 data: 0x0000000b ((file) res/JJ.9.png type=PNG)
[ResTable_entry] id: 0x0166 name: abc_btn_check_to_on_mtrl_000 keyIndex: 1 size: 8 flags: 0x0000
[Res_value] size: 8 dataType: 0x03 data: 0x00000000 ((file) res/-B.png type=PNG)
Obsérvese la máscara del typeSpec: solo la entrada 0x19e de las 810 varía, y varía por
layout_dir. Ese es el índice de la sección 6 en acción.
15.4 Volcado de chunks propio
El mismo recorrido de XML binario, sección 8.2,
descendiendo por headerSize y avanzando por size, aplicado a RES_TABLE_TYPE y
RES_TABLE_PACKAGE_TYPE. Sobre com.termux_1002.apk:
fichero: resources.arsc (771032 bytes)
+0x000000 RES_TABLE_TYPE headerSize=12 size=771032 packageCount=1
+0x00000c RES_STRING_POOL_TYPE headerSize=28 size=220604 stringCount=6338 UTF-8
+0x035dc8 RES_TABLE_PACKAGE_TYPE headerSize=288 size=550416 id=0x7f
name='com.termux' typeStrings=0x120 keyStrings=0x2a0
+0x035ee8 RES_STRING_POOL_TYPE headerSize=28 size=384 stringCount=19 UTF-16
+0x036068 RES_STRING_POOL_TYPE headerSize=28 size=119564 stringCount=3507 UTF-8
+0x053374 RES_TABLE_TYPE_SPEC_TYPE headerSize=16 size=144 id=0x01 entryCount=32
+0x053404 RES_TABLE_TYPE_TYPE headerSize=84 size=724 id=0x01 flags=0x00
entryCount=32 entriesStart=0xd4 config.size=64
…
El fichero completo tiene 260 chunks: 1 tabla, 3 string pools, 1 paquete, 19 typeSpec y 236
ResTable_type. Y el censo de todo el corpus, con el mismo recorrido, del que salen las cifras
de las secciones 7.2, 7.3, 8.2, 12.1, 12.2 y 12.3:
| Medida | 58 APK standalone | 505 APK internos de contenedores |
|---|---|---|
ResTable_config.size |
16.028 de 64 bytes, 302 de 56 | 5.917 de 64 bytes, ninguno de 56 |
ResTable_type.flags |
16.330 con 0x00 |
4.941 con 0x00, 976 con FLAG_SPARSE |
FLAG_OFFSET16 |
0 | 0 |
| String pools | 121 UTF-8, 63 UTF-16, 33 con estilos | 1.005 UTF-8, 500 UTF-16, 252 con estilos |
RES_TABLE_LIBRARY_TYPE |
5 | 6 |
RES_TABLE_OVERLAYABLE_TYPE |
0 | 34 |
RES_TABLE_STAGED_ALIAS_TYPE |
0 | 0 |
15.5 apktool
apktool d -f -o salida app.apk
# los recursos decodificados quedan en salida/res/, en XML de texto
apktool d -r -f -o salida app.apk
# -r deja resources.arsc intacto y no decodifica nada de res/
⚠️ no ejecutado localmente: apktool no está instalado en la máquina de referencia. La sintaxis procede de la documentación oficial de parámetros de la CLI (fuente 8).
Fuentes
frameworks/base/libs/androidfw/include/androidfw/ResourceTypes.h(ramamain) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/libs/androidfw/include/androidfw/ResourceTypes.h Consultado el 12 de agosto de 2026, descargado con?format=TEXTy decodificado. De aquí salen íntegras las tablas de campos de las secciones 3, 5, 6, 7, 8.1, 10, 11, 12.1, 12.2 y 12.3, las constantesSPEC_*,FLAG_*,ATTR_*,TYPE_*,PolicyFlags, las macrosRes_MAKEID/Res_GETPACKAGE/Res_GETTYPE/Res_GETENTRY,kResTableTypeMinSize,offset_from16y elstatic_assertde queResTable_configva al final deResTable_type.frameworks/base/libs/androidfw/ResourceTypes.cpp(ramamain) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/libs/androidfw/ResourceTypes.cpp Consultado el 12 de agosto de 2026. De aquí salenResTable_config::copyFromDeviceNoSwap(sección 8.2),unpackLanguageOrRegionypackLanguageOrRegion(sección 8.3), yResTable_config::match(),isBetterThan(),isLocaleBetterThan()eisMoreSpecificThan()(sección 9).frameworks/base/libs/androidfw/LoadedArsc.cpp(ramamain) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/libs/androidfw/LoadedArsc.cpp Consultado el 12 de agosto de 2026. De aquí salenVerifyResTableType,VerifyResTableEntry(sección 10.3),GetEntryOffsetcon la búsqueda binaria sobre las entradas dispersas (sección 7.3), el límite de 65.536 entradas por tipo (sección 11) y las reglas de orden entretypeSpecytypede la sección 2.frameworks/base/tools/aapt2/Resource.h(ramamain) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/tools/aapt2/Resource.h Consultado el 12 de agosto de 2026. De aquí salenkAppPackageId = 0x7f,kFrameworkPackageId = 0x01y la descripción canónica de0xPPTTEEEEde la sección 11.frameworks/base/tools/aapt2/cmd/Link.cpp(ramamain) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/tools/aapt2/cmd/Link.cpp Consultado el 12 de agosto de 2026. De aquí salen la validación del rango de--package-id, el0x00que asigna--shared-lib, el rango reservado0x02–0x7ede--allow-reserved-package-idy la explicación del comportamiento previo a Android 8, todo en la sección 11.frameworks/base/tools/aapt2/format/binary/TableFlattener.cppyTableFlattener.h(ramamain) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/tools/aapt2/format/binary/TableFlattener.cpp Consultado el 12 de agosto de 2026. De aquí salen las condiciones exactas con queaapt2emiteFLAG_SPARSE,FLAG_OFFSET16yFLAG_COMPACT, y la constantekSparseEncodingThreshold = 60de las secciones 7.2, 7.3 y 10.2.frameworks/base/include/androidfw/ResourceTypes.hen las etiquetasandroid-4.4_r1,android-5.0.0_r1,android-6.0.0_r1,android-7.1.2_r36,android-9.0.0_r1yandroid-11.0.0_r1— https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-9.0.0_r1/libs/androidfw/include/androidfw/ResourceTypes.h Consultado el 12 de agosto de 2026, las seis etiquetas. De aquí sale el historial de campos deResTable_configde la tabla de la sección 8.2.- apktool — parámetros de la CLI — https://apktool.org/docs/cli-parameters Consultado el 12 de agosto de 2026. De aquí sale la sintaxis de la receta 15.5, que no se ha podido ejecutar.
- Corpus de verificación — 5,9 GB, 86 contenedores y 717 APK: 58 sueltos de F-Droid y 659
dentro de contenedores de splits.
Medido el 12 de agosto de 2026 con
aapt22.20-15087165 y un volcador propio enpython3sobre los 58 APK standalone y los 505 APK internos conresources.arsc. De aquí salen todas las cifras y volcados de las secciones 3, 4, 5, 6, 7.1 a 7.3, 8.2, 8.3, 10.1, 10.2, 11, 12, 14 y 15.