Fusión de splits

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

1. Qué es y por qué existe

Desde que el Android App Bundle es obligatorio en Google Play, una aplicación instalada ya no es un fichero: es un base.apk más los split de la ABI, la densidad y el idioma del dispositivo, y PackageManager los monta como una sola aplicación con un solo AssetManager. Ese modelo es excelente para el usuario y un estorbo para casi todo lo demás.

Fusionar es producir un único APK instalable con todo dentro. Hace falta en tres situaciones, y solo en esas tres:

  • Análisis con una herramienta que solo acepta un APK. La mayor parte del instrumental —MobSF, buena parte de los escáneres comerciales, cualquier pipeline que asuma un fichero— no entiende un conjunto. Y aunque jadx acepte .apks, .apkm y .xapk directamente, el resultado sigue siendo un análisis por partes.
  • Instalación fuera de Play, sin adb. Un usuario que descarga un .xapk y quiere instalarlo desde el propio teléfono no tiene install-multiple.
  • Archivado. Guardar «la versión 8.76 de esta aplicación» como un artefacto y no como una carpeta de cuarenta ficheros cuya composición depende del dispositivo que la descargó.

Y hace falta decir enseguida lo que la sección 8 desarrolla: muchas veces la respuesta correcta es no fusionar. adb install-multiple y bundletool install-apks llevan el conjunto al dispositivo tal cual, sin tocar nada, y preservan el comportamiento original. La fusión es una transformación con pérdida, y este documento es sobre todo el inventario de qué se pierde.

La comparativa de herramientas, §4.1, deja el reparto claro: de las trece herramientas de manipulación de APK evaluadas, tres fusionan splits, dos decompilan a Java y ninguna hace las dos cosas. La referencia funcional de la fusión es APKEditor, sobre ARSCLib.

2. Qué exige la fusión: el mapa

   40 splits                              1 APK
  ┌──────────┐                        ┌──────────┐
  │ base     │──┐                     │ manifest │ ← saneado (§5)
  │ config.* │  │  1 SELECCIONAR (§3) │ arsc     │ ← tablas fusionadas (§4)
  │ voip     │  ├──▶ ¿cuáles?  ───────▶ classes* │ ← DEX renumerados (§6)
  │ partner… │  │                     │ res/     │ ← copiado (§7)
  │ …        │──┘                     │ lib/     │ ← copiado (§7)
  └──────────┘                        │ assets/  │ ← copiado (§7)
                                      └────┬─────┘
                                           ▼
                                     alinear · FIRMAR (§8)

Cinco operaciones, y solo dos son difíciles. Copiar res/, lib/ y assets/ es copiar ficheros; lo caro es la tabla de recursos y el saneado del manifiesto, y lo sutil es la selección.

2.1 Los tres invariantes que se comprueban antes de empezar

Si alguno falla, el conjunto está mal formado y no hay nada que fusionar:

  1. Mismo package en todos los splits.
  2. Mismo android:versionCode en todos. Medido en el corpus: 220671357 en el base de Google Keep y en cada uno de sus 26 config splits; 50451 en los 39 del SAI de Netflix.
  3. Misma clave de firma en todos. PackageManager los trata como una sola aplicación, y un digest de certificado distinto produce INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES.

Lo que no comparten, y no es un error: el versionName —los config splits lo traen vacío—, el minSdkVersion y, evidentemente, el contenido.

2.2 Qué colisiona de verdad: medido

Antes de teorizar sobre colisiones conviene medirlas. Cruzando todas las rutas de todos los splits de cuatro contenedores del corpus y comparando el CRC-32 de cada entrada repetida:

27 splits · 1415 rutas distintas ·   1 duplicada idéntica ·  2 en conflicto real   Google Keep (.xapk)
12 splits · 7048 rutas distintas · 723 duplicadas idénticas · 5 en conflicto real   Twitch (.apkm)
39 splits · 5783 rutas distintas · 201 duplicadas idénticas · 6 en conflicto real   Netflix (SAI zip)
42 splits ·   11 rutas distintas ·   9 duplicadas idénticas · 2 en conflicto real   fixture (bundletool)

Y los conflictos reales son siempre los mismos ficheros:

Ruta en conflicto Aparece en Cómo se resuelve
AndroidManifest.xml Los cuatro Se toma el del base y se sanea (§5)
resources.arsc Los cuatro Se fusionan las tablas (§4)
META-INF/MANIFEST.MF, *.SF, *.RSA Twitch, Netflix Se descartan: hay que volver a firmar (§8)
classes.dex Solo Netflix Se renumera (§6)

Nada de res/, lib/ ni assets/ colisiona con contenido distinto en ninguno de los cuatro. Las 723 rutas duplicadas de Twitch y las 201 de Netflix son ficheros byte a byte idénticos repetidos en varios splits, que se copian una vez y ya está.

Es un resultado más fuerte de lo que parece y acota el problema: la fusión es difícil en dos ficheros, no en cinco mil. Y explica por qué la fusión ingenua de la sección 9 «funciona» hasta que se mira la tabla.

3. Selección de configuración

3.1 El problema

Un contenedor puede traer cuarenta splits para cubrir la matriz de dispositivos. Fusionarlos todos produce un APK universal grande pero correcto; fusionar de menos produce uno que instala y le faltan cosas. Las tres dimensiones son ortogonales:

Dimensión Splits típicos Qué pasa si sobran Qué pasa si faltan
ABI config.arm64_v8a, config.armeabi_v7a, config.x86, config.x86_64 Tamaño: se llevan las .so de arquitecturas que el dispositivo no usa INSTALL_FAILED_NO_MATCHING_ABIS, o la aplicación instala y muere en System.loadLibrary
Densidad config.ldpiconfig.xxxhdpi Tamaño Android hace fallback a otra densidad: los iconos salen borrosos, no falla
Idioma config.es, config.ja, … Tamaño Se cae al idioma por defecto del base. No falla, pero la aplicación deja de estar traducida

La asimetría es la lección: de las tres, solo la ABI produce un fallo duro. Densidad e idioma degradan en silencio, que es peor de diagnosticar.

3.2 La herramienta oficial que casi nadie usa: split-select

Está en las build-tools del SDK, junto a aapt2 y zipalign, y hace exactamente la selección que Play hace del lado del servidor. Está ejecutada de verdad sobre el .xapk de Google Keep del corpus, con sus 26 config splits:

BT=~/Library/Android/sdk/build-tools/37.0.0
set --; for f in keep/config.*.apk; do set -- "$@" --split "$f"; done
$BT/split-select --target "es-rES-xhdpi-v34:armeabi-v7a" \
                 --base keep/com.google.android.keep.apk "$@"
keep/config.armeabi_v7a.apk
keep/config.es.apk
keep/config.xhdpi.apk

Tres de veintiséis. El <config> es un calificador de recurso de aapt extendido con la arquitectura detrás de dos puntos: resource-qualifiers:extended-qualifiers.

Cambiando el objetivo se ve la lógica —y sus límites:

--target en-rUS-xxhdpi-v34:arm64-v8a   →  keep/config.armeabi_v7a.apk
--target fr-rFR-mdpi-v30:x86_64        →  keep/config.fr.apk  ·  keep/config.xhdpi.apk
--target eu-rES-hdpi-v34:arm64-v8a     →  keep/config.armeabi_v7a.apk  ·  keep/config.xhdpi.apk
  • El inglés no tiene split: es el idioma por defecto y vive en el base, así que en-rUS no selecciona ninguno. Que no aparezca no significa que falte. El euskera tampoco, pero por el motivo contrario: no está traducido. Los dos casos son indistinguibles desde la línea de órdenes, y hay que resolverlos mirando qué locales declara cada split (aapt2 dump badging los lista).
  • Un objetivo arm64-v8a selecciona config.armeabi_v7a. No es un error: el .xapk de Keep solo trae un split de ABI, el de 32 bits. APKCombo empaquetó una variante concreta, no la matriz completa, y un dispositivo de 64 bits ejecutará la biblioteca de 32 por compatibilidad.

Ese último punto es el más importante de la sección: un contenedor de terceros no es el APK Set completo, es un recorte. Fusionar «todo lo que hay dentro» no reconstruye lo que Play habría instalado; reconstruye lo que ese sitio decidió empaquetar.

⚠️ Aviso sobre split-select --generate, que emite las reglas de selección en JSON: en la máquina de referencia produce JSON inválido —los valores del array salen sin comillas, "args": [ar]— y aborta con una aserción fatal (key not found) de su KeyedVector interno, aunque imprime la salida igualmente. Verificado con build-tools 37.0.0 el 12 de agosto de 2026. No es utilizable en un script.

3.3 La alternativa: bundletool extract-apks

Sobre un APK Set genuino —con toc.pb— la selección se hace con una especificación de dispositivo escrita a mano, sin necesidad de dispositivo:

cat > dev.json <<'EOF'
{"supportedAbis":["arm64-v8a"],"supportedLocales":["es"],"screenDensity":420,"sdkVersion":34}
EOF
java -jar bundletool.jar extract-apks --apks fixture.apks --output-dir extracted \
     --device-spec dev.json
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 de cuarenta y tres. Solo funciona con toc.pb, así que en el corpus solo sirve para los dos .apks generados localmente: los otros catorce son .xapk con la extensión cambiada y hay que seleccionar con split-select o a mano.

3.4 La regla práctica

Objetivo Qué seleccionar
Analizar la aplicación como unidad Todo. El tamaño da igual y no perder nada sí importa
Archivar Todo, y anotar que el contenedor de origen ya era un recorte (§3.2)
Instalar en un dispositivo concreto La ABI de ese dispositivo, su densidad y sus idiomas
Reducir tamaño Una ABI, una o dos densidades, los idiomas que hagan falta

Y una asimetría que conviene tener presente: coger de más solo cuesta bytes; coger de menos cuesta funcionalidad. Ante la duda, todo.

4. Fusión de las tablas de recursos

Es el paso difícil, y el que hace que la fusión no sea concatenar ZIPs.

4.1 El malentendido que hay que quitarse primero

La formulación habitual —«hay que resolver las colisiones de resource ID entre splits»— es falsa para el caso mayoritario, y creérsela lleva a implementar el algoritmo equivocado.

Un config split no aporta identificadores nuevos: aporta valores nuevos para identificadores que ya existen en el base. Medido sobre Google Keep:

$BT/aapt2 dump resources keep/config.es.apk | head -12
Binary APK
Package name=com.google.android.keep id=7f
  type plurals id=12 entryCount=6
    resource 0x7f120004 plurals/time_difference_short_days
      (es) (plurals) size=3
        one="%dd"  many="%dd"  other="%dd"
      (es-rUS) (plurals) size=3
        one="%d d"  many="%d d"  other="%d d"

Mismo paquete com.google.android.keep, mismo package ID 0x7f, y el mismo resource ID 0x7f120004 que ya está en el base. Lo que el split trae son dos ConfigValue más para esa entrada: uno con el calificador es y otro con es-rUS. En el base, esa misma entrada tiene su valor () por defecto.

Fusionar un config split no es renumerar nada: es añadir ConfigValue a entradas existentes. El type spec de cada tipo —el chunk que declara qué configuraciones hacen variar cada entrada— tiene que actualizarse en consecuencia, y el string pool de la tabla tiene que absorber las cadenas nuevas. La estructura de todo eso está en Tabla de recursos.

4.2 Dónde sí hay dos paquetes

En los módulos de funcionalidad, y el mecanismo por el que Android lo evita es elegante: cada feature module recibe su propio package ID. Medido en el SAI de Netflix:

base.apk                        →  Package name=com.netflix.mediaclient               id=7f
split_config.es.apk             →  Package name=com.netflix.mediaclient               id=7f
split_config.xxhdpi.apk         →  Package name=com.netflix.mediaclient               id=7f
split_partnermodule.apk         →  Package name=com.netflix.mediaclient.partnermodule id=7d
split_partnermodule.config.en.apk → Package name=com.netflix.mediaclient.partnermodule id=7d
split_voip.apk                  →  (sin ningún paquete: su resources.arsc está vacío)

0x7d frente a 0x7f. Los identificadores del módulo son 0x7d...... y no pueden colisionar con los del base por construcción. La consecuencia para la fusión es directa: la tabla resultante tiene que contener dos RES_TABLE_PACKAGE_TYPE, no fundirlos en uno. Un fusionador que asuma un solo paquete o bien pierde el módulo o bien tiene que renumerar de verdad — y renumerar significa reescribir todas las referencias a esos identificadores, incluidas las que están incrustadas en las instrucciones del DEX como constantes de 32 bits, que es exactamente lo que no se puede hacer sin reescribir el DEX.

4.3 El caso degenerado: la tabla de 40 bytes

Cinco splits del SAI de Netflix traen un resources.arsc de 40 bytes exactos:

unzip -p split_voip.config.xhdpi.apk resources.arsc | xxd
00000000: 0200 0c00 2800 0000 0000 0000 0100 1c00  ....(...........
00000010: 1c00 0000 0000 0000 0000 0000 0001 0000  ................
00000020: 1c00 0000 0000 0000                      ........

Descodificado: RES_TABLE_TYPE = 0x0002, headerSize = 12, size = 40, packageCount = 0; seguido de un RES_STRING_POOL_TYPE = 0x0001 de 28 bytes con stringCount = 0, styleCount = 0, flags = 0x00000100 (UTF8_FLAG) y stringsStart = 28. Doce más veintiocho, cuarenta.

Es una tabla válida y vacía. aapt2 dump resources la lee sin protestar y responde solo Binary APK. Pero rompe cualquier lector que asuma al menos un paquete —el bucle sobre packageCount no se ejecuta ni una vez, y el código que dé por hecho «el paquete de la aplicación» lee fuera de rango o desreferencia un nulo—. Es el fixture de caso límite que hay que conservar.

Y hay un caso todavía más extremo en el mismo contenedor: split_voip.apk no tiene resources.arsc en absoluto. Sus cinco entradas son el manifiesto, un stamp-cert-sha256 y los tres ficheros de firma. Un split puede no tener tabla.

4.4 El resumen operativo

Situación Qué hay que hacer
Config split del base (idioma, densidad, ABI) Añadir sus ConfigValue a las entradas existentes del paquete 0x7f; actualizar el type spec; fundir el string pool
Feature module con paquete propio Copiar su RES_TABLE_PACKAGE_TYPE entero a la tabla resultante. No renumerar
Config split de un feature module Lo mismo que la primera fila, pero sobre el paquete del módulo (0x7d)
Tabla vacía de 40 bytes Ignorar el split a efectos de recursos. No es un error
Sin resources.arsc Ídem

5. Saneado del manifiesto

El manifiesto del resultado sale del base, y hay que quitarle todo lo que declara que forma parte de un conjunto. Si se deja, el instalador sigue buscando splits que ya no existen.

5.1 Los atributos, medidos sobre el corpus

Atributo Resource ID Dónde aparece Qué hacer al fusionar
split — (sin namespace) En todo split, nunca en el base Quitar
configForSplit — (sin namespace) En los config splits de un feature module Quitar
android:isFeatureSplit 0x0101055b Solo en módulos de funcionalidad Quitar
android:isSplitRequired 0x01010591 En el base y en los feature modules Quitar o poner a false
android:requiredSplitTypes 0x0101064e En el base (mecanismo moderno) Quitar
android:splitTypes 0x0101064f En cada split (mecanismo moderno) Quitar
android:hasCode 0x0101000c false en los config splits Del base se conserva tal cual
android:extractNativeLibs 0x010104ea Puede diferir entre base y splits Unificar, §7.2

⚠️ El identificador 0x01010591 de android:isSplitRequired está medido aquí, sobre base.apk, split_voip.apk y split_partnermodule.apk del SAI de Netflix con aapt2 dump xmltree. La sección 4.2 de AAB, splits y contenedores lo dejaba sin medir por no haber encontrado ninguna muestra que lo llevara.

5.2 Los dos mecanismos conviven

El corpus tiene los dos y un fusionador tiene que manejarlos a la vez. Netflix usa el antiguo —un booleano— y Google Keep el moderno —tipos declarados y requeridos—:

Netflix   base.apk                         isSplitRequired=true
          split_voip.apk                   isSplitRequired=true isFeatureSplit=true split="voip"
          split_config.es.apk              hasCode=false  split="config.es"
          split_voip.config.arm64_v8a.apk  hasCode=false  split="voip.config.arm64_v8a"
                                                          configForSplit="voip"
Keep      base.apk                requiredSplitTypes="base__abi,base__density" splitTypes=""
          config.es.apk           splitTypes=""            split="config.es"
          config.armeabi_v7a.apk  splitTypes="base__abi"   split="config.armeabi_v7a"

En el mecanismo moderno el base declara qué tipos necesita y cada split declara cuáles aporta; el instalador comprueba que el conjunto es completo antes de aceptarlo. Es la razón número uno por la que instalar splits sueltos falla con INSTALL_FAILED_MISSING_SPLIT (Diagnóstico de fallos, sección 2).

Obsérvese configForSplit="voip": el espacio de nombres de splits no es plano. split_voip.config.arm64_v8a.apk es a la vez config split y parte de un módulo de funcionalidad, y un fusionador que asuma un solo nivel de config.* sobre un base lo coloca mal o lo descarta.

5.3 El resultado correcto, comprobado contra la referencia de Google

La comprobación honesta del saneado es contra lo que produce bundletool --mode=universal, que es la vía oficial para obtener un instalable único desde un bundle. Sobre el fixture del corpus:

java -jar bundletool.jar build-apks --bundle=com.ejemplo.fixture.aab \
     --output=uni.apks --mode=universal --overwrite
unzip -o uni.apks && $BT/aapt2 dump xmltree universal.apk --file AndroidManifest.xml | head -8
N: android=http://schemas.android.com/apk/res/android (line=2)
  E: manifest (line=2)
    A: http://schemas.android.com/apk/res/android:versionCode(0x0101021b)=1
    A: http://schemas.android.com/apk/res/android:versionName(0x0101021c)="1.0" (Raw: "1.0")
    A: http://schemas.android.com/apk/res/android:compileSdkVersion(0x01010572)=36
    A: package="com.ejemplo.fixture" (Raw: "com.ejemplo.fixture")

Ni split, ni splitTypes, ni isSplitRequired, ni isFeatureSplit. Y el contenido:

$BT/aapt2 dump badging universal.apk | grep -E "^(locales|native-code|densities)"
locales: '--_--' 'de' 'es' 'fr'
densities: '160' '240' '320' '480'
native-code: 'arm64-v8a' 'armeabi-v7a' 'x86_64'

Todos los idiomas, todas las densidades, todas las ABIs, y el manifiesto limpio. Ese es el objetivo. Nota al margen que confirma la sección 8 de Pipeline de modificación: el universal.apk sale sin firmar, y apksigner verify responde DOES NOT VERIFY / ERROR: Missing META-INF/MANIFEST.MF.

6. Renumeración de los DEX

6.1 Cuándo hace falta

Solo cuando más de un split trae código, que es solo el caso de los módulos de funcionalidad: los config splits declaran android:hasCode="false" y no llevan DEX. Medido en el SAI de Netflix, de sus 39 splits solo dos tienen código:

5 dex  base.apk                → classes.dex … classes5.dex
1 dex  split_partnermodule.apk → classes.dex

Ahí está la colisión: dos entradas llamadas classes.dex con contenido distinto, que es una de las seis rutas en conflicto de la sección 2.2. Un ZIP admite dos entradas con el mismo nombre; Android no.

6.2 Qué hay que hacer, y qué no

La operación es renombrar, no recompilar. Los classes*.dex del módulo continúan la numeración del base sin huecos: split_partnermodule/classes.dexclasses6.dex.

Tres cosas que no hay que hacer:

  • No hay que reescribir el DEX. El nombre de la entrada del ZIP no está dentro del fichero —el header_item no lo contiene— y las referencias entre clases son por descriptor de tipo, no por fichero. Copiar los bytes y cambiar el nombre de la entrada basta.
  • No hay que recalcular el checksum ni la signature. No se ha tocado un byte del contenido.
  • No hay que fusionar los DEX en uno. El límite de 65.536 referencias a método por fichero existe justamente para que no haga falta, y fusionarlos exigiría reescribir todas las tablas de índices.

6.3 El detalle semántico que sí importa

El orden de carga es semántico. Si una clase está definida en dos DEX, gana la del primero en orden de carga (classes.dex, luego classes2.dex…). Colocar el DEX del módulo detrás de los del base es por tanto lo correcto: el módulo no debe poder sustituir clases del base. Colocarlo delante cambia el comportamiento de la aplicación de forma difícil de diagnosticar.

Y el límite duro: el conjunto de nombres tiene que ser classes.dex, classes2.dex, … sin huecos. Un classes7.dex sin classes6.dex no se carga entero.

7. Bibliotecas nativas y assets

7.1 Las rutas no colisionan, y está medido

La sección 2.2 lo cuantifica: en los cuatro contenedores analizados, ninguna ruta bajo lib/, res/ o assets/ aparece en dos splits con contenido distinto. Y tiene sentido estructural: cada config split de ABI aporta un directorio lib/<abi>/ diferente, y cada split de densidad o idioma aporta ficheros de res/ que el base no tiene.

Netflix lo enseña bien: split_config.arm64_v8a.apk trae siete .so de la aplicación y split_voip.config.arm64_v8a.apk trae siete del módulo VoIP, todas bajo lib/arm64-v8a/ y ninguna con el mismo nombre:

split_config.arm64_v8a       libavif_android.so  libbugsnag-ndk.so  libcronet…so  libe5e7.so  …
split_voip.config.arm64_v8a  libbctoolbox.so  libc++_shared.so  liblinphone.so  libortp.so  …

Lo que sí hay que hacer es deduplicar las rutas idénticas: 723 en Twitch, 201 en Netflix. Son el mismo fichero repetido en varios splits, y meterlo dos veces produce un ZIP con entradas duplicadas —legal en el formato, rechazado por Android, y con toda la historia de la vulnerabilidad Master Key detrás (Contenedor ZIP, sección 12).

7.2 extractNativeLibs: el atributo que hay que unificar

Puede diferir entre el base y los splits, y determina si las .so van comprimidas o no. En Netflix, split_voip.apk declara extractNativeLibs="true" y split_config.arm64_v8a.apk declara false.

En el APK fusionado hay un solo manifiesto, así que hay que elegir, y la elección tiene consecuencias mecánicas:

Valor elegido Qué exige del ZIP
true Las .so pueden ir deflate. Se extraen al instalar: ocupa el doble en disco
false Las .so tienen que ir stored y alineadas — a 4 bytes, y a 16 KiB desde targetSdk 35

Elegir false y dejar las .so comprimidas produce un APK que instala y muere en el primer System.loadLibrary. Es el mismo requisito que la sección 6.3 de Pipeline de modificación, y la comprobación es zipalign -c -P 16 4.

7.3 El caso de Netflix con la alineación de 16 KB

Merece registrarse porque es el único incumplimiento del corpus. Seis de los 39 splits del SAI de Netflix fallan zipalign -c -P 16 4 y pasan zipalign -c 4, y son exactamente los de arquitectura:

FALLA16  split_config.arm64_v8a.apk        FALLA16  split_config.x86.apk
FALLA16  split_config.armeabi_v7a.apk      FALLA16  split_voip.config.arm64_v8a.apk
FALLA16  split_config.x86_64.apk           FALLA16  split_voip.config.armeabi_v7a.apk
$BT/zipalign -c -P 16 -v 4 split_config.arm64_v8a.apk
    4096 lib/arm64-v8a/libavif_android.so (BAD - 4096)
  851968 lib/arm64-v8a/libbugsnag-ndk.so (OK)
 2039808 lib/arm64-v8a/libbugsnag-plugin-android-anr.so (BAD - 8192)
 7974912 lib/arm64-v8a/libtensorflowlite_jni_gms_client.so (BAD - 12288)
Verification FAILED

El número entre paréntesis del (BAD - n) es el resto de dividir el offset entre 16.384, no un código de error. Estos splits están alineados a 4 KiB, que era lo correcto cuando se construyeron. Al fusionar hay que realinear de todos modos, porque los offsets de destino no son los de origen (§8), así que se resuelve solo — pero conviene saber que el material de partida no cumplía.

7.4 assets/

Se copian tal cual y sin índice: no hay tabla que actualizar porque assets/ no está en resources.arsc. La única regla es la deduplicación de §7.1. Los asset packs de Play Asset Delivery son otra cosa —viajan en asset-slices/ dentro de un APK Set y no forman parte del APK— y quedan fuera de este documento.

8. Firma, y por qué no hay alternativa

El APK fusionado es un ZIP nuevo: entradas en offsets nuevos, Central Directory nuevo, EOCD nuevo. Ninguna de las firmas de los splits de origen vale, y no por una limitación de las herramientas: la firma cubre bytes, los bytes han cambiado, y producir una firma válida para el contenido nuevo exige la clave privada del autor. El razonamiento completo está en la sección 7.3 de Pipeline de modificación.

Lo que hay que hacer, en este orden y sin excepción:

BT=~/Library/Android/sdk/build-tools/37.0.0
$BT/zipalign -P 16 -f 4  fusionado.apk  alineado.apk
$BT/apksigner sign --ks clave.p12 --ks-type PKCS12 --ks-key-alias alias \
  --ks-pass env:KSPASS --key-pass env:KEYPASS --min-sdk-version 24 \
  --v2-signing-enabled true --v3-signing-enabled true --out final.apk alineado.apk
$BT/apksigner verify --verbose final.apk
$BT/zipalign -c -P 16 4 final.apk; echo "align exit=$?"

Invertir los dos primeros pasos destruye la firma y produce el error Missing META-INF/MANIFEST.MF, que acusa de un stripping que no hubo. Está demostrado con salida real en la sección 7.1 de Pipeline de modificación.

Y la consecuencia de usuario, que hay que decir antes de que se descubra sola: el APK fusionado está firmado con otra clave, así que no se instala encima de la aplicación original, no hereda sus datos, pierde los permisos de nivel signature y deja de recibir actualizaciones de Play.

9. La alternativa: no fusionar

Casi siempre es mejor opción.

⚠️ No ejecutado localmente: no hay dispositivo Android ni emulador conectado al entorno de referencia, y adb no está en el PATH. La sintaxis procede de la documentación oficial de adb y de bundletool, consultada el 12 de agosto de 2026. Ninguna de estas dos órdenes se ha ejecutado. Lo que sí se ejecutó, y confirma la dependencia, es bundletool get-device-spec, que responde [BT:1.18.3] Error: Unable to determine the location of ADB.

adb install-multiple base.apk config.arm64_v8a.apk config.es.apk config.xxhdpi.apk
java -jar bundletool.jar install-apks --apks app.apks

install-multiple entrega los APK por separado y el sistema los monta como una sola aplicación, exactamente igual que Play. install-apks hace get-device-spec, extract-apks e install-multiple en un paso, pero exige toc.pb, así que sobre el corpus solo sirve para los dos .apks generados con bundletool.

Situación Fusionar No fusionar
Hay dispositivo o emulador y adb Preserva el comportamiento original
La herramienta de análisis solo acepta un fichero
Instalación desde el propio teléfono, sin PC
Archivar un artefacto reproducible
La aplicación comprueba su propia estructura de splits La fusión la rompe (§10)
Hay que modificar y volver a firmar Un solo fichero que firmar

10. Qué se rompe al fusionar, y cómo se detecta

Cinco fallos. Los cuatro primeros son de implementación; el quinto no tiene arreglo.

10.1 NoClassDefFoundError en ejecución

Causa: falta un DEX, o la renumeración dejó un hueco, o el DEX de un feature module se colocó delante de los del base y sombreó una clase (§6.3).

Detección, antes de instalar:

unzip -l final.apk | grep -oE "classes[0-9]*\.dex" | sort -V

La lista tiene que ser classes.dex, classes2.dex, … consecutiva y sin huecos, y tiene que contener tantos ficheros como la suma de los DEX de todos los splits fusionados.

10.2 Recursos que no aparecen

Es el fallo característico de la fusión ingenua, y se reproduce en dos órdenes. Descomprimiendo el base de Google Keep y encima sus splits de es, armeabi_v7a y xhdpisin fusionar la tabla— y volviendo a comprimir:

$BT/aapt2 dump badging naive.apk | grep -E "^(locales|native-code|densities)"
$BT/aapt2 dump resources naive.apk | grep -A2 "resource 0x7f140a13 "
locales: '--_--'
densities: '120' '160' '240' '320' '480' '640' '65535'
native-code: 'armeabi-v7a'

    resource 0x7f140a13 string/abc_action_bar_up_description
      () "Navigate up"

locales: '--_--': ni un solo idioma, y de la cadena solo queda su valor por defecto (). Las .so sí llegaron —copiar ficheros funciona—, pero las traducciones viven en la tabla y la tabla es la del base: el (es) que traía config.es.apk se ha perdido.

Efecto colateral: los 43 ficheros de res/ que aportaba config.xhdpi.apk están en el ZIP resultante y ninguna entrada de la tabla apunta a ellos. res/-B.png aparece una vez en el ZIP fusionado, cero veces en su resources.arsc y una vez en el del split original. Son peso muerto.

La comprobación: aapt2 dump badging sobre el resultado tiene que listar todos los idiomas, densidades y ABIs que traían los splits de entrada. Si locales sale con solo '--_--', la tabla no se fusionó.

10.3 Módulos de funcionalidad que se pierden

Causa: el fusionador ignoró los splits con isFeatureSplit=true, o descartó su paquete de recursos propio (§4.2), o no renumeró su DEX (§6).

Detección: el paquete 0x7d tiene que estar en la tabla del resultado.

$BT/aapt2 dump resources final.apk | grep "^Package"

Sobre un APK de Netflix bien fusionado deberían aparecer dos líneas Package: com.netflix.mediaclient id=7f y com.netflix.mediaclient.partnermodule id=7d.

10.4 Colisiones de identificador

El caso real es el de §4.2 y no se resuelve renumerando: si dos paquetes distintos comparten package ID, o el fusionador lo detecta y aborta, o produce una tabla en la que una de las dos mitades resuelve a los recursos de la otra. Renumerar exigiría reescribir las constantes de 32 bits incrustadas en las instrucciones del DEX, que es precisamente lo que la fusión no hace.

Detección: dos Package con el mismo id= en la salida de dump resources. Es un aborto, no un aviso.

10.5 Aplicaciones que comprueban su propia estructura de splits

El que no tiene arreglo. Una aplicación puede consultar en ejecución sus propios splits —ApplicationInfo.splitSourceDirs, splitNames, o la API de Play Feature Delivery SplitInstallManager— y comportarse distinto si no los encuentra. En un APK fusionado esos arrays están vacíos.

El síntoma es el de §10 de Pipeline de modificación: la aplicación instala, arranca y se cierra, o funciona en modo degradado, sin traza útil. Y hay un antecedente documentado en el ecosistema: la issue #211 de APKEditor, abierta desde septiembre de 2025 y sin respuesta, recoge alrededor de veinte aplicaciones fusionadas desde Play que caen con segfault en libpairipcore.so en Android 14 y 15.

Neutralizar esa comprobación está fuera del alcance de este corpus. Lo que se hace es detectarlo, informarlo y, si el objetivo era instalar, usar install-multiple (§9).

11. Receta completa sobre el caso difícil del corpus

El SAI de Netflix es el caso difícil porque cruza los tres problemas a la vez: dos feature modules con paquete de recursos propio, un classes.dex que colisiona, config splits de segundo nivel y seis splits que no cumplen la alineación de 16 KB.

BT=~/Library/Android/sdk/build-tools/37.0.0
mkdir netflix && cd netflix && unzip -q $CORPUS/splits/com.netflix.mediaclient.sai.zip

# 0 · invariantes: mismo paquete, mismo versionCode y misma firma en los 39
for f in *.apk; do $BT/aapt2 dump badging "$f" 2>/dev/null | head -1 \
  | grep -oE "name='[^']*' versionCode='[^']*'"; done | sort -u
#  → una sola línea:  name='com.netflix.mediaclient' versionCode='50451'
for f in *.apk; do $BT/apksigner verify --print-certs "$f" 2>/dev/null \
  | grep -m1 -o "certificate SHA-256 digest: .*"; done | sort -u | wc -l      # → 1

# 1 · quién trae código, para saber si hay que renumerar
for f in *.apk; do n=$(unzip -l "$f" | grep -cE " classes[0-9]*\.dex$"); \
  [ "$n" -gt 0 ] && echo "$n dex  $f"; done
#  → 5 dex  base.apk    ·    1 dex  split_partnermodule.apk

# 2 · qué paquetes de recursos hay
for f in base.apk split_voip.apk split_partnermodule.apk; do echo -n "$f -> "; \
  $BT/aapt2 dump resources "$f" 2>/dev/null | grep -m1 "^Package" || echo "(sin paquetes)"; done
#  → base 7f  ·  voip (sin paquetes)  ·  partnermodule 7d

# 3 · qué rutas colisionan de verdad, y 4 · alineación del material de partida
python3 colisiones.py *.apk    # → 5783 rutas · 201 idénticas · 6 en conflicto real
for f in *.apk; do $BT/zipalign -c -P 16 4 "$f" >/dev/null 2>&1 || echo "FALLA16 $f"; done

Esos cuatro pasos están ejecutados y su salida es la de §2.2, §4.2, §6.1 y §7.3. La fusión propiamente dicha —unir tablas, sanear el manifiesto, renumerar— necesitaría APKEditor.

⚠️ No ejecutado localmente: APKEditor no está instalado y estas reglas prohíben descargar JAR. La orden documentada es java -jar APKEditor.jar m -i com.netflix.mediaclient.sai.zip -o netflix-fusionado.apk, tomada del README oficial del proyecto consultado el 12 de agosto de 2026. No se ha comprobado ni la salida ni el resultado, y en particular no se ha verificado que APKEditor conserve el paquete 0x7d del partnermodule ni que renumere el classes.dex. Las comprobaciones de §10.1 a §10.3 son exactamente las que habría que aplicarle.

Fuentes

Todas las URL se consultaron el 12 de agosto de 2026.

  1. About Android App Bundles — https://developer.android.com/guide/app-bundle · el modelo de distribución por splits y la generación del lado de Play (§1).
  2. bundletool — https://developer.android.com/tools/bundletool · la sintaxis de build-apks, --mode=universal, extract-apks, install-apks y el formato de la especificación de dispositivo (§3.3, §5.3, §9).
  3. adb — https://developer.android.com/tools/adb · install-multiple (§9), no ejecutado.
  4. split-select de build-tools 37.0.0 — texto de ayuda de la propia herramienta, ejecutado el 12 de agosto de 2026. De aquí sale el formato del <config> extendido (resource-qualifiers:extended-qualifiers) y la lista de arquitecturas admitidas (§3.2).
  5. APKEditor — README — https://raw.githubusercontent.com/REAndroid/APKEditor/master/README.md · la sintaxis de merge de §11, no ejecutada.
  6. Ejecuciones locales sobre el corpus — 12 de agosto de 2026 con build-tools 37.0.0 (aapt2 2.20-15087165, split-select, zipalign, apksigner 0.9), bundletool 1.18.3, OpenJDK 21.0.10 y Python 3.12.10 sobre macOS Darwin 25.5.0. De aquí salen todas las salidas pegadas de §2.1, §2.2, §3.2, §3.3, §4.1, §4.2, §4.3, §5.1, §5.2, §5.3, §6.1, §7.1, §7.3, §10.2 y §11, contra com.netflix.mediaclient.sai.zip, GoogleKeep_com.google.android.keep.xapk, tv.twitch.android.app.apkm, com.ejemplo.fixture.apks y com.ejemplo.fixture.aab. En ningún momento se ha escrito en el corpus: las extracciones y la fusión ingenua de §10.2 se hicieron sobre copias en el scratchpad.
  7. Documentos de este corpusAAB, splits y contenedores (taxonomía de splits, atributos del manifiesto y detección de contenedores), Tabla de recursos (type spec, ConfigValue, string pool), Alineación y zipalign (§7.3) y Desempaquetado y reempaquetado (APKEditor merge, ARSCLib y la issue #211) y Comparativa y selección (§4.1: el reparto entre herramientas que fusionan y herramientas que decompilan a Java).