Gets a named Android app onto a device and running, from whatever the user has:
a Play Store link, a bare package name, or APK files they already hold.
scripts/playapp.py (standard library only) does the deterministic parts —
parsing, preflight, state checks, sideloading, verification, launching.
The one step it cannot do alone is tapping Play's Install button, which is UI
work; use android-ui-driver for that, bracketed by open-listing and
wait-installed.
When NOT to use
- Tapping around inside an app that is already installed →
android-ui-driver. - Permissions, version, crash logs of an installed app →
android-package-diagnostics. - "Review this app end to end" →
android-app-review(it calls this skill first). - Publishing your app to Play,
bundletool, internal test tracks → developer workflows, out of scope here.
The one thing to know first
A Play link alone does not guarantee an install. Play can refuse for country, account entitlement, age rating, payment, ABI, API level, staged rollout, or an emulator exclusion, and integrity-gated apps (banking, DRM, anti-cheat) commonly refuse emulators outright. Treat installation as a state machine with a real "cannot, and here is why" outcome — not a step that always succeeds.
Never download an APK for the user from a Play scraper or a mirror site. Unofficial Play clients breach Play's terms and risk the account; mirrors have no provenance. Either install through Play, or use an APK the user already lawfully has.
Workflow
Run everything as python3 "${CLAUDE_SKILL_DIR}/scripts/playapp.py" <cmd>.
-
Resolve the link. The package name is already in the URL's
id=parameter — no web search needed.playapp.py resolve "https://play.google.com/store/apps/details?id=com.spotify.music&hl=en" # -> com.spotify.musicHandles
play.google.com/...?id=,market://details?id=,.../details/App_Name?id=, and a bare package name. Shortenedplay.app.goo.gllinks cannot be resolved offline — ask for the full URL. -
Preflight. Never skip this; it is where the two blocking conditions surface.
playapp.py preflight # add --fix to switch off UI animationsPrints one machine-readable line per component so you can act on a single grep, then a verdict:
STATUS device: OK emulator-5554 STATUS play_store: OK com.android.vending STATUS play_account: MISSING no Google account — a human must sign in ... STATUS animations: WARN on (window=1.0, ...) — re-run with --fix RESULT: failOK/WARN/MISSING/FAIL, andRESULT: ok|fail.--fixswitches off the three animation scales — the only blocker here that is safely scriptable; the Play image choice and the Google sign-in are not. -
Check current state before installing anything:
playapp.py status com.spotify.music # exit 1 = not installedUses
pm path, notpm list packages— the latter substring-matches and will happily reportcom.foo.barwhen you asked aboutcom.foo. -
Install, by whichever route applies:
a. From Play (needs a Play image and a signed-in account):
playapp.py open-listing com.spotify.music # opens market://details on device # then, with android-ui-driver: # uia.py snap # find the button # uia.py tap --text "Install" # may read Install/Update/Open/Buy playapp.py wait-installed com.spotify.music --timeout 300b. From an APK the user has:
playapp.py install-apk ./app.apk playapp.py install-apk ./base.apk ./split_config.arm64_v8a.apk ./split_config.en.apkOne file uses
adb install, several useinstall-multiple. Both pass-r(replace) and-g(grant runtime permissions). -
Launch and confirm it survived launch:
playapp.py launch com.spotify.musicResolves the real launcher activity, starts it, then checks the package is actually in the foreground. If it is not, the app crashed or bounced — exit 1, and
android-package-diagnostics crasheswill say why.
Output spec
- The package name, stated once and reused (never re-derived by guessing).
- A preflight verdict naming any blocker in the user's terms.
- After install: version name + code and install source, from
status. - On failure: which state was reached and the specific reason, not a retry loop.
Gotchas
- A fresh Play AVD has no Google account. Play then opens
...unauthenticated.activity.UnauthenticatedMainActivityand nothing can be installed. A human must sign in once, by hand. Do not automate Google sign-in — 2FA and CAPTCHA aside, it breaches Play's terms.preflightandopen-listingboth detect and report this state. -gskews a privacy review.install-apkpre-grants runtime permissions so the app runs without prompts. If the point is auditing what the app asks for, install without-gand let the prompts happen — otherwiseandroid-package-diagnosticswill show everything already granted.- Google Play images are release-signed, so
adb rootfails ("adbd cannot run as root in production builds") and app-private storage stays unreadable. That is the price of having the Play Store; an AOSP image is rootable but has no Play. google_apis≠google_apis_playstore. Only the latter ships the Store app.preflightchecks this explicitly.- Apple Silicon needs
arm64-v8asystem images. An x86-only APK then fails withINSTALL_FAILED_NO_MATCHING_ABIS.install-apkexplains eachINSTALL_FAILED_*code it sees. - Split APKs: installing only
base.apkgivesINSTALL_FAILED_MISSING_SPLIT. Pass every split in oneinstall-apkcall. statuson a preinstalled app shows installer(none)— a genuine provenance signal, not a bug. Play-installed apps reportcom.android.vending.
Files
scripts/playapp.py— resolve, preflight, status, open-listing, install-apk, wait-installed, launch, uninstall.--helpper subcommand.references/emulator-setup.md— creating and booting a Play-enabled AVD from scratch, image choice, and the one-time Play sign-in.../../docs/authorized-use.md— what these skills may and may not be used for.