AAB, splits y contenedores
1. Por qué existen los splits
Durante los primeros diez años de Android una aplicación era un fichero: el APK universal.
Dentro iban las bibliotecas nativas de las cuatro arquitecturas, los iconos de las ocho
densidades y las cadenas de los ochenta idiomas. Un teléfono arm64-v8a en español con pantalla
xxhdpi descargaba, instalaba y conservaba en disco el código x86, los iconos ldpi y las
traducciones al finés.
En agosto de 2018 Google Play introdujo el Android App Bundle y con él invirtió el modelo.
El desarrollador ya no sube el fichero instalable: sube un contenedor intermedio, el .aab, y
es Play quien genera los APK concretos para cada dispositivo. En agosto de 2021 pasó a ser
obligatorio para las aplicaciones nuevas.
La consecuencia técnica es que una aplicación instalada ya no es un APK, sino un conjunto:
un base.apk más los split que le correspondan a ese dispositivo. PackageManager los monta
como una sola aplicación con un solo AssetManager. La consecuencia práctica para quien analiza
es más incómoda: descargar «la aplicación» de una fuente que no sea Play devuelve un ZIP de ZIPs
con una extensión inventada por el sitio de descargas, y hay que averiguar qué es antes de poder
abrirlo.
Este documento cubre los tres niveles: el AAB que produce el desarrollador, el APK Set que
produce bundletool, y los contenedores de terceros que produce cada sitio de descargas.
2. El Android App Bundle (.aab)
2.1 Estructura interna
Un .aab es un ZIP, pero no es un APK y no se instala. Su estructura, verificada sobre
com.ejemplo.fixture.aab del corpus, generado con bundletool build-bundle 1.18.3:
unzip -l ~/corpus-apk/splits/com.ejemplo.fixture.aab
Length Date Time Name
--------- ---------- ----- ----
10 01-01-2010 00:00 BundleConfig.pb
114 01-01-2010 00:00 base/res/drawable-xxhdpi-v4/ic_fixture.png
104 01-01-2010 00:00 base/res/drawable-xhdpi-v4/ic_fixture.png
115 01-01-2010 00:00 base/res/drawable-mdpi-v4/ic_fixture.png
100 01-01-2010 00:00 base/res/drawable-hdpi-v4/ic_fixture.png
6492 01-01-2010 00:00 base/lib/armeabi-v7a/libtermux.so
9008 01-01-2010 00:00 base/lib/arm64-v8a/libtermux.so
9248 01-01-2010 00:00 base/lib/x86_64/libtermux.so
1800 01-01-2010 00:00 base/dex/classes.dex
1714 01-01-2010 00:00 base/manifest/AndroidManifest.xml
68 01-01-2010 00:00 base/native.pb
1083 01-01-2010 00:00 base/resources.pb
El mapa completo, incluyendo lo que este fixture mínimo no trae:
| Entrada | Contenido | Formato |
|---|---|---|
BundleConfig.pb |
Configuración del bundle: versión de bundletool, optimizaciones, compresión, locales |
protobuf android.bundle.BundleConfig |
BUNDLE-METADATA/ |
Metadatos para herramientas y tiendas: mapping.txt de R8, lista de ficheros DEX. No se empaquetan en los APK |
Variable |
base/ |
El módulo base. Obligatorio | Directorio |
<feature>/ |
Un directorio por módulo de funcionalidad, con el nombre del atributo split de su manifiesto |
Directorio |
<asset_pack>/ |
Un directorio por asset pack de Play Asset Delivery | Directorio |
<módulo>/manifest/AndroidManifest.xml |
El manifiesto del módulo, en un directorio aparte, a diferencia del APK | AXML binario, no proto |
<módulo>/dex/ |
Los classes*.dex del módulo, en un directorio aparte |
DEX |
<módulo>/res/ |
Recursos con calificador, idénticos a los de un APK | Ficheros de recurso |
<módulo>/lib/<abi>/ |
Bibliotecas nativas | ELF |
<módulo>/assets/ |
Assets | Cualquiera |
<módulo>/root/ |
Ficheros que se reubican a la raíz del APK generado. Es donde acaban los recursos Java que se leen con Class.getResource() |
Cualquiera |
<módulo>/resources.pb |
La tabla de recursos del módulo | protobuf aapt.pb.ResourceTable |
<módulo>/native.pb |
Descripción del código nativo del módulo | protobuf |
<módulo>/assets.pb |
Descripción de los assets del módulo, cuando hay asset packs | protobuf |
Tres diferencias con la estructura de un APK que se olvidan enseguida:
AndroidManifest.xmlno está en la raíz del módulo, sino en<módulo>/manifest/.- Los
.dexno están en la raíz, sino en<módulo>/dex/. - No hay
resources.arsc, hayresources.pb. No es el mismo formato ni remotamente.
2.2 Por qué protobuf y no los formatos binarios del APK
resources.arsc está diseñado para leerse en un teléfono: es un índice denso, mapeable, con los
valores ya resueltos por configuración. Sus tres virtudes —compacidad, acceso aleatorio y coste
de análisis cero— solo importan en el dispositivo.
El AAB no se ejecuta nunca. Es una entrada de un proceso de compilación que corre en los
servidores de Play y cuyo trabajo es exactamente dividir y recombinar la tabla: quedarse con
los recursos de un idioma, tirar los de las demás densidades, fusionar módulos. Para eso el
formato denso es un estorbo, porque toda edición reindexa el string pool y recalcula los offsets
de todos los chunks (ver Tabla de recursos, secciones 3.5 y 7).
Protocol Buffers da lo contrario: campos con nombre, opcionales, extensibles hacia delante y hacia atrás, sin offsets absolutos y sin punteros. Un servicio puede añadir un campo sin romper a los lectores viejos. El coste es tamaño y velocidad de análisis, que ahí no importan.
Y hay una consecuencia de diseño que se olvida: el manifiesto del módulo sigue siendo AXML
binario, no proto. Play lo reescribe para añadir los atributos de split de la sección 4.4, y
lo hace sobre el formato binario. La tabla de recursos sí se convierte: aapt2 link --proto-format produce resources.pb, y al generar cada APK se convierte de vuelta a
resources.arsc.
2.3 Los .proto relevantes
Las definiciones están publicadas en el repositorio de bundletool, bajo src/main/proto/, y
en AOSP para las de aapt2. Los cuatro que hacen falta para leer un bundle o un APK Set:
| Fichero | Paquete proto | Java | Mensaje raíz | Describe |
|---|---|---|---|---|
config.proto |
android.bundle |
com.android.bundle |
BundleConfig |
BundleConfig.pb del .aab |
commands.proto |
android.bundle |
com.android.bundle |
BuildApksResult |
toc.pb del .apks |
targeting.proto |
android.bundle |
com.android.bundle |
ApkTargeting, VariantTargeting |
A qué dispositivos aplica cada APK |
files.proto |
android.bundle |
com.android.bundle |
— | native.pb, assets.pb |
Resources.proto |
aapt.pb |
com.android.aapt |
ResourceTable |
resources.pb de cada módulo |
BundleConfig tiene nueve campos de primer nivel: bundletool, optimizations, compression,
master_resources, apex_config, unsigned_embedded_apk_config, asset_modules_config, type
—REGULAR, APEX o ASSET_ONLY— y locales. El del fixture del corpus ocupa 10 bytes,
porque solo lleva la versión de bundletool:
java -jar ~/herramientas/bundletool.jar dump config \
--bundle ~/corpus-apk/splits/com.ejemplo.fixture.aab
{
"bundletool": {
"version": "1.18.3"
}
}
Resources.proto de aapt2 define la jerarquía equivalente a la de resources.arsc:
ResourceTable → Package → Type → Entry → ConfigValue, con PackageId, TypeId y
EntryId como mensajes propios. Es una traducción campo a campo de las estructuras del
documento Tabla de recursos, no un formato distinto.
3. El APK Set (.apks)
3.1 Qué es
Es lo que produce bundletool build-apks a partir de un .aab: un ZIP con todos los APK
que Play podría llegar a generar, más una tabla de contenidos que dice cuál va a cada
dispositivo. No se instala directamente; se le extrae el subconjunto que toca.
De com.ejemplo.fixture.apks del corpus, generado localmente:
toc.pb
splits/base-master.apk splits/base-master_2.apk splits/base-master_3.apk
splits/base-arm64_v8a.apk splits/base-armeabi_v7a.apk splits/base-x86_64.apk
splits/base-de.apk splits/base-es.apk splits/base-fr.apk
splits/base-ldpi.apk splits/base-mdpi.apk splits/base-hdpi.apk
splits/base-tvdpi.apk splits/base-xhdpi.apk splits/base-xxhdpi.apk
splits/base-xxxhdpi.apk
43 ficheros para una aplicación de una sola actividad. Los sufijos _2 y _3 son las
variantes: bundletool genera un juego de splits por cada rango de versión de Android que
requiera un empaquetado distinto, y toc.pb dice cuál corresponde a cada nivel de API.
Los directorios que puede traer un APK Set:
| Directorio | Contenido |
|---|---|
splits/ |
Los split APK de instalación por Play, agrupados por variante |
standalones/ |
APK monolíticos completos para dispositivos anteriores a Android 5.0, que no soportan splits |
instant/ |
Los splits de la versión instant, si el bundle la declara |
system/ |
APK para preinstalación en la imagen del sistema |
asset-slices/ |
Las porciones de los asset packs |
| (raíz) | universal.apk cuando se generó con --mode=universal |
El modo universal produce un APK Set degenerado: una tabla de contenidos de 90 bytes y un solo APK con todo dentro. Es la vía oficial para obtener un instalable único a partir de un bundle:
Archive: com.ejemplo.fixture-universal.apks
90 toc.pb
66347 universal.apk
3.2 toc.pb: BuildApksResult
El fichero toc.pb en la raíz es la firma estructural del formato y un mensaje protobuf
android.bundle.BuildApksResult. Sus campos:
| Campo | Nº | Tipo | Contenido |
|---|---|---|---|
variant |
1 | repeated Variant |
Las variantes generadas |
bundletool |
2 | Bundletool |
La versión de bundletool que lo produjo |
asset_slice_set |
3 | repeated AssetSliceSet |
Porciones de asset packs |
package_name |
4 | string |
El nombre del paquete |
local_testing_info |
5 | LocalTestingInfo |
Modo de prueba local |
asset_modules_info |
6 | AssetModulesInfo |
Metadatos de bundles de solo assets |
default_targeting_value |
7 | repeated DefaultTargetingValue |
Valores por defecto de las dimensiones |
permanently_fused_modules |
8 | repeated PermanentlyFusedModule |
Módulos fusionados en base en todas las variantes |
device_group_config |
9 | DeviceGroupConfig |
Grupos de dispositivos y conjuntos de países |
Y la jerarquía: Variant → ApkSet → ApkDescription, donde ApkSet lleva un
ModuleMetadata —nombre del módulo, tipo, modo de entrega, dependencias— y cada
ApkDescription lleva su ApkTargeting, su path dentro del ZIP y uno de los siete
metadatos posibles: split_apk_metadata (con split_id y is_master_split),
standalone_apk_metadata, instant_apk_metadata, system_apk_metadata,
asset_slice_metadata, apex_apk_metadata o archived_apk_metadata.
Decodificando el toc.pb del fixture con un lector genérico de wire format, sin biblioteca de
protobuf, se ve exactamente esa estructura:
toc.pb: 3250 bytes
campo 1 (length-delimited) x3 ← tres Variant
campo 2 (length-delimited) x1 ← Bundletool: b'\x12\x061.18.3'
campo 4 (length-delimited) x1 ← package_name: b'com.ejemplo.fixture'
campo 5 (length-delimited) x1 ← local_testing_info, vacío
Variant[0] → ApkSet[0] → 14 ApkDescription
path = splits/base-ldpi.apk
path = splits/base-mdpi.apk
path = splits/base-hdpi.apk
path = splits/base-xhdpi.apk
Tres Variant, que es el origen de los sufijos _2 y _3 del listado de 3.1.
4. Taxonomía de splits
4.1 Los tres tipos
| Tipo | Nombre habitual | Qué lleva |
|---|---|---|
| Base | base.apk, o <paquete>.apk en los contenedores de terceros |
El manifiesto completo, los classes*.dex, resources.arsc y los recursos sin calificador |
| Config split | config.<abi>, config.<densidad>, config.<idioma>, o split_config.* |
Solo lo de esa configuración. No tiene código |
| Feature module | split_<nombre>, o <nombre>.apk |
Un módulo de funcionalidad descargable bajo demanda. Puede tener código propio |
Un split de configuración puede a su vez pertenecer a un módulo de funcionalidad, y entonces
lleva los dos nombres. El zip SAI de Netflix del corpus lo muestra en estado puro:
base.apk
split_voip.apk ← feature module
split_partnermodule.apk ← feature module
split_config.armeabi_v7a.apk ← config split del base
split_config.es.apk split_config.ja.apk split_config.zh.apk …
split_voip.config.armeabi_v7a.apk ← config split de un feature module
split_partnermodule.config.ko.apk ← ídem
split_partnermodule.config.xxhdpi.apk
4.2 Los atributos del manifiesto
Todo se decide en AndroidManifest.xml. Verificado con aapt2 dump xmltree sobre APK reales
del corpus.
| Atributo | Resource ID | Dónde aparece | Significado |
|---|---|---|---|
split |
— (sin namespace) | En el <manifest> de todo split, nunca en el base |
El identificador del split: config.es, voip, config.armeabi_v7a |
android:isFeatureSplit |
0x0101055b |
Solo en módulos de funcionalidad | true |
android:isSplitRequired |
0x01010591 |
En el base y en los feature modules | El sistema debe negarse a arrancar si faltan splits requeridos |
android:requiredSplitTypes |
0x0101064e |
En el base | Lista de tipos de split obligatorios, separados por comas |
android:splitTypes |
0x0101064f |
En cada split | Los tipos que ese split aporta. Cadena vacía si no aporta ninguno |
android:hasCode |
0x0101000c |
En <application> de los config splits |
false |
Todos los identificadores de la tabla proceden de la salida real de aapt2 dump xmltree sobre
APK del corpus. El de isSplitRequired se midió sobre el base.apk del SAI de Netflix, que es
el único contenedor del corpus que usa el mecanismo antiguo:
BT=~/Library/Android/sdk/build-tools/37.0.0
$BT/aapt2 dump xmltree --file AndroidManifest.xml base.apk | grep isSplitRequired
A: http://schemas.android.com/apk/res/android:isSplitRequired(0x01010591)=true
Los demás contenedores inspeccionados usan el mecanismo más reciente de
requiredSplitTypes/splitTypes. Conviven los dos, y un fusionador tiene que tratar ambos:
lo detalla la sección 6 de
Fusión de splits.
El mecanismo moderno, sobre el base.apk de Google Keep extraído del .apks del corpus:
E: manifest (line=2)
A: android:versionCode(0x0101021b)=220671357
A: android:versionName(0x0101021c)="5.26.321.01.97"
A: android:requiredSplitTypes(0x0101064e)="base__abi,base__density"
A: android:splitTypes(0x0101064f)=""
A: package="com.google.android.keep"
Y su config.armeabi_v7a.apk:
E: manifest (line=0)
A: android:versionCode(0x0101021b)=220671357
A: android:splitTypes(0x0101064f)="base__abi"
A: package="com.google.android.keep"
A: split="config.armeabi_v7a"
E: application (line=0)
A: android:hasCode(0x0101000c)=false
El base declara que necesita alguien que aporte base__abi y base__density; cada config split
declara qué aporta. El instalador comprueba que el conjunto es completo antes de aceptarlo.
Esa es la razón número uno por la que instalar splits sueltos falla con
INSTALL_PARSE_FAILED_MANIFEST_MALFORMED o con INSTALL_FAILED_MISSING_SPLIT.
Un módulo de funcionalidad añade además el namespace dist. De split_voip.apk de Netflix:
N: dist=http://schemas.android.com/apk/distribution
E: manifest (line=0)
A: android:versionCode(0x0101021b)=50451
A: android:isFeatureSplit(0x0101055b)=true
A: split="voip"
E: dist:module (line=0)
A: dist:onDemand=true
A: dist:title=@0x7f140db8
E: dist:fusing (line=0)
A: dist:include=true
dist:fusing include=true es la marca de que ese módulo debe fusionarse en el APK monolítico
cuando se genera uno para dispositivos anteriores a Android 5.0.
4.3 Lo que todos los splits comparten obligatoriamente
Tres cosas, y las tres son invariantes que un merge tiene que comprobar antes de empezar:
package, el nombre del paquete. Idéntico en todos.android:versionCode. Idéntico en todos. En el ejemplo de Keep,220671357en el base y en cada uno de los 26 config splits.- La firma. Todos los splits tienen que estar firmados con la misma clave, porque
PackageManagerlos trata como una sola aplicación. Ver Esquemas de firma.
Lo que no comparten: versionName —los config splits lo traen vacío—, minSdkVersion
—cada variante puede declarar el suyo— y, evidentemente, el contenido.
5. Contenedores de terceros
Ninguno de estos formatos está especificado. Lo que sigue está medido sobre el corpus.
5.1 .xapk — APKCombo y APKPure
ZIP plano con el base y los config splits en la raíz, más un manifest.json de metadatos y un
icon.png. Los de APKCombo añaden un APKComboInstaller.url. De
GoogleAuthenticator_com.google.android.apps.authenticator2.xapk, 36 entradas:
com.google.android.apps.authenticator2.apk ← el base, con el nombre del paquete
config.en.apk config.es.apk config.de.apk … (25 idiomas)
config.ldpi.apk … config.xxxhdpi.apk (7 densidades)
manifest.json
icon.png
APKComboInstaller.url
La clave que identifica el formato es xapk_version dentro de manifest.json:
{
"xapk_version": "2",
"package_name": "com.google.android.apps.authenticator2",
"name": "Authenticator",
"locales_name": { "ar": "Authenticator", "de": "Authenticator", … },
"version_code": "7002007",
"version_name": "7.2",
"min_sdk_version": "28",
"target_sdk_version": "37",
"permissions": [ "android.permission.CAMERA", … ],
"total_size": 6647133,
"icon": "icon.png",
"split_apks": [ … ]
}
split_apks es una lista de objetos con el nombre de fichero y el id de cada split, así que
el contenedor es autodescriptivo sin necesidad de abrir los APK.
5.2 .apkm — APKMirror
ZIP con el base y los splits en la raíz, un info.json, un icon.png y su propia firma JAR
sobre el contenedor. De tv.twitch.android.app.apkm:
META-INF/MANIFEST.MF
META-INF/APKMIRRO.SF
META-INF/APKMIRRO.RSA ← firma del contenedor, no de la aplicación
info.json
icon.png
base.apk
split_config.armeabi_v7a.apk split_config.arm64_v8a.apk split_config.x86.apk …
split_config.ldpi.apk … split_config.xxxhdpi.apk
Esa firma JAR (ver Esquemas de firma) es de APKMirror sobre el contenedor, no de la aplicación: acredita la procedencia de la descarga y no tiene ninguna relación con la firma de los APK que hay dentro. Confundirlas es un error de análisis frecuente.
info.json tiene 17 claves, entre ellas la que identifica el formato:
apkm_version, apk_title, app_name, release_version, variant, release_title,
versioncode, pname, post_date, capabilities, languages, arches, dpis,
min_api, accent_color, apk_id, release_id
apkm_version vale 5 en los ocho .apkm del corpus, y pname es el nombre del paquete.
5.3 SAI — Split APKs Installer
El más simple de todos: un ZIP plano con los .apk y nada más. Sin metadatos, sin
manifest.json, sin firma propia. El del corpus, com.netflix.mediaclient.sai.zip, trae 40
entradas, todas .apk, con el prefijo split_ en los splits y base.apk en el base.
La ausencia de cualquier fichero descriptivo es precisamente lo que lo define, y por eso está el último en el orden de precedencia de la sección 6.
5.4 Los OBB heredados
Antes del AAB, la vía oficial para superar el límite de tamaño de Play eran los expansion
files: hasta dos ficheros .obb de 2 GB cada uno que viajaban fuera del APK y se
descargaban aparte. Su nombre sigue un formato fijo:
[main|patch].<expansion-version>.<package-name>.obb
donde <expansion-version> es el versionCode del APK en su primera subida —por ejemplo
main.314159.com.example.app.obb—, y se instalan en
<almacenamiento-compartido>/Android/obb/<package-name>/.
Están superados: desde agosto de 2021 las aplicaciones nuevas de más de 200 MB usan Play
Feature Delivery o Play Asset Delivery. Siguen apareciendo en descargas de juegos antiguos, a
veces dentro de un .xapk, donde ocupan una entrada Android/obb/… junto a los APK. El
corpus de verificación no contiene ninguno; lo de esta sección procede de la documentación oficial,
no de una medición.
6. Detección por estructura, nunca por extensión
6.1 El principio
La extensión miente. No es una precaución teórica: es el hallazgo medido de la sección 6.2.
Un fichero .apks puede no ser un APK Set; un .zip puede ser un contenedor SAI; un .apk
puede ser un .aab renombrado. La única fuente fiable es el contenido del Central Directory del ZIP, que se lee sin descomprimir nada.
Cada formato tiene una evidencia estructural que ningún otro comparte:
| Formato | Evidencia | Por qué es exclusiva |
|---|---|---|
| APK Set | toc.pb en la raíz |
Solo bundletool lo escribe |
.apkm |
info.json más META-INF/*.RSA en la raíz |
Ningún otro contenedor firma el propio ZIP |
.xapk |
manifest.json con la clave xapk_version |
La clave es privativa de APKCombo y APKPure |
| SAI / zip plano | Varias entradas *.apk en la raíz o bajo un prefijo |
Es lo que queda cuando no hay metadatos |
| APK suelto | AndroidManifest.xml más resources.arsc en la raíz |
Un APK real siempre tiene manifiesto |
.aab |
BundleConfig.pb más base/manifest/AndroidManifest.xml |
El manifiesto en subdirectorio es exclusivo del bundle |
6.2 El orden de precedencia
El orden importa porque las evidencias se solapan. Un .apkm tiene entradas *.apk en la
raíz, así que también cumple la regla del zip plano; un APK Set tiene splits/*.apk. Se
comprueba de lo más específico a lo más genérico:
1. ¿toc.pb en la raíz? → APK Set genuino de bundletool
2. ¿info.json + META-INF/*.RSA en la raíz? → .apkm de APKMirror
3. ¿manifest.json con clave xapk_version? → .xapk de APKCombo o APKPure
4. ¿varias entradas *.apk en raíz o prefijo? → zip plano de splits (incluido SAI)
5. ¿AndroidManifest.xml + resources.arsc? → APK suelto
6. ¿BundleConfig.pb + base/manifest/…? → .aab
Un detector que empiece por el paso 4 clasifica los .apkm y los APK Set como zips planos y
pierde sus metadatos. Uno que empiece por el 5 no distingue un APK de un split.
Y cuando la estructura contradice a la extensión, no se falla: se continúa y se advierte en el informe.
6.3 La comprobación sobre el corpus
Medido sobre los 25 contenedores del directorio splits/ del corpus de verificación, mirando
el Central Directory de cada uno:
cd ~/corpus-apk/splits
for f in *; do
case "$f" in *.aab) continue;; esac
toc=$(unzip -l "$f" 2>/dev/null | grep -c " toc\.pb$")
mf=$(unzip -l "$f" 2>/dev/null | grep -c " manifest\.json$")
ij=$(unzip -l "$f" 2>/dev/null | grep -c " info\.json$")
echo "$f|toc.pb=$toc|manifest.json=$mf|info.json=$ij"
done
com.duolingo.apks|toc.pb=0|manifest.json=1|info.json=0
com.google.android.apps.docs.apks|toc.pb=0|manifest.json=1|info.json=0
com.google.android.apps.maps.apks|toc.pb=0|manifest.json=1|info.json=0
com.google.android.apps.photos.apks|toc.pb=0|manifest.json=1|info.json=0
com.google.android.gm.apks|toc.pb=0|manifest.json=1|info.json=0
com.google.android.keep.apks|toc.pb=0|manifest.json=1|info.json=0
com.google.android.youtube.apks|toc.pb=0|manifest.json=1|info.json=0
com.king.candycrushsaga.apks|toc.pb=0|manifest.json=1|info.json=0
com.moonactive.coinmaster.apks|toc.pb=0|manifest.json=1|info.json=0
com.ejemplo.fixture-universal.apks|toc.pb=1|manifest.json=0|info.json=0
com.ejemplo.fixture.apks|toc.pb=1|manifest.json=0|info.json=0
com.outfit7.mytalkingtomfree.apks|toc.pb=0|manifest.json=1|info.json=0
com.spotify.music.apks|toc.pb=0|manifest.json=1|info.json=0
com.supercell.clashroyale.apks|toc.pb=0|manifest.json=1|info.json=0
com.whatsapp.apks|toc.pb=0|manifest.json=1|info.json=0
org.telegram.messenger.apks|toc.pb=0|manifest.json=1|info.json=0
…
com.netflix.mediaclient.sai.zip|toc.pb=0|manifest.json=0|info.json=0
tv.twitch.android.app.apkm|toc.pb=0|manifest.json=0|info.json=1
El resultado es inequívoco. De los 16 ficheros con extensión
.apks:
- 2 llevan
toc.pb:com.ejemplo.fixture.apksycom.ejemplo.fixture-universal.apks, los dos generados localmente conbundletool. - 14 no lo llevan y los 14 llevan
manifest.json. Y esemanifest.jsontiene la clavexapk_version:
unzip -p com.google.android.keep.apks manifest.json | python3 -m json.tool | head -4
{
"xapk_version": "2",
"package_name": "com.google.android.keep",
"name": "Keep Notes",
Comprobado también en com.whatsapp.apks, con el mismo "xapk_version": "2".
La conclusión es más fuerte que «no son APK Set»: son .xapk de APKCombo con la extensión
cambiada. El detector de la sección 6.2 los clasifica correctamente en el paso 3, y el paso 1
—el que la extensión sugiere— falla como debe.
Los 8 .apkm y el .zip de SAI se clasifican igual de bien: los primeros por info.json más
META-INF/APKMIRRO.RSA, el segundo por descarte en el paso 4.
7. Instalar sin fusionar frente a fusionar
Hay dos maneras de llevar un conjunto de splits a un dispositivo, y hacen cosas distintas.
Instalar sin fusionar. El sistema recibe los APK por separado y los monta como una sola aplicación. Es lo que hace Play y lo que preserva el comportamiento original:
adb install-multiple base.apk config.es.apk config.arm64_v8a.apk config.xxhdpi.apk
bundletool install-apks --apks=/ruta/app.apks
bundletool install-apks --apks=/ruta/app.apks --device-id=<serie>
install-apks consulta el toc.pb, interroga al dispositivo conectado y extrae solo los splits
que le corresponden. Es lo correcto siempre que haya un dispositivo. ⚠️ no ejecutado localmente:
adb no está en el PATH de la máquina de referencia y no hay dispositivo ni emulador
conectado; la sintaxis procede de la documentación oficial (fuentes 6 y 7).
Fusionar. Producir un único APK instalable con todo dentro. Es lo que hace falta cuando no
hay adb, cuando el objetivo es analizar la aplicación como una unidad, o cuando se va a
modificar y volver a firmar. Exige mucho más que concatenar ZIPs:
- Unir las tablas de recursos de todos los splits en una sola, resolviendo las colisiones de identificador (ver Tabla de recursos y Fusión de splits).
- Sanear el manifiesto: quitar
split,isFeatureSplit,requiredSplitTypesysplitTypes, porque el resultado ya no es un conjunto. - Renumerar los
classes*.dexde los módulos de funcionalidad que tengan código propio. - Seleccionar qué configuraciones se conservan y cuáles se descartan.
- Realinear y volver a firmar, porque la firma original ya no vale.
Lo que no se puede hacer es fusionar un AAB directamente: hay que pasar antes por
bundletool build-apks, porque el .aab no contiene ningún resources.arsc, solo
resources.pb. La vía oficial es --mode=universal, que produce el universal.apk de la
sección 3.1.
8. Recetas
Máquina de referencia: macOS, bundletool 1.18.3, aapt2 2.20-15087165 de las
build-tools 37.0.0, OpenJDK 21.
8.1 bundletool validate y dump sobre un .aab
Los subcomandos dump y validate solo aceptan --bundle. Invocarlos con --apks falla:
BTOOL=~/herramientas/bundletool.jar
CORPUS=~/corpus-apk
java -jar $BTOOL dump manifest --apks $CORPUS/splits/com.ejemplo.fixture.apks
[BT:1.18.3] Error: Missing the required --bundle flag.
com.android.tools.build.bundletool.flags.Flag$RequiredFlagNotSetException: …
Sobre el bundle sí funcionan:
java -jar $BTOOL validate --bundle $CORPUS/splits/com.ejemplo.fixture.aab | head -12
App Bundle information
------------
Feature modules:
Feature module: base
File: res/drawable-xxhdpi-v4/ic_fixture.png
File: res/drawable-xhdpi-v4/ic_fixture.png
File: res/drawable-mdpi-v4/ic_fixture.png
File: res/drawable-hdpi-v4/ic_fixture.png
File: lib/armeabi-v7a/libtermux.so
File: lib/arm64-v8a/libtermux.so
File: lib/x86_64/libtermux.so
File: dex/classes.dex
java -jar $BTOOL dump manifest --bundle $CORPUS/splits/com.ejemplo.fixture.aab | head -6
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:compileSdkVersion="36" android:compileSdkVersionCodename="16" android:versionCode="1" android:versionName="1.0" package="com.ejemplo.fixture" platformBuildVersionCode="36" platformBuildVersionName="16">
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="36"/>
<uses-permission android:name="android.permission.INTERNET"/>
<application android:extractNativeLibs="false" android:icon="@drawable/ic_fixture" android:label="@string/app_name"/>
dump resources --values lee el resources.pb de los módulos y lo presenta con la misma forma
que aapt2 dump resources presenta un resources.arsc:
java -jar $BTOOL dump resources --bundle $CORPUS/splits/com.ejemplo.fixture.aab --values
Package 'com.ejemplo.fixture':
0x7f010000 - drawable/ic_fixture
density: 160 - [FILE] res/drawable-mdpi-v4/ic_fixture.png
density: 240 - [FILE] res/drawable-hdpi-v4/ic_fixture.png
density: 320 - [FILE] res/drawable-xhdpi-v4/ic_fixture.png
density: 480 - [FILE] res/drawable-xxhdpi-v4/ic_fixture.png
0x7f020000 - string/app_name
(default) - [STR] "Demo Fixture"
locale: "de" - [STR] "Demo Beispiel"
locale: "fr" - [STR] "Echantillon Demo"
locale: "es" - [STR] "Muestra Demo"
8.2 Extraer los splits de un dispositivo concreto
Sin dispositivo conectado se puede escribir la especificación a mano:
cat > /tmp/dev.json <<'EOF'
{"supportedAbis":["arm64-v8a"],"supportedLocales":["es"],"screenDensity":420,"sdkVersion":34}
EOF
java -jar $BTOOL extract-apks --apks $CORPUS/splits/com.ejemplo.fixture.apks \
--output-dir ./extracted --device-spec /tmp/dev.json
ls extracted
The APKs have been extracted in the directory: ./extracted
base-arm64_v8a_3.apk
base-es_3.apk
base-master_3.apk
base-xxhdpi_3.apk
Cuatro APK de los 43: el maestro, la ABI, el idioma y la densidad, todos de la variante _3,
que es la que corresponde a sdkVersion 34. Es exactamente lo que instalaría Play.
Y el tamaño de descarga por combinación:
java -jar $BTOOL get-size total --apks $CORPUS/splits/com.ejemplo.fixture.apks \
--dimensions=ABI,LANGUAGE | head -5
ABI,LANGUAGE,MIN,MAX
arm64-v8a,de,14549,14703
x86_64,de,14933,15080
x86_64,es,14928,15074
x86_64,fr,14937,15083
8.3 Inspeccionar el manifiesto de un split
BT=~/Library/Android/sdk/build-tools/37.0.0
$BT/aapt2 dump badging /tmp/keep/config.es.apk | head -4
package: name='com.google.android.keep' versionCode='220671357' versionName='' split='config.es'
application: label='' icon=''
minSdkVersion:'32'
feature-group: label=''
versionName vacío y split='config.es': la marca inequívoca de un config split.
aapt2 dump badging también lista los locales y densidades que aporta:
locales: 'es' 'es-419' 'es-US'
densities: '160'
Un solo config.es.apk cubre tres locales, porque es-419 y es-US son variantes regionales
que Play agrupa en el mismo split.
Fuentes
- Formato del Android App Bundle — https://developer.android.com/guide/app-bundle/app-bundle-format
Consultado el 12 de agosto de 2026. De aquí sale la tabla de estructura interna de la sección
2.1, incluidos
BUNDLE-METADATA/, el directorioroot/y la separación demanifest/ydex/, con las citas literales. google/bundletool,src/main/proto/commands.proto— https://raw.githubusercontent.com/google/bundletool/master/src/main/proto/commands.proto Consultado el 12 de agosto de 2026. De aquí sale íntegra la tabla de campos deBuildApksResultde la sección 3.2 y la jerarquíaVariant→ApkSet→ApkDescriptioncon sus siete metadatos alternativos.google/bundletool,src/main/proto/config.proto— https://raw.githubusercontent.com/google/bundletool/master/src/main/proto/config.proto Consultado el 12 de agosto de 2026. De aquí salen los nueve campos deBundleConfigy los valores deBundleTypede la sección 2.3.google/bundletool,src/main/proto/targeting.protoyfiles.proto— https://raw.githubusercontent.com/google/bundletool/master/src/main/proto/targeting.proto Consultado el 12 de agosto de 2026. De aquí sale la fila correspondiente de la tabla de.protode la sección 2.3.frameworks/base/tools/aapt2/Resources.proto(ramamain) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/tools/aapt2/Resources.proto Consultado el 12 de agosto de 2026, descargado con?format=TEXTy decodificado. De aquí salen el paqueteaapt.pb, eljava_packagecom.android.aapty la jerarquíaResourceTable→Package→Type→Entry→ConfigValuede la sección 2.3.- bundletool — documentación oficial — https://developer.android.com/tools/bundletool
Consultado el 12 de agosto de 2026. De aquí sale la sintaxis de
build-apks,install-apks,extract-apks,get-device-specyget-size totalde las secciones 7 y 8, y la definición del APK Set. - adb — documentación oficial — https://developer.android.com/tools/adb
Consultado el 12 de agosto de 2026. De aquí sale
adb install-multiplede la sección 7 y la confirmación de que la documentación no publica su sintaxis completa de parámetros. - APK expansion files (OBB) — https://developer.android.com/google/play/expansion-files Consultado el 12 de agosto de 2026. De aquí salen el formato de nombre, la ruta de instalación, el límite de 2 GB y el estado de obsolescencia de la sección 5.4.
- 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
unzip,zipinfo,python3,bundletool1.18.3 yaapt22.20-15087165. De aquí salen todos los listados, volcados y cifras de las secciones 2.1, 3.1, 3.2, 4.1, 4.2, 4.3, 5.1, 5.2, 5.3, 6.3 y 8, sobrecom.ejemplo.fixture.aab,com.ejemplo.fixture.apks,com.ejemplo.fixture-universal.apks,com.google.android.keep.apks,com.whatsapp.apks,GoogleAuthenticator_com.google.android.apps.authenticator2.xapk,tv.twitch.android.app.apkmycom.netflix.mediaclient.sai.zip.