21 KiB
Split APKs and App Bundles
A store build is frequently not one file. Play and most OEM stores ship App Bundles, and the
device receives a set: base.apk plus one split_config.*.apk per ABI, screen density and language
(and, for a dynamic-feature bundle, the feature modules as well). Every member carries the same
package name and its own signature, and the package manager treats the set as one package.
Two failures follow from treating such a set as an APK, and both look like a broken build when they are nothing of the kind:
- Signing only the base leaves the set disagreeing about its certificate; the installer rejects the whole set and names the package, not the member you forgot.
- "Just merge them into one APK" is free only for members that carry code or native libraries. A
member that carries resources cannot be folded into the base without merging
resources.arsc, which is a resource-compiler job. A wrongly merged archive installs and then renders the wrong thing, or dies on the first resource lookup.
scripts/repack.py handles both routes and refuses to guess: it inventories the set, decides
which route is legal from what the members actually carry, prints the reason, and only then acts.
Strength labels (same three this repository uses everywhere): observed = reproduced with an
exact command and output; inferred = follows from observed facts, step not executed;
unverified = reported or assumed, not independently confirmed. The measurements behind this
file are recorded in references/evidence-summary.md §The capability matrix.
Load this when: the target arrives as base.apk plus split_config.*.apk, or a rebuild is refused as a set although every member verifies. It gives reading the set, merge versus unified re-signing, and the refusal each mistake produces.
1. What the set is
| Member | Manifest split attribute |
Carries | Foldable into the base? |
|---|---|---|---|
| base | (absent) | all classes*.dex, the main resources.arsc, every resource not moved out, the real <application> |
— it is the base |
| ABI split | config.arm64_v8a, config.armeabi_v7a, config.x86_64, … |
lib/<abi>/*.so only |
yes — entry names do not collide |
| density split | config.hdpi … config.xxxhdpi |
its own resources.arsc and res/ entries |
no — requires a resource merge |
| language split | config.en, config.zh, … |
its own resources.arsc (strings) |
no — same reason |
| feature split | feature_x (+ isFeatureSplit="true", configForSplit="base") |
its own classes*.dex, possibly resources |
code only; its resources hit the same wall |
Observed on a real 9-member set pulled from a stock Android 11 device (arm64-v8a target):
- the base carried
classes.dex(872 KB) andresources.arsc(71 KB); - the ABI member carried two
lib/arm64-v8a/*.sofiles and noresources.arscat all; - each density member carried its own
resources.arscand its ownres/entries; - a second, 8-member set's density members carried 39-78
res/entries each and 9-15 KB tables.
So "which member owns the resources" is answerable from the zip entry list alone — no device, no resource compiler, no decompiler. Copy a set to disk and ask.
A member can own a nearly empty table. A split is required to carry a resources.arsc, so a
placeholder one is normal: on the same real set, eight density members carried a 40-byte table
and no res/ entries at all — they are placeholders, not resources. Treating "has an arsc" as "owns
resources" would block a merge that is perfectly legal. repack.py uses entries under res/ or a
table bigger than 1 KB as the test.
2. Getting the set off a device
pm path prints one line per member; a package with several lines is a split set. This is how
you find one to copy, and it is also how you tell, before anything else, that the thing you are about
to patch is not a single APK:
adb shell 'pm path <PKG>'
package:/system/priv-app/<DIR>/<APP>.apk
package:/system/priv-app/<DIR>/<APP>-arm64_v8a.apk
package:/system/priv-app/<DIR>/<APP>-xxhdpi.apk
To enumerate every split set on a device (toybox shell — no --time-style, no bash arrays):
adb shell 'for p in $(pm list packages | cut -d: -f2); do c=$(pm path $p | wc -l); \
if [ $c -gt 1 ]; then echo "$c $p"; fi; done'
Copy the whole directory out; the members live side by side:
adb shell 'su -c "ls -la /system/priv-app/<DIR>/"'
adb pull /system/priv-app/<DIR> <dest>/
Two traps, both observed:
adb pull <dir> <dest>creates<dest>/<dir>/— the set arrives one level deeper than you expect.repack.py --split-dirsearches recursively for exactly this reason.- A third-party split set is rare on a stock ROM. On the device measured here,
pm list packages -3returned zero split sets among ~40 third-party apps, while four preinstalled Google components were split. If you need a real set and the third-party list is empty, that is the normal distribution, not a mistake on your side.
Do not test by reinstalling a pulled set under its own package name. It is already installed on that device, owned by the vendor certificate you do not have, so the install fails for a reason that has nothing to do with your pipeline (). Either work on a set you own, or rename the pulled one () to get an installable fixture.
3. The decision: merge or resign
Run this first — it writes nothing and answers with a reason:
python skills/apkseep/scripts/repack.py --split-dir <set>/ --split-mode analyze
| Situation | Route | Why |
|---|---|---|
Every non-base member carries only code and/or lib/ (a placeholder resources.arsc is fine) |
merge | nothing that needs a resource table moves |
Any member carries res/ entries or a real table |
resign | a resource merge rewrites the table; see §5 |
| You must keep the build installable and the app is distributed as a bundle | resign | this is what the store does; the set stays a set |
| You need one file to hand someone, sideload, or feed to a tool that takes an APK | merge, if legal | the only way to get a single artifact |
| The delivery must survive store updates, or the ROM's own installer refuses multi-APK installs | resign (pm install-multiple), or merge and accept a fixed build |
— |
A member declares isFeatureSplit="true" and ships classes*.dex and resources |
resign | its code could fold, its resources cannot; a half-merged build is worse than either route |
Failure modes, one per route:
- merge, when a resource-carrying member is dropped to make it legal: the app starts and shows
the base's resources. Missing density variants show up as blurry or missing images; missing
language splits show as untranslated strings. This is a downgraded build and has to be
delivered as one —
repack.pyprintsDROPPED n entry/iesand refuses unless you pass--drop-split-resources, so the loss cannot be silent. - merge, when the base still declares
isSplitRequired="true": install succeeds and start is refused (INSTALL_FAILED_MISSING_SPLIT,). The attribute has to be cleared first, and that is a binary-AXML edit. - resign, when one member is missed: the set fails as a set, with a signature error naming the package. Sign the directory, not a list you typed by hand.
- resign, when the target ROM's installer is not the platform installer:
adb install-multiplecan be intercepted by an OEM security centre, exactly as single-APK installs are (references/repack-and-sign.md§Vendor install interception (OEM shells)). Use the rootpm install-multiplepath.
4. Unified signing — the route that always works
python skills/apkseep/scripts/repack.py \
--split-dir <set>/ --split-mode resign \
--split-out-dir <signed_set>/ \
--ks key.jks --apksigner <path/to/apksigner>
Each member is de-signed (only the signature entries), rewritten as a 4-byte-aligned archive, and signed with one keystore and v1+v2+v3; the run then verifies every member and prints the signer fingerprint per file. The acceptance criterion is not "each file verifies" — it is one certificate across the whole set:
[result] OK: 8 members, one certificate (sha256 1eb31d9c…2c44)
Then install the whole set, base included:
adb install-multiple -r <signed_set>/*.apk
adb shell "su -c 'pm install-multiple -r /data/local/tmp/set/*.apk'" # when an OEM installer refuses the multi-APK form
The signature must cover every member, and --abi/density selection happens at install time, not
at signing time — sign them all, let the installer pick (see).
Toolchain: uber-apk-signer is optional, apksigner is not
repack.py's original signing route went through uber-apk-signer only, so a machine with a full
Android build-tools directory and no uber-apk-signer could not produce a signed build at all. The
script now picks the route itself:
--signer jar— the uber-apk-signer path (used automatically when the jar exists);--signer apksigner—zipalign -p -f 4thenapksigner sign --v1 --v2 --v3, which is the whole of what the jar was doing for us;--signer auto(default) — jar if present, else apksigner.
--apksigner accepts all three shapes a build-tools install ships: apksigner.bat, the apksigner
shell wrapper, or the bare lib/apksigner.jar. On Windows pass the .jar or the .bat — the
extensionless wrapper is a shell script and will not execute. A .jar value is run through
java -jar, so --java (or java on PATH) is required for it.
Order is load-bearing: align, then sign. apksigner adds signature entries without reshuffling
the archive; the v1 (JAR) path emulates jarsigner, whose rewrite of the zip would destroy the
alignment Android R+ requires of resources.arsc.
5. Merging into a single APK
python skills/apkseep/scripts/repack.py \
--split-dir <set>/ --split-mode merge --out merged.apk \
--abi arm64-v8a --ks key.jks --apksigner <path/to/apksigner>
What folds: classes*.dex (renumbered so a member's classes.dex becomes classes2.dex, keeping
the base's own dex intact), lib/**, assets/**. What does not, and why: res/** and
resources.arsc.
Why resource merging is not attempted. resources.arsc is an indexed table: a global string
pool plus type-spec and type chunks, with every resource identified by a (package, type, entry)
triple and every string referenced by pool offset. Folding a member's table into the base's means
rewriting pool offsets, type/entry counts and the alignment of every string, in both directions —
that is aapt2 link, not a byte-level edit. The failure it produces is the worst kind: the archive
is structurally valid, installs, and then resolves resource ids to the wrong entries or to nothing.
An APK that renders garbage is harder to diagnose than one that refuses to install.
If you genuinely need one standalone file for a bundle whose resources are split, the honest routes are:
bundletool build-apks --mode=universal(the vendor tool, needs the original.aab) — this is what merges resources correctly, and it is the only route that does.- Keep the split set and install it as a set ().
- Accept the downgrade with
--drop-split-resources, and say so in the delivery.
Manifest constraints when you do merge — and when you patch any split at all. These attributes are the difference between "installs and runs" and an install or start refusal, and they are easy to miss because they live in binary AXML:
| Attribute | Lives on | Effect if you get it wrong |
|---|---|---|
split="config.xxhdpi" |
<manifest> of a split |
Identifies the member. A split whose package differs from the base's, or whose split name is duplicated, is refused as a set |
configForSplit="base" |
<manifest> of a split |
Ties a configuration split to a feature module. Changing package/module names without updating it silently detaches the split |
isFeatureSplit="true" |
<manifest> of a feature split |
Marks an install-time/on-demand module. A merge that folds code in but leaves the attribute declared describes a member that no longer exists |
isSplitRequired="true" |
<manifest> of the base |
The platform refuses to start a base that believes it is incomplete. This is the one that breaks merged builds () |
Read them without a decompiler through scripts/repack.py's own AXML reader (--split-mode analyze
prints the split name per member; inspect_apk() returns package, split, configForSplit,
isSplitRequired, isFeatureSplit). Changing any of them means editing the binary string pool and
the attribute chunks around it, which is what apktool is for — and note that a full
apktool b rebuild rewrites the whole archive
(references/repack-and-sign.md §Repacking an unpacked (de-shelled) app).
6. Install refusals, and what each one means
| Message | What it actually says | Attribution |
|---|---|---|
Failure [INSTALL_FAILED_INVALID_APK: ... signatures do not match previously installed version] / Package <PKG> signatures do not match |
The set you are installing is signed with a different certificate than the one already on the device | Not a malformed build. Either re-sign to the original key, or install to a package name that does not exist yet |
Failure [INSTALL_FAILED_ALREADY_EXISTS] / INSTALL_FAILED_UPDATE_INCOMPATIBLE |
Same package, different signer (or a downgrade without -d) |
A pulled system component cannot be reinstalled over itself; see §7 |
Failure [INSTALL_FAILED_MISSING_SPLIT: Missing split for <PKG>] |
The base declares isSplitRequired="true" and the set is incomplete — or you merged it |
The base is telling the truth: it expects members. Clear the attribute, or install the complete set |
Failure [INSTALL_FAILED_INVALID_APK: Failed to parse ... split ... has different signature] |
One member of the set is signed differently from the rest | This is the failure mode of signing only the base. Sign the whole directory |
Failure [-124: ... resources.arsc ... stored uncompressed and aligned on a 4-byte boundary] |
One member's resources.arsc is compressed or unaligned |
Per-member, not per-set. repack.py reports it per member before signing (references/repack-and-sign.md §2a) |
Failure [-99], or a numeric-only code with no INSTALL_FAILED_* |
An OEM installer interceptor rejected the request | Nothing to do with the set; use the root pm install-multiple path |
adb install-multiple on a device that has no install-multiple support (very old pm) |
Install each member with pm install in order, base last |
Use pm install-create / install-write / install-commit explicitly |
INSTALL_FAILED_INVALID_APK in particular is a family, not one error: it covers stale
signatures, malformed base/split relationships, and alignment violations. Read the text after the
colon before attributing anything — the constant alone will send you looking at the wrong file.
7. Making an installable fixture from a pulled set
Observed, and the reason this section exists: a set pulled from the device is installed on that device, under a name owned by the vendor certificate. Re-signed copies cannot be installed back over it, so "does our signed set install?" cannot be answered with the pulled set as it stands.
The cheap, structure-preserving way out is an equal-length package rename: replace every
occurrence of the old package name in AndroidManifest.xml, resources.arsc and classes*.dex
with a new name of exactly the same length. Nothing moves — no length prefix, no string-pool offset,
no dex string_ids offset, no resource index — so the only field that must be recomputed is the dex
header's integrity pair (signature first, checksum last, see
references/repack-and-sign.md §Repacking an unpacked (de-shelled) app). Then run the ordinary
--split-mode resign over the renamed directory and install that.
A detail worth knowing before you touch a manifest: a member's Java classes need not live under its
manifest package at all. On the real set measured here, the manifest package was
com.google.android.<name> while all 677 class references in the dex were
Lcom/android/<name>/... — and every <activity android:name> was fully qualified. Renaming the
manifest package therefore left every component resolvable, with no dex edit needed. Assume the
opposite (that the two must match), and you will edit a dex for nothing.
8. ABI and density matching
The device tells you what it can run:
adb shell getprop ro.product.cpu.abilist # e.g. arm64-v8a,armeabi-v7a,armeabi
adb shell getprop ro.product.cpu.abi # the primary ABI
adb shell getprop ro.product.cpu.abilist32 # 32-bit list, on a 64-bit device
Match against the split name, not the file name, and mind the separator: split qualifiers use
underscores (config.arm64_v8a) while lib/ directories use hyphens (lib/arm64-v8a/). The
mapping is a substitution, not a lookup — arm64_v8a → arm64-v8a, armeabi_v7a → armeabi-v7a;
x86 and x86_64 are unchanged. That is why --abi arm64-v8a matches a split named
config.arm64_v8a.
Four rules that decide ABI questions:
- A 64-bit device still installs a 32-bit-only set, and then runs it 32-bit. If the base carries
no native code and only the
armeabi_v7asplit does, the app is a 32-bit app on that device. - The ABI split is selected by the installer, not by
repack.py.--abiexists for merging (where the choice is baked into one file); for--split-mode resign, sign every member and letpmpick. - Merging without
--abifolds in every ABI member, which makes one APK with two or three copies of the same library set. Legal, larger, and pointless on a single-device delivery. getpropreports what the device claims; what runs is a live mapping question (scripts/lib_map.py). A device that reportsarm64-v8afirst can still execute a 32-bit process.
Density is the same shape without the separator trap: config.xxhdpi matches
res/drawable-xxhdpi/, and the installer picks the closest variant the device's ro.sf.lcd_density
can use. There is nothing to select on your side.
9. Traps worth paying for once
resources.arscbelongs to whoever ships it. In a split set it exists per member, and the base's table does not contain the density member's entries. Reading the base's table and concluding "the resource exists" is wrong; it exists in one configuration only.android:extractNativeLibs. If the base declaresextractNativeLibs="false"(the default for modern targetSdk), the.sofiles are mmapped straight out of the archive, and they must be uncompressed and page-aligned — this is whatzipalign -pexists for. Merging native members into a base and then writing the archive without that alignment produces an install that succeeds and a start that dies in the linker.repack.pywrites uncompressedlib/**4-byte aligned, andzipalign -p -f 4adds the page alignment the loader wants for them.- A placeholder
resources.arscis not a resource split (). Blocking on "has an arsc" refuses legal merges. - Do not
zipalignafter signing to "fix" a member. It rewrites the archive and invalidates the signature. Align, then sign. apktoolmay not be usable even when a wrapper exists. Measured on the pass machine: theapktool.batwrapper pointed at a jar that was not on disk (Error: Unable to access jarfile <missing>.jar), whileapksigner.jarandzipalign.exein the same layout were fine. Check the wrapper's target before planning a manifest edit around it.- The AXML
splitattribute is a string-pool entry, which is why an equal-length rename of it is safe and why a length-changing edit is not: the pool's own length prefixes and the attribute chunk sizes around it would both need recomputing.
10. What this file does not claim
- It does not merge resources. No route here reproduces
bundletool build-apks --mode=universal. A bundle whose resources live in splits stays a set, or becomes a downgraded single APK by explicit choice. - It does not split a monolithic APK back into modules, and it cannot turn a
.aabinto APKs — that isbundletool, and the.aabis the input it needs. --split-mode mergecovers code and native libraries only (). Feature-split code folds; feature-split resources do not.- The install-failure texts in §6 are the shape the platform produces; the exact wording varies by Android version and by OEM installer. Read the text after the constant, not the constant.