πŸ€– Android Build Pipeline β€” Complete Wireframe & Build Plan
Everything needed to give NOVA one command and have an Android build land on a tester's phone, mirroring the existing iOS TestFlight pipeline.
Nothing in this plan is built yet. Items marked EXISTS are already in the repo today; everything else is the specification. Written for Leong Sen Fong, 2026-09-17, investigation only β€” no code, config or CI has been changed.
Navi42026-09-17 0 of 18 builtawaiting go-ahead

The whole flow on one page

One command, two stores, one build number. The left half already exists and ships today; the right half is what this plan builds.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Someone tells NOVA: β”‚ β”‚ cut build N β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ GATE 1 CHANGELOG has a β”‚ β”‚ section naming N β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ GATE 2 that section is on β”‚ β”‚ origin/master AND β”‚ β”‚ matches word for word β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ both pass β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ SEQUENTIAL, not parallel β”‚ β”‚ both builds write into app/build and β”‚ β”‚ share .dart_tool β€” run at once in one β”‚ β”‚ checkout and they corrupt each other β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ iOS (EXISTS) β”‚ β”‚ ANDROID (NEW) β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ unlock keychain β”‚ β”‚ read key.propertiesβ”‚ β”‚ build ipa β”‚ β”‚ build appbundle β”‚ β”‚ unzip, verify β”‚ β”‚ unzip, verify β”‚ β”‚ CFBundleVersion β”‚ β”‚ versionCode == N β”‚ β”‚ altool upload β”‚ β”‚ Play API upload β”‚ β”‚ wait VALID β”‚ β”‚ (no review wait) β”‚ β”‚ add 2 groups β”‚ β”‚ internal track β”‚ β”‚ + READ BACK β”‚ β”‚ + READ BACK β”‚ β”‚ whatsNew β”‚ β”‚ release notes β”‚ β”‚ + READ BACK β”‚ β”‚ + READ BACK β”‚ β”‚ submit review β”‚ β”‚ roll out 100% β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ ANNOUNCE β€” one line per store β”‚ β”‚ never a single combined OK β”‚ β”‚ iOS βœ… / Android ❌ must be β”‚ β”‚ sayable in the same message β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Tester on iPhone β”‚ β”‚ Tester on Android β”‚ β”‚ TestFlight notifies β”‚ β”‚ Play Store updates it β”‚ β”‚ β†’ taps Update β”‚ β”‚ β†’ like any other app β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
The one principle carried over from iOS: every claim the pipeline makes about itself must be backed by something it read back. The iOS pipeline learned this the hard way β€” a version of it once announced APPROVED to the whole company based on an exit code, while the thing it claimed had not happened. Each + READ BACK above is a scar, not decoration.

Why one number for both stores

Your call on 2026-09-17: shared. Build 231 means the same commit on both stores. Apple needs CFBundleVersion to rise per upload; Play needs versionCode to rise per upload. A single counter satisfies both, and makes "which build is this" answerable in one place instead of two.

Cost of the choice, stated plainly: if one store rejects an upload and the other accepts it, the number is consumed on both. We skip a number rather than reuse one. Numbers are free; ambiguity is not.

What exists today, and what does not

Someone built the Android build half deliberately on 2026-09-09. That work is real and it holds up. What is missing is a key, an account, and the whole distribution half.

PieceStateEvidence
Android project (35 files, real Kotlin plugins)EXISTS app/android/ β€” CameraX, ML Kit text + face
Release signing wired into GradleEXISTS app/android/app/build.gradle.kts reads key.properties
Missing key β†’ debug fallback so any machine can buildEXISTS Deliberate: nobody is blocked by not holding the key
Key present but incomplete β†’ hard failureEXISTS Refuses to silently produce a debug-signed build that looks official
Key template committed, no real valuesEXISTS app/android/key.properties.example
Signing documentationEXISTS docs/ANDROID_RELEASE_SIGNING.md, written 09-09
applicationIdEXISTS com.hoop.hoop β€” permanent at first upload
The keystore itselfYOURS By design, only you generate and hold it
Google Play developer accountYOURS No trace anywhere in the repo
Android OAuth client for Google sign-inMISSING β€” BREAKS LOGIN See the warning below
Android build scriptTO BUILDNothing exists
Play upload / distribution automationTO BUILDNothing exists
Android row in the deployment tableTO BUILD CLAUDE.md has no Android row at all
⚠️ Google sign-in will not work on Android as things stand.

The login screen offers Google sign-in (login_screen.dart:338, account_screen.dart:330). app/lib/api/google_signin.dart holds an iOS client ID and a Web client ID. There is no Android client anywhere in the repo, and no google-services.json. On Android, Google sign-in needs its own OAuth client registered against the package name plus the SHA-1 of the certificate that signs the app.

The trap inside the trap. With Play App Signing enabled, the app a tester installs is signed by Google's certificate, not by your upload key. So the SHA-1 to register is the app signing certificate from Play Console β†’ Setup β†’ App integrity β€” not the one from the keystore you generate. Register the wrong one and it works on a build you sideload and fails for every tester coming through Play, with an unhelpful error.

This is why the SHA-1 step in β‘’ sits after the first upload: it cannot be done before Play has generated that certificate. Left undiscovered, this surfaces as "testers cannot log in" only after everything else is finished.

Why the deployment table matters more than it looks

The web version once sat 20 days and a hundred versions stale because there was no row for it in the deployment table in CLAUDE.md. Nobody was lazy β€” people follow the table, and what is not in the table does not get remembered. Android has no row today. Adding one is task 4.2 and it is not paperwork.

One-time setup β€” who does what, and where

Four places. Parts 1 and 2 block everything else, so start them first; the account approval clock is the long pole and it is outside our control.

Part 1 Β· Your Mac, Terminal

$ keytool -genkey -v \ -keystore ~/.hoop-keys/hoop-release.jks \ -keyalg RSA -keysize 2048 -validity 10000 \ -alias hoop Enter keystore password: ******** What is your first and last name? HOOP ... [Storing ~/.hoop-keys/hoop-release.jks]
-validity 10000 is not arbitrary. Play requires the certificate to stay valid beyond 2033-10-22. If it expires, this app can never publish again. Do not shorten it.
Back up the file and its password to two separate places. Lose either and this app can never be updated again β€” there is no recovery path, only a new listing with zero users.

Part 2 Β· Google Play Console  play.google.com/console

play.google.com/console/signup
Create developer account
Account type
Organisation or personal
Registration fee
US$25, one time
Identity verification
Documents, then a wait
Pay and continue
2.1 Β· Account YOU β€” the long pole. Approval is measured in days and nothing else can finish until it clears. Start here.
play.google.com/console/app/create
Create app
App name
HOOP
Package name
com.hoop.hoop β€” permanent
Play App Signing
Offered once. Turn it ON.
Create app
2.2–2.4 Β· Two permanent choices YOU β€” the package name and Play App Signing are both locked from first upload. Neither can be changed later.
…/console/app/app-content
App content
Privacy policy URL
Usually the blocker β€” have one ready
Data safety
Camera, coarse location declared; plugins add mic, photos, files
Content rating
Questionnaire
Target audience Β· Ads
Declarations
2.5 Β· Declarations YOU β€” answer for what the app actually collects and sends. The manifest declares CAMERA and ACCESS_COARSE_LOCATION; plugins merge in more.
…/console/testing/internal
Internal testing
Testers
Email list β€” Google accounts only
Limit
Up to 100
Review
None on this track
Opt-in link
play.google.com/apps/internaltest/…
2.6–2.8 Β· The track YOU β€” this is the TestFlight equivalent. No review means minutes, not days.

Part 3 Β· Google Cloud Console  console.cloud.google.com

#StepWhy
3.1Create or pick a project Reuse the one owning client ID 584364169898 so all HOOP OAuth lives together
3.2Enable the Google Play Android Developer APIWithout it, uploads must be done by hand
3.3Create a service account, create a JSON key, download itThe pipeline's credentials
3.4Note the service account emailNeeded to grant it access in Play Console
3.5Create an Android OAuth client: com.hoop.hoop + SHA-1 Fixes Google sign-in. SHA-1 comes from Play Console β†’ Setup β†’ App integrity, after the first upload. Optionally add a second client with the upload key's SHA-1 so sign-in also works on sideloaded builds.

Part 4 Β· Back in Play Console β€” linking the two

#StepNote
4.1Setup β†’ API access β†’ link the Cloud projectSpans both consoles; easy to miss
4.2Grant the service account release-to-testing-tracks permission Do not grant production release rights yet

Part 5 Β· NOVA's build machine

#StepNote
5.1Put the .jks outside the repo (~/.hoop-keys/)Never inside app/android/
5.2Create app/android/key.properties, four valuesNever enters git
5.3Put the service account JSON outside the repoSame rule
5.4flutter doctor --android-licenses The mac mini reports these unaccepted today; NOVA's machine needs its own check
5.5Confirm JDK 17Gradle config targets 17
Secrets travel as files, not as messages. Put them directly on the machine. Do not paste keystore passwords or the service account JSON into HOOP chat β€” message history retains them, and the pipeline never needs a human to relay a secret.

The build β€” what NOVA does, and what it prints

One command, detached from the session. The iOS pipeline exists because builds outlive the agent conversation that started them: packages once sat finished and unsent for six hours because the step that said "now upload it" was still attached to a session that had ended.

What is typed

# same shape as iOS today β€” detached, survives the session dying $ python3 -c "import subprocess, os subprocess.Popen(['/bin/bash', os.path.expanduser('~/.hoop/nova/build/cut_build.sh'), '231', '/Users/Shared/wt/nova/build231'], stdout=open('/tmp/cut231.log','w'), stderr=subprocess.STDOUT, start_new_session=True)"
Not nohup setsid. macOS has no setsid; that command prints one line and the pipeline never starts. It has already cost one build. start_new_session=True, then verify the process is alive and the log is growing β€” "I launched it" is not "it is running".

What the log looks like when it goes well

[08:12:03] βœ… CHANGELOG gate passed [08:12:05] βœ… build 231 section is on master, text identical [08:12:06] πŸ”¨ iOS flutter build ipa --build-number=231 [08:31:44] βœ… unzipped, CFBundleVersion = 231 [08:34:19] βœ… upload UPLOAD SUCCEEDED [08:41:02] βœ… VALID Β· 2 groups added and read back Β· notes written and read back [08:41:20] βœ… Beta review APPROVED [08:41:21] πŸ”¨ Android flutter build appbundle --build-number=231 [08:49:55] βœ… unzipped, versionCode = 231 [08:50:31] βœ… uploaded to internal track [08:50:38] βœ… release notes written and read back (412 chars) [08:50:44] βœ… rolled out 100% Β· read back: track internal, versionCode 231 [08:50:45] πŸŽ‰ build 231 complete β€” iOS APPROVED Β· Android LIVE on internal

What it looks like when one store fails

[08:41:20] βœ… iOS Beta review APPROVED [08:49:55] βœ… Android versionCode = 231 [08:50:31] πŸ›‘ Android upload rejected: versionCode 231 already used [08:50:31] πŸ›‘ stopping before rollout β€” nothing partial left on the track
This is the case the design is actually for. iOS succeeded and Android did not. A single combined "done" would be a lie in both directions. The announcement must be able to say one succeeded and one failed in the same message, and the General-room post must stay silent unless the thing it claims is true.

The two announcements

Build room08:50
πŸ“¦ build 231 complete
Posted by the pipeline, not by a session.
iOS Β· TestFlight
APPROVED Β· groups read back Β· notes written
Android Β· Play internal
LIVE Β· rolled out 100% Β· read back OK
Both lines come from the log, not from a template.
1 Β· Build room TO BUILD β€” one line per store. Always posted, success or failure.
Build room08:50
⚠️ build 231 β€” half landed
One store is live, one is not.
iOS Β· TestFlight
APPROVED β€” testers can update
Android Β· Play internal
FAILED at upload β€” nothing on the track
Do not tell Android testers anything yet. Log: /tmp/cut231.log
2 Β· Partial failure TO BUILD β€” the shape that matters. Never a green tick covering both.
General08:50
Build 231 is live for testers
iOS and Android.
iPhone
Open TestFlight and update
Android
Play Store will update it, or open the app page
Posted automatically by the build pipeline.
3 Β· General TO BUILD β€” mandated by Li Min 2026-09-02 for every approved build. Posted only when the log proves both stores really succeeded; otherwise the whole-company room stays silent.

The tester β€” first install, then every update after

This is the half that decides whether the whole thing was worth building. Requirement 5 was "testers receive subsequent builds through the same mechanism" β€” that rules out sending APK files, and it is why Play internal testing wins over every alternative.

Mail9:41
You are invited to test HOOP
Sent once, by you, from Play Console.
Open the opt-in link
play.google.com/apps/internaltest/…
Must be opened while signed in with the Google account you listed. A different account silently sees nothing.
Accept invitation
1 Β· Opt-in ONE TIME β€” the only manual step in a tester's whole life. TestFlight has the same beat.
Play Store9:42
H
HOOP
Internal test Β· build 231
Install
What's new
Read straight from CHANGELOG β€” the same text the iOS testers see
No review wait on this track. Minutes after the pipeline finishes.
2 Β· Install FIRST TIME β€” from the Play Store the tester already has. No separate app, unlike Firebase.
Play StoreTue
Update available
Build 232 Β· 4 days later
H
HOOP
231 β†’ 232
Update
Auto-update on means it simply appears. This is requirement 5, and it costs the tester nothing.
3 Β· Every build after THE POINT β€” ordinary app updates forever. Arguably smoother than TestFlight.
HOOP9:45
Sign in
First launch after install.
Continue with Google
⚠️ Fails today
No Android OAuth client registered. Needs task 1.4 done first, using the Play app signing SHA-1.
Email OTP still works. But a tester hitting this will report "login is broken", not "OAuth client missing".
4 Β· The one that bites BLOCKER β€” invisible until a real tester on a real Play build taps it.
Why not Firebase App Distribution. It is free, needs no developer account, and could run this week. But testers install a separate "App Tester" app and updates arrive through that, not through the Play Store β€” so it is a different mechanism that we would later replace, meaning distribution gets built twice. Your call on 2026-09-17 was to wait for Play. This plan reflects that.

Build plan β€” 4 phases, 18 tasks

Each task is separately trackable and has one objective. Nothing here is started. Phase 0 is yours and blocks the rest; phases 1–3 are mine.

Phase 0 Β· Accounts and keys YOU
Blocks everything. The account approval wait is the long pole β€” start 0.2 first, today.
#TaskDone when
0.1 YOU Generate the keystore, back it up twice, keep it outside the repo Two independent backups exist, one of them not on your Mac
0.2 YOU Google Play developer account, US$25, identity verification Console lets you create an app
0.3 YOU Create the app Β· Play App Signing ON Β· confirm com.hoop.hoop Both permanent choices made deliberately, not clicked past
0.4 YOU App content declarations, privacy policy URL Internal testing track can accept a release
0.5 YOU Cloud project Β· enable Play Developer API Β· service account + JSON key JSON key downloaded
0.6 YOU Link Cloud project in Play Console, grant the service account testing-track rights Not production rights β€” testing only
Phase 1 Β· First build by hand ME + A PHONE
Prove the thing works at all before automating it. This phase is where reality gets a vote.
#TaskDone when
1.1Place keystore + key.properties + service account JSON on NOVA's machine; accept SDK licences; confirm JDK 17 :app:signingReport shows Config: release pointing at the real key
1.2First real signed .aab and .apk The .aab is signed by the real key, not debug β€” verified from the artifact
1.3 RISKInstall on a real Android phone and use it App opens, camera works, a message sends. Nothing in this project has ever run on Android hardware.
1.4 BLOCKER Android OAuth client with the Play app signing SHA-1; add the client ID to google_signin.dart Google sign-in works on a build installed from Play, not just sideloaded
1.5First upload to the internal track, by hand One tester installs from Play and opens it
1.3 is the task not to compress. Everything after it assumes the app actually works on Android. The build config has been verified to compile and to pick the right signing path β€” that is all. No real-key build exists and no Android phone has opened this app. First contact always surfaces something nobody could predict from source.
Phase 2 Β· Automate it ME
Only after phase 1 proves the manual path. Automating an unproven path just hides where it breaks.
#TaskDone when
2.1play_versions.py β€” highest versionCode on the track, pick next Mirrors asc_builds.py; agrees with the iOS counter
2.2cut_android.sh β€” build, verify versionCode out of the artifact, reuse both CHANGELOG gates A deliberately wrong build number is caught by unzipping the .aab, not by trusting the log
2.3play_publish.py β€” upload, notes, rollout, each with read-back Every write is followed by a read that proves it landed
2.4Merge into one command: iOS then Android, sequential They never run concurrently in one checkout
2.5Per-store announcements; General only when the log proves both Forcing a half-failure produces the half-failure message, not a green tick
Phase 3 Β· Make it stick ME
The parts that stop it rotting in a month.
#TaskDone when
3.1Android row in the CLAUDE.md deployment table Someone who only reads the table still ships Android
3.2Update docs/ANDROID_RELEASE_SIGNING.md for the automated path Same commit as the code β€” a doc that lags is a doc that lies
3.3Deliberately broken runs: wrong build number, bad key, revoked service account Each fails loudly at the right step. An untested gate is not a gate.
3.4End-to-end rehearsal, then a second build A tester receives the update without being told to do anything
Why 3.4 needs two builds. The first proves install. Only the second proves the update path β€” which is the actual requirement. Plenty of distribution setups install fine once and never update again; that failure is invisible if you only ever ship one build.

Deliberately not scheduled

  • Closed / open testing and production release. Those need review and more declarations. Internal testing is the TestFlight equivalent and is where this stops.
  • Firebase App Distribution. Ruled out on 2026-09-17 β€” building distribution twice to save a wait.
  • CI-hosted builds. Android could build on ubuntu-latest where iOS cannot, but splitting the two across machines breaks the shared build number and the single command. Not worth it yet.
  • In-app purchases / Play Billing. Separate signing, separate webhooks, doubles the work (IAP_GREEN_ENERGY.md Β§9). Out of scope.

Decisions on record

#DecisionDecided byDate
1Distribution is Play internal testing, not Firebase, not raw APK Navi4 recommended Β· Leong accepted2026-09-17
2One build number shared across iOS and AndroidLeong2026-09-17
3No Firebase bridge while the Play account clears β€” we waitLeong2026-09-17
4One command, sequential iOS then Android, per-store reporting Navi4 Β· technical constraint2026-09-17
5The keystore is generated and held by Leong onlyPre-existing, 2026-09-092026-09-09
6Missing key β†’ debug fallback; partial key β†’ hard failurePre-existing, 2026-09-092026-09-09

Open questions

QuestionBlocksWho
Who has an Android phone for task 1.3?Phase 1, and therefore everything after itLeong
Does a privacy policy URL already exist?Task 0.4Leong
Keep com.hoop.hoop or move to a company-domain id?Task 0.3 β€” permanent once uploadedLeong

Risks, stated plainly

This app has never run on Android. Not once, on any device. The Gradle config is verified to compile and to select the correct signing path β€” that is the entire body of evidence. Two Kotlin plugins (CameraX, ML Kit) have never executed. Treat phase 1 as discovery, not as a formality.
Google sign-in is broken on Android today and the fix depends on a certificate that does not exist until after the first Play upload. Sequencing matters: task 1.4 cannot move earlier.
Play Console changes and my knowledge has a cutoff. Menu names and the exact set of content declarations move. Treat the console steps here as the shape of what you will be asked, not a script. If the Console disagrees with this page, the Console is right and this page is stale.
I have not performed the Play Console or Cloud Console steps myself. Those sections are written from documentation, not from having clicked through them. Everything about the repo, the iOS pipeline and the existing Android config was read directly out of origin/master.
The account approval wait is outside anyone's control here. It is the single largest schedule risk and no amount of preparation shortens it. That is why 0.2 is the first thing to start.