SUB/WAVE Android release → Play Store / testers
The app is live in production on the Google Play Store. There are two ways to ship a fresh Android binary, and which you want depends on the audience:
- Play Store release (
productionprofile) — builds a signed.aabApp Bundle on EAS. You then upload it to the Play Console by hand (submission is manual — see the Play Store section). This is how real users get the update. - Quick sideload test (
previewprofile) — builds an APK and returns an install link + QR any Android phone can tap, no Google account involved. Good for handing a build to a trusted tester fast, outside the Play Store machinery.
Either way EAS generates+stores the signing keystore for you. Ask which one the
user wants if it's ambiguous — "release"/"ship to the store" → production
.aab; "send to a tester"/"install link" → preview APK.
For a phone you can physically plug in (USB/adb, live hot-reload), use the local
subwave-app-android skill instead.
First: does this even need a new build? (OTA)
The app ships expo-updates (OTA). If the change is JS/TS only — components, hooks, styles, copy, logic, Metro-bundled assets — you do not need a new APK/AAB. Push it over-the-air to the binaries already installed:
cd "$APP"
eas update --channel preview --message "fix: …" # tester (internal-APK) builds
eas update --channel production --message "fix: …" # Play builds
It reaches every installed build whose runtime version (fingerprint) matches.
Testers see it on the next cold start (it fetches in the background;
fallbackToCacheTimeout: 0 keeps launch instant, so kill + relaunch twice to
confirm it applied).
A new build is only required when native inputs changed: a dependency
add/upgrade, anything under app/patches/, a config plugin, or app.json's
android/plugins sections. The fingerprint policy guarantees an OTA can't land
on a binary with mismatched native code. Rule of thumb: ran npx expo install
or touched patches/? → build. Otherwise → OTA. Full decision table:
app/docs/RELEASE.md.
The rest of this skill is the build path (native change, or a store release).
Fixed facts about this app
Derive the repo root once; don't hardcode it. The Expo project and eas.json
live in app/, and every eas command must run from there.
REPO=$(git -C "<this skill's base directory>" rev-parse --show-toplevel)
APP="$REPO/app" # eas.json lives here — cd into it before any eas command
| Thing | Value |
|---|---|
| EAS project | @pinku1/subwave (Expo account pinku1) |
| Android package | com.getsubwave.app |
| Internal-test profile | preview (distribution: internal, buildType: apk, channel preview) |
| Play Store profile | production (builds an .aab App Bundle, autoIncrement on, channel production) |
| OTA channels | preview / production — JS-only updates via eas update (see OTA section) |
| Runtime version | fingerprint policy — hashes native deps + patches/ so OTAs only reach matching binaries |
| Signing keystore | EAS-managed (auto-generated in the cloud, stored on EAS) |
Preflight (10 seconds)
cd "$APP"
eas whoami # expect: pinku1 (if not: eas login)
If eas isn't found: npm i -g eas-cli.
Play Store release (the real release) — production
cd "$APP"
eas build --platform android --profile production --non-interactive # builds the .aab
It:
- Reuses the EAS-stored keystore (generated once, self-signed — no TTY needed).
- Auto-increments
versionCode(autoIncrementon theproductionprofile). - Builds a signed
.aabApp Bundle on EAS (~10–15 min) and links it on the build page as the Application Archive.
Submission is manual. This project uploads the .aab to the Play Console by
hand — there's no play-service-account.json on this machine, so eas submit
won't run headlessly here, and the operator's flow is a manual Console upload.
When the build finishes:
- Open the build page (
eas build:view <id>→ Application Archive URL) and download the.aab. - Play Console → SUB/WAVE (
com.getsubwave.app) → Production (or a testing track) → Create new release → upload the.aab→ review → roll out.
Data-safety form answers are pre-drafted in app/docs/store/PLAY-DATA-SAFETY.md.
A new marketing version (what users see) comes from expo.version in
app/app.json — bump + commit it before building; versionCode auto-increments
on its own.
If you ever wire automated submit: drop the Google service-account JSON at
app/secrets/play-service-account.json(gitignored; the patheas.json'ssubmit.production.androidalready expects) and theneas submit --platform android --profile production --non-interactivepushes to theinternaltrack as adraft. Until that file exists, upload manually.
Local build — when the EAS cloud Android quota is exhausted (--local)
The EAS Free plan caps Android cloud builds per calendar month (resets on the
1st; iOS has a separate quota). When it's used up, eas build -p android --profile production fails at queue time with "used its Android builds from the Free
plan this month." Build the .aab locally instead — it bypasses the cloud
quota entirely and still signs with the EAS-managed keystore, so the bundle is
Play-valid. Same artifact, same signing, just compiled on this Mac.
cd "$APP"
export ANDROID_HOME="$HOME/Library/Android/sdk"
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home # JDK 17 (Expo SDK 56)
export PATH="$JAVA_HOME/bin:$PATH"
eas build --platform android --profile production --local --non-interactive \
--output "$APP/build/subwave-android-<ver>.aab"
versionCode still auto-increments remotely. When it finishes, verify and upload
exactly as in the Play Store section above:
"$JAVA_HOME/bin/jarsigner" -verify "$APP/build/subwave-android-<ver>.aab" # expect: "jar verified."
Prereqs on this machine (all in place as of the 1.3.0 release): Android SDK at
~/Library/Android/sdk (build-tools 35/36, platform-tools), JDK 17 via Homebrew
(brew install openjdk@17), and a logged-in eas whoami. The withGradleMemory
config plugin (app/plugins/) raises the Gradle/Kotlin JVM metaspace during
prebuild, so the local build no longer needs a manual ~/.gradle/gradle.properties
tweak.
Three failure modes, each fixed once (learned cutting 1.3.0):
- Disk. Needs ~15+ GB free — native compile across ABIs is hungry. A run
that dies on
ENOSPCleaves a ~2 GB stale temp at$TMPDIR/eas-build-local-nodejs;rm -rfit before retrying. - Corrupt NDK. A disk-interrupted NDK download leaves a partial
~/Library/Android/sdk/ndk/27.0.12077973with nosource.properties, and the next run dies with[CXX1101] … did not have a source.properties file. Fix:rm -rfthat NDK dir so Gradle re-downloads it clean. - KSP Metaspace OOM (only if the
withGradleMemoryplugin is somehow absent).:expo-updates:kspReleaseKotlinthrowsOutOfMemoryError: Metaspaceand the Gradle daemon dies. The plugin fixes this in-repo; the manual fallback is a~/.gradle/gradle.propertieswithorg.gradle.jvmargs=-Xmx3072m -XX:MaxMetaspaceSize=1536m+kotlin.daemon.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m(user-home wins over the regenerated project file).
A full clean local build is ~20 min. If the cloud quota has reset, prefer the
plain cloud production build above — it's simpler and offloads the compile.
Quick sideload test (skip the store) — preview
For handing a build straight to a tester without the Play Store:
cd "$APP"
eas build --platform android --profile preview --non-interactive
Builds an APK + prints an install link + QR:
🤖 Open this link on your Android devices (or scan the QR code) to install:
https://expo.dev/accounts/pinku1/projects/subwave/builds/<BUILD_ID>
That build page is the install page — testers open it on their phone, tap
Install, and approve the one-time "install from this source / unknown
sources" prompt (expected for anything outside the Play Store). The raw .apk is
also linked from the build page if someone wants to download it directly.
Detached variant (don't block the session on a 15-min build)
Works for either profile — swap preview/production:
cd "$APP"
eas build --platform android --profile production --no-wait --non-interactive
# grab the BUILD_ID from the output, then:
eas build:view <BUILD_ID> # Status + the Application Archive (.aab/.apk) URL
Poll to completion without babysitting:
BID=<BUILD_ID>
until eas build:view "$BID" --json 2>/dev/null \
| grep -qiE '"status":[[:space:]]*"(finished|errored|canceled)"'; do sleep 60; done
eas build:view "$BID" | grep -iE "Status|Application Archive"
Sharing the build
The install link works for anyone you send it to — there's no per-tester allow-list like TestFlight, so treat the link as "anyone with it can install." That's fine for a small trusted group. The build (and its link) stays available on EAS; you don't need to rebuild to re-share.
Bumping the version vs. just rebuilding
- Another test build of the same version (the common case): change nothing and rebuild — internal APKs install over each other regardless of build number.
- New marketing version (e.g.
1.0.0→1.1.0, what users see): editexpo.versioninapp/app.jsonfirst, commit, then rebuild. That bump is four fields —app/app.json,app/package.json, and both version fields inapp/package-lock.json(.versionand.packages[""].version). See the iOS skill's version section;appis outside release-please, so nothing keeps them in step for you.
Unlike Apple, Play won't reject a same-versionName upload — it keys
releases on versionCode (which autoIncrement always bumps), so you can
upload a new build under an unchanged marketing version. But when you're cutting a
real public release alongside iOS (which does force a bump — see the iOS skill),
bump expo.version here too so both stores show the same version. Don't pin the
current version/versionCode in this doc — read expo.version from
app/app.json and eas build:list for the live versionCode.
You don't hand-edit versionCode — the production profile's autoIncrement
owns it (it only matters for the Play Store path; internal APKs don't care).
Things that bite
- Wrong directory.
easneedseas.json, which is inapp/. Alwayscd "$APP"first. preview= APK for the link;production= AAB for Play. Don't send an.aabto testers directly — phones can't install App Bundles; the internal APK is what's installable.- Unknown-sources prompt is normal. Sideloaded (non-Play) installs trigger a one-time per-source permission on the device. Approve it.
- Guard the EAS keystore — the app is already on Play. Google permanently
binds the app to its upload key; lose it and you can't ship updates. EAS stores
the keystore, but keep an offline backup (
eas credentials --platform android). eas submitwon't work headlessly here. Noplay-service-account.jsonis present, so the Play upload is manual via the Console. Don't tell the user a build "submitted to Play" — it built an.aabthey still upload by hand.- This is cloud, not adb. For live hot-reload on a tethered phone, use
subwave-app-androidinstead.
Quick reference
| Want | Do |
|---|---|
| Ship a JS-only change (no new build) | cd "$APP" && eas update --channel production --message "…" (testers: --channel preview) |
| Play Store release (.aab → manual upload) | cd "$APP" && eas build -p android --profile production --non-interactive, then upload the .aab in Play Console |
| Build + shareable install link for a tester | cd "$APP" && eas build -p android --profile preview --non-interactive |
| New app version first | edit expo.version in app/app.json, commit, then build |
| Queue without waiting | add --no-wait, then eas build:view <id> |
| Get the .aab / .apk of a build | eas build:view <id> (Application Archive URL) |
| List recent builds | eas build:list --platform android |
| Confirm login | eas whoami (expect pinku1) |