AAB, splits y contenedores

referencia técnica · Revisado el 12 de agosto de 2026

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.xml no está en la raíz del módulo, sino en <módulo>/manifest/.
  • Los .dex no están en la raíz, sino en <módulo>/dex/.
  • No hay resources.arsc, hay resources.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, typeREGULAR, 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: ResourceTablePackageTypeEntryConfigValue, 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 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: VariantApkSetApkDescription, 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:

  1. package, el nombre del paquete. Idéntico en todos.
  2. android:versionCode. Idéntico en todos. En el ejemplo de Keep, 220671357 en el base y en cada uno de los 26 config splits.
  3. La firma. Todos los splits tienen que estar firmados con la misma clave, porque PackageManager los 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.apks y com.ejemplo.fixture-universal.apks, los dos generados localmente con bundletool.
  • 14 no lo llevan y los 14 llevan manifest.json. Y ese manifest.json tiene la clave xapk_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, requiredSplitTypes y splitTypes, porque el resultado ya no es un conjunto.
  • Renumerar los classes*.dex de 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

  1. 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 directorio root/ y la separación de manifest/ y dex/, con las citas literales.
  2. 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 de BuildApksResult de la sección 3.2 y la jerarquía VariantApkSetApkDescription con sus siete metadatos alternativos.
  3. 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 de BundleConfig y los valores de BundleType de la sección 2.3.
  4. google/bundletool, src/main/proto/targeting.proto y files.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 .proto de la sección 2.3.
  5. frameworks/base/tools/aapt2/Resources.proto (rama main) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/tools/aapt2/Resources.proto Consultado el 12 de agosto de 2026, descargado con ?format=TEXT y decodificado. De aquí salen el paquete aapt.pb, el java_package com.android.aapt y la jerarquía ResourceTablePackageTypeEntryConfigValue de la sección 2.3.
  6. 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-spec y get-size total de las secciones 7 y 8, y la definición del APK Set.
  7. adb — documentación oficial — https://developer.android.com/tools/adb Consultado el 12 de agosto de 2026. De aquí sale adb install-multiple de la sección 7 y la confirmación de que la documentación no publica su sintaxis completa de parámetros.
  8. 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.
  9. 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, bundletool 1.18.3 y aapt2 2.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, sobre com.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.apkm y com.netflix.mediaclient.sai.zip.