Files

1499 lines
64 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Repack an APK with replaced dex files, drop ONLY the signature entries, and re-sign.
Pipeline: replace dex files -> drop signature artifacts -> zip, keeping
AndroidManifest.xml and resources.arsc STORED -> zipalign + sign -> verify.
Everything machine-specific is a parameter or resolved from PATH. No JDK path is
baked in, no keystore password is baked in, no target app is assumed.
Keystore handling:
--ks <path> is where the keystore lives. If it does not exist, one is generated
with keytool and its password is stored next to it as <ks>.pass.txt, so the next
run reuses it. Passwords are never hardcoded in this script. That file is a local
artifact -- do not commit it.
Usage examples:
# roundtrip: reuse the original dexes unchanged, proves the pipeline itself works
python repack.py --apk work/orig.apk --dexdir work/x_orig --out work/out/roundtrip.apk
# real patch: swap a single dex (name=path), other dexes kept from the apk
python repack.py --apk work/orig.apk --dex classes8.dex=work/patch/classes8.dex \
--out work/out/patched.apk
# no signing, no java tooling required at all
python repack.py --apk work/orig.apk --dexdir work/x_orig --out work/unsigned.apk --no-sign
Notes:
* All java tooling is invoked through python subprocess, never through a host
shell (PowerShell drops arguments in ways that look like tool failures).
* uber-apk-signer silently skips already-signed apks (reports 0 processed), which
is why signature entries are always dropped before signing.
* uber-apk-signer is used for SIGNING to keep the verified path unchanged;
apksigner is used for VERIFYING when present, with an explicit sdk range.
"""
import argparse
import os
import re
import secrets
import shutil
import subprocess
import sys
import zipfile
STORE_ONLY = ('AndroidManifest.xml', 'resources.arsc')
def log(msg):
print(msg, flush=True)
def run(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, errors='replace', **kw)
# ---------------------------------------------------------------------------
# Tool resolution: PATH first, explicit flag second, never a baked-in path.
# ---------------------------------------------------------------------------
def sibling_tool(exe, name):
"""Look for `name` next to `exe`, for JDKs whose bin dir is not on PATH."""
if not exe:
return None
folder = os.path.dirname(exe)
for cand in (name, name + '.exe', name + '.bat'):
path = os.path.join(folder, cand)
if os.path.exists(path):
return path
return None
def resolve_tool(name, explicit=None, hint_exe=None):
if explicit:
return explicit
found = shutil.which(name)
if found:
return found
return sibling_tool(hint_exe, name)
def resolve_tools(args):
"""Locate java/keytool/jarsigner/zipalign/apksigner. Missing ones stay None."""
java = resolve_tool('java', args.java)
tools = {
'java': java,
'keytool': resolve_tool('keytool', args.keytool, java),
'jarsigner': resolve_tool('jarsigner', args.jarsigner, java),
'zipalign': resolve_tool('zipalign', args.zipalign),
'apksigner': resolve_tool('apksigner', args.apksigner),
}
return tools
def require(tools, key, why):
if tools.get(key):
return tools[key]
raise SystemExit(
"error: '%s' was not found on PATH, and it is needed to %s.\n"
" install a JDK (17+ recommended) / Android build-tools and put them on PATH,\n"
" or pass the path explicitly (--%s <path>).\n"
" Path lookup is deliberate: a machine-specific absolute path must never be\n"
" baked into this script." % (key, why, key))
# ---------------------------------------------------------------------------
# Keystore
# ---------------------------------------------------------------------------
def read_pwd_file(path):
if not os.path.exists(path):
return None
try:
with open(path, encoding='utf-8') as fh:
txt = fh.read().strip()
return txt or None
except OSError:
return None
def ensure_keystore(ks_path, alias, password, keytool):
"""Return the keystore password, generating the keystore on first use."""
pwd_file = ks_path + '.pass.txt'
if os.path.exists(ks_path):
pwd = password or read_pwd_file(pwd_file)
if not pwd:
raise SystemExit(
'error: keystore %s exists but its password is unknown.\n'
' pass --ks-pass <pw>, or write the password into %s' % (ks_path, pwd_file))
log('[ks] reuse %s (alias=%s)' % (ks_path, alias))
return pwd
pwd = password or read_pwd_file(pwd_file)
generated = pwd is None
if generated:
# keytool rejects passwords shorter than 6 characters.
pwd = secrets.token_urlsafe(12)
folder = os.path.dirname(ks_path)
if folder:
os.makedirs(folder, exist_ok=True)
cmd = [keytool, '-genkeypair', '-keystore', ks_path, '-alias', alias,
'-keyalg', 'RSA', '-keysize', '2048', '-validity', '10000',
'-storepass', pwd, '-keypass', pwd,
'-dname', 'CN=apkreverse, OU=dev, O=dev, L=NA, ST=NA, C=NA']
r = run(cmd)
if r.returncode != 0:
log('[ks] keytool failed rc=%d\n%s\n%s' % (r.returncode, r.stdout, r.stderr))
raise SystemExit(1)
try:
with open(pwd_file, 'w', encoding='utf-8') as fh:
fh.write(pwd + '\n')
os.chmod(pwd_file, 0o600)
except OSError as exc:
log('[ks] could not persist the password: %s' % exc)
log('[ks] generated %s (alias=%s)' % (ks_path, alias))
if generated:
log('[ks] password stored in %s -- local artifact, do not commit it' % pwd_file)
return pwd
# ---------------------------------------------------------------------------
# Split APK / App Bundle sets
#
# A store build is often NOT one file. An App Bundle turns into
# base.apk + split_config.arm64_v8a.apk + split_config.xxhdpi.apk + ...
# and every member carries the same package name and its own signature. Two
# consequences, and they are the reason this section exists:
#
# * Signing only the base leaves the set disagreeing about its certificate.
# `pm install-multiple` then refuses the whole set, with a signature error
# that points at the base rather than at the member you did not sign.
# * "Just merge them into one APK" is free only for splits that carry CODE or
# NATIVE LIBRARIES. A split that carries RESOURCES cannot be folded in
# without merging `resources.arsc` -- rewriting the table, its global string
# pool and its type/entry offsets -- which is a resource-compiler job, not a
# byte-level one. A wrongly merged arsc produces an APK that installs and
# then renders the wrong thing, or fails on the first resource lookup.
#
# So the job here is to say which of the two routes is legal for a given set and
# then do that one, instead of always emitting the same artifact.
# ---------------------------------------------------------------------------
ABI_SPLIT_NAMES = ('armeabi', 'armeabi_v7a', 'arm64_v8a', 'x86', 'x86_64',
'riscv64', 'mips', 'mips64')
DENSITY_SPLIT_NAMES = ('ldpi', 'mdpi', 'tvdpi', 'hdpi', 'xhdpi', 'xxhdpi',
'xxxhdpi', 'anydpi', 'nodpi')
# A split is required to carry an `resources.arsc`, so an almost-empty table is
# normal and must not be mistaken for real resources. Measured on a real set:
# the density splits that hold nothing but a placeholder table are 40 bytes,
# while the ones that actually own drawables are 9-15 KB.
EMPTY_ARSC_BYTES = 1024
def find_apks(root):
"""Every .apk at or under `root`.
Recursive on purpose: `adb pull <dir> <dest>` creates `<dest>/<dir>/`, so a
pulled set arrives one level deeper than the caller expects (measured).
"""
if os.path.isfile(root):
return [os.path.abspath(root)]
found = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
for name in sorted(filenames):
if name.lower().endswith('.apk'):
found.append(os.path.abspath(os.path.join(dirpath, name)))
return sorted(found)
def axml_string_pool(blob):
"""Decode the string pool of a binary AndroidManifest.xml. No dependencies.
Only the pool is decoded, not the element tree: every name this script needs
(`package`, `split`, `configForSplit`, `isSplitRequired`) is a pool entry, and
a hand-rolled element walk is where such parsers usually go wrong. The pool
is also what makes equal-length hex patching of a manifest possible later.
"""
import struct
if len(blob) < 36 or struct.unpack_from('<H', blob, 0)[0] != 0x0003:
return None # not binary XML
if struct.unpack_from('<H', blob, 8)[0] != 0x0001:
return None # no string pool where one must be
hdr_size = struct.unpack_from('<H', blob, 10)[0]
count, _styles, flags, strings_start, _styles_start = struct.unpack_from(
'<IIIII', blob, 16)
if not 0 < count <= 1000000:
return None
offsets = struct.unpack_from('<%dI' % count, blob, 8 + hdr_size)
utf8 = bool(flags & (1 << 8))
base = 8 + strings_start
out = []
for off in offsets:
p = base + off
if p >= len(blob):
return None
try:
if utf8:
n = blob[p]
p += 1
if n & 0x80:
n = ((n & 0x7F) << 8) | blob[p]
p += 1
m = blob[p]
p += 1
if m & 0x80:
m = ((m & 0x7F) << 8) | blob[p]
p += 1
out.append(blob[p:p + m].decode('utf-8', 'replace'))
else:
n = struct.unpack_from('<H', blob, p)[0]
p += 2
if n & 0x8000:
n = ((n & 0x7FFF) << 16) | struct.unpack_from('<H', blob, p)[0]
p += 2
out.append(blob[p:p + n * 2].decode('utf-16-le', 'replace'))
except (IndexError, struct.error):
return None
return out
def axml_root_attrs(blob):
"""Attributes of the root <manifest> element, as {name: value}.
Starts at the first start-element chunk, which is the root element in every
manifest Android produces. Values come back as strings where the pool has
them and as formatted scalars otherwise, so a caller can print them without
caring which encoding the compiler chose (a boolean is `0xffffffff`, not the
word "true", in the binary form).
"""
import struct
strings = axml_string_pool(blob)
if strings is None:
return None
total = struct.unpack_from('<I', blob, 4)[0] or len(blob)
end = min(len(blob), total)
off = 8
while off + 8 <= end:
ctype, _hsize, csize = struct.unpack_from('<HHI', blob, off)
if csize < 8 or off + csize > end:
break
if ctype == 0x0102: # RES_XML_START_ELEMENT
attr_start, attr_size, attr_count = struct.unpack_from(
'<HHH', blob, off + 24)
attrs = {}
first = off + 16 + attr_start
for i in range(attr_count):
p = first + i * attr_size
if p + 20 > off + csize:
break
name_i, raw_i = struct.unpack_from('<II', blob, p + 4)
_size, _res0, dtype = struct.unpack_from('<HBB', blob, p + 12)
data = struct.unpack_from('<I', blob, p + 16)[0]
name = strings[name_i] if name_i < len(strings) else '?'
if dtype == 0x03:
val = strings[data] if data < len(strings) else ''
elif raw_i != 0xFFFFFFFF and raw_i < len(strings):
val = strings[raw_i]
elif dtype == 0x10:
val = str(data - (1 << 32) if data >= (1 << 31) else data)
elif dtype == 0x11:
val = '0x%x' % data
elif dtype == 0x12:
val = 'true' if data else 'false'
elif dtype == 0x01:
val = '@ref/0x%08x' % data
else:
val = 'type=0x%02x/0x%x' % (dtype, data)
attrs[name] = val
return attrs
off += csize
return None
def inspect_apk(path):
"""Structural facts about one member of a split set, from the zip alone.
Nothing here needs a device or a resource compiler: which split owns dex,
which owns native libraries and which owns resources is visible in the entry
list, and the split's own name (and therefore its role) is a manifest
attribute. Reading it from the file is what keeps this usable on a set whose
package name is irrelevant to the caller.
"""
info = {'path': os.path.abspath(path), 'name': os.path.basename(path),
'size': os.path.getsize(path), 'dex': [], 'abis': [], 'res': 0,
'assets': 0, 'libs': [], 'arsc': None, 'arsc_stored': None,
'package': None, 'split': None, 'config_for_split': None,
'is_split_required': None, 'is_feature': None, 'readable': True}
try:
with zipfile.ZipFile(path, 'r') as z:
names = []
for item in z.infolist():
name = item.filename
names.append(name)
if item.is_dir():
continue
if re.match(r'^classes\d*\.dex$', name):
info['dex'].append(name)
elif name == 'resources.arsc':
info['arsc'] = item.file_size
info['arsc_stored'] = (item.compress_type == 0)
elif name.startswith('lib/') and name.count('/') >= 2:
abi = name.split('/')[1]
if abi not in info['abis']:
info['abis'].append(abi)
info['libs'].append(name)
elif name.startswith('res/'):
info['res'] += 1
elif name.startswith('assets/'):
info['assets'] += 1
if 'AndroidManifest.xml' in names:
attrs = axml_root_attrs(z.read('AndroidManifest.xml')) or {}
info['package'] = attrs.get('package')
info['split'] = attrs.get('split')
info['config_for_split'] = attrs.get('configForSplit')
info['is_split_required'] = attrs.get('isSplitRequired')
info['is_feature'] = attrs.get('isFeatureSplit')
except (zipfile.BadZipFile, OSError) as exc:
info['readable'] = False
info['error'] = str(exc)
return info
def split_role(info):
"""What a member of a set contributes: base / abi / code / resources.
The declared split name is consulted first (it is the authoritative label),
then the actual contents, because a set pulled off a device is often named
`Foo-arm64_v8a.apk` while its manifest still says `config.arm64_v8a`.
"""
if not info.get('readable'):
return 'unreadable'
if not info.get('split'):
return 'base'
name = info['split']
tail = name.split('.', 1)[1] if '.' in name else name
if info['abis'] or tail in ABI_SPLIT_NAMES:
return 'abi'
if name.startswith('config.'):
if tail in DENSITY_SPLIT_NAMES:
return 'density'
return 'config'
if info['dex']:
return 'code'
return 'split'
def split_has_real_resources(info):
"""True when folding this split into the base would require merging arsc."""
if info.get('res'):
return True
return (info.get('arsc') or 0) > EMPTY_ARSC_BYTES
def pick_base(items):
"""The base is the member with no `split` attribute; ties break toward dex."""
cands = [i for i in items if not i.get('split')]
if not cands:
return None, []
cands.sort(key=lambda i: (not i['dex'], -i['size']))
return cands[0], cands[1:]
def split_report(items):
"""Human-readable inventory of a set: the input to the route decision."""
rows = []
for i in items:
role = split_role(i)
extra = []
if i['dex']:
extra.append('dex=%s' % ','.join(i['dex']))
if i['abis']:
extra.append('libs=%s' % ','.join(i['abis']))
if i['res']:
extra.append('res=%d entries' % i['res'])
if i['arsc'] is not None:
extra.append('arsc=%d%s' % (i['arsc'], '' if i['arsc_stored'] else ' COMPRESSED'))
if i['assets']:
extra.append('assets=%d' % i['assets'])
rows.append('%-10s %-40s %10d B split=%-18s %s'
% (role, i['name'], i['size'], i.get('split') or '-',
' '.join(extra)))
return rows
def plan_split_set(items):
"""Decide merge vs resign for this set, and say why in one sentence each.
Returns (base, others, blockers, notes). `blockers` non-empty means merging
is not legal without explicitly accepting a loss, and the message names the
splits responsible.
"""
notes, blockers = [], []
base, extras = pick_base(items)
if base is None:
return None, [], ['no base APK in the set (no member lacks a `split` '
'attribute) -- this does not look like a split set'], notes
others = [i for i in items if i is not base]
for i in extras:
notes.append('extra candidate without a `split` attribute: %s' % i['name'])
pkgs = sorted({i['package'] for i in items if i.get('package')})
if len(pkgs) > 1:
notes.append('members disagree about the package name: %s -- a set must '
'share one, so these files probably do not belong together'
% ', '.join(pkgs))
if base.get('is_split_required') == 'true':
notes.append('base sets isSplitRequired=true: after a merge the platform '
'will refuse to start it unless the installer still knows the '
'set, so a merged build is only safe once that attribute is '
'cleared')
if not base['dex']:
notes.append('base carries no classes*.dex (unusual: %s)'
% (', '.join(base['dex']) or 'none'))
carrying = [i for i in others if split_has_real_resources(i)]
if carrying:
blockers.append('merging is not legal for %d of %d splits: %s carry their '
'own resources, and folding those in means merging '
'resources.arsc (a resource-compiler job). Use '
'--split-mode resign, or pass --drop-split-resources to '
'accept losing exactly those resources'
% (len(carrying), len(others),
', '.join(i.get('split') or i['name'] for i in carrying)))
elif others:
notes.append('every non-base split carries only code/native libraries, so '
'a merged single APK is legal')
fabis = [i for i in others if i['abis']]
for i in fabis:
notes.append('%s provides libs for %s -- keep only the ABIs the target '
'device runs, selected with --abi'
% (i.get('split') or i['name'], ','.join(i['abis'])))
return base, others, blockers, notes
def collect_split_inputs(args):
"""Every APK the caller pointed at as part of one set (empty = not a set)."""
if not (args.split_dir or args.split):
return []
paths = []
if args.split_dir:
paths += find_apks(os.path.abspath(args.split_dir))
for p in (args.split or []):
ap = os.path.abspath(p)
paths += find_apks(ap) if os.path.isdir(ap) else [ap]
if args.apk:
ap = os.path.abspath(args.apk)
if ap not in paths:
paths.append(ap)
# A set is a set: dedupe by real path so `--split-dir X --split X/base.apk`
# does not try to merge an APK into itself.
seen, out = set(), []
for p in paths:
key = os.path.normcase(p)
if key not in seen:
seen.add(key)
out.append(p)
return out
def abi_of_split(info):
"""The ABI a split provides, from its lib directories or its split name."""
if info['abis']:
return info['abis'][0]
name = (info.get('split') or '').split('.', 1)[-1]
return name.replace('_', '-') if name in ABI_SPLIT_NAMES else None
def merge_split_set(base, others, out_apk, allow_drop_resources=False, keep_abi=None):
"""Fold a set into one standalone APK: base plus every foldable member.
Folded: `classes*.dex` (renumbered so the second dex becomes classes2.dex),
`lib/**` and `assets/**`. Not folded: `res/**` and `resources.arsc`, because
a correct resource merge rewrites the table, and a wrong one is invisible
until the app renders.
Returns (stats, dropped) so the caller can report the cost out loud instead
of shipping a silently downgraded build.
"""
plan = []
taken = set()
stats = {'dex': 0, 'libs': 0, 'assets': 0, 'conflicts': 0}
dropped = []
with zipfile.ZipFile(base['path'], 'r') as z:
for item in z.infolist():
name = item.filename
if item.is_dir() or is_signature_entry(name):
continue
taken.add(name)
plan.append((name, item, z.read(name),
name in STORE_ONLY or os.path.basename(name) in STORE_ONLY))
base_dex = len([n for n in taken if re.match(r'^classes\d*\.dex$', n)])
next_dex = base_dex + 1 # a base with no dex still wants classes.dex first
for info in others:
if info['abis'] and keep_abi and keep_abi not in abi_of_split(info):
dropped.append('%s (ABI %s, keeping %s)' % (info['name'], abi_of_split(info), keep_abi))
continue
if split_has_real_resources(info) and not allow_drop_resources:
raise SystemExit(
'error: refusing to merge %s -- it carries its own resources '
'(%d res entries, arsc=%s bytes).\n'
' Folding it in would require merging resources.arsc, which this '
'script does not do.\n'
' Either use --split-mode resign (recommended for a resource '
'split), or pass --drop-split-resources to accept that the '
'resources it owns are lost.'
% (info['name'], info['res'], info['arsc']))
with zipfile.ZipFile(info['path'], 'r') as z:
for item in z.infolist():
name = item.filename
if item.is_dir() or is_signature_entry(name):
continue
if name == 'AndroidManifest.xml':
continue # the base manifest already exists
if name == 'resources.arsc' or name.startswith('res/'):
dropped.append('%s: %s' % (info['name'], name))
continue
if re.match(r'^classes\d*\.dex$', name):
new = 'classes%d.dex' % next_dex if next_dex > 1 else 'classes.dex'
while new in taken:
next_dex += 1
new = 'classes%d.dex' % next_dex
data = z.read(name)
zi = zipfile.ZipInfo(new, date_time=(2024, 1, 1, 0, 0, 0))
plan.append((new, zi, data, False))
taken.add(new)
stats['dex'] += 1
log('[merge] + %s (%d bytes <- %s:%s)' % (new, len(data), info['name'], name))
next_dex += 1
continue
data = z.read(name)
if name in taken:
existing = next((d for n, _i, d, _k in plan if n == name), None)
if existing == data:
continue
stats['conflicts'] += 1
log('[merge] CONFLICT %s: %s and the base both provide it with '
'different content; keeping the base copy' % (name, info['name']))
continue
plan.append((name, item, data, name in STORE_ONLY))
taken.add(name)
if name.startswith('lib/'):
stats['libs'] += 1
elif name.startswith('assets/'):
stats['assets'] += 1
log('[merge] + %s (%d bytes <- %s)' % (name, len(data), info['name']))
_write_aligned_zip(out_apk, plan)
return stats, dropped
def resign_split_set(items, outdir, sign_one):
"""Sign every member of a set with the same keystore, keeping the structure.
Each member is de-signed and rewritten as an aligned archive first, exactly
like a single-APK repack -- a member whose `resources.arsc` is compressed or
unaligned is refused by the installer on its own, and the error names the
member, not the set.
"""
os.makedirs(outdir, exist_ok=True)
scratch = os.path.join(outdir, '.unsigned')
if os.path.isdir(scratch):
shutil.rmtree(scratch, ignore_errors=True)
os.makedirs(scratch, exist_ok=True)
outs = []
for info in items:
unsigned = os.path.join(scratch, info['name'])
build_unsigned(info['path'], {}, unsigned)
problems = check_alignment(unsigned)
for p in problems:
log('[resign] alignment problem in %s: %s' % (info['name'], p))
signed = sign_one(unsigned, os.path.join(outdir, info['name']))
outs.append((info, signed))
log('[resign] %s -> %s (%d bytes)' % (info['name'], signed, os.path.getsize(signed)))
shutil.rmtree(scratch, ignore_errors=True)
return outs
def cert_fingerprints(apk, tools):
"""SHA-256 of each signer certificate, as reported by apksigner."""
apksigner = tools.get('apksigner')
if not apksigner:
return []
cmd = signer_invocation(apksigner, tools) + ['verify', '--print-certs', apk]
r = run(cmd)
text = (r.stdout or '') + (r.stderr or '')
return sorted({m.lower() for m in re.findall(r'SHA-256 digest:\s*([0-9a-fA-F:]+)', text)})
# ---------------------------------------------------------------------------
# Packing
# ---------------------------------------------------------------------------
def collect_replacement_dex(dexdir=None, dex_pairs=None):
repl = {}
if dexdir:
for name in sorted(os.listdir(dexdir)):
if re.match(r'^classes\d*\.dex$', name):
repl[name] = os.path.join(dexdir, name)
for pair in (dex_pairs or []):
if '=' not in pair:
raise SystemExit('--dex expects name=path, got %r' % pair)
name, path = pair.split('=', 1)
repl[name] = path
return repl
def is_signature_entry(name):
"""True ONLY for JAR/APK signature artifacts directly under META-INF/.
META-INF/services/**, META-INF/androidx/**, META-INF/native-image/** and every
other subdirectory are RUNTIME RESOURCES and must survive repacking. Anything
that is not directly under META-INF/ is kept by construction.
"""
if not name.upper().startswith('META-INF/'):
return False
rest = name[len('META-INF/'):]
if '/' in rest:
return False
up = rest.upper()
if up == 'MANIFEST.MF':
return True
return up.endswith(('.SF', '.RSA', '.DSA', '.EC'))
def build_unsigned(apk, repl, out_apk, drop_signatures=True):
"""Write a new apk with dex replaced and only signature entries dropped.
IMPORTANT (root cause of a startup crash, do not regress):
Stripping the WHOLE META-INF/ breaks ServiceLoader-based runtime wiring. Android
reads these registries at runtime, and they live in the APK:
META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory
META-INF/services/io.ktor.client.HttpClientEngineContainer
META-INF/services/<lib>.core.* (third-party SDK registries)
META-INF/services/<obfuscated-class-name> (R8-renamed providers)
Deleting them makes the app die at startup with something like:
IllegalStateException: Module with the Main dispatcher is missing ...
and the message never points at META-INF, so the cause is very hard to find.
Therefore: drop signature artifacts only, keep every META-INF subdirectory.
ALIGNMENT. Writing the entries with a plain zipfile writer produces an archive
Android R+ refuses to install:
Failure [-124: Failed parse during installPackageLI: Targeting R+ (version
30 and above) requires the resources.arsc of installed APKs to be stored
uncompressed and aligned on a 4-byte boundary]
Two independent requirements hide in that message: `resources.arsc` must be
STORED (not deflated), and its data must start at a 4-byte boundary. The same
applies to uncompressed `lib/*.so`. Python's zipfile cannot express an entry
offset, so this writer emits the local headers itself and pads the local extra
field to hit the boundary.
"""
seen = set()
replaced_names = set(repl)
# Collect (name, source, data, keep_stored) in output order.
plan = []
with zipfile.ZipFile(apk, 'r') as zin:
for item in zin.infolist():
name = item.filename
base = os.path.basename(name)
if item.is_dir():
continue
if drop_signatures and is_signature_entry(name):
log('[zip] - %s (signature artifact)' % name)
continue
if name in replaced_names:
continue
plan.append((name, item, zin.read(name),
name in STORE_ONLY or base in STORE_ONLY))
for name, path in sorted(repl.items()):
with open(path, 'rb') as fh:
data = fh.read()
info = zipfile.ZipInfo(name, date_time=(2024, 1, 1, 0, 0, 0))
plan.append((name, info, data, False))
log('[zip] + %s (%d bytes <- %s)' % (name, len(data), path))
_write_aligned_zip(out_apk, plan)
seen.update(name for name, _i, _d, _k in plan)
return seen
ALIGN = 4
# Entries Android requires to be STORED, and (for some) 4-byte aligned.
ALIGNED_STORED = ('resources.arsc',)
# ABI-split libraries live at `lib/<abi>/*.so`, so the pattern must allow one more path segment:
# `^lib/[^/]+\.so$` matches nothing in a real APK and silently disabled STORED/alignment handling
# for every native library -- measured on a 435-entry real package (zip-safety pass, 2026-09).
ALIGNED_PATTERNS = (re.compile(r'^lib/(?:[^/]+/)*[^/]+\.so$'),)
FILLER_NAME = 'META-INF/ALIGN.RSV'
def _needs_alignment(name):
if name in ALIGNED_STORED:
return True
return any(p.match(name) for p in ALIGNED_PATTERNS)
def _dos_word(dt):
"""(time, date) MS-DOS words for a zip local header."""
y, mo, d, h, mi, s = dt
if y < 1980:
y, mo, d, h, mi, s = 1980, 1, 1, 0, 0, 0
return (h << 11) | (mi << 5) | (s // 2), ((y - 1980) << 9) | (mo << 5) | d
def _local_header(nlen, method, crc, csize, usize, dt, flags=0, extralen=0):
t, dd = _dos_word(dt)
import struct
return struct.pack('<IHHHHHIIIHH', 0x04034B50, 20, flags, method,
t, dd, crc, csize, usize, nlen, extralen)
def _deflate_raw(data):
"""Raw deflate (no zlib wrapper), which is what a zip method-8 entry holds.
zlib.compress() prepends a 2-byte zlib header. Some readers tolerate it, some
do not, and the failure reads as a corrupt entry rather than a bad compressor
call, so use wbits=-15 and get it right the first time.
"""
import zlib
c = zlib.compressobj(9, zlib.DEFLATED, -15)
return c.compress(data) + c.flush()
def _write_aligned_zip(out_path, plan):
"""Write a zip whose STORED entries can be relied on for offset alignment.
Padding rule, and why it is not just `(-offset) % 4`: a zip extra area is a
sequence of (id, size, payload) records, so its minimum useful length is 4
bytes. A required pad of 1-3 bytes therefore CANNOT be expressed in the extra
field. When that happens this writer inserts a stored filler entry of exactly
the needed size instead -- the filler has a computable size, so the following
entry still lands on the boundary.
"""
import struct
import zlib
order = []
meta = {}
offset = 0
scratch = out_path + '.tmp'
with open(scratch, 'wb') as out:
for name, info, data, keep_stored in plan:
name_b = name.encode('utf-8')
if keep_stored:
method, payload = 0, data
else:
method, payload = 8, _deflate_raw(data)
crc = zlib.crc32(data) & 0xFFFFFFFF
# reach the boundary before the local header of an aligned entry
if _needs_alignment(name):
need = (ALIGN - ((offset + 30 + len(name_b)) % ALIGN)) % ALIGN
if need in (1, 2, 3):
off2, fill_meta = _emit_filler(out, offset, need)
order.append(FILLER_NAME)
meta[FILLER_NAME] = fill_meta
offset = off2
extra, extralen = b'', 0
if _needs_alignment(name):
head = 30 + len(name_b)
if (offset + head) % ALIGN != 0:
# expressible pad: one (id,size,payload) record >= 4 bytes
pad = (ALIGN - ((offset + head) % ALIGN)) % ALIGN
if pad < 4:
pad += ALIGN
extra = b'\xfe\xca' + struct.pack('<H', pad - 4) + b'\x00' * (pad - 4)
extralen = len(extra)
flags = getattr(info, 'flag_bits', 0)
local_header_offset = offset # the CD points at the HEADER
out.write(_local_header(len(name_b), method, crc, len(payload),
len(data), info.date_time, flags, extralen))
out.write(name_b)
out.write(extra)
data_off = offset + 30 + len(name_b) + extralen
if out.tell() != data_off:
raise RuntimeError('offset bookkeeping drift for %s' % name)
if _needs_alignment(name) and data_off % ALIGN != 0:
raise RuntimeError('%s not aligned: data at 0x%x' % (name, data_off))
out.write(payload)
offset = out.tell()
order.append(name)
meta[name] = {'name_b': name_b, 'method': method, 'crc': crc,
'csize': len(payload), 'usize': len(data),
'local_offset': local_header_offset,
'date_time': info.date_time, 'flag_bits': flags,
'external_attr': getattr(info, 'external_attr', 0)}
cd_start = out.tell()
for name in order:
m = meta[name]
t, dd = _dos_word(m['date_time'])
# central directory: sig, ver_made, ver_need, flags, method, time,
# date, crc, csize, usize, namelen, extralen, commentlen, disk,
# int_attr, ext_attr, local_header_offset
out.write(struct.pack(
'<IHHHHHHIIIHHHHHII', 0x02014B50, 20, 20, m.get('flag_bits', 0),
m['method'], t, dd, m['crc'], m['csize'], m['usize'],
len(m['name_b']), 0, 0, 0, 0,
(m['external_attr'] >> 16) & 0xFFFF, m['local_offset']))
out.write(m['name_b'])
cd_size = out.tell() - cd_start
out.write(struct.pack('<IHHHHIIH', 0x06054B50, 0, 0, len(order), len(order),
cd_size, cd_start, 0))
shutil.move(scratch, out_path)
return order
def _emit_filler(out, offset, size):
"""Insert a stored, zero-filled entry of exactly `size` payload bytes."""
import struct
import zlib
name_b = FILLER_NAME.encode('utf-8')
payload = b'\x00' * size
crc = zlib.crc32(payload) & 0xFFFFFFFF
out.write(_local_header(len(name_b), 0, crc, size, size, (1980, 1, 1, 0, 0, 0)))
out.write(name_b)
out.write(payload)
return out.tell(), {'name_b': name_b, 'method': 0, 'crc': crc,
'csize': size, 'usize': size,
'local_offset': offset,
'date_time': (1980, 1, 1, 0, 0, 0), 'external_attr': 0}
def check_alignment(apk):
"""Report storage + 4-byte alignment of the entries Android insists on.
Returns a list of complaint strings; empty means the install gate is satisfied.
"""
import struct
bad = []
with zipfile.ZipFile(apk, 'r') as z, open(apk, 'rb') as fh:
for i in z.infolist():
if not (i.filename in ALIGNED_STORED or _needs_alignment(i.filename)):
continue
fh.seek(i.header_offset)
lh = fh.read(30)
nlen, elen = struct.unpack('<HH', lh[26:30])
data_off = i.header_offset + 30 + nlen + elen
if i.compress_type != 0:
bad.append('%s is compressed (method=%d); Android R+ requires '
'STORED' % (i.filename, i.compress_type))
if i.filename != FILLER_NAME and data_off % ALIGN != 0:
bad.append('%s data offset 0x%x is not %d-byte aligned'
% (i.filename, data_off, ALIGN))
return bad
def zip_report(apk):
"""Entry summary, including the storage/alignment facts Android gates on.
Only entries that MUST be aligned are judged on alignment. `AndroidManifest.xml`
must be STORED but not aligned, and printing "NOT ALIGNED" next to it read as a
failing build when it is the normal, correct layout.
"""
import struct
rows = []
with zipfile.ZipFile(apk, 'r') as z, open(apk, 'rb') as fh:
for i in z.infolist():
base = os.path.basename(i.filename)
must_align = _needs_alignment(i.filename)
needs = (i.filename in STORE_ONLY or base in STORE_ONLY or must_align)
if needs:
fh.seek(i.header_offset)
lh = fh.read(30)
nlen, elen = struct.unpack('<HH', lh[26:30])
data_off = i.header_offset + 30 + nlen + elen
verdict = ('aligned' if data_off % ALIGN == 0
else 'NOT ALIGNED') if must_align else '-'
stored = 'STORED' if i.compress_type == 0 else \
'COMPRESSED(method=%d)' % i.compress_type
rows.append('%-40s %-22s offset=0x%-8x %s'
% (i.filename, stored, data_off, verdict))
if re.match(r'^classes\d*\.dex$', i.filename):
rows.append('%s %d bytes method=%d crc=%08x' % (
i.filename, i.file_size, i.compress_type, i.CRC))
return rows
# ---------------------------------------------------------------------------
# Sign / verify
# ---------------------------------------------------------------------------
def signer_invocation(path, tools):
"""Command prefix that runs apksigner, whatever shape it was shipped as.
A build-tools directory offers `apksigner.bat` (Windows), `apksigner` (a
shell wrapper) or the bare `lib/apksigner.jar`. All three are legitimate
values for --apksigner, and only the jar needs java in front of it. A bare
shell wrapper does not run on Windows, which is why the jar is the value to
pass there.
"""
if path.lower().endswith('.jar'):
java = require(tools, 'java', 'run apksigner.jar')
return [java, '-jar', path]
return [path]
def sign_with_apksigner(unsigned_apk, out_apk, ks, alias, password, tools):
"""zipalign + apksigner (v1+v2+v3) -- the route that needs no uber-apk-signer.
Order is load-bearing: align first, sign second. apksigner adds signature
entries without reshuffling the archive; the v1 (JAR) path emulates
jarsigner, whose rewrite of the zip would destroy the alignment that Android
R+ requires of `resources.arsc`.
"""
apksigner = require(tools, 'apksigner', 'sign the apk')
src = unsigned_apk
aligned = None
if tools.get('zipalign'):
aligned = unsigned_apk + '.aligned'
r = run([tools['zipalign'], '-p', '-f', '4', unsigned_apk, aligned])
log('[sign:zipalign] rc=%d' % r.returncode)
if r.returncode != 0 or not os.path.exists(aligned):
log(((r.stdout or '') + (r.stderr or '')).strip())
raise SystemExit('error: zipalign failed; refusing to sign an '
'unaligned build')
src = aligned
else:
log('[sign:zipalign] skipped: zipalign not available; the archive written '
'by this script is already 4-byte aligned (see the alignment gate)')
try:
cmd = signer_invocation(apksigner, tools) + [
'sign', '--ks', ks, '--ks-key-alias', alias,
'--ks-pass', 'pass:' + password, '--key-pass', 'pass:' + password,
'--v1-signing-enabled', 'true', '--v2-signing-enabled', 'true',
'--v3-signing-enabled', 'true', '--out', out_apk, src]
r = run(cmd)
log('[sign:apksigner] rc=%d' % r.returncode)
log(((r.stdout or '') + (r.stderr or '')).strip())
if r.returncode != 0 or not os.path.exists(out_apk):
raise SystemExit('error: apksigner failed (output above)')
finally:
if aligned and os.path.exists(aligned):
os.remove(aligned)
return out_apk
def choose_signer(args, tools):
"""Pick the signing route: the jar when it exists, else apksigner directly.
The jar stays first so an existing workflow does not change behaviour. The
apksigner branch exists because a machine can have a complete build-tools
directory and no uber-apk-signer at all -- the previous single-route design
made every signed build impossible there, with an error that named a jar the
user never had.
"""
jar = args.signer_jar or os.environ.get('APK_SIGNER_JAR', 'uber-apk-signer.jar')
kind = getattr(args, 'signer', 'auto')
if kind in ('auto', 'jar') and os.path.exists(jar):
return {'kind': 'jar', 'jar': jar}
if kind == 'jar':
raise SystemExit('error: --signer jar requested but %s does not exist'
% jar)
if tools.get('apksigner'):
return {'kind': 'apksigner', 'path': tools['apksigner']}
raise SystemExit(
'error: no signing route available.\n'
' * uber-apk-signer.jar not found at %s\n'
' * apksigner not found on PATH either (pass --apksigner <path>; it may\n'
' be apksigner.bat, an apksigner wrapper, or lib/apksigner.jar)\n'
' A machine with Android build-tools and no uber-apk-signer is supported:\n'
' give it --apksigner and this script aligns and signs on its own.' % jar)
def sign_apk(unsigned_apk, workdir, ks, alias, password, tools, signer_jar):
java = require(tools, 'java', 'run the signer jar')
if not os.path.exists(signer_jar):
raise SystemExit(
'error: signer jar not found: %s\n'
' pass --signer-jar <path>, or set APK_SIGNER_JAR.\n'
' Any zipalign+apksigner based signer works here.' % signer_jar)
outdir = os.path.join(workdir, 'signed')
if os.path.isdir(outdir):
shutil.rmtree(outdir, ignore_errors=True)
os.makedirs(outdir, exist_ok=True)
cmd = [java, '-jar', signer_jar,
'--apks', unsigned_apk,
'--ks', ks, '--ksAlias', alias,
'--ksPass', password, '--ksKeyPass', password,
'-o', outdir, '--verbose']
r = run(cmd)
log('[sign] rc=%d' % r.returncode)
log(r.stdout.strip())
if r.stderr.strip():
log('[sign][stderr] ' + r.stderr.strip())
cand = os.path.join(outdir, os.path.basename(unsigned_apk))
if not os.path.exists(cand):
outs = [os.path.join(outdir, f) for f in os.listdir(outdir)] \
if os.path.isdir(outdir) else []
if not outs:
raise SystemExit('signing produced no output')
cand = outs[0]
return cand
def verify_apk(apk, tools, signer_jar=None, expect_signed=True):
log('== verify: %s' % apk)
ok = True
sig_checks = 0 # real signature-verification tools that actually ran
# 1) apksigner -- the authoritative check, but ONLY with an explicit sdk range.
#
# READ THIS BEFORE CONCLUDING THE SIGNATURE IS BROKEN:
# `apksigner verify` with no range checks the signature schemes implied by the
# APK's own minSdkVersion. With minSdk >= 24 it prints v1/v2 as false while the
# files in META-INF are perfectly fine, which looks exactly like a failed signing
# step. Always pass --min-sdk-version / --max-sdk-version so the v1/v2/v3 results
# are meaningful.
if tools.get('apksigner'):
sig_checks += 1
r = run(signer_invocation(tools['apksigner'], tools) + [
'verify', '--print-certs', '--verbose',
'--min-sdk-version', '21', '--max-sdk-version', '34', apk])
log('[verify:apksigner] rc=%d\n%s' % (r.returncode, ((r.stdout or '') +
(r.stderr or '')).strip()[:3000]))
if r.returncode != 0:
ok = False
else:
log('[verify:apksigner] skipped: apksigner not on PATH.\n'
' Do NOT judge the signature from a bare `apksigner verify`: with\n'
' minSdk >= 24 its default range reports v1/v2 as false even when the\n'
' signature is valid. The correct invocation is:\n'
' apksigner verify --print-certs --verbose --min-sdk-version 21 '
'--max-sdk-version 34 <apk>')
# 2) signer jar's own verify (also re-checks alignment)
if signer_jar and os.path.exists(signer_jar) and tools.get('java'):
sig_checks += 1
r = run([tools['java'], '-jar', signer_jar, '-a', apk, '-y'])
log('[verify:signer] rc=%d\n%s' % (r.returncode, (r.stdout or '').strip()))
if r.returncode != 0 and 'DOES NOT VERIFY' in (r.stdout or ''):
ok = False
# 3) jarsigner (v1 / JAR signature)
if tools.get('jarsigner'):
sig_checks += 1
r = run([tools['jarsigner'], '-verify', '-certs', apk])
txt = ((r.stdout or '') + (r.stderr or '')).strip()
log('[verify:jarsigner] rc=%d\n%s' % (r.returncode, txt[:2000]))
if 'jar verified' not in txt and 'verified' not in txt.lower():
log('[verify:jarsigner] note: not reported as verified (see output above)')
# 4) keytool cert dump
if tools.get('keytool'):
r = run([tools['keytool'], '-printcert', '-jarfile', apk])
log('[verify:keytool] rc=%d\n%s' % (r.returncode, (r.stdout or '').strip()[:1500]))
# 5) zipalign check
if tools.get('zipalign'):
r = run([tools['zipalign'], '-c', '-v', '4', apk])
log('[verify:zipalign] rc=%d' % r.returncode)
if r.returncode != 0:
log((r.stdout or '').strip()[:1500])
else:
log('[verify:zipalign] skipped: zipalign not on PATH')
# 6) v2/v3 APK Signing Block presence, read straight from the file
with open(apk, 'rb') as fh:
blob = fh.read()
log('[verify:v2block] %s' % ('present' if b'APK Sig Block 42' in blob else 'MISSING'))
# A build nobody could verify is not a passing build. Saying OK here would
# violate the first rule of this skill ("an APK is not done until it is
# verified"): report UNVERIFIED and fail instead.
if expect_signed and sig_checks == 0:
log('[result] UNVERIFIED: no signature verification tool was available '
'(apksigner / jarsigner / signer jar). This is NOT an OK result.')
return False
return ok
def fingerprint(apk, tools):
"""Signer fingerprints. keytool when there is one, apksigner otherwise.
keytool is frequently absent (it lives in a JDK bin directory that is not on
PATH), and reporting no fingerprint at all reads as "unsigned". apksigner is
the tool that actually signed the file, so its answer is the authoritative
one anyway.
"""
if tools.get('keytool'):
r = run([tools['keytool'], '-printcert', '-jarfile', apk])
found = re.findall(r'(SHA1|SHA256):\s*([0-9A-F:]+)', r.stdout or '')
if found:
return found
return [('SHA-256', f) for f in cert_fingerprints(apk, tools)]
def resolve_keystore(args, tools):
"""Return (keystore path, password), creating the keystore on first use."""
if not args.ks:
raise SystemExit(
"error: --ks is required when signing (there is no default keystore).\n"
" e.g. --ks work/out/release.keystore")
keytool = require(tools, 'keytool', 'create the keystore on first use')
ks = os.path.abspath(args.ks)
return ks, ensure_keystore(ks, args.ks_alias, args.ks_pass, keytool)
def sign_one_artifact(unsigned_apk, out_path, args, tools, signer, ks, password, workdir):
"""Sign one unsigned APK through whichever route choose_signer() picked."""
if signer['kind'] == 'jar':
signed = sign_apk(unsigned_apk, workdir, ks, args.ks_alias, password,
tools, signer['jar'])
if os.path.abspath(signed) != os.path.abspath(out_path):
shutil.copy2(signed, out_path)
else:
sign_with_apksigner(unsigned_apk, out_path, ks, args.ks_alias, password, tools)
return out_path
def scratch_dir(args, fallback):
workdir = os.path.abspath(args.workdir) if args.workdir else fallback
if not workdir:
workdir = os.getcwd()
os.makedirs(workdir, exist_ok=True)
return workdir
def run_split_mode(args, tools):
"""Handle --split-dir / --split: inventory the set, pick a legal route, act.
Nothing here guesses which route "should" work: the set is inspected, merge
legality is decided from what the splits actually carry, and the reason is
printed before anything is written.
"""
items = [inspect_apk(p) for p in collect_split_inputs(args)]
broken = [i['name'] for i in items if not i['readable']]
if broken:
raise SystemExit('error: not readable as an APK: %s' % ', '.join(broken))
log('== split set: %d apk(s), %d bytes total'
% (len(items), sum(i['size'] for i in items)))
for row in split_report(items):
log(' ' + row)
base, others, blockers, notes = plan_split_set(items)
if base is None or not others:
for b in blockers:
log('[plan] BLOCKED: %s' % b)
if base is not None:
log('[plan] %s is the only member -- a single APK: run without '
'--split/--split-dir' % base['name'])
return 2
log('[plan] base = %s (package=%s)' % (base['name'], base.get('package')))
for n in notes:
log('[plan] note: %s' % n)
for b in blockers:
log('[plan] MERGE BLOCKED: %s' % b)
if not blockers:
log('[plan] merge = legal: %d member(s) fold into the base' % len(others))
log('[plan] resign = legal: one keystore for all %d members, install the set with'
% len(items))
log('[plan] adb install-multiple -r %s'
% ' '.join(i['name'] for i in items))
mode = args.split_mode
if mode == 'auto':
if blockers:
mode = 'resign'
elif (args.out or '').lower().endswith('.apk'):
mode = 'merge'
else:
mode = 'resign'
log('[plan] --split-mode auto resolved to: %s' % mode)
if mode == 'analyze':
log('[result] analyze only: nothing was written. Pass --split-mode '
'merge|resign|auto to act on this set.')
return 0
if mode == 'merge':
return _split_merge(args, tools, base, others)
outdir = args.split_out_dir or (os.path.abspath(args.out) if args.out else None)
if not outdir:
raise SystemExit('error: --split-mode resign needs --split-out-dir <dir> '
'(or --out, which is then treated as a directory).')
outdir = os.path.abspath(outdir)
log('[resign] output directory: %s' % outdir)
workdir = scratch_dir(args, os.path.join(outdir, '.work'))
signer = None
ks = password = None
if not args.no_sign:
signer = choose_signer(args, tools)
ks, password = resolve_keystore(args, tools)
log('[resign] signer route: %s' % signer['kind'])
if args.no_sign:
def sign_member(unsigned, out_path):
shutil.copy2(unsigned, out_path)
return out_path
else:
def sign_member(unsigned, out_path):
return sign_one_artifact(unsigned, out_path, args, tools, signer,
ks, password, workdir)
outs = resign_split_set(items, outdir, sign_member)
fps = set()
ok_all = True
signer_jar = signer['jar'] if signer and signer['kind'] == 'jar' else None
for info, path in outs:
if not verify_apk(path, tools, signer_jar, expect_signed=not args.no_sign):
ok_all = False
for f in cert_fingerprints(path, tools):
fps.add(f)
log('[cert] %s %s' % (info['name'], f))
if not args.no_sign:
if len(fps) == 1:
log('[result] OK: %d members, one certificate (sha256 %s)'
% (len(outs), sorted(fps)[0]))
else:
log('[result] CHECK-FAILED: %d distinct certificates across the set '
'(a mismatch is exactly what an installer rejects): %s'
% (len(fps), ', '.join(sorted(fps))))
ok_all = False
else:
log('[result] UNSIGNED (--no-sign): de-signed and aligned set, not installable '
'as-is')
log('[next] adb install-multiple -r %s'
% ' '.join(os.path.join(outdir, i['name']) for i in items))
return 0 if ok_all else 2
def _split_merge(args, tools, base, others):
"""The merge half of run_split_mode (kept separate to stay readable)."""
if not args.out:
raise SystemExit('error: --split-mode merge needs --out <merged.apk>')
out = os.path.abspath(args.out)
folder = os.path.dirname(out)
if folder:
os.makedirs(folder, exist_ok=True)
unsigned = out + '.unsigned'
if os.path.exists(unsigned):
os.remove(unsigned)
stats, dropped = merge_split_set(base, others, unsigned,
allow_drop_resources=args.drop_split_resources,
keep_abi=args.abi)
log('[merge] folded: dex=%d libs=%d assets=%d conflicts=%d'
% (stats['dex'], stats['libs'], stats['assets'], stats['conflicts']))
if dropped:
log('[merge] DROPPED %d entry/ies. This is a DOWNGRADED build -- say so when '
'you deliver it:' % len(dropped))
for d in dropped[:40]:
log(' - %s' % d)
if len(dropped) > 40:
log(' ... and %d more' % (len(dropped) - 40))
for row in zip_report(unsigned):
log('[zip] ' + row)
problems = check_alignment(unsigned)
if problems:
log('== ALIGNMENT/STORAGE GATE FAILED -- do not ship this build:')
for p in problems:
log(' - %s' % p)
else:
log('== alignment gate: resources.arsc STORED and 4-byte aligned (OK)')
if args.no_sign:
shutil.move(unsigned, out)
log('[out] %s (%d bytes)' % (out, os.path.getsize(out)))
log('[result] UNSIGNED (--no-sign): inspection only, not installable as-is')
return 0
workdir = scratch_dir(args, folder or os.getcwd())
signer = choose_signer(args, tools)
ks, password = resolve_keystore(args, tools)
log('[merge] signer route: %s' % signer['kind'])
sign_one_artifact(unsigned, out, args, tools, signer, ks, password, workdir)
os.remove(unsigned)
log('[out] %s (%d bytes)' % (out, os.path.getsize(out)))
ok = verify_apk(out, tools,
signer['jar'] if signer['kind'] == 'jar' else None)
for algo, val in fingerprint(out, tools):
log('[cert] %s %s' % (algo, val))
log('[result] %s' % ('OK' if ok else 'CHECK-FAILED'))
log('[next] adb install -r %s (one file, no set to keep together)' % out)
return 0 if ok else 2
def main():
ap = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description='Repack an APK with replaced dex files, drop only the signature '
'entries, re-sign and verify.\n'
'Also handles SPLIT APK / App Bundle sets (base.apk plus\n'
'split_config.*.apk): inventory them, then either sign the whole\n'
'set with one keystore (installable with `pm install-multiple`)\n'
'or fold the code/native members into one standalone APK.',
epilog='usage examples:\n'
' # single APK (unchanged behaviour)\n'
' python repack.py --apk original.apk --dexdir dex/ --out out.apk '
'--ks ks.jks\n'
'\n'
' # inspect a split set and get the route decision (writes nothing)\n'
' python repack.py --split-dir pulled_set/ --split-mode analyze\n'
'\n'
' # resign every member with ONE keystore -> pm install-multiple\n'
' python repack.py --split-dir pulled_set/ --split-mode resign \\\n'
' --split-out-dir signed_set/ --ks ks.jks \\\n'
' --apksigner /path/to/lib/apksigner.jar\n'
'\n'
' # fold code/native splits into one standalone APK\n'
' python repack.py --split-dir pulled_set/ --split-mode merge \\\n'
' --out merged.apk --ks ks.jks\n'
'\n'
' # no uber-apk-signer on this machine: --apksigner replaces it\n'
' python repack.py --apk app.apk --out out.apk --ks ks.jks \\\n'
' --apksigner /path/to/lib/apksigner.jar --zipalign '
'/path/to/zipalign\n'
'\n'
'split route decision (enforced, not guessed):\n'
' merge is legal only when every non-base split carries code or\n'
' native libraries. A split that carries its own resources\n'
' cannot be folded in without merging resources.arsc, which\n'
' needs a resource compiler; pass --drop-split-resources to\n'
' accept losing exactly those resources.\n'
' resign always works: one keystore, v1+v2+v3 on every member.\n')
ap.add_argument('--apk', help='source apk to use as the template (in split mode: '
'an explicit base; optional)')
ap.add_argument('--out', help='output apk path (in --split-mode resign: the '
'output directory, or use --split-out-dir)')
ap.add_argument('--dexdir', help='directory of classes*.dex to swap in')
ap.add_argument('--dex', action='append', default=[],
help='name=path, repeatable, e.g. classes8.dex=work/patch/classes8.dex')
ap.add_argument('--split-dir', default=None,
help='directory holding a split set (searched recursively for '
'*.apk); every member must share one package name')
ap.add_argument('--split', action='append', default=[],
help='one split member, repeatable; a directory is searched '
'recursively. Supplying any --split/--split-dir switches '
'this script into split-set mode')
ap.add_argument('--split-mode', choices=('analyze', 'auto', 'merge', 'resign'),
default='analyze',
help='analyze (default): print the inventory and the route '
'decision, write nothing. auto: merge when that is legal '
'and --out ends in .apk, else resign. merge: fold into one '
'standalone APK (--out). resign: sign every member with one '
'keystore (--split-out-dir)')
ap.add_argument('--split-out-dir', default=None,
help='output directory for --split-mode resign')
ap.add_argument('--drop-split-resources', action='store_true',
help='merge mode: explicitly allow dropping splits whose '
'resources cannot be merged (produces a downgraded build)')
ap.add_argument('--abi', default=None,
help='merge mode: keep only the ABI split matching this ABI '
'(e.g. arm64-v8a). Compare with the device via '
'`getprop ro.product.cpu.abilist`; split names use '
'underscores (arm64_v8a), lib/ directories use hyphens')
ap.add_argument('--workdir', default=None,
help='scratch directory (default: next to --out)')
ap.add_argument('--ks', default=None, help='keystore path (created on first use)')
ap.add_argument('--ks-alias', default='apkreverse', help='keystore alias')
ap.add_argument('--ks-pass', default=None,
help='keystore password; default: <ks>.pass.txt, else generated')
ap.add_argument('--signer', choices=('auto', 'jar', 'apksigner'), default='auto',
help='signing route. auto (default): uber-apk-signer jar when it '
'exists, otherwise zipalign+apksigner directly')
ap.add_argument('--signer-jar', default=None,
help='jar used for zipalign+sign (default: $APK_SIGNER_JAR or '
'uber-apk-signer.jar)')
ap.add_argument('--no-sign', action='store_true', help='stop after writing the apk')
ap.add_argument('--java', default=None, help='path to java (default: from PATH)')
ap.add_argument('--keytool', default=None, help='path to keytool (default: from PATH)')
ap.add_argument('--jarsigner', default=None, help='path to jarsigner (default: from PATH)')
ap.add_argument('--zipalign', default=None, help='path to zipalign (default: from PATH)')
ap.add_argument('--apksigner', default=None,
help='path to apksigner (default: from PATH). Accepts '
'apksigner.bat, an apksigner wrapper, or lib/apksigner.jar')
args = ap.parse_args()
tools = resolve_tools(args)
signer_jar = args.signer_jar or os.environ.get('APK_SIGNER_JAR', 'uber-apk-signer.jar')
split_mode = bool(args.split_dir or args.split)
if not split_mode:
if not args.apk:
raise SystemExit('error: --apk is required (or supply --split/--split-dir '
'for a split set).')
if not args.out:
raise SystemExit('error: --out is required.')
if split_mode:
return run_split_mode(args, tools)
apk = os.path.abspath(args.apk)
out = os.path.abspath(args.out)
if not os.path.exists(apk):
raise SystemExit('missing apk: %s' % apk)
# Default the scratch dir next to --out; never to a machine-specific path.
workdir = scratch_dir(args, os.path.dirname(out))
repl = collect_replacement_dex(args.dexdir, args.dex)
log('[in ] %s (%d bytes)' % (apk, os.path.getsize(apk)))
log('[tools] java=%s keytool=%s apksigner=%s zipalign=%s'
% (tools['java'], tools['keytool'], tools['apksigner'], tools['zipalign']))
log('[repl] %s' % (', '.join('%s<-%s' % (k, v) for k, v in sorted(repl.items())) or '<none>'))
unsigned = os.path.join(workdir, 'unsigned.apk')
if os.path.exists(unsigned):
os.remove(unsigned)
build_unsigned(apk, repl, unsigned)
log('[zip] unsigned written: %d bytes' % os.path.getsize(unsigned))
for row in zip_report(unsigned):
log('[zip] ' + row)
signer = None
if args.no_sign:
shutil.copy2(unsigned, out)
else:
signer = choose_signer(args, tools)
log('[sign] route: %s' % signer['kind'])
ks, password = resolve_keystore(args, tools)
sign_one_artifact(unsigned, out, args, tools, signer, ks, password, workdir)
log('[out] %s (%d bytes)' % (out, os.path.getsize(out)))
log('== zip layout of final apk')
for row in zip_report(out):
log(' ' + row)
alignment_problems = check_alignment(out)
if alignment_problems:
log('== ALIGNMENT/STORAGE GATE FAILED -- do not ship this build:')
for p in alignment_problems:
log(' - %s' % p)
log(' Android R+ (targetSdk 30+) refuses to install an APK whose '
'resources.arsc is compressed or not 4-byte aligned.')
else:
log('== alignment gate: resources.arsc STORED and 4-byte aligned (OK)')
ok = verify_apk(out, tools,
signer['jar'] if signer and signer['kind'] == 'jar' else None,
expect_signed=not args.no_sign)
if not args.no_sign:
for algo, val in fingerprint(out, tools):
log('[cert] %s %s' % (algo, val))
log('[result] %s' % ('OK' if ok else 'CHECK-FAILED'))
else:
log('[result] UNSIGNED (--no-sign): good for inspection, not installable as-is')
log('[reminder] repacking is not done until the app launches and the changed '
'behavior is exercised on a device: see scripts/install_test.py')
return 0 if ok else 2
if __name__ == '__main__':
sys.exit(main())