Skip to main content

apkpull logo

apkpull

Download Android apps from Google Play as one installable bundle

PyPI Version Downloads Python Versions Tests pre-commit.ci status License Code Quality

apkpull drives Google Play like a person would — installs or updates an app on real, adb-connected devices or emulators, then pulls the base APK and every split into one verified, installable .apks bundle, powered by apkfile. Point it at several devices at once and it merges their splits into a single artifact anyone can install, whatever their phone's CPU, screen, or language.

apkpull com.whatsapp                                        # pull one app from every connected device
apkpull com.whatsapp,com.spotify.music -d ~/Apps            # pull several apps, into a custom folder

Install

pip install -U apkpull
# or
uv tool install apkpull   # installs the `apkpull` command standalone
uv add apkpull            # ...or as a dependency, to use it as a library (see "As a library" below)

Requires Python 3.10+ and ADB on your PATH. That's it — apkpull com.whatsapp is ready to run. See Development below for setting up a local clone instead.

Why run more than one device?

Google Play doesn't hand out one universal APK — every install is built from a base APK plus a handful of splits, chosen to match that specific device: its CPU architecture (ABI), screen density, and every language it has configured. Pull from a single phone and you only get that phone's splits — perfectly installable on that phone, but potentially missing pieces someone else's phone needs: a different ABI, a higher-density screen, a language you don't happen to have installed.

apkpull's answer is to run against several devices at once and merge the union of everything they each pulled into one bundle (see Cross-device merging below) — so instead of "the splits my phone happened to get," you end up with "every split any of these devices got," installable by a much wider set of real phones. Three cheap emulators (or devices) get you almost all the way there:

  • Two ABIs — one armeabi-v7a (32-bit) and one arm64-v8a (64-bit). Between them they cover virtually every Android phone in the wild; skip x86/x86_64 unless you specifically care about Chromebooks or x86 tablets, which few apps bother splitting for anyway.
  • Two screen densities — Android buckets every real screen into one of a handful of density tiers (ldpi/mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi), and Play only ever serves the one closest to a device's actual dpi. xxhdpi (~480dpi) and xxxhdpi (~640dpi) between them cover most phones sold in the last several years, from mid-range through current flagships — set one emulator's AVD screen density to each.
  • As many languages as possible, all on one device — Play fetches one split per installed language, so running a separate device per language is wasted effort. Add every language you care about to a single device (Settings → System → Languages) instead, and one pull covers all of them — apkpull already unions a device's full configured language list into the bundle, not just its primary locale.

This is also exactly what apkpull's own duplicate-device detection is built around: two devices that agree on ABI, density bucket, SDK, and configured languages will just pull the same files twice, so apkpull warns about it (but still processes both); two devices that agree on everything except language get flagged too, with a suggestion to fold them into one multi-language device instead — precisely the setup recommended above. See Duplicate/redundant devices below for the exact mechanics.

Setting up a device or emulator

apkpull needs at least one adb-reachable device or emulator with Google Play installed and signed in.

Real device

  1. Enable Developer Options (tap Settings → About phone → Build number 7 times), then enable USB debugging inside Developer Options.
  2. Plug it in over USB (or set up wireless debugging) and accept the "Allow USB debugging?" prompt on the device.
  3. adb devices should list it as device (not unauthorized — re-check the on-device prompt if so).

Emulator (AVD)

Any AVD works as long as its system image includes Play Store, not just Play Services — look for images tagged Google Play in Android Studio's AVD wizard, or google_apis_playstore on the command line (a plain google_apis image has no Play Store app at all, and apkpull can't do anything without one).

Android Studio (easiest): Tools → Device Manager → Create Device → pick a phone profile → pick a system image with a Play Store icon next to it → Finish, then press ▶ to boot it.

Command line, if you'd rather not open Android Studio (sdkmanager/avdmanager/emulator live under your SDK root, e.g. $ANDROID_HOME/cmdline-tools/latest/bin and $ANDROID_HOME/emulator — add those to PATH, or run them with their full path):

# list installable system images, install one with a Play Store (adjust api level/abi as needed)
sdkmanager --list | grep google_apis_playstore
sdkmanager "system-images;android-34;google_apis_playstore;arm64-v8a"   # arm64-v8a on Apple Silicon, x86_64 on Intel/most Linux

avdmanager create avd -n apkpull -k "system-images;android-34;google_apis_playstore;arm64-v8a" -d pixel_6
emulator -avd apkpull

Either way, confirm it shows up before running apkpull:

adb devices        # should list it as `device`
apkpull com.whatsapp -v

Hardware acceleration

The emulator is only fast with real hardware virtualization behind it — apkpull's UI automation does several uiautomator dump/screenshot round-trips per screen, so this matters a lot in practice:

  • macOS: uses Apple's Hypervisor.framework (HVF) automatically, no setup needed. Check with sysctl kern.hv_support (1 = supported).
  • Linux: needs KVM — sudo apt install qemu-kvm (Debian/Ubuntu) or equivalent, and your user in the kvm group. Check with ls -l /dev/kvm.
  • Windows: uses WHPX (Windows Hypervisor Platform) or Hyper-V — enable via "Turn Windows features on or off" → Windows Hypervisor Platform.

Without one of these, the emulator falls back to full software emulation — it'll still work, just painfully slowly for anything UI-automation-heavy.

Docker?

Short answer: great on Linux, not currently useful on macOS.

On a Linux host, running the emulator inside Docker with hardware acceleration is a well-trodden path — pass --device /dev/kvm into the container and it gets near-native speed. This is exactly how CI systems do it (e.g. reactivecircus/android-emulator-runner for GitHub Actions, or budtmo/docker-android as a standalone image); apkpull just needs adb connect/adb devices to see whatever container(s) you bring up, same as any device.

On macOS, there's no equivalent: Docker Desktop for Mac runs containers inside its own lightweight Linux VM, and that inner VM doesn't expose nested virtualization (no /dev/kvm) or HVF passthrough to containers — a containerized emulator would fall back to software emulation, i.e. slower than just running it natively. On Mac, running the emulator directly on the host (as above) already gets you HVF acceleration for free; Docker wouldn't add anything here. If you need many parallel emulators for scale rather than raw speed, a Linux box/CI runner (or a cloud device farm) is the more productive direction than fighting macOS's Docker sandboxing.

Usage

apkpull com.whatsapp
apkpull com.whatsapp,com.spotify.music -d ~/Documents/my_apks --uninstall-after -v
apkpull com.whatsapp --devices emulator-5554,emulator-5556 --json
usage: apkpull [-h] [-d DIR] [--uninstall-after] [--devices ID1,ID2,...]
               [--max-workers MAX_WORKERS] [--max-poll-rounds MAX_POLL_ROUNDS]
               [--unlock-timeout SECONDS] [--download-timeout SECONDS]
               [--download-retries N] [--format {apks,zip,folder}]
               [--notify] [--no-keep-screen-on] [--no-verify] [--strict-verify]
               [--full-manifest]
               [--skip-existence-check] [--skip-duplicate-check] [--skip-update-check]
               [--no-live] [--adb-path ADB_PATH] [--json] [-v] [--version]
               packages

  packages              Package name, e.g. com.whatsapp. Comma-separated for more than
                        one, e.g. com.whatsapp,com.spotify.music — apps on the same
                        device download concurrently, same as tapping Install on each
                        from Google Play.
  -d, --dest DIR        Directory to pull files into (default: ~/Downloads/APKpull)
  --uninstall-after     Uninstall the app after pulling it.
  --devices ID1,ID2,... Comma-separated device ids to target (default: every device adb sees).
  --max-workers N       Max devices processed concurrently.
  --max-poll-rounds N   UI-polling rounds before giving up on a device.
  --unlock-timeout SEC  Seconds to wait for a locked device to unlock (0 = forever). Default: 300.
  --download-timeout SEC
                        Seconds to wait for a download/update to finish before retrying
                        or giving up on it (0 = forever). Default: 300.
  --download-retries N  Times to restart a timed-out download/update before reporting
                        it as failed. Default: 1.
  --format {apks,zip,folder}
                        Output format (default: apks). See "Output formats" below.
  --notify              Send native desktop notifications (macOS/Linux).
  --no-keep-screen-on   Don't force the screen to stay awake while plugged in during the
                        pull (default: on; the device may lock and interrupt automation).
  --no-verify           Skip apkfile verification of pulled apks.
  --strict-verify       Treat a verification mismatch as a device failure.
  --full-manifest       Write the full apkfile manifest (every permission's AOSP detail,
                        exported/deep-link components, size breakdown, dex info, full
                        certificate fields) to manifest.json instead of the trimmed
                        default. No effect with --no-verify.
  --skip-existence-check
                        Don't do the advisory pre-check of whether the package exists on
                        Google Play (see below) before touching devices.
  --skip-duplicate-check
                        Don't warn about devices that look identical or share hardware
                        but differ only in configured languages (see below).
  --skip-update-check   For an already-installed package, pull whatever's currently on
                        the device instead of checking Google Play for an update first
                        (see below) — faster, but may pull a stale version.
  --no-live             Don't show the live per-device/per-package status table — just
                        plain, unbounded log lines. Automatic when stderr isn't a real
                        terminal.
  --adb-path PATH       Path to the adb executable (default: search PATH).
  --json                Print the run summary as JSON instead of text.
  -v, -vv               Verbose logging: -v for info, -vv for debug.

Exit code is 0 when every targeted device succeeds, otherwise the number of failed devices (capped at 9); 50 if no devices were found at all.

As a library

from apkpull import OutputFormat, run

summary = run(
    ["com.whatsapp", "com.spotify.music"],  # a single package name also works
    "~/Documents/my_apks",
    uninstall=True,
    output_format=OutputFormat.FOLDER,
)
for outcome in summary.outcomes:
    print(outcome.device.model, outcome.package, outcome.status, outcome.destination)

run() returns a RunSummary dataclass (.as_dict() for JSON) — see apkpull/models.py for the full shape.

How it works

  • Why UI automation, and why text instead of resource-ids or coordinates: apkpull needs something stable to find a button on. Raw screen coordinates break the moment the layout shifts — a different screen size, or Play Store itself pushing an update that reflows a screen; resource-ids would be the normal fix for that, but Google Play's own UI exposes none on its buttons at all — confirmed by dumping the live UI tree, every text node's resource-id comes back empty. What is stable is a button's visible, localized text, so that's what apkpull matches on: dump the screen with uiautomator, find a node by its text for the device's language, tap its bounds — the same way a person would. English, Hebrew, Spanish, French and Russian are built in; add a language by extending the table in apkpull/locales.pyscripts/extract_play_strings.sh helps bootstrap most of a new locale's strings straight from Google Play's own APK (see its header comment for how, and its limits).
  • Each connected device runs on its own thread, so multiple devices — potentially with different architectures/densities/languages — download and pull in parallel. apkpull waits for every targeted device to finish a given package, then merges the union of all their distinct splits into one bundle (see "Why run more than one device?" above and Cross-device merging below) instead of one incomplete bundle per device.
  • Multiple packages on the same device also download concurrently rather than one at a time: apkpull briefly visits each package's Play Store page just long enough to tap Install/Update, then tracks every in-flight package purely over adb (pm path/dumpsys package) and pulls each as it finishes — no UI polling involved, since Google Play's own download manager genuinely overlaps downloads once they're started (confirmed by watching two real installs queue and finish out of tap order on a live device). One tradeoff: because apkpull isn't camped on any single package's page anymore, it can no longer auto-retry a download a human cancelled by hand mid-run — only a device going offline is still recovered from mid-flight.
  • For a package already installed on the device, apkpull's default is to check Google Play for an update first and pull whatever that leaves installed (the existing version if already up to date, the new one if an update was just applied) — --skip-update-check skips that check entirely for such a package: no Play Store launch, no UI polling, straight to pulling whatever's on the device right now. Faster and more reliable (a whole class of UI automation just doesn't happen for that package), at the cost of possibly pulling a stale version — reported as skipped_update_check rather than already_up_to_date, since apkpull never actually checked.
  • Every pulled app is packaged into a single <package>-<version_code> artifact — base apk + every split, always zipped and verified with apkfile first regardless of format (see below), only materialized differently at the end. That's what makes it immediately useful on its own: install it straight from apkfile (ApksFile(path).install()), hand it to SAI on another Android device, or just unzip it — instead of a directory tree of loose files only that one pull could make sense of.
  • The zip is re-opened locally with apkfile and cross-checked against what adb reported on-device (package name, version code) — catching a truncated pull, or a bug in the zipping step, that adb pull's own exit code and a successful zip write would both miss. A manifest.json is written with the aggregated metadata: permissions, features, ABIs, languages, signing certificates and more, unioned across the base apk and every split — trimmed by default to keep it digestible (dropping per-permission AOSP detail, exported/deep-link component lists, size breakdown, dex info, and most certificate fields); pass --full-manifest for all of it.

Output formats (--format)

Format (default apks) Layout
apks <package>-<version_code>.apks — a bundletool/SAI-format zip (meta.sai_v2.json + base + splits)
zip <package>-<version_code>.zip — byte-identical contents, just a familiar extension for tools that expect a plain zip
folder <package>-<version_code>/ — extracted: base.apk, each split, and manifest.json as loose files

All output lands flat in one destination directory — one artifact + one manifest.json per (package, version) (inside the folder for folder, a .manifest.json sidecar otherwise), plus a loose OBB file on the rare app that ships one (inside the folder too, for folder):

APKpull/
├── com.whatsapp-243119027.apks
├── com.whatsapp-243119027.manifest.json
├── tfilon.tfilon-41.zip
├── tfilon.tfilon-41.manifest.json
└── com.some.game-9/
    ├── base.apk
    ├── config.arm64_v8a.apk
    ├── manifest.json
    └── main.9.com.some.game.obb

An artifact that already exists locally is never rebuilt — a rerun of the same package/version skips straight past every device without touching any of them.

Cross-device merging

apkpull waits for every targeted device to finish a given package (success or failure) before packaging it, then merges the union of all their distinct splits — and any OBB — into one bundle. Devices reporting different version_codes for the same package can't be merged (Play Store can stage rollouts), so those get one bundle per version instead. If some devices fail while others succeed, apkpull still builds the bundle from whoever succeeded and logs a warning naming which device(s)/architecture are missing from it — a device's own outcome can end up error purely because a sibling's contribution (or the shared bundle's strict-verify check) broke the merge, even though that device's own install/update succeeded; the error message says so explicitly. Raw pulls are staged under a .apkpull-staging/ directory inside the destination while a group is still in flight, cleaned up once its bundle is built (or the merge fails) — and best-effort swept at the start of every run in case a previous one crashed before cleaning up its own.

Reliability

  • Ctrl+C: confirmed hands-on that the naive ThreadPoolExecutor + as_completed() pattern silently swallows SIGINT — an untimed wait on thread-pool futures just doesn't get interrupted, so Ctrl+C used to do nothing until every device finished or timed out on its own (which, with default timeouts, could be minutes). Fixed by polling with a 1-second timeout instead of blocking indefinitely, so the interrupt is noticed promptly. On Ctrl+C, apkpull stops launching any more work (cancels devices that haven't started yet), prints a clear message, and exits with 130 (the standard shell convention) instead of a raw traceback — but a device already mid-adb-call can't be safely force-killed, so apkpull waits for it to reach its own natural stopping point (bounded by its existing --download-timeout/--unlock-timeout/etc.) rather than abandoning it uncleanly.

  • Foreground detection: apkpull needs to know whether the device is still showing Google Play (vs. having navigated away) to decide whether to relaunch it. This used to read dumpsys activity activities' mResumedActivity field — which turns out to not exist at all on Android 14 (API 34) and up, replaced by topResumedActivity/ResumedActivity:. With that field silently missing, apkpull would conclude it was never on Google Play and relaunch the details page on every single poll, even mid-download — resetting the page and derailing the install. Fixed by reading dumpsys window's mCurrentFocus instead, which has been a stable single-line answer across the versions checked.

  • Package existence: before touching any device, apkpull does an advisory-only check of whether the package has a listing on the Play Store website (--skip-existence-check turns it off). It only ever warns, never aborts the run — a confirmed 404 there doesn't reliably mean the package can't be installed on the actual target device, since that request runs from apkpull's host machine's own network/region, not the device's Play Store account's region. A region-locked app (most banking apps, for instance — verified hands-on against a real one) can easily 404 the web listing from one country while installing fine on a device signed in with the right region. The on-device automation is the real source of truth, and it now recognizes two more screens instead of falling through to a slow, unhelpful AutomationTimeoutError: a dedicated "item not found" page (confirmed hands-on), and — more generally — Play's red warning-triangle icon, which reliably pairs with a plain-text error message on the details page (confirmed hands-on for the region-restricted screen). Any banner using that icon that apkpull doesn't have a specific rule for still gets caught immediately, with the actual on-screen text surfaced in the error instead of a generic timeout.

  • Insufficient device storage: confirmed hands-on against a genuinely low-storage emulator, Play Store's "Not enough storage" dialog only ever appears after tapping Install/Update — never on the details page beforehand — so apkpull now takes one extra look right after that tap specifically to catch it (and, by the same change, any other post-tap dialog), instead of assuming a download silently started in the background and polling adb forever for an install that will never happen.

  • Paid apps: detected by a price tag (e.g. $4.99) anywhere on the details page, which is driven by the signed-in Google account's billing region, not the display language — confirmed hands-on: a Russian-language page for a paid app still showed its price in Shekels, matching the test account's actual billing country. So this needs no per-locale strings, just currency-symbol coverage (₪ $ € £ ₽ ¥ ₹).

  • Offline, two different ways: Play Store shows two distinct screens for no network, confirmed hands-on — a themed "You're offline" browsing page (usually hit navigating straight to a package's details page) and a separate plainer "Something went wrong" / "No internet connection..." page with a "Try again" button (seen via other navigation paths, e.g. from the store's home feed). Both are caught and raise the same error, since it's the same underlying condition either way.

  • Locked devices: apkpull waits for a locked device to be unlocked before starting automation, polling --unlock-timeout seconds (default 300, 0 = forever) before giving up on that device — without this, a single locked device would hang the entire run forever, since it waits for every targeted device before printing a summary.

  • Disconnected devices: every polling loop (waiting for a button, waiting for install/update to finish, waiting for unlock) re-checks the connection each iteration and fails that device cleanly the moment it drops, rather than hanging or crashing the whole run — a device going offline mid-pull is reported as a normal per-device failure, and every other device keeps running.

  • Stalled downloads: the adb-only poll that waits for a download/update to finish (see above) is bounded by --download-timeout seconds (default 300, 0 = forever); on timeout apkpull restarts that package's kickoff up to --download-retries times (default 1) before reporting it as failed, so one stuck download can't hang a device — or, with 0/no other in-flight packages, the whole run — forever.

  • Duplicate/redundant devices: when targeting more than one device, apkpull warns (but still processes every device) about two kinds of overlap, based on the exact properties Google Play actually uses to pick which apk/splits to serve — ABI, screen density bucket, SDK version, and the full set of languages configured on the device (not just the primary one — Play fetches a language split per installed language, so a device with three languages configured contributes three language splits, not one):

    • Devices matching on all four will download byte-identical files — a straightforward waste, since running one of them would produce the exact same contribution to the merged bundle as running both.
    • Devices matching on ABI/density/SDK but with different configured languages will each pull different language splits, but redundantly re-pull the shared base/ABI/density splits to get there — apkpull suggests configuring every language on a single device instead (see "Why run more than one device?" above), so one pull covers all of them without repeating the shared splits per language.

    Density is compared by bucket (ldpi/mdpi/tvdpi/hdpi/xhdpi/xxhdpi/xxxhdpi — Play only ever serves one of these, never a device's exact raw dpi), not the raw ro.sf.lcd_density value, using apkfile's DensityBucket and nearest-match-by-distance: two devices reporting different raw density (e.g. 420 vs 439) can round to the same bucket and get an identical dpi split, so comparing raw density would both miss real duplicates and flag devices whose difference doesn't actually matter.

    --skip-duplicate-check turns this off entirely (no warning, and no extra per-device getprop round-trip to check for one) for setups where the overlap is intentional — e.g. deliberately running two identical devices just to compare timing, or a fleet where every device really is meant to be a like-for-like clone.

  • Run with -vv to see every adb call and UI-polling decision. If automation gets stuck (an unrecognized screen after --max-poll-rounds polls), a screenshot + UI dump are saved for a bug report (path logged at the time).

Development

Uses uv for dependency management:

git clone https://github.com/david-lev/apkpull.git
cd apkpull
uv sync
uv run pre-commit install

Run the CLI from a clone with uv run apkpull ... (no need to pip install -e . first).

uv run pytest                       # full test suite (unit + integration)
uv run pytest -m "not integration"  # unit tests only — no device required
uv run pytest -m integration        # only the tests that need a real adb device/emulator

uv run ruff check .                 # lint
uv run ruff format .                # format
uv run ty check                     # type check
uv run pre-commit run --all-files   # everything pre-commit enforces

The integration tests only ever target com.android.vending (the Play Store app, always already installed), so they never install/update/uninstall anything on your device — they exercise device discovery, the "already up to date" automation path, real pulling, and apkfile verification against genuine adb output. They skip themselves automatically when no device is connected, so plain uv run pytest is always safe to run.

CI (.github/workflows/tests.yml) runs the unit test suite across Python 3.10–3.14 on Linux, plus 3.10/3.14 (min/max) on macOS and Windows, and a separate lint job runs ruff check, ruff format --check, and ty check.

Legacy bash version

The original single-file apkpull.sh still works and needs nothing but bash + adb, but is no longer maintained — the Python CLI above is the supported tool going forward.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

apkpull-1.0.0.tar.gz (68.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

apkpull-1.0.0-py3-none-any.whl (66.9 kB view details)

Uploaded Python 3

File details

Details for the file apkpull-1.0.0.tar.gz.

File metadata

  • Download URL: apkpull-1.0.0.tar.gz
  • Upload date:
  • Size: 68.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.12.6 {"installer":{"name":"uv","version":"0.12.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for apkpull-1.0.0.tar.gz
Algorithm Hash digest
SHA256 3cf11a55a52ae22a964ffc8740dbea4dd470294ce0bbe88a1313131e0fd75179
MD5 95bf021bbdf5e83058dd6b432396fe52
BLAKE2b-256 4948813ddf0d12dc682a0f8814978a6033cdf8ef3b4f57c3c1ad118502eb77e8

See more details on using hashes here.

File details

Details for the file apkpull-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: apkpull-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 66.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.12.6 {"installer":{"name":"uv","version":"0.12.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for apkpull-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 36d8a2a933c7a231dd3352501fb8c61bc2dabb62d33c9189e4b5e7174563bf1c
MD5 0d271c02571e87df8d912c29767b13a2
BLAKE2b-256 32f42e921f6edb5aabaf7533d4f7ff344cfe0b5bfc62ac8b2f98b162f0e8ae12

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page