#!/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 is where the keystore lives. If it does not exist, one is generated with keytool and its password is stored next to it as .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 ).\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 , 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 ` creates `//`, 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('= 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(' 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(' end: break if ctype == 0x0102: # RES_XML_START_ELEMENT attr_start, attr_size, attr_count = struct.unpack_from( ' off + csize: break name_i, raw_i = struct.unpack_from('= (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/.core.* (third-party SDK registries) META-INF/services/ (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//*.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('= 4 bytes pad = (ALIGN - ((offset + head) % ALIGN)) % ALIGN if pad < 4: pad += ALIGN extra = b'\xfe\xca' + struct.pack('> 16) & 0xFFFF, m['local_offset'])) out.write(m['name_b']) cd_size = out.tell() - cd_start out.write(struct.pack('; 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 , 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 ') # 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 ' '(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 ') 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: .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 '')) 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())