AAB, splits y contenedores

Antes conviene leer «Contenedor ZIP», «XML binario (AXML)», «Tabla de recursos»

referencia técnica · Actualizado el 25 de septiembre 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 mayo de 2018, en el Google I/O, Google Play presentó 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 «muestrario», generado con bundletool build-bundle 1.18.3:

unzip -l ~/muestras-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 protobuf aapt.pb.XmlNode, no AXML
<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 «4» 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 la conversión alcanza también al manifiesto, cosa que se olvida: el manifiesto del módulo viaja en protobuf, no en AXML. Es un mensaje aapt.pb.XmlNode del mismo Resources.proto que define ResourceTable —XmlNode → XmlElement → XmlAttribute, con resource_id y compiled_item ya resueltos—, lo produce aapt2 link --proto-format y bundletool lo lee con XmlProtoNode, no con un parser de AXML. Sobre ese árbol proto añade Play los atributos de split de la «sección 4.2 · Los atributos del manifiesto». La conversión al formato binario va en el sentido contrario, al materializar cada APK: aapt2 convert --output-format binary para el manifiesto, y resources.pb → resources.arsc para la tabla.

Se comprueba en un solo paso: unzip -p app.aab base/manifest/AndroidManifest.xml | xxd | head -1 devuelve 0a… —etiqueta de campo protobuf—, no 0300 0800, que es lo que sí aparece en el AndroidManifest.xml de cualquiera de los splits/*.apk generados.

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 muestrario ocupa 10 bytes, porque solo lleva la versión de bundletool:

java -jar ~/herramientas/bundletool.jar dump config \
  --bundle ~/muestras-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 muestrario, 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 muestrario 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 muestrario.

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 <manifest> del base y de los feature modules El sistema rechaza la instalación (INSTALL_FAILED_MISSING_SPLIT) si faltan splits requeridos. En <application> se ignora
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 muestrario. El de isSplitRequired se midió sobre el base.apk del SAI de Netflix, que es el único contenedor del muestrario 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 5.2 de Fusión de splits · Los dos mecanismos conviven».

El mecanismo moderno, sobre el base.apk de Google Keep extraído del .apks del muestrario:

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: sin el base, con INSTALL_FAILED_INVALID_APK: Full install must include a base package; con el base y el conjunto incompleto, con INSTALL_FAILED_MISSING_SPLIT: Missing split for <paquete>. Los dos están reproducidos en la «sección 2.5 de Diagnóstico de fallos · Lo que responde adb install, reproducido».

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 muestrario.

Esta sección y la 6 describen los contenedores desde el lado del formato. Si lo que hay es un fichero concreto y la pregunta es «¿qué es esto y qué hago con ello?», el documento que responde es «Formatos y extensiones», que además cubre la exportación de SAI y la distinción entre un APK completo y un split suelto.

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 muestrario, 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 muestrario, 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 · Detección por estructura, nunca por extensión».

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 muestrario 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.3 · La comprobación sobre el muestrario». 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, y ningún AndroidManifest.xml en la raíz Es lo que queda cuando no hay metadatos
APK suelto AndroidManifest.xml en la raíz; resources.arsc no se puede exigir, porque los splits de ABI no lo llevan 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 y sin manifiesto?     → zip plano de splits (incluido SAI)
5. ¿AndroidManifest.xml en la raíz?             → APK suelto (resources.arsc es opcional)
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.

La tabla de la «sección 4 de Formatos y extensiones · Cómo saber en diez segundos qué tienes delante» aplica el mismo criterio con dos diferencias: adelanta el .aab al segundo lugar, lo que no cambia ningún resultado, y añade antes del zip plano una regla para los .apks exportados por SAI (meta.sai_v1.json o meta.sai_v2.json en la raíz), que este orden clasificaría como zip plano.

Y cuando la estructura contradice a la extensión, no se falla: se continúa y se advierte en el informe. Es exactamente el fallo silencioso que caracteriza al sector.

6.3 La comprobación sobre el muestrario

La cifra de partida era que 14 de los 16 .apks del muestrario no llevan toc.pb. Comprobado de forma independiente, sobre los 25 contenedores del directorio splits/, mirando el Central Directory de cada uno:

cd ~/muestras-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 concluyente. 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 · El orden de precedencia» 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:

Verificado el 13 de agosto de 2026 contra un emulador Android 14 arm64-v8a creado para la ocasión, con adb 1.0.41 de las platform-tools 37.0.0 y los splits de Telegram del muestrario:

adb install-multiple org.telegram.messenger.apk config.es.apk \
                     config.arm64_v8a.apk config.xxhdpi.apk
Success

bundletool hace lo mismo pero eligiendo él los splits:

java -jar bundletool.jar install-apks --apks=$MUESTRAS/splits/com.ejemplo.fixture.apks
The APKs have been extracted in the directory: /var/folders/lk/…/8598555168175782903

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. También acepta --device-id=<serie> cuando hay más de uno.

Instalar el conjunto no es instalar los ficheros que uno le pasa. El sistema los renombra según el atributo split de cada manifiesto, no según el nombre del fichero de entrada:

adb shell pm path org.telegram.messenger
package:/data/app/~~cB6DHo33I…==/org.telegram.messenger-F_5lfNc…==/base.apk
package:/data/app/~~cB6DHo33I…==/org.telegram.messenger-F_5lfNc…==/split_config.arm64_v8a.apk
package:/data/app/~~cB6DHo33I…==/org.telegram.messenger-F_5lfNc…==/split_config.es.apk
package:/data/app/~~cB6DHo33I…==/org.telegram.messenger-F_5lfNc…==/split_config.xxhdpi.apk

Entraron cuatro config.*.apk y quedaron cuatro split_config.*.apk. Los dos sufijos aleatorios de la ruta cambian en cada instalación, y el directorio con ~~ es de Android 11 en adelante.

Los dos fallos que salen a la primera, y que reproducen con su salida literal las secciones «3.1» y «6.2» de «Formatos y extensiones», son INSTALL_FAILED_MISSING_SPLIT —falta algún split del conjunto— e INSTALL_FAILED_NO_MATCHING_ABIS —el config.<abi> no corresponde a la arquitectura del dispositivo—.

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 · Qué es».

8. Recetas

Máquina de referencia: macOS, bundletool 1.18.3 en ~/herramientas/bundletool.jar, 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
MUESTRAS=~/muestras-apk
java -jar $BTOOL dump manifest --apks $MUESTRAS/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 $MUESTRAS/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 $MUESTRAS/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 $MUESTRAS/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] "Ejemplo Fixture"
	locale: "de" - [STR] "Ejemplo Beispiel"
	locale: "fr" - [STR] "Echantillon Ejemplo"
	locale: "es" - [STR] "Muestra Ejemplo"

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 $MUESTRAS/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 $MUESTRAS/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 · Estructura interna», 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 · toc.pb: BuildApksResult» y la jerarquía Variant → ApkSet → ApkDescription 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 · Los .proto relevantes».
  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 · Los .proto relevantes».
  5. frameworks/base/tools/aapt2/Resources.proto (etiqueta android-17.0.0_r1) — https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-17.0.0_r1/tools/aapt2/Resources.proto Consultado el 25 de septiembre 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 ResourceTable → Package → Type → Entry → ConfigValue de la «sección 2.3 · Los .proto relevantes».
  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 · Instalar sin fusionar frente a fusionar» 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 · Los OBB heredados».
  9. services/core/java/com/android/server/pm/PackageInstallerSession.java (etiqueta android-17.0.0_r1) — https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-17.0.0_r1/services/core/java/com/android/server/pm/PackageInstallerSession.java Consultado el 25 de septiembre de 2026. De aquí sale que isSplitRequired hace fallar la instalación con INSTALL_FAILED_MISSING_SPLIT («sección 4.2 · Los atributos del manifiesto»).
  10. bundletool — ModuleSplit.java — https://github.com/google/bundletool/blob/master/src/main/java/com/android/tools/build/bundletool/model/ModuleSplit.java Consultado el 25 de septiembre de 2026. De aquí sale que los splits de bibliotecas nativas se crean sin tabla de recursos (forNativeLibraries, setResourceTable = false), y por tanto sin resources.arsc («sección 6 · Detección por estructura, nunca por extensión»).