📦 Ship to Google Play
Wrap your web app as a Trusted Web Activity, prove the site owns it, and handle the paperwork Play asks for: privacy, data safety and deletion.
You'll leave with
- A signed Android app bundle built from your live web app
- An assetlinks.json listing every fingerprint Play needs
- Privacy, terms and account-deletion pages ready for review
You don't need to write an Android app to be in Google Play. A thin wrapper can open your live website full-screen; the real work is proving your site owns it, then doing Play's paperwork. I'm a data person, not an Android developer, and I didn't become one for this. This is the path that got First Bites, my first-foods tracker for babies, and Habooby, my Phoenix dust map, ready for the store. It builds on Make it an app on their phone.
A web app in Google Play without Android code
What's a Trusted Web Activity?
A TWA is a thin Android app whose only job is to open your website in Chrome, full-screen, with no browser bar. It shows your live site; nothing is copied in.
So every web deploy updates the Play app the moment you ship; you rebuild the Android file only for a new version, icon or signing change.
What are an .aab and an .apk?
Two packagings of an Android app: the App Bundle (.aab) is what you upload to Play; an .apk installs directly, for testing on your phone.
Bubblewrap, Google's command-line tool, generates the wrapper from your manifest (PWABuilder, a web page, does too, but Bubblewrap can be scripted). First confirm the previous lesson's basics are live with curl, which fetches an address. Throughout, replace myapp, yourdomain.com and YOUR_VPS_IP with yours:
for f in /manifest.webmanifest /sw.js /icon-192.png /icon-512.png; do
curl -s -o /dev/null -w "%{http_code} $f\n" https://myapp.yourdomain.com$f
done
Each line should start with 200 (OK). Then, in a terminal in VS Code (your code editor), start Bubblewrap in a new folder beside your website, never inside it. npx, part of Node.js, downloads it and runs it:
mkdir ../myapp-android
cd ../myapp-android
npx @bubblewrap/cli init --manifest https://myapp.yourdomain.com/manifest.webmanifest
What are the JDK, the Android SDK and Gradle?
The toolchain that builds Android files: Java, Google's build tools, and Gradle, which runs the build. Bubblewrap downloads the first two once into your home folder, so one toolchain serves every app you wrap.
What are a signing key and a keystore?
Android installs and updates only signed apps. The keystore is the password-protected file holding your private signing key; the alias is the key's name inside. Updates need the same key.
It asks questions one by one. Claude Code, your AI assistant, can't answer them for you, so answer them yourself and paste any puzzler into Claude. The ones that matter:
| It asks | Answer |
|---|---|
| Install the JDK? The Android SDK? | Yes, once: your toolchain for every app. |
| Application ID | com.yourdomain.myapp, the package name: your domain backwards, then the app. It's permanent. |
| Play Billing? Geolocation? | No, unless you need them. |
| Key store, key name, passwords | Accept ./android.keystore and android, create the key now, and answer its name, organization and country questions. Type the same long password (letters and digits) at both password prompts, or signing fails. Save it in your password manager. |
Afterwards the folder holds twa-manifest.json, the Android project and android.keystore.
Like First Bites, keep one config file as the source of truth (twa-manifest.json) and one build script, which reads the password from environment variables (settings a program reads at startup). Run mkdir ~/secrets, then save these two lines, with your password on both, as myapp-android.env in that folder, outside every project. Use LF line endings (VS Code: click CRLF in the bottom bar, pick LF), or bash misreads them:
BUBBLEWRAP_KEYSTORE_PASSWORD=YOUR_PASSWORD
BUBBLEWRAP_KEY_PASSWORD=YOUR_PASSWORD
In the Claude Code box, type /add-dir ../myapp-android first (as in Deploy), then ask:
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: update regenerates the project from twa-manifest.json and, unless told not to, raises the version code; Play refuses one it has already seen. build finds the password in the environment under both names, so nothing is typed or stored.
#!/usr/bin/env bash
# build.sh: a signed .aab for Play and an .apk for your phone. Usage: bash build.sh [--bump]
set -euo pipefail
set -a; . "$HOME/secrets/myapp-android.env"; set +a # the password, as two environment variables
if [ "${1:-}" = "--bump" ]; then
npx @bubblewrap/cli update # new version code: every Play upload needs one
else
npx @bubblewrap/cli update --skipVersionUpgrade
fi
npx @bubblewrap/cli build
ls -l app-release-bundle.aab app-release-signed.apk
To try it, send the .apk to an Android phone, open it and allow the install. Your site opens full-screen, apart from a browser bar on top. Step 2 removes it.
What's an API level?
Android's version number for developers: Android 16 is API level 36. Your app's target level says whose rules it follows, and Play raises the minimum for new apps every year.
Play refuses uploads that target too low a level; my notes say 36 for new apps from late August 2026. Ask Claude what targetSdkVersion says in app/build.gradle, and to update Bubblewrap and rebuild if it's lower.
📍 Honest status: First Bites and Habooby both have signed bundles built, and both store listings are still in progress. Habooby's bundle targets API level 36 with zero Android permissions, because Chrome handles location.
Prove the site and the app belong together
That bar is Android being careful: anyone can wrap anyone's website, so the app goes full-screen only when the site vouches for it.
What's Digital Asset Links?
Google's way for a site and an app to vouch for each other. Your site publishes /.well-known/assetlinks.json, a small JSON (structured text) file naming the app's package and its signing keys' fingerprints. At launch, Chrome reads it; if the signature matches, the bar goes.
What's a SHA-256 fingerprint?
A short, unique summary of a signing key: 32 pairs of characters like AA:BB:…. It's public and safe to publish; only the keystore and its passwords are secret.
What's Play App Signing?
Google keeps the key that signs your app for the public. You sign uploads with your own upload key (Bubblewrap's); Play checks it, then re-signs with Google's key, so installs from Play carry Google's fingerprint.
So the file lists both: yours, for the copy you install, and Play's, which appears after your first upload. keytool, part of the JDK, prints yours. It asks for the keystore password; replace the names if yours differ:
keytool -list -v -keystore android.keystore -alias android
Copy the part after SHA256: on this line. If the terminal can't find keytool, ask Claude to run it from the JDK Bubblewrap installed.
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath. The finished file. Replace the package name and both placeholders; until your first upload, delete the second fingerprint line and the comma before it.
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourdomain.myapp",
"sha256_cert_fingerprints": [
"UPLOAD_KEY_SHA256",
"PLAY_APP_SIGNING_SHA256"
]
}
}]
It ships in the allowlist tarball from Deploy behind one front door; PowerShell works too:
tar -czf release.tgz .well-known/assetlinks.json
scp release.tgz root@YOUR_VPS_IP:/tmp/
ssh root@YOUR_VPS_IP 'gzip -t /tmp/release.tgz && tar -xzf /tmp/release.tgz -C /root/myapp && rm /tmp/release.tgz'
Check it yourself (PowerShell: curl.exe). Look for HTTP/2 200 and content-type: application/json:
curl -sI https://myapp.yourdomain.com/.well-known/assetlinks.json
Then paste Google's own checker into any browser, with your domain:
https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://myapp.yourdomain.com&relation=delegate_permission/common.handle_all_urls
Your package and fingerprint should appear under statements; if not, debugString says why. Close the app fully and reopen it: the check runs at launch, and the bar should be gone.
📍 First Bites published this file early with placeholders, added its upload key's fingerprint, and kept a checklist item for Play's signing fingerprints after the first upload. Your own test install looks perfect, since your upload key signed it. Skip the item, and everyone who installs from Play sees a URL bar.
What are testing tracks?
Play's stages before the public: internal testing (up to 100 people you invite, quick to reach their phones), closed testing (an invited group) and production (everyone).
After your first upload (step 6), the App integrity page in Play Console, Google's website for app developers, lists Play's fingerprints; menus move, so use its search. Add every SHA-256 it shows, ship the file, rerun the checker, and install from internal testing: no bar.
🛑 The keystore and its password are how you ship every update. A password committed to git is how signing secrets leak, so it lives only in that env file. Keep the keystore out of git and the website folder, and back both up somewhere separate, like a password manager. Then prove the backup: run the keytool command above on the saved copy and compare the SHA256 line. Lose them, and updates wait on Play's upload-key reset.
Make the web app behave like an app
In an app, Back closes a popup. Android's Back walks the page's history, so a popup with no history entry gets skipped and the app closes. First Bites gives every popup (it calls them sheets) an entry of its own.
What are pushState and popstate?
The history is the browser's list of where you've been. history.pushState adds an entry without loading a page; when Back removes one, your page gets a popstate event, a signal it can listen for.
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath:
// sheets.js: every popup owns one history entry, so Android's Back closes it.
// Sheets live in <div id="modalroot"> (directly inside <body>), class "sheet", hidden until opened.
function openSheet(sheet, locked = false) {
sheet.hidden = false;
if (locked) { // the terms sheet in step 4
sheet.dataset.locked = '1';
for (const el of document.body.children) if (el.id !== 'modalroot') el.inert = true;
}
history.pushState({ overlay: 1 }, '');
}
function closeSheet(sheet) { // your close buttons call this
if (history.state && history.state.overlay) history.back(); // popstate does the hiding
else sheet.hidden = true;
}
function unlockSheet(sheet) { // after "I agree"
delete sheet.dataset.locked;
for (const el of document.body.children) el.inert = false;
closeSheet(sheet);
}
addEventListener('popstate', () => {
const sheet = document.querySelector('#modalroot > .sheet:not([hidden])');
if (!sheet) return;
if (sheet.dataset.locked) history.pushState({ overlay: 1 }, ''); // locked: Back does nothing
else sheet.hidden = true;
});
Close buttons call history.back() rather than hiding the sheet, so no entry lingers to swallow the next Back. check.mjs is the checker from Check the AI's work. By hand, in Chrome on a computer: open a sheet and press Alt+← (Mac: Cmd+[). It closes; the page stays.
Share files; never show an error page
Downloads do nothing in the wrapper: a file your page builds itself (a "blob" download, like a CSV export) only shows a small notice. Hand it to the phone's share sheet instead.
What's the Web Share API?
navigator.share opens the phone's own share sheet; given files, it shares a file your page made. It must run from a tap, and navigator.canShare says first whether the phone accepts that file.
async function shareFile(text, name, type) { // e.g. shareFile(csv, 'myapp-export.csv', 'text/csv')
const file = new File([text], name, { type });
if (navigator.canShare && navigator.canShare({ files: [file] })) {
try { await navigator.share({ files: [file] }); } catch {} // they closed the share sheet
return;
}
const a = document.createElement('a'); // computers: an ordinary download
a.href = URL.createObjectURL(file);
a.download = name;
a.click();
}
Ask Claude: "Make my export button share the file with navigator.share where supported, and download it elsewhere." In the installed app, a tap should open Android's share sheet with your file.
Error pages count as crashes. The wrapper treats an error page as a crash, so a bare 404 (the "no such page" answer) must never show. Your service worker from the previous lesson already answers unknown pages with your branded 404.html. Load your home page in Chrome, then /no-such-page: your own page should appear.
The paperwork Play asks for (it's good for you)
Play asks every app for a privacy policy link and a Data safety form, accounts or not. Write the page first; the form copies from it.
What's the Data safety form?
A questionnaire in Play Console: what data your app collects or shares, why, whether it travels encrypted, and whether people can ask for it to be deleted. The answers appear on your store page.
Even an app without logins handles data. Habooby's privacy page says: no accounts; location only when you tap, sent from your device to the named weather and geocoding (place-to-coordinates) services; ZIP codes through the app's own proxy, a small relay on its server. Those same facts answered the Data safety form.
Ask Claude: "Write privacy.html from what my code really does: every request to another site (file and line), then a table of each piece of data: where it lives, why, how long, how to delete it, and which outside services get it. Contact: support@yourdomain.com." What's happening underneath: Claude searches your code, and a search can miss things. So check: open DevTools, the browser's developer tools (F12, Mac: Cmd+Option+I), click Network and use every screen. Every other site that appears must be named on the page.
Health-adjacent apps need more. Before launch, First Bites, a baby-feeding and allergen log, published terms ("a logging tool, not medical advice"), a privacy table of its data and every outside service (analytics, email, hosting), a consumer-health page (some US states expect one, like Washington under its My Health My Data Act), a delete-account page and a branded 404. It also removed every "prevents allergies" style claim. Ask Claude: "Write terms.html: what the app does and doesn't, not medical advice, limits of liability, governing law in my state. Then rewrite every medical or prevention claim in my copy as something to discuss with a doctor." Read each rewrite; link every page from your footer, About page and store listing.
⚠️ This isn't legal advice, and Claude isn't a lawyer. These pages are the honest minimum; when the stakes are high (health data, children, money), pay for a professional review.
Terms nobody can slip past
During Play prep, First Bites swapped a simple disclaimer for a terms sheet you must accept before the app works, saving which version you accepted. Bump the version, and everyone sees it once more. While it's open, the rest of the page is inert.
What does inert do?
A browser attribute that makes part of a page unclickable and hidden from keyboards and screen readers. A dimmed overlay hides things only visually; without inert, the Tab key walks into the app behind it.
<div id="modalroot">
<div id="terms" class="sheet" hidden>
<p>MyApp is a logging tool, not medical advice. Please read the
<a href="/terms.html">terms</a> and the <a href="/privacy.html">privacy page</a>.</p>
<button id="agree">I agree</button>
</div>
</div>
const TERMS_VERSION = '2026-10-01'; // bump when the terms change: everyone sees the sheet once more
let prefs = {};
try { prefs = JSON.parse(localStorage.getItem('myapp.prefs')) || {}; } catch {}
const terms = document.getElementById('terms');
if (prefs.termsAck !== TERMS_VERSION) openSheet(terms, true);
document.getElementById('agree').addEventListener('click', () => {
prefs.termsAck = TERMS_VERSION;
localStorage.setItem('myapp.prefs', JSON.stringify(prefs));
unlockSheet(terms);
});
The acceptance lives in myapp.prefs in localStorage, the browser's small storage for your site on this device, so it never syncs. Ask Claude: "Add a required, versioned terms sheet with openSheet(terms, true), saved in myapp.prefs." Test in a private window (Ctrl+Shift+N, Mac: Cmd+Shift+N): neither Tab nor Back gets past the sheet. Accept and reload: gone. Bump TERMS_VERSION, ship, reload: back, once.
📍 After the terms sheet arrived, every First Bites screenshot script had to pre-accept it, saving the current version into the preferences before the page loads. Otherwise every screenshot is the terms sheet.
Account deletion that's real
No accounts? Skip to step 6. If people can create one, Play requires a way to delete it in the app and from a web page. First Bites tested deletion end to end: sign up, delete, then confirm on the server that the user, family and membership are gone.
What are PocketBase, collections, rules and hooks?
PocketBase is the backend from Accounts without building auth. Collections are its tables; each has rules for who may list, view, create, update or delete, and a locked rule means superusers (admins) only. Hooks are your server-side JavaScript.
In the app
The users delete rule lets signed-in people delete only themselves: @request.auth.id != '' && id = @request.auth.id. Shared data is harder: First Bites' family logs record who logged each feeding, so they hold members' emails. With households from Sync and sharing that never lose work, the order is:
- Pause sync (
pauseSync()), so no queued upload puts anything back. - Delete each household only you are in.
- In shared ones, scrub your email from the data (member lists, "logged by"), hand ownership to a remaining member, and delete your membership row.
- Delete your user record, then sign out, keeping this device's copy.
No sharing? Only the last item; cascade delete takes their items.
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: each step is its own request, and any can fail; ignore a failure, and "deleted" hides data still on the server. So the code stops at the first failure, and the test re-checks as the superuser, who sees everything.
On the web, for people who can't log in
First Bites' delete-account page gives the in-app steps, a request form and a plain mailto: link (it opens the visitor's mail app). The form posts to a collection anyone can add to, for that app only; only you can read it, each request emails you, and there's a cap per hour.
data_requests
fields app (text, required) · email (email, required) · message (text, max 2000) · created (autodate, on create)
create app = 'myapp'
list, view, update, delete locked (null): superusers only
// Requests from people who can't log in: a cap per hour, then an email to you.
onRecordCreateRequest((e) => {
const CAP = 5; // requests per hour, everyone together
const since = new Date(Date.now() - 3600 * 1000).toISOString().replace("T", " ");
const recent = e.app.countRecords("data_requests", $dbx.exp("created >= {:since}", { since: since }));
if (recent >= CAP) throw new ApiError(429, "Too many requests right now. Please email us instead.");
e.next(); // saves the request
e.app.newMailClient().send(new MailerMessage({
from: { address: e.app.settings().meta.senderAddress, name: e.app.settings().meta.senderName },
to: [{ address: "support@yourdomain.com" }],
subject: "Data request for " + e.record.getString("app"),
text: "From: " + e.record.getString("email") + "\n\n" + e.record.getString("message"),
}));
}, "data_requests");
<form id="req">
<label>Your account's email <input type="email" name="email" required></label>
<label>Anything else (optional) <textarea name="message" maxlength="2000"></textarea></label>
<button>Ask us to delete my data</button>
</form>
<p id="said" role="status"></p>
<p>Or email <a href="mailto:support@yourdomain.com?subject=Delete%20my%20data">support@yourdomain.com</a>.</p>
<script>
document.getElementById('req').addEventListener('submit', async e => {
e.preventDefault();
const f = new FormData(e.target), said = document.getElementById('said');
try {
const r = await fetch('/api/collections/data_requests/records', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app: 'myapp', email: f.get('email'), message: f.get('message') }) });
if (!r.ok) throw new Error(r.status);
said.textContent = 'Request received. We will reply to that address.';
} catch {
said.innerHTML = 'That did not go through. Please email <a href="mailto:support@yourdomain.com">support@yourdomain.com</a>.';
}
});
</script>
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: created fills itself in, so the hook counts the last hour's requests, refuses above the cap with 429 ("too many requests"), saves, and emails you through the mail settings from Email that arrives. Anyone can type any address, so reply, and delete only after they confirm. The hook routine is the accounts lesson's: syntax check, back up, upload, health check. Prove it as a guest:
U=https://myapp.yourdomain.com/api/collections/data_requests/records
curl -s -o /dev/null -w "create %{http_code}\n" -X POST $U -H "Content-Type: application/json" \
-d '{"app":"myapp","email":"you@yourdomain.com","message":"test, please ignore"}'
curl -s -o /dev/null -w "list %{http_code}\n" $U
curl -s -o /dev/null -w "other app %{http_code}\n" -X POST $U -H "Content-Type: application/json" \
-d '{"app":"otherapp","email":"you@yourdomain.com"}'
The test email should arrive. Rerun the create command until it says create 429 (the sixth time in an hour, with a cap of 5), then delete the test requests in your dashboard.
⚠️ Tempted to restart the shared server after editing a hook? Don't: PocketBase watches pb_hooks and reloads changed files itself, and a restart blips every app on it. Upload, then test the behavior.
Listing gotchas
Play needs more than the app file. Habooby keeps LAUNCH.md (the Play Console steps, in order) and PLAY-LISTING.md (paste-ready store text and Data safety answers) beside its build. What trips people up:
| Play asks for | What works |
|---|---|
| Phone screenshots | 1080x1920. Play caps them at 2:1: First Bites' first set, 1071x2319, was rejected. |
| Store icon, feature graphic | A 512x512 PNG and a 1024x500 banner (the top of your store page), rendered from one clean source you have rights to. Look at each. |
| Title and icon | No "free", "best", "top" or "#1". First Bites took "Free" and "top-9" out of its store text. Say what the app does. |
| Privacy, deletion | Live URLs: privacy.html, delete-account.html. |
| Data safety form | Copied from your privacy page. |
| Category, declarations | Match the content: keeping First Bites' in-app infant CPR guide meant declaring Emergency & First Aid alongside nutrition. |
| App access | If part of the app needs an account, a reviewer test login with realistic sample data, given only in the private review notes. |
For screenshots, ask Claude: "Add a store mode to check.mjs: a 360x640 window at device scale 3, terms pre-accepted, sample data, one JPEG per main screen, failing unless each is 1080x1920." Three real pixels per screen pixel makes 1080x1920. Open each yourself: the app, not the terms sheet.
Which developer account
Decide early between a personal and an Organization developer account. New personal accounts must run a closed test with 12 testers for 14 days before publishing to everyone. An Organization account skips that, but needs a D-U-N-S number, a free business id from Dun & Bradstreet. Under Play's newer rules (from 30 September 2026, per my notes), medical apps need an Organization account. Policies change often: check the current rules before you submit.
The first pass, as LAUNCH.md checkboxes: create the app; upload the .aab from bash build.sh --bump under Test and release → Testing → Internal testing (menus move), with your own email under Testers; add Play's fingerprints (step 2); open the test's shareable link on your phone, signed in as that email, to install; then fill in the listing.
Do it with Claude: the prompts
The wrapper as three prompts, sent in order and checked as above:
- "Generate a Trusted Web Activity project for https://myapp.yourdomain.com with Bubblewrap, package com.yourdomain.myapp, reading signing secrets from environment variables." You answer Bubblewrap's questions.
- "Write /.well-known/assetlinks.json for that package and my upload key's SHA-256, and add a checklist item to add Play's signing key after the first upload."
- "Make Android's Back button close any open sheet."
What's happening underneath: at launch, Android, through Chrome, checks your site's assetlinks.json; if it can't match the app's signing key, it shows the browser bar. So when the bar appears, compare the package name, the file's fingerprints and the key that signed your copy before changing anything.
Your turn
- Build:
bash build.shlists the.aaband.apk,git statusshows no keystore, and the .apk opens your site on an Android phone. - Ownership:
curl -sIon your assetlinks.json shows 200 andapplication/json, statements:list shows your package and fingerprint, and the installed app has no browser bar. After your first upload, Play's SHA-256 is in the file and the internal-testing copy has no bar either. - App behavior: Back closes an open sheet without leaving the app, your export opens Android's share sheet, and
/no-such-pageshows your branded 404. - Paperwork:
privacy.html,terms.htmlanddelete-account.htmlanswer 200 from your footer, the privacy page names every outside site in DevTools' Network tab, and in a private window the terms sheet blocks Tab and Back until accepted. - If your app has accounts:
test-delete.mjsends withPASS, and the guest checks answercreate 200,list 403, thencreate 429past your cap, with the test email in your inbox.
Next, Big public data on a small server turns huge, messy public files into small ones a cheap server can serve.