Last active
September 27, 2026 20:01
-
-
Save thurask/8731c5913ca4572ce7d164526d6b2975 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Rip APKs en masse over ADB. | |
| Requires an authorized ADB connection before running (USB, or `adb connect` | |
| for Wi-Fi / Bluetooth debugging on a watch). Needs a platform-tools release | |
| that forwards stdin to `adb shell` (anything from 2016 onwards) and a device | |
| shell with `grep` (Android 6+ toybox). | |
| All per-package metadata (paths, versionCode, versionName, ABI) is gathered in | |
| ONE `adb shell` round trip instead of one-per-package-per-split, which is where | |
| nearly all of the old script's time went on a slow link. | |
| Output filenames keep the original scheme so existing rips are recognised and | |
| skipped on re-runs: | |
| <package>_<versionName>-<versionCode>_minAPI<minSdk>(<abi>)[_<split>].apk | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import time | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| # Runs on the device inside a single `adb shell` call. Package names arrive on | |
| # stdin, one per line; metadata comes back as @@PKG-delimited records. | |
| COLLECT_SCRIPT = r""" | |
| while IFS= read -r p; do | |
| [ -z "$p" ] && continue | |
| echo "@@PKG $p" | |
| pm path "$p" 2>/dev/null | |
| dumpsys package "$p" 2>/dev/null | grep -E '^ *(versionCode=|versionName=|primaryCpuAbi=)' | |
| done | |
| exit 0 | |
| """ | |
| # Characters that are illegal in filenames on at least one of Windows/macOS/Linux, | |
| # plus whitespace (the old script already turned spaces into underscores). | |
| UNSAFE_CHARS = re.compile(r'[\\/:*?"<>|\s]+') | |
| # --------------------------------------------------------------------------- # | |
| # Data | |
| # --------------------------------------------------------------------------- # | |
| @dataclass | |
| class Device: | |
| serial: str | |
| state: str | |
| transport_id: str | None | |
| model: str | None | |
| def describe(self) -> str: | |
| bits = [self.serial, self.state] | |
| if self.model: | |
| bits.append(f"model:{self.model}") | |
| if self.transport_id: | |
| bits.append(f"transport_id:{self.transport_id}") | |
| return " ".join(bits) | |
| @dataclass | |
| class Package: | |
| name: str | |
| paths: list[str] = field(default_factory=list) | |
| version_code: str | None = None | |
| version_name: str | None = None | |
| min_sdk: str | None = None | |
| abi: str | None = None | |
| def label(self) -> str: | |
| abi = "noarch" if self.abi in (None, "null") else self.abi | |
| return f"{self.version_name or 'null'}-{self.version_code or '0'} minAPI{self.min_sdk or 'unknown'} {abi}" | |
| @dataclass | |
| class Summary: | |
| pulled: int = 0 | |
| skipped: int = 0 | |
| failed: list[tuple[str, str]] = field(default_factory=list) | |
| # --------------------------------------------------------------------------- # | |
| # ADB plumbing | |
| # --------------------------------------------------------------------------- # | |
| def adb_run(cmd: list[str], *, check: bool = True, input: bytes | None = None) -> tuple[int, str, str]: | |
| """Run an adb command, returning (returncode, stdout, stderr) as text. | |
| Everything is handled as bytes and decoded here so Windows CRLF, stray | |
| non-UTF-8 output and stdin line endings can't trip anything up. | |
| """ | |
| try: | |
| proc = subprocess.run(cmd, input=input, capture_output=True) | |
| except FileNotFoundError: | |
| sys.exit("adb was not found on PATH.") | |
| out = proc.stdout.decode("utf-8", "replace") | |
| err = proc.stderr.decode("utf-8", "replace") | |
| if check and proc.returncode != 0: | |
| shown = " ".join(cmd[:5]) + (" ..." if len(cmd) > 5 else "") | |
| sys.exit(f"command failed (exit {proc.returncode}): {shown}\n{err.strip()}") | |
| return proc.returncode, out, err | |
| def list_devices() -> list[Device]: | |
| _, out, _ = adb_run(["adb", "devices", "-l"]) | |
| devices: list[Device] = [] | |
| for line in out.splitlines()[1:]: # first line is "List of devices attached" | |
| tokens = line.split() | |
| if len(tokens) < 2: | |
| continue | |
| attrs = dict(t.split(":", 1) for t in tokens[2:] if ":" in t) | |
| devices.append(Device( | |
| serial=tokens[0], | |
| state=tokens[1], | |
| transport_id=attrs.get("transport_id"), | |
| model=attrs.get("model"), | |
| )) | |
| return devices | |
| def select_device(serial: str | None, tid: str | None) -> tuple[Device, list[str]]: | |
| """Return the chosen device and the adb argv prefix that targets it.""" | |
| devices = list_devices() | |
| listing = "\n".join(f" {d.describe()}" for d in devices) or " (none)" | |
| if serial: | |
| match = [d for d in devices if d.serial == serial] | |
| prefix = ["adb", "-s", serial] | |
| what = f"serial {serial}" | |
| elif tid: | |
| match = [d for d in devices if d.transport_id == tid] # whole-token match: 1 != 10 | |
| prefix = ["adb", "-t", tid] | |
| what = f"transport id {tid}" | |
| else: | |
| online = [d for d in devices if d.state == "device"] | |
| if len(online) == 1: | |
| match = online | |
| prefix = ["adb", "-s", online[0].serial] | |
| elif not online: | |
| sys.exit(f"No authorized devices online.\nDevices seen:\n{listing}") | |
| else: | |
| sys.exit(f"More than one device online; pick one with -s or -t.\nDevices seen:\n{listing}") | |
| what = "the only connected device" | |
| if not match: | |
| sys.exit(f"No device with {what}.\nDevices seen:\n{listing}") | |
| dev = match[0] | |
| if dev.state != "device": | |
| hint = "" | |
| if dev.state == "unauthorized": | |
| hint = " (accept the USB/wireless debugging prompt on the device)" | |
| elif dev.state == "offline": | |
| hint = " (try `adb disconnect` / `adb connect` again, or replug)" | |
| sys.exit(f"{dev.serial} is '{dev.state}', not ready{hint}.") | |
| return dev, prefix | |
| # --------------------------------------------------------------------------- # | |
| # Package discovery | |
| # --------------------------------------------------------------------------- # | |
| def list_packages(adb: list[str], third_party_only: bool) -> list[str]: | |
| cmd = adb + ["shell", "pm", "list", "packages"] + (["-3"] if third_party_only else []) | |
| _, out, _ = adb_run(cmd) | |
| names = {line.split(":", 1)[1].strip() for line in out.splitlines() if line.startswith("package:")} | |
| return sorted(names) | |
| def collect_metadata(adb: list[str], names: list[str]) -> list[Package]: | |
| """One round trip: feed package names on stdin, get paths + dumpsys fields back.""" | |
| payload = ("\n".join(names) + "\n").encode("utf-8") | |
| rc, out, err = adb_run(adb + ["shell", COLLECT_SCRIPT], check=False, input=payload) | |
| packages = parse_records(out) | |
| if names and not packages: | |
| sys.exit( | |
| "The on-device collector returned nothing (exit {rc}). This usually means adb is too\n" | |
| "old to forward stdin to `adb shell`, or the device shell lacks grep.\n" | |
| f"stderr:\n{err.strip()}" | |
| ) | |
| return packages | |
| def parse_records(text: str) -> list[Package]: | |
| packages: list[Package] = [] | |
| cur: Package | None = None | |
| for raw in text.splitlines(): | |
| line = raw.strip() | |
| if not line: | |
| continue | |
| if line.startswith("@@PKG "): | |
| cur = Package(name=line[6:].strip()) | |
| packages.append(cur) | |
| continue | |
| if cur is None: | |
| continue | |
| # Only the first occurrence of each dumpsys field is taken: that is the | |
| # active package; "Hidden system packages:" sections come later. | |
| if line.startswith("package:"): | |
| cur.paths.append(line[8:].strip()) | |
| elif line.startswith("versionCode=") and cur.version_code is None: | |
| fields = dict(tok.split("=", 1) for tok in line.split() if "=" in tok) | |
| cur.version_code = fields.get("versionCode") | |
| cur.min_sdk = fields.get("minSdk") # absent on old Android; don't guess targetSdk | |
| elif line.startswith("versionName=") and cur.version_name is None: | |
| cur.version_name = line.split("=", 1)[1].strip() | |
| elif line.startswith("primaryCpuAbi=") and cur.abi is None: | |
| cur.abi = line.split("=", 1)[1].strip() | |
| return packages | |
| # --------------------------------------------------------------------------- # | |
| # Naming | |
| # --------------------------------------------------------------------------- # | |
| def sanitize(text: str) -> str: | |
| cleaned = UNSAFE_CHARS.sub("_", text).strip("._") | |
| return cleaned or "unknown" | |
| def apk_filename(pkg: Package, remote_path: str, is_base: bool) -> str: | |
| version_name = sanitize(pkg.version_name or "null") | |
| version_code = sanitize(pkg.version_code or "0") | |
| min_sdk = sanitize(pkg.min_sdk or "unknown") | |
| abi = "noarch" if pkg.abi in (None, "null") else sanitize(pkg.abi) | |
| split = "" | |
| if not is_base: | |
| split = "_" + sanitize(remote_path.rsplit("/", 1)[-1].removesuffix(".apk")) | |
| return f"{pkg.name}_{version_name}-{version_code}_minAPI{min_sdk}({abi}){split}.apk" | |
| # --------------------------------------------------------------------------- # | |
| # Pulling | |
| # --------------------------------------------------------------------------- # | |
| class Puller: | |
| def __init__(self, adb: list[str], compress: bool) -> None: | |
| self.adb = adb | |
| self.compress = compress | |
| def pull(self, remote: str, dest: Path) -> tuple[bool, str]: | |
| """Pull to a .part file and rename on success so a dead transfer never | |
| leaves a truncated file that later runs would mistake for a finished rip.""" | |
| tmp = dest.with_name(dest.name + ".part") | |
| attempts = [True, False] if self.compress else [False] | |
| last_error = "" | |
| for use_compression in attempts: | |
| cmd = self.adb + ["pull", "-a"] | |
| if use_compression: | |
| cmd += ["-z", "any"] | |
| cmd += [remote, str(tmp)] | |
| rc, out, err = adb_run(cmd, check=False) | |
| if rc == 0 and tmp.exists(): | |
| if self.compress and not use_compression: | |
| print(" note: compressed pull failed, uncompressed worked; disabling -z for the rest of the run") | |
| self.compress = False | |
| tmp.replace(dest) | |
| return True, "" | |
| last_error = (err.strip() or out.strip() or f"exit {rc}").splitlines()[-1] | |
| tmp.unlink(missing_ok=True) | |
| return False, last_error | |
| def human_size(num_bytes: int) -> str: | |
| if num_bytes < 1_000_000: | |
| return f"{num_bytes / 1_000:.0f} kB" | |
| return f"{num_bytes / 1_000_000:.1f} MB" | |
| def pull_packages(adb: list[str], packages: list[Package], out_dir: Path, | |
| dry_run: bool, compress: bool) -> Summary: | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| existing = {p.name for p in out_dir.iterdir()} | |
| puller = Puller(adb, compress) | |
| summary = Summary() | |
| width = len(str(len(packages))) | |
| for index, pkg in enumerate(packages, 1): | |
| print(f"[{index:>{width}}/{len(packages)}] {pkg.name} {pkg.label()}") | |
| if not pkg.paths: | |
| print(" no APK path reported (not installed for this user?), skipping") | |
| summary.skipped += 1 | |
| continue | |
| for position, remote in enumerate(pkg.paths): | |
| if "/overlay/" in remote: | |
| summary.skipped += 1 | |
| continue | |
| # `pm path` always lists the base APK first. | |
| name = apk_filename(pkg, remote, is_base=(position == 0)) | |
| dest = out_dir / name | |
| if name in existing: | |
| summary.skipped += 1 | |
| continue | |
| if dry_run: | |
| print(f" would pull {remote}\n -> {name}") | |
| summary.pulled += 1 | |
| continue | |
| print(f" {remote.rsplit('/', 1)[-1]} ... ", end="", flush=True) | |
| started = time.monotonic() | |
| ok, error = puller.pull(remote, dest) | |
| elapsed = time.monotonic() - started | |
| if ok: | |
| existing.add(name) | |
| summary.pulled += 1 | |
| print(f"ok ({human_size(dest.stat().st_size)}, {elapsed:.1f}s)") | |
| else: | |
| summary.failed.append((f"{pkg.name} {remote}", error)) | |
| print(f"FAILED: {error}") | |
| return summary | |
| # --------------------------------------------------------------------------- # | |
| # CLI | |
| # --------------------------------------------------------------------------- # | |
| def parse_args(argv: list[str] | None = None) -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description="Rip APKs en masse using ADB.") | |
| target = parser.add_mutually_exclusive_group() | |
| target.add_argument("-s", "--serial", help="device serial (adb devices); stable across adb restarts") | |
| target.add_argument("-t", "--transport-id", help="transport id (adb devices -l)") | |
| parser.add_argument("-o", "--out", type=Path, default=Path.cwd(), help="output directory (default: cwd)") | |
| parser.add_argument("-3", "--third-party", action="store_true", help="only third-party (non-system) packages") | |
| parser.add_argument("--include", metavar="REGEX", help="only packages whose name matches") | |
| parser.add_argument("--exclude", metavar="REGEX", help="skip packages whose name matches") | |
| parser.add_argument("-n", "--dry-run", action="store_true", help="show what would be pulled, pull nothing") | |
| parser.add_argument("--no-compress", action="store_true", help="never pass -z to adb pull") | |
| args = parser.parse_args(argv) | |
| for opt in ("include", "exclude"): | |
| pattern = getattr(args, opt) | |
| if pattern: | |
| try: | |
| setattr(args, opt, re.compile(pattern)) | |
| except re.error as exc: | |
| parser.error(f"--{opt}: bad regex: {exc}") | |
| return args | |
| def main(argv: list[str] | None = None) -> int: | |
| args = parse_args(argv) | |
| if shutil.which("adb") is None: | |
| sys.exit("adb was not found on PATH.") | |
| device, adb = select_device(args.serial, args.transport_id) | |
| print(f"CONNECTED TO {device.describe()}") | |
| print("Listing packages... ", end="", flush=True) | |
| names = list_packages(adb, args.third_party) | |
| total = len(names) | |
| if args.include: | |
| names = [n for n in names if args.include.search(n)] | |
| if args.exclude: | |
| names = [n for n in names if not args.exclude.search(n)] | |
| print(f"{len(names)} selected" + (f" of {total}" if len(names) != total else "")) | |
| if not names: | |
| print("Nothing to do.") | |
| return 0 | |
| print("Collecting paths and versions (one round trip)... ", end="", flush=True) | |
| started = time.monotonic() | |
| packages = collect_metadata(adb, names) | |
| print(f"done in {time.monotonic() - started:.1f}s") | |
| print(f"{'Would pull' if args.dry_run else 'Pulling'} into {args.out.resolve()}") | |
| summary = pull_packages(adb, packages, args.out, args.dry_run, compress=not args.no_compress) | |
| verb = "would pull" if args.dry_run else "pulled" | |
| print(f"\nCOMPLETE: {verb} {summary.pulled}, skipped {summary.skipped}, failed {len(summary.failed)}") | |
| for what, why in summary.failed: | |
| print(f" FAILED {what}\n {why}") | |
| return 1 if summary.failed else 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment