Trusted Web Activity¶
A Trusted Web Activity (TWA) is an Android app whose only job is to open your Progressive Web App full screen in the user's browser, without any browser UI, after the browser has proved that the app and the website belong to the same developer. It's the mechanism behind almost every PWA listed on Google Play: the APK or App Bundle holds a launcher activity, icons, a splash screen and some configuration, while Chrome (or another supporting browser) renders the web content with its own engine, storage and service workers. Verification uses Digital Asset Links, so a missing or wrong fingerprint in /.well-known/assetlinks.json is the most common reason a "TWA" ships with a visible URL bar. This page covers the protocol, verification, the Bubblewrap and Android Browser Helper tooling, the delegated features (notifications, location, Play Billing), updates, debugging and how TWAs compare to WebAPKs and WebViews.
Key takeaways
- A TWA is a Custom Tab with the browser UI removed. The app binds to the browser's
CustomTabsService, the browser checks Digital Asset Links, and only when verification passes does it hide the toolbar. Content, cookies, storage and service workers belong to the browser, not to your APK. - Verification needs statements on both sides. The app declares its site in an
asset_statementsresource, and the site lists the app's package name and every SHA-256 signing certificate fingerprint in/.well-known/assetlinks.json, including the Play App Signing key (Play re-signs your bundle), served with HTTP 200,Content-Type: application/jsonand no redirects. - Failure is soft. If verification fails, Chrome still shows your content, but in a Custom Tab with a URL bar. The crash-based "quality enforcement" announced for Chrome 86 was removed from Chromium in 2023.
- Bubblewrap is the reference toolchain.
bubblewrap init --manifest=<url>turns a web app manifest into an Android project described bytwa-manifest.json;bubblewrap buildproduces a signed APK and AAB. Version 1.25.0 (July 2026) targets API level 36, which Google Play requires for new apps and updates from August 31, 2026. - Some features are delegated to the app. Notifications (with the Android 13+
POST_NOTIFICATIONSruntime permission), geolocation and Google Play Billing (through the Digital Goods API) are handled by services in your APK, not by Chrome. - Play Billing needs a rebuild in 2026. Android Browser Helper's billing module 1.2.0 moves to Play Billing Library 8.3.0 and requires
minSdkVersion23;listPurchaseHistory()now always returns an empty list, and subscriptions with several base plans or offers are flattened to one.
What a Trusted Web Activity actually is¶
Chrome shipped Trusted Web Activities in Chrome 72 for Android, in early 2019. The concept builds on two older pieces of Android infrastructure:
- Custom Tabs, introduced in Chrome 45, let an app open a URL in the user's browser inside the app's task, with a toolbar the app can color and customize. The page runs in the real browser, with the user's cookies, saved passwords and permissions.
- Digital Asset Links, Google's protocol for publicly declaring that a website and an app (or two websites) belong to the same principal. The same statement file powers Android App Links verification and Credential Manager sharing between an app and a site.
A TWA combines them: the app launches a Custom Tab with a flag that asks the browser to drop all browser UI, and the browser agrees only if Digital Asset Links show that the app's signing certificate and the site's origin are controlled by the same developer. The result looks like a native app (launcher icon, entry in Recents, no URL bar), but everything inside the window is the browser.
flowchart LR
subgraph APK["Your Android app (APK / AAB)"]
LA["LauncherActivity"]
DS["DelegationService"]
RES["asset_statements, icons, splash, colors"]
end
subgraph Browser["Chrome (TWA provider)"]
CTS["CustomTabsService"]
OV["Origin verifier"]
TAB["Full-screen tab: renderer, storage, service workers"]
end
SITE["https://app.example.com/.well-known/assetlinks.json"]
LA -- "bind + TWA launch intent" --> CTS
CTS --> OV
OV -- "fetch and match statements" --> SITE
OV -- "verified: hide browser UI" --> TAB
TAB -- "notifications, location, billing" --> DS What that architecture means in practice:
| Concern | Who owns it in a TWA | Consequence |
|---|---|---|
| Rendering engine, JavaScript, Web APIs | The browser (Chrome) | You get evergreen Chrome features without shipping an engine; the app never sees the DOM |
Cookies, localStorage, IndexedDB, Cache Storage, service workers | The browser profile, shared with normal tabs of the same origin | A user signed in on your site in Chrome is signed in inside the TWA, and vice versa |
| Site permissions (camera, microphone, and so on) | The browser | Normal web permission prompts, except for the delegated ones below |
| Notifications, geolocation, Play Billing | Delegated to your APK when configured | Notifications are attributed to your app, location uses your app's Android permission, purchases go through Play |
| App identity, icon, name, Recents entry | Your APK | Store listing, versioning and signing follow normal Android rules |
| Updates to the UI and logic | Your web server | Deploying the site updates the app instantly; the APK changes only when its configuration does |
The app has no direct access to web state. The Chrome documentation puts it bluntly: "The host app doesn't have direct access to web content in a Trusted Web Activity or any other kind of web state, like cookies and localStorage." Data flows from the app to the page through the launch URL (query parameters, share data, file handles) and, since Chrome 115, through a postMessage channel described later in this page.
Storage is shared with the browser, and Chrome tells the user¶
Because the TWA runs in the browser's profile, Chrome shows a one-time disclosure the first time a given app package launches a TWA. Chromium ships two variants of it: the older, shorter "Running in Chrome" and the newer "You'll see your site sign-in status, browsing data, and site data in Chrome", shown as a snackbar or as a notification depending on the Chrome version, device and configuration. Developers regularly mistake the "Running in Chrome" message for a verification failure; it isn't one. A failed verification shows a toolbar with the URL, not a one-time message. Chrome records per package whether the disclosure was seen and accepted, so it appears once per app, not once per launch.
When the user uninstalls the app or clears its data, Chrome can show a dialog titled "App name also has data in Chrome" with a "Keep Data" button and a path to the site settings, because clearing the APK's data doesn't touch the browser's storage for the origin. Bubblewrap-generated apps also add a "Site settings" launcher shortcut (enableSiteSettingsShortcut, on by default) that opens the browser's settings for the origin. For how browsers partition and evict that storage, see Storage Quotas & Persistence and Privacy & Storage Partitioning.
Timeline of the pieces you depend on¶
| Date | Change |
|---|---|
| 2015 | Custom Tabs ship in Chrome 45 |
| Early 2019 | Trusted Web Activities ship in Chrome 72 for Android |
| 2019 | Splash screen transfer from app to browser, Chrome 75 |
| June 2020 | Chromium blog announces crash-based quality criteria starting in Chrome 86 |
| 2022 | Digital Goods API enabled by default: Chrome 100 (ChromeOS), Chrome 101 (Android) |
| May 2023 | Chromium commit "Remove TWA QualityEnforcer related code" (Chromium 115) |
| 2023 | postMessage between app and TWA page, Chrome 115.0.5790.13 or later |
| September 2025 | Bubblewrap 1.24.0 adds displayOverride to twa-manifest.json |
| April 2026 | Android Browser Helper 2.7.0 (Play Billing Library 7.1.1, Auth Tab fallback, edge-to-edge splash, display override) |
| July 2026 | Android Browser Helper billing 1.2.0 (Play Billing Library 8.3.0); Bubblewrap 1.25.0 (targetSdkVersion 36, billing 1.2.0) |
| August 2026 | Android Browser Helper 2.7.3 (high-priority notification channels) |
The Custom Tabs protocol underneath¶
You rarely touch the protocol directly (Android Browser Helper wraps it), but knowing it explains most TWA behavior, from browser selection to why the splash screen needs a FileProvider.
Discovering a TWA provider¶
A browser advertises Custom Tabs support by exporting a service that handles the intent action android.support.customtabs.action.CustomTabsService (CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION). It advertises TWA support by adding the category androidx.browser.trusted.category.TrustedWebActivities (CustomTabsService.TRUSTED_WEB_ACTIVITY_CATEGORY) to that service's intent filter. Android Browser Helper's TwaProviderPicker queries the package manager for activities that handle an http VIEW intent (default-category matches first, then, on Android 6.0+, all matches), walks that list, which in practice usually starts with the user's default browser, and returns the first match in this priority:
- The first browser whose Custom Tabs service carries the TWA category → launch mode
TRUSTED_WEB_ACTIVITY. (Chrome 72 to 74 supported TWAs before the category existed, so the library special-cases them by version code.) - Otherwise, the first browser with any Custom Tabs service →
CUSTOM_TAB. - Otherwise, any browser that handles
httpVIEW intents →BROWSER.
This ordering has a consequence you should plan for: if the user's default browser supports TWAs, your app typically opens in that browser, not necessarily in Chrome. On a phone whose default browser is Samsung Internet or Edge, the TWA runs there, with that browser's storage, cookies and feature set. If a feature you depend on (notification delegation, for example) isn't supported by every TWA browser, test on each, or pin a browser with the LAUNCHING_BROWSER metadata described in the Android Browser Helper section.
On Android 11 and later, package visibility rules hide other apps unless you declare what you need to query. Android Browser Helper's own manifest contributes a <queries> entry for VIEW intents on https URLs, which the manifest merger adds to your app. If you implement the protocol yourself, also declare the Custom Tabs service action:
<queries>
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
Sessions, warm-up and the launch intent¶
The launch sequence Android Browser Helper runs for you looks like this:
sequenceDiagram
participant User
participant LA as LauncherActivity
participant CT as Browser CustomTabsService
participant OV as Browser origin verifier
participant Site as assetlinks.json
User->>LA: tap launcher icon
LA->>LA: pick provider, show splash screen
LA->>CT: bindService(ACTION_CUSTOM_TABS_CONNECTION)
CT-->>LA: onCustomTabsServiceConnected(client)
LA->>CT: warmup(), newSession(callback)
LA->>CT: transfer splash image through FileProvider
LA->>CT: launch TWA intent (session, URL, colors, display mode, splash params)
CT->>OV: verify origin of the launch URL for the calling package
OV->>Site: GET /.well-known/assetlinks.json
Site-->>OV: statements
alt statement matches package and certificate
OV-->>User: full-screen content, splash fades out on first paint
else no match
OV-->>User: same content in a Custom Tab with URL bar
end
LA->>LA: finish() once the browser is launched A few protocol details are worth knowing:
- The session identifies the caller. The browser learns the calling package from the Custom Tabs session. Verification binds three things: that package name, its signing certificate as reported by Android's package manager, and the origin of the URL being displayed.
warmup()starts the browser process early. The Custom Tabs guide cites savings of up to 700 ms. Android Browser Helper calls it for you.- The TWA intent is a Custom Tabs intent with extras.
TrustedWebActivityIntentBuilder(inandroidx.browser.trusted) builds it. Its public setters map almost one-to-one to Bubblewrap options:setToolbarColor,setNavigationBarColor,setNavigationBarDividerColor,setColorScheme,setColorSchemeParams,setDefaultColorSchemeParams,setAdditionalTrustedOrigins,setSplashScreenParams,setShareParams,setFileHandlingData,setDisplayMode,setDisplayOverrideList,setScreenOrientation,setOriginalLaunchUrlandsetLaunchHandlerClientMode. The corresponding intent extras use theandroidx.browser.trusted.extra.prefix (for exampleandroidx.browser.trusted.extra.DISPLAY_MODE). - Display modes are classes, not strings.
TrustedWebActivityDisplayModehasDefaultMode,ImmersiveMode(isSticky, layoutInDisplayCutoutMode),MinimalUiMode,BrowserMode,TabbedModeandWindowControlsOverlayModeimplementations; the browser decides which it supports. - Relationship validation has two relations.
CustomTabsService.RELATION_HANDLE_ALL_URLS(value2) corresponds to thedelegate_permission/common.handle_all_urlsstatement that TWAs rely on.RELATION_USE_AS_ORIGIN(value1) corresponds todelegate_permission/common.use_as_origin, needed for thepostMessagechannel.
What "verified" means while the user navigates¶
Verification isn't a one-off launch check; the browser evaluates every top-level navigation:
- Navigations within the verified origin (or any origin you list in
additionalTrustedOriginsand verified with its ownassetlinks.json) stay full screen. - A navigation to any other origin shows the Custom Tabs toolbar with the URL and an X button that returns the user to the app, so users always know when they have left your site. OAuth and payment redirects to third-party origins therefore show a toolbar briefly, which is expected.
- Subresources and iframes aren't checked; only the top-level document's origin matters.
Digital Asset Links: proving the app and site belong together¶
Verification needs statements in two places: the app says "I want to act for this site", and the site says "this app, signed with this certificate, may act for me". Getting either side wrong produces the dreaded URL bar.
flowchart TD
A["TWA launch for https://app.example.com/"] --> B{"Browser supports TWA?"}
B -- no --> F1["Android Browser Helper fallback: Custom Tab or WebView"]
B -- yes --> C{"assetlinks.json fetched: HTTP 200, valid TLS, no redirect, JSON?"}
C -- no --> F2["Custom Tab with URL bar"]
C -- yes --> D{"Statement with handle_all_urls, this package_name and a matching sha256 fingerprint?"}
D -- no --> F2
D -- yes --> E["Full screen, no browser UI"] The app side: asset_statements¶
The APK points at the site with a string resource referenced from <application> metadata. Bubblewrap generates both:
<application ...>
<meta-data
android:name="asset_statements"
android:resource="@string/assetStatements" />
</application>
<resources>
<string name="assetStatements">
[{
\"relation\": [\"delegate_permission/common.handle_all_urls\"],
\"target\": {
\"namespace\": \"web\",
\"site\": \"https://app.example.com\"
}
}]
</string>
</resources>
The site value is an origin: scheme and host, plus port only if it isn't the default. No path, no trailing slash needed. For every extra origin you list in additionalTrustedOrigins, add another statement with that origin.
The site side: /.well-known/assetlinks.json¶
The website publishes a JSON array of statements at exactly /.well-known/assetlinks.json on the origin being opened. Google's documentation is explicit that statement lists "in any other location, or with any other name, are not valid for this site."
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app.twa",
"sha256_cert_fingerprints": [
"14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5",
"FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C"
]
}
}
]
The fingerprints above are placeholders; yours come from the keys described in the next section. The rules the Digital Asset Links documentation sets for serving the file:
| Requirement | Detail |
|---|---|
| Path | Exactly /.well-known/assetlinks.json at the origin root |
| Status | "Any response from the server besides HTTP 200 is treated as an error, and will result in an empty statement list" |
| Redirects | Not followed: "301 or 302 response codes are not followed". An http→https or apex→www redirect on this path breaks verification |
| TLS | A certificate chain that can't be verified against the trusted roots also yields an empty list |
| Content type | Content-Type: application/json |
| Format | A JSON array; a trailing comma or comment makes it invalid |
| Access | Publicly reachable with no authentication, geo-blocking, bot challenge or cookie wall |
Every origin needs its own file. app.example.com and www.example.com are different origins, and so are example.com and example.com:8443.
# Inside the server { } block for https://app.example.com
# Exact-match location: wins over SPA fallbacks and trailing-slash rewrites.
location = /.well-known/assetlinks.json {
root /srv/www/app; # file lives at /srv/www/app/.well-known/assetlinks.json
types { } # ignore mime.types for this location...
default_type application/json; # ...and force application/json
add_header Cache-Control "public, max-age=3600";
}
import express from "express";
const router = express.Router();
// Fingerprints come from configuration so staging and production can differ.
// Fail at startup rather than serve an empty or broken statement list.
const { TWA_PACKAGE_ID, TWA_SHA256_FINGERPRINTS } = process.env;
if (!TWA_PACKAGE_ID || !TWA_SHA256_FINGERPRINTS) {
throw new Error("TWA_PACKAGE_ID and TWA_SHA256_FINGERPRINTS must be set");
}
const statements = [
{
relation: ["delegate_permission/common.handle_all_urls"],
target: {
namespace: "android_app",
package_name: TWA_PACKAGE_ID,
sha256_cert_fingerprints: TWA_SHA256_FINGERPRINTS.split(",").map((f) =>
f.trim().toUpperCase()
),
},
},
];
const body = JSON.stringify(statements);
// Register this before any SPA catch-all route or HTTPS redirect middleware.
router.get("/.well-known/assetlinks.json", (req, res) => {
res.set("Content-Type", "application/json");
res.set("Cache-Control", "public, max-age=3600");
res.status(200).send(body);
});
export default router;
{
"hosting": {
"public": "dist",
"ignore": ["firebase.json", "**/node_modules/**"],
"headers": [
{
"source": "/.well-known/assetlinks.json",
"headers": [{ "key": "Content-Type", "value": "application/json" }]
}
],
"rewrites": [{ "source": "**", "destination": "/index.html" }]
}
}
The ignore list that firebase init generates contains "**/.*", which silently drops .well-known/ from the deploy; remove that entry or narrow it. Existing files are served before rewrites apply, so the SPA fallback doesn't shadow the statement file once it's deployed.
Static site generators skip dot-directories
Many build tools ignore directories that start with a dot. Jekyll needs include: [.well-known] in _config.yml; other generators need the file in a "public" or "static" folder that is copied verbatim. After every deploy, request the URL with curl -i and check the status, the Content-Type and that the body is your JSON rather than your SPA's index.html.
Which SHA-256 fingerprints to list¶
Android verifies an app by its signing certificate, and the certificate that signs the APK on a user's device is often not the one on your laptop. List every certificate that can sign a build users or testers install:
| Key | Where it's used | How to get the SHA-256 fingerprint |
|---|---|---|
Local signing key created by bubblewrap init (android.keystore, alias android by default) | APKs you sideload with bubblewrap install or adb install | keytool -list -v -keystore android.keystore -alias android, or bubblewrap fingerprint add |
| Upload key | Signs the AAB you upload; Play strips it | Same as above if the local key is your upload key. Only needed in assetlinks.json if you distribute builds signed with it |
| Play App Signing key | Signs every APK Google Play delivers to users | Play Console → Protected with Play → Play Store distribution → Go to Play app signing → "App signing key" section |
| Additional Play signing keys (quantum-ready hybrid signing) | New apps using AABs are automatically enrolled; Android 17+ devices verify with a separate key | The same Play app signing page lists them; Google's help says you "must copy the fingerprints for three keys and register each of them with your API providers" |
The most frequent production bug is shipping an assetlinks.json that lists only the local key: sideloaded builds work, the Play Store build shows a URL bar. With Play App Signing, Google re-signs your app with the app signing key, so that key's fingerprint must be in the file. With Play's quantum-ready hybrid signing (a classical RSA-4096 key plus an ML-DSA-65 post-quantum key, verified by Android 17+ devices through APK Signature Scheme v3.2), Play uses a different classical key for newer devices than for older ones, so copy every SHA-256 fingerprint the Play app signing page shows. A fingerprint too many does no harm; a missing one breaks verification for a subset of devices, which is much harder to diagnose.
# From a keystore (Bubblewrap's default path and alias):
keytool -list -v -keystore ./android.keystore -alias android | grep "SHA256:"
# From a built APK or AAB (shows the certificate that actually signed it):
keytool -printcert -jarfile app-release-signed.apk | grep "SHA256:"
# From an APK with the Android SDK build tools (handles v2/v3 signature schemes):
apksigner verify --print-certs app-release-signed.apk | grep "SHA-256"
# From an app installed on a device (useful to inspect the Play-delivered build):
adb shell pm path com.example.app.twa # prints package:/data/app/.../base.apk
adb pull /data/app/<path-from-above>/base.apk play-build.apk
apksigner verify --print-certs play-build.apk | grep "SHA-256"
apksigner prints fingerprints as lowercase hex without colons, while assetlinks.json uses uppercase, colon-separated pairs. Convert rather than retype them.
Bubblewrap can keep the list in twa-manifest.json and generate the file for you:
# Add the Play App Signing key (paste from Play Console) with a label:
bubblewrap fingerprint add "FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C" --name="play-app-signing"
bubblewrap fingerprint list
bubblewrap fingerprint remove "<fingerprint>"
# Writes an assetlinks.json containing all stored fingerprints:
bubblewrap fingerprint generateAssetLinks --output=assetlinks.json
Verifying the statement list the way Google sees it¶
Google exposes the Digital Asset Links API, which fetches and parses your file with the same rules. Two endpoints are useful:
# Everything Google can parse from your site's statement list:
curl -s "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://app.example.com&relation=delegate_permission/common.handle_all_urls"
# A yes/no answer for one package and one certificate:
curl -s "https://digitalassetlinks.googleapis.com/v1/assetlinks:check?source.web.site=https://app.example.com&relation=delegate_permission/common.handle_all_urls&target.android_app.package_name=com.example.app.twa&target.android_app.certificate.sha256_fingerprint=FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C"
# → { "linked": true, "maxAge": "3599.99s" }
The maxAge in the response (about an hour) shows that Google caches statement lists; after fixing the file, a stale negative result can persist for a while. The Digital Asset Links Statement List Generator and Tester wraps the same API in a form.
For CI, a script that checks the transport rules directly catches the problems the API reports only as "not linked":
// Usage: node scripts/check-assetlinks.mjs https://app.example.com com.example.app.twa FINGERPRINT [FINGERPRINT...]
// Exits non-zero when the statement list would fail Digital Asset Links verification.
const [origin, packageName, ...fingerprints] = process.argv.slice(2);
if (!origin || !packageName || fingerprints.length === 0) {
console.error("usage: check-assetlinks.mjs <origin> <package> <sha256> [sha256...]");
process.exit(2);
}
const RELATION = "delegate_permission/common.handle_all_urls";
const normalize = (fp) => fp.replace(/[^0-9a-f]/gi, "").toUpperCase();
const errors = [];
async function checkTransport() {
const url = new URL("/.well-known/assetlinks.json", origin);
// redirect: "manual" because Digital Asset Links does not follow redirects.
const res = await fetch(url, { redirect: "manual" });
if (res.status !== 200) {
errors.push(`${url} returned HTTP ${res.status}; only 200 is accepted (redirects are not followed)`);
return null;
}
const type = res.headers.get("content-type") || "";
if (!type.toLowerCase().startsWith("application/json")) {
errors.push(`Content-Type is "${type}", expected application/json`);
}
const text = await res.text();
try {
const json = JSON.parse(text);
if (!Array.isArray(json)) errors.push("Top-level value must be a JSON array");
return json;
} catch (err) {
errors.push(`Body is not valid JSON (${err.message}); first bytes: ${text.slice(0, 60)}`);
return null;
}
}
function checkStatements(statements) {
const listed = new Set();
for (const s of statements) {
const relations = Array.isArray(s?.relation) ? s.relation : [];
const t = s?.target;
if (!relations.includes(RELATION)) continue;
if (t?.namespace !== "android_app" || t?.package_name !== packageName) continue;
for (const fp of t.sha256_cert_fingerprints ?? []) listed.add(normalize(fp));
}
if (listed.size === 0) {
errors.push(`No ${RELATION} statement for package ${packageName}`);
return;
}
for (const fp of fingerprints) {
if (!listed.has(normalize(fp))) errors.push(`Fingerprint ${fp} is not listed for ${packageName}`);
}
}
async function checkWithGoogle() {
// Ask Google's Digital Asset Links API, which applies the production parsing rules.
for (const fp of fingerprints) {
const api = new URL("https://digitalassetlinks.googleapis.com/v1/assetlinks:check");
api.searchParams.set("source.web.site", origin);
api.searchParams.set("relation", RELATION);
api.searchParams.set("target.android_app.package_name", packageName);
api.searchParams.set("target.android_app.certificate.sha256_fingerprint", fp);
const res = await fetch(api);
const body = await res.json().catch(() => ({}));
if (!res.ok || body.linked !== true) {
errors.push(`Digital Asset Links API: not linked for ${fp} (HTTP ${res.status})`);
}
}
}
try {
const statements = await checkTransport();
if (statements && Array.isArray(statements)) checkStatements(statements);
await checkWithGoogle();
} catch (err) {
errors.push(`Network error: ${err.message}`);
}
if (errors.length) {
for (const e of errors) console.error(`✗ ${e}`);
process.exit(1);
}
console.log(`✓ ${origin} delegates handle_all_urls to ${packageName} for ${fingerprints.length} key(s)`);
One statement file, three consumers¶
The handle_all_urls relation is shared with Android App Links: Bubblewrap's generated manifest declares an https VIEW intent filter with android:autoVerify="true" for your host, so Android itself also verifies the app against the same file and, when verification succeeds, opens links to your site directly in the app instead of offering a chooser. On Android 12 and later you can inspect that system-level verification with adb shell pm get-app-links <package> (see Debugging). The browser's TWA verification and Android's App Links verification are separate checks against the same statements, so one can pass while the other is still pending. If you also share credentials between the app and the site, add delegate_permission/common.get_login_creds to the same statement's relation array. For link capture and launch behavior on the web side, see Protocol Handlers & Launch Handling.
Building a TWA with Bubblewrap¶
Bubblewrap is Google's command-line tool that generates, builds and signs a TWA Android project from a web app manifest. PWABuilder uses it under the hood for its Android package. The current release is 1.25.0 (published July 31, 2026), which raises targetSdkVersion and compileSdkVersion to 36 and moves the Play Billing module to 1.2.0.
Requirements¶
| Requirement | Detail |
|---|---|
| Node.js | 18 or later. The CLI README still says 14.15.0, but the published @bubblewrap/[email protected] package declares "engines": { "node": ">=18.0.0" } |
| JDK | JDK 17 exactly. The README warns that a lower version makes it impossible to compile the project and that higher versions are incompatible with the Android command-line tools. Bubblewrap offers to download Temurin 17 on first run (including an aarch64 build on Apple silicon since 1.22.6) |
| Android SDK | The command-line tools; Bubblewrap offers to download them and asks you to accept the SDK terms. Avoid spaces in androidSdkPath |
| Config file | ~/.bubblewrap/config.json stores jdkPath and androidSdkPath; change them with bubblewrap updateConfig --jdkPath=... --androidSdkPath=... and check them with bubblewrap doctor |
| Alternative | A Docker image: docker run --rm -ti ghcr.io/googlechromelabs/bubblewrap:latest <command> |
Install it globally without sudo: npm i -g @bubblewrap/cli. Check what you actually got with bubblewrap version before a release build. The 1.25.0 rollout showed why: for its first days the CLI couldn't be installed from npm because its @bubblewrap/[email protected] dependency hadn't been published (issue #1055), and until late August 2026 the ghcr.io/googlechromelabs/bubblewrap:1.25.0 image contained CLI 1.24.1 and generated targetSdkVersion 35 (issue #1056). Both are fixed. In CI, pin an exact CLI version and assert targetSdkVersion in the generated app/build.gradle, so a stale toolchain fails the build instead of failing Play's review.
Step 1: bubblewrap init¶
mkdir my-twa && cd my-twa
bubblewrap init --manifest=https://app.example.com/manifest.webmanifest
init downloads your web app manifest and asks you to confirm or change each derived value. The prompts, in order of the underlying strings, are: Domain, URL path, Application name, Short name, Application ID, Starting version code, Display mode, Orientation, Status bar color, Splash screen color, Icon URL, Maskable icon URL, Monochrome icon URL, Include app shortcuts?, Include support for Play Billing?, Request geolocation permission?, Key store location and Key name, followed by the keystore questions (names, organization, two-letter country, keystore and key passwords) if the key doesn't exist yet.
Flags change what init generates:
| Flag | Effect |
|---|---|
--directory=<path> | Output directory (defaults to the current one) |
--chromeosonly | Adds <uses-feature android:name="org.chromium.arc" android:required="true"/> so Play only offers the app on ChromeOS devices |
--metaquest | Generates a Meta Quest compatible build (hand-tracking permission and features, minSdkVersion 23, com.oculus.pwa.* metadata) |
--alphaDependencies | Uses the alpha Android Browser Helper (2.7.0-alpha02 in 1.25.0) instead of the stable one (2.6.2) |
The Application ID defaults to your host reversed with .twa appended: app.example.com becomes com.example.app.twa. Choose it carefully: the package name is permanent on Google Play, and it's the package_name in assetlinks.json.
Back up the keystore and passwords
init creates android.keystore in the project. If it's also your upload key, losing it means asking Play support for an upload key reset; if you opted out of Play App Signing, losing it means you can never update the app. Store the keystore and both passwords in a secrets manager, not in the repository. In CI, pass the passwords with the BUBBLEWRAP_KEYSTORE_PASSWORD and BUBBLEWRAP_KEY_PASSWORD environment variables.
Step 2: understand twa-manifest.json¶
init writes twa-manifest.json, the single source of truth for the Android project. Bubblewrap regenerates the Android files from it on update, and warns that it "doesn't expect the generated Android project to be updated using external editors", so make changes here rather than in Gradle or XML files. A realistic, complete example:
{
"packageId": "com.example.app.twa",
"host": "app.example.com",
"name": "Example Tasks",
"launcherName": "Tasks",
"display": "standalone",
"displayOverride": [],
"orientation": "default",
"themeColor": "#1A73E8",
"themeColorDark": "#0B1A2E",
"navigationColor": "#FFFFFF",
"navigationColorDark": "#000000",
"navigationDividerColor": "#00000000",
"navigationDividerColorDark": "#000000",
"backgroundColor": "#FFFFFF",
"enableNotifications": true,
"startUrl": "/?source=twa",
"iconUrl": "https://app.example.com/icons/icon-512.png",
"maskableIconUrl": "https://app.example.com/icons/maskable-512.png",
"monochromeIconUrl": "https://app.example.com/icons/monochrome-192.png",
"splashScreenFadeOutDuration": 300,
"signingKey": {
"path": "./android.keystore",
"alias": "android"
},
"appVersion": "7",
"appVersionCode": 7,
"shortcuts": [
{
"name": "New task",
"shortName": "New",
"url": "https://app.example.com/tasks/new",
"chosenIconUrl": "https://app.example.com/icons/shortcut-new-96.png"
}
],
"generatorApp": "bubblewrap-cli",
"webManifestUrl": "https://app.example.com/manifest.webmanifest",
"fallbackType": "customtabs",
"features": {
"locationDelegation": { "enabled": false },
"playBilling": { "enabled": true },
"firstRunFlag": { "enabled": true, "queryParameterName": "first_run" }
},
"alphaDependencies": { "enabled": false },
"enableSiteSettingsShortcut": true,
"isChromeOSOnly": false,
"isMetaQuest": false,
"minSdkVersion": 23,
"additionalTrustedOrigins": ["checkout.example.com"],
"fingerprints": [
{ "name": "upload", "value": "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5" },
{ "name": "play-app-signing", "value": "FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C" }
],
"shareTarget": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": { "title": "title", "text": "text", "url": "url", "files": [{ "name": "file", "accept": ["image/*"] }] }
},
"protocolHandlers": [
{ "protocol": "web+tasks", "url": "/open?link=%s" }
],
"fileHandlers": [
{ "actionUrl": "https://app.example.com/import", "mimeTypes": ["text/csv"] }
],
"launchHandlerClientMode": "focus-existing"
}
init writes shortcuts entries (name, shortName, url and the chosen icon URLs) and the other nested objects from your web manifest, so treat the example as illustrative and edit the file Bubblewrap generated for you. The fields, their defaults and what they turn into in the Android project:
| Field | Default | What it controls |
|---|---|---|
packageId | Reversed host + .twa | Android applicationId, the package_name in assetlinks.json |
host | From the manifest URL | The verified origin (always https), the App Links intent filter host |
startUrl | From manifest start_url | Path relative to host; becomes the DEFAULT_URL metadata. Must be same-origin |
name / launcherName | Manifest name / manifest short_name | App label and launcher label. Without a short_name, Bubblewrap truncates name to 12 characters for the launcher |
display | standalone | standalone, minimal-ui, fullscreen (immersive), fullscreen-sticky (sticky immersive) or browser |
displayOverride | none | Ordered fallback list, like the manifest's display_override; since Bubblewrap 1.24.0 |
orientation | default | default, any, natural, portrait, portrait-primary, portrait-secondary, landscape, landscape-primary, landscape-secondary |
themeColor / themeColorDark | #FFFFFF / #000000 | Status bar color in light and dark system themes |
navigationColor / navigationColorDark | #000000 | Navigation bar colors |
navigationDividerColor / navigationDividerColorDark | #00000000 (transparent) / #000000 | Navigation bar divider |
backgroundColor | #FFFFFF | Splash screen background |
iconUrl | Largest suitable manifest icon | Launcher and splash icon; must be at least 512 × 512 |
maskableIconUrl | Manifest icon with purpose: "maskable" | Adaptive launcher icon on devices that support it |
monochromeIconUrl | Manifest icon with purpose: "monochrome" | Notification small icon (at least 48 × 48) |
splashScreenFadeOutDuration | 300 (ms) | Fade-out when the browser removes the splash screen |
signingKey | { "path": "./android.keystore", "alias": "android" } | Keystore used by build |
appVersionCode / appVersion | 1 / "1" | Android versionCode and versionName; update increments the code |
enableNotifications | true | Enables the DelegationService and adds POST_NOTIFICATIONS |
shortcuts | From manifest shortcuts | Launcher shortcuts; the Gradle build asserts "You can have at most 4 shortcuts." |
webManifestUrl | The manifest URL | Used by ChromeOS and Meta Quest to install the web version instead of running the TWA |
fallbackType | customtabs | customtabs or webview when no TWA-capable browser exists |
features | {} | locationDelegation, playBilling, firstRunFlag, appsFlyer, arCore |
alphaDependencies | disabled | Switches to the alpha Android Browser Helper |
enableSiteSettingsShortcut | true | Adds ManageDataLauncherActivity and a "Site settings" shortcut |
isChromeOSOnly / isMetaQuest | false | Same as the init flags |
fullScopeUrl | none | Scope as a full URL (Meta Quest) |
minSdkVersion | 21 (23 for Meta Quest) | Lowest Android API level the app installs on. Must be at least 23 when Play Billing is enabled or with alphaDependencies (see below) |
additionalTrustedOrigins | [] | Extra hosts that stay full screen; each needs its own assetlinks.json |
fingerprints | [] | Used by bubblewrap fingerprint generateAssetLinks |
shareTarget | From manifest share_target | Android share intent filter plus the METADATA_SHARE_TARGET JSON |
protocolHandlers | From manifest protocol_handlers | Intent filters for custom schemes, converted to https URLs with the %s template |
fileHandlers | From manifest file_handlers | actionUrl and mimeTypes for opening files from other apps |
launchHandlerClientMode | From manifest launch_handler | navigate-existing, focus-existing, navigate-new or auto |
serviceAccountJsonFile / retainedBundles | none | Used by bubblewrap play publishing commands |
Most of these mirror web app manifest members; the key difference is that in a TWA they're baked into the APK at build time. Changing theme_color in your web manifest doesn't recolor the status bar until you run bubblewrap merge (or edit twa-manifest.json), update and ship a new version. See App Identity & Updates for the equivalent rules for browser-installed PWAs.
Play Billing 1.2.0 and Android Browser Helper 2.7.x need minSdkVersion 23
Bubblewrap's default minSdkVersion is still 21, which matches the stable androidbrowserhelper:2.6.2 it pins. But the libraries you need in 2026 raised their floor: com.google.androidbrowserhelper:billing:1.2.0 and every androidbrowserhelper 2.7.x release (including the 2.7.0-alpha02 that alphaDependencies selects) declare minSdkVersion 23 in their Gradle builds. If the app still declares 21, Gradle's manifest merger stops the build with uses-sdk:minSdkVersion 21 cannot be smaller than version 23 declared in library, which Bubblewrap users reported when the alpha dependency moved to 2.7.0. Set "minSdkVersion": 23 in twa-manifest.json and run bubblewrap update. The alternative the error message suggests, tools:overrideLibrary, forces the library onto devices it wasn't built for and "may lead to runtime failures". Android 6.0 (API level 23) is from 2015, so the reach you lose is very small. The locationdelegation:1.1.2 release still supports API level 21.
Play Billing also requires enableNotifications: true. The billing handler runs inside DelegationService, which Bubblewrap only enables when notifications are on, and init/update fail with "Play Billing requires enableNotifications" otherwise.
Step 3: bubblewrap build¶
bubblewrap build
# → app-release-signed.apk (for testing and sideloading)
# → app-release-bundle.aab (upload this to Google Play)
build compares twa-manifest.json with the checksum stored in manifest-checksum.txt; if you edited the manifest since the last generation, it offers to run update first. It then compiles with Gradle, zip-aligns and signs the APK, and builds and signs the App Bundle. Options: --skipSigning (produces unsigned outputs for signing elsewhere), --signingKeyPath and --signingKeyAlias (override the manifest), --manifest (path to twa-manifest.json) and --skipPwaValidation. The last one is still documented, but the 1.25.0 build command no longer calls the PWA validator at all, so it has no effect. When build prompts for the keystore and key passwords, it requires at least six characters, the same minimum keytool enforces.
bubblewrap validate depends on a removed Lighthouse category
bubblewrap validate --url=<url> runs PageSpeed Insights and requires a performance score of at least 0.8 and a passing Lighthouse PWA category. Lighthouse 12 removed the PWA category, so this check can't pass as designed. Use Lighthouse & Auditing and Core Web Vitals as your quality gate instead.
Step 4: install, test and publish¶
bubblewrap install # adb install of ./app-release-signed.apk on the connected device
bubblewrap install --verbose # prints the adb commands it runs
adb logcat -v brief | grep -e TWAProviderPicker -e OriginVerifier -e digital_asset_links
Then upload app-release-bundle.aab to an internal testing track, copy the Play App Signing fingerprint(s) into assetlinks.json, and install the Play-delivered build to confirm it opens without a URL bar. Publishing to App Stores covers the store listing, policy and review side; the bubblewrap play commands (publish with --track, which defaults to the internal track, versionCheck, and the experimental retain) automate uploads with a Play service account.
Other Bubblewrap commands¶
| Command | Purpose |
|---|---|
bubblewrap update [--appVersionName=<name>] [--skipVersionUpgrade] | Regenerates the Android project from twa-manifest.json. By default it increments appVersionCode by one and asks for a new versionName |
bubblewrap merge [--ignore <fields>] | Pulls changed values from the live web manifest into twa-manifest.json |
bubblewrap fingerprint with add, remove, list or generateAssetLinks | Manages the fingerprints list and writes assetlinks.json |
bubblewrap doctor | Checks the JDK and SDK paths and versions |
bubblewrap updateConfig | Changes the JDK or SDK path |
bubblewrap play with publish, versionCheck or retain | Talks to the Google Play Developer API with a service account |
bubblewrap help, bubblewrap version | Usage and version |
Android Browser Helper in a hand-written project¶
Android Browser Helper is the library Bubblewrap's generated project depends on. It sits on top of androidx.browser and provides LauncherActivity, TwaLauncher, TwaProviderPicker, DelegationService, the splash screen strategy, the WebView fallback and the site-settings activity. Use it directly when you add a TWA to an existing native app, need Kotlin/Gradle conventions Bubblewrap doesn't generate, or want to launch the TWA from a native screen rather than from the launcher.
The artifacts are versioned separately:
| Artifact | Latest release | Notes |
|---|---|---|
com.google.androidbrowserhelper:androidbrowserhelper | 2.7.3 (August 2026) | 2.7.0 added Play Billing Library 7.1.1, a Custom Tabs fallback for Auth Tab, edge-to-edge splash screens, display override and getUrlForIntent(); 2.7.1 added explicit browser targeting; 2.7.2 added extras on the TWA intent; 2.7.3 added high-priority notification channels |
com.google.androidbrowserhelper:billing | 1.2.0 (July 2026) | Play Billing Library 8.3.0; required to publish updates after August 31, 2026 |
com.google.androidbrowserhelper:locationdelegation | 1.1.2 | Geolocation delegation |
Bubblewrap 1.25.0 still pins androidbrowserhelper 2.6.2 for stable builds and 2.7.0-alpha02 with alphaDependencies. To use a feature that arrived later (high-priority notifications, explicit browser targeting), raise the version in app/build.gradle after each bubblewrap update, or maintain the project by hand. The 2.7 line depends on androidx.browser 1.10.0 and, like billing 1.2.0, requires minSdk 23.
android {
compileSdk 36
defaultConfig {
applicationId "com.example.app.twa"
minSdk 23 // androidbrowserhelper 2.7.x and billing 1.2.0 declare minSdkVersion 23
targetSdk 36 // Required by Google Play for new apps and updates from Aug 31, 2026
versionCode 7
versionName "7"
// Values referenced from AndroidManifest.xml
resValue "string", "launchUrl", "https://app.example.com/?source=twa"
resValue "string", "hostName", "app.example.com"
resValue "string", "providerAuthority", "com.example.app.twa.fileprovider"
}
}
dependencies {
implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.7.3'
implementation 'com.google.androidbrowserhelper:billing:1.2.0' // only with Play Billing
}
The manifest configures everything declaratively; LauncherActivity reads the android.support.customtabs.trusted.* metadata at launch:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:manageSpaceActivity="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<meta-data android:name="asset_statements" android:resource="@string/assetStatements" />
<!-- Target of manageSpaceActivity above and of the "Site settings" launcher shortcut.
Like any activity, it only works if it's declared here. -->
<activity
android:name="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"
android:exported="false"
android:excludeFromRecents="true">
<meta-data android:name="android.support.customtabs.trusted.MANAGE_SPACE_URL"
android:value="@string/launchUrl" />
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name="com.google.androidbrowserhelper.trusted.LauncherActivity"
android:alwaysRetainTaskState="true"
android:exported="true">
<meta-data android:name="android.support.customtabs.trusted.DEFAULT_URL"
android:value="@string/launchUrl" />
<meta-data android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
android:resource="@color/colorPrimary" />
<meta-data android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
android:resource="@color/navigationColor" />
<meta-data android:name="android.support.customtabs.trusted.SPLASH_IMAGE_DRAWABLE"
android:resource="@drawable/splash" />
<meta-data android:name="android.support.customtabs.trusted.SPLASH_SCREEN_BACKGROUND_COLOR"
android:resource="@color/backgroundColor" />
<meta-data android:name="android.support.customtabs.trusted.SPLASH_SCREEN_FADE_OUT_DURATION"
android:value="300" />
<meta-data android:name="android.support.customtabs.trusted.FILE_PROVIDER_AUTHORITY"
android:value="@string/providerAuthority" />
<meta-data android:name="android.support.customtabs.trusted.FALLBACK_STRATEGY"
android:value="customtabs" />
<meta-data android:name="android.support.customtabs.trusted.SCREEN_ORIENTATION"
android:value="portrait" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- App Links: open https://app.example.com/* in the app, verified via assetlinks.json -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="@string/hostName" />
</intent-filter>
</activity>
<activity android:name="com.google.androidbrowserhelper.trusted.FocusActivity" />
<activity android:name="com.google.androidbrowserhelper.trusted.WebViewFallbackActivity"
android:configChanges="orientation|screenSize" />
<activity android:name="com.google.androidbrowserhelper.trusted.NotificationPermissionRequestActivity" />
<!-- The splash image is handed to the browser through this provider. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/providerAuthority"
android:grantUriPermissions="true"
android:exported="false">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" />
</provider>
<!-- Receives notifications and extra commands (location, billing) from the browser. -->
<service
android:name=".DelegationService"
android:exported="true">
<meta-data android:name="android.support.customtabs.trusted.SMALL_ICON"
android:resource="@drawable/ic_notification_icon" />
<intent-filter>
<action android:name="android.support.customtabs.trusted.TRUSTED_WEB_ACTIVITY_SERVICE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
</application>
</manifest>
<paths>
<files-path path="twa_splash/" name="twa_splash" />
</paths>
The manifest's .DelegationService is a class in your own package. It can be a one-line subclass; Bubblewrap generates the same thing and injects the handlers for the features you enabled into onCreate():
package com.example.app.twa;
import com.google.androidbrowserhelper.playbilling.digitalgoods.DigitalGoodsRequestHandler;
public class DelegationService extends com.google.androidbrowserhelper.trusted.DelegationService {
@Override
public void onCreate() {
super.onCreate();
// The base class already registers the notification-permission handler.
// Register one handler per delegated feature; only needed with the billing module.
registerExtraCommandHandler(new DigitalGoodsRequestHandler(getApplicationContext()));
// With the locationdelegation module, also:
// registerExtraCommandHandler(new LocationDelegationExtraCommandHandler());
}
}
onExtraCommand() offers each command to the registered handlers in order and returns the first result that reports success, so the order matters only if two handlers claim the same command name.
The complete list of LauncherActivity metadata keys in the current library:
| Metadata name | Value |
|---|---|
android.support.customtabs.trusted.DEFAULT_URL | Launch URL when the intent carries none |
…STATUS_BAR_COLOR, …STATUS_BAR_COLOR_DARK | Color resources |
…NAVIGATION_BAR_COLOR, …NAVIGATION_BAR_COLOR_DARK | Color resources |
androix.browser.trusted.NAVIGATION_BAR_DIVIDER_COLOR, …_DARK | Color resources (the androix spelling is what the library reads) |
…SPLASH_IMAGE_DRAWABLE, …SPLASH_SCREEN_BACKGROUND_COLOR, …SPLASH_SCREEN_FADE_OUT_DURATION, …FILE_PROVIDER_AUTHORITY | Splash screen configuration |
…START_CHROME_BEFORE_ANIMATION_COMPLETE | Start the browser before the launcher's enter animation finishes (default false) |
…METADATA_SHARE_TARGET | Web Share Target JSON |
…ADDITIONAL_TRUSTED_ORIGINS | String-array resource of extra origins |
…FALLBACK_STRATEGY | customtabs (default, also used for unknown values) or webview |
…DISPLAY_MODE | default, immersive, sticky-immersive, minimal-ui or browser |
…DISPLAY_OVERRIDE | String-array resource: standalone, minimal-ui, fullscreen, browser, window-controls-overlay, tabbed |
…SCREEN_ORIENTATION | A Screen Orientation API value such as portrait-primary |
…FILE_HANDLING_ACTION_URL | URL opened when the app is launched with a content:// file |
…LAUNCH_HANDLER_CLIENT_MODE | navigate-existing, focus-existing, navigate-new or auto |
…LAUNCHING_BROWSER, …LAUNCHING_BROWSER_NAME | Package and display name of the one browser the TWA may use; if it's missing, the user sees a "browser unavailable" dialog instead of a fallback |
(… stands for android.support.customtabs.trusted..)
Customizing launch behavior in code¶
Subclass LauncherActivity when the launch URL depends on runtime state. Overridable hooks include getLaunchingUrl(), getUrlForIntent(Intent), shouldLaunchImmediately() with launchTwa(), getFallbackStrategy(), getDisplayMode(), getProtocolHandlers(), getSplashImageScaleType() and getCustomTabsCallback().
package com.example.app.twa;
import android.net.Uri;
import androidx.annotation.NonNull;
import java.util.Map;
public class LauncherActivity extends com.google.androidbrowserhelper.trusted.LauncherActivity {
private static final String PARAM_SOURCE = "source";
private static final String PARAM_APP_VERSION = "app_version";
/**
* Adds query parameters so the web app knows it runs inside this TWA and which
* APK version launched it. The page reads them once and stores them, because
* later same-document navigations won't carry them.
*/
@Override
protected Uri getLaunchingUrl() {
Uri uri = super.getLaunchingUrl();
return uri.buildUpon()
.appendQueryParameter(PARAM_SOURCE, "twa")
.appendQueryParameter(PARAM_APP_VERSION, String.valueOf(BuildConfig.VERSION_CODE))
.build();
}
/** Maps web+tasks://... links (declared as an intent filter) to an https URL. */
@NonNull
@Override
protected Map<String, Uri> getProtocolHandlers() {
return Map.of("web+tasks", Uri.parse("https://app.example.com/open?link=%s"));
}
}
BuildConfig.VERSION_CODE requires buildFeatures { buildConfig true } on recent Android Gradle Plugin versions. Map.of needs API level 30 or core library desugaring; use a HashMap if you target older devices without desugaring. When you replace the library's activity with your own subclass, point the manifest's <activity android:name> at your class.
Two behaviors of LauncherActivity explain task-management quirks you may see:
- One task per app.
LauncherActivityalways relaunches itself withFLAG_ACTIVITY_NEW_TASK(clearingFLAG_ACTIVITY_NEW_DOCUMENT), so the TWA occupies a single entry in Recents. If a TWA is already running and the user taps the launcher icon, the newLauncherActivityfinishes immediately instead of navigating the existing TWA, unless the intent carries a URL or share data. - It finishes after launching. Since Android Browser Helper 2.7.0,
LauncherActivitycallsfinish()as soon as the launch callback reports that the browser activity started. The 2.6.2 release that Bubblewrap pins by default leaves it in the back stack and finishes it inonRestart()when the user comes back to it. Either way, Back from the TWA returns the user to wherever they came from, not to an empty launcher activity.
To open a TWA from inside a native app screen, for example a "Help" button that opens your web help center full screen, use TwaLauncher directly:
private TwaLauncher twaLauncher;
private void openHelp() {
twaLauncher = new TwaLauncher(this);
twaLauncher.launch(Uri.parse("https://app.example.com/help"));
}
@Override
protected void onDestroy() {
super.onDestroy();
if (twaLauncher != null) twaLauncher.destroy(); // unbinds the Custom Tabs service
}
What happens when a TWA can't be shown¶
There are three different failure paths, handled by different components. Confusing them is common, because two of them look the same to the user.
| Situation | Who decides | What the user sees |
|---|---|---|
| A TWA-capable browser exists, but Digital Asset Links verification fails (or the user navigates to an unverified origin) | The browser | Your content inside a Custom Tab with a toolbar showing the URL and an X button |
| No installed browser supports TWAs, but one supports Custom Tabs | Android Browser Helper's FallbackStrategy (fallbackType) | customtabs: a Custom Tab with your toolbar color. webview: WebViewFallbackActivity, a WebView inside your app |
No browser at all, or the pinned LAUNCHING_BROWSER is missing | Android Browser Helper | A "browser unavailable" dialog |
Verification failure is a soft failure again¶
In June 2020 the Chromium blog announced that, starting in Chrome 86, Chrome would treat three conditions as native app crashes reported to Android vitals: an HTTP 404 or 5xx for a main document on a trusted origin, failure to return HTTP 200 for a main document while offline, and Digital Asset Links verification failure at launch. The mechanism was a Custom Tabs callback named quality_enforcement.crash; Android Browser Helper's default callback, QualityEnforcer, throws a RuntimeException when it receives it.
That enforcement didn't survive. Chromium removed its side with the commit "Remove TWA QualityEnforcer related code" in May 2023 (Chromium 115), and Chrome's related plan to require a working offline page for installability was put on hold "after listening to your feedback". Android Browser Helper still registers QualityEnforcer, so a browser that sent the message would still crash the app, but current Chrome doesn't send it. Current Chrome behavior on failed verification is the Custom Tab UI, as the Bubblewrap quick start describes.
The quality bar is still worth meeting: a 404 page or the browser's offline dinosaur inside a store-installed app is a bad review waiting to happen, and Google Play reviews the app like any other. Handle navigation failures in the service worker with an offline fallback page, as described in Offline UX & Fallbacks and Handling Fetch Events.
Choosing customtabs or webview¶
The WebView fallback keeps the app full screen on devices without a TWA-capable browser, but it's a different runtime: its own cookie jar and storage (the user isn't signed in), the system WebView's feature set (no service worker-based push notifications, no Web Share Target or Payment Request with Play Billing), and your app becomes responsible for WebView security settings. Bubblewrap adds the INTERNET permission when you pick webview. For most PWAs, the Custom Tabs fallback is the safer default: the user sees a URL bar, but everything works.
Detecting the TWA context in your web app¶
The page inside a TWA is an ordinary browser page. display-mode media queries reflect the mode the TWA was launched in, and for the default mode that's the same standalone value a browser-installed PWA reports, so they can't reliably distinguish a TWA from a WebAPK. Use signals the app controls:
- A query parameter on the start URL (
startUrl: "/?source=twa"or thegetLaunchingUrl()override above). AstartUrlparameter is only on launches from the icon: App Links arrive with their own URL, and notification clicks are handled by your service worker, not byLauncherActivity. ThegetLaunchingUrl()override also tags App Link launches, because they pass throughLauncherActivity. Persist the flag on first sight either way. document.referrer, which isandroid-app://<package-name>/on the first navigation of a TWA launched by your app. It's a hint, not a proof: it disappears after the first navigation and can be spoofed by other apps.- Bubblewrap's
firstRunFlagfeature, which appends a query parameter (named byqueryParameterName) withtrueon the app's first launch andfalseafterwards.
// Detects whether this page runs inside our Trusted Web Activity and remembers it
// for the lifetime of the browsing session.
const STORAGE_KEY = "twa-context";
const TWA_PACKAGE = "com.example.app.twa";
function readStored() {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY));
} catch {
return null; // storage blocked or corrupt: fall back to live detection
}
}
function store(value) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} catch {
/* ignore: detection still works for this document */
}
}
export function getTwaContext() {
const stored = readStored();
// A positive result sticks for the session; a negative one is re-evaluated, because
// the first document of the session may have been opened without the parameter.
if (stored?.isTwa) return stored;
const params = new URLSearchParams(location.search);
const fromParam = params.get("source") === "twa";
const fromReferrer = document.referrer.startsWith(`android-app://${TWA_PACKAGE}`);
const context = {
isTwa: fromParam || fromReferrer,
appVersion: Number(params.get("app_version")) || null,
firstRun: params.get("first_run") === "true",
};
store(context);
// Remove our launch parameters from the visible URL so they don't end up in
// bookmarks, shares or analytics page paths.
if (fromParam) {
params.delete("source");
params.delete("app_version");
params.delete("first_run");
const query = params.toString();
history.replaceState(history.state, "", `${location.pathname}${query ? `?${query}` : ""}${location.hash}`);
}
return context;
}
Typical uses: hide an "Install app" banner (see Install Prompts & Custom UI), switch the checkout to Play Billing, tag analytics sessions (see Analytics for PWAs) and show a "please update the app" hint when appVersion is too old for a feature. Detecting Installed Apps covers getInstalledRelatedApps(), which lets a regular browser tab detect that your Play app is installed through the manifest's related_applications.
Splash screens¶
TWAs have their own splash screen mechanism, supported by Chrome since Chrome 75. It exists because, on a cold start, the browser process may take a noticeable time to start (Android Browser Helper's documentation mentions "seconds" on low-end devices), and a blank screen would look broken.
The handover works like this:
LauncherActivity(with a translucent theme) shows anImageViewwithSPLASH_IMAGE_DRAWABLEcentered onSPLASH_SCREEN_BACKGROUND_COLOR, and colors the system bars to match what the TWA will show.- If the chosen browser supports splash screens (
TrustedWebUtils.areSplashScreensSupported()for versionV1), the library writes the bitmap tofiles/twa_splash/and grants the browser read access through theFileProvider. - The TWA intent carries the splash parameters: version, background color, scale type, optional transformation matrix and
KEY_FADE_OUT_DURATION_MS. - The browser shows the same image on top of the loading page and removes it with the configured fade when the page has painted, so the user sees one continuous splash.
If the browser doesn't support splash screens, LauncherActivity degrades to a transparent trampoline. Bubblewrap generates the splash drawable from iconUrl and uses backgroundColor; hand-built projects should supply density-specific drawables or a vector drawable.
On Android 12 and later, the system also shows its own splash screen (the app icon on the window background) before any activity draws. With a translucent launcher theme, what users see depends on the theme and device, and some apps show the system splash followed by the TWA splash. Keep the two consistent: the same background color in the app theme and in backgroundColor, and an icon that reads well in both. Since Android Browser Helper 2.7.0 the splash view is laid out edge to edge, which matters because apps targeting API level 36 can't opt out of edge-to-edge on Android 16.
The web app's own splash and theming choices still matter after the handover: keep the first paint's background color equal to the splash color so the fade-out doesn't flash.
Display modes, orientation and system bars¶
The display value in twa-manifest.json becomes DISPLAY_MODE metadata:
twa-manifest.json display | DISPLAY_MODE metadata | Result |
|---|---|---|
standalone (default) | none (DefaultMode) | Status bar and navigation bar visible, colored by your theme colors |
fullscreen | immersive | Android immersive mode: system bars hidden; a swipe shows them temporarily |
fullscreen-sticky | sticky-immersive | Sticky immersive: bars reappear translucently on swipe and auto-hide |
minimal-ui | minimal-ui | Minimal browser UI, if the browser supports it |
browser | browser | Browser-style UI, if the browser supports it |
displayOverride works like the web manifest's display_override: an ordered list the browser walks until it finds a mode it supports, including window-controls-overlay and tabbed for larger screens. It needs Android Browser Helper 2.7.0 or later to be passed to the browser.
Orientation reaches two places. The browser receives SCREEN_ORIENTATION and applies it to the TWA; separately, Bubblewrap's generated LauncherActivity calls setRequestedOrientation() for the splash screen, and only on API levels above 26, because setting an orientation on a translucent activity crashes on Android 8.0. Bubblewrap maps web orientation values to Android constants for the splash: portrait → SCREEN_ORIENTATION_USER_PORTRAIT, portrait-primary → PORTRAIT, portrait-secondary → REVERSE_PORTRAIT, landscape → LANDSCAPE, landscape-secondary → REVERSE_LANDSCAPE, and everything else (including landscape-primary, because of a landspace-primary typo in the current source) → UNSPECIFIED.
Orientation locks are ignored on large screens with API level 36
For apps targeting Android 16 (API level 36), Android ignores android:screenOrientation, setRequestedOrientation(), resizability and aspect-ratio restrictions on displays whose smallest width is at least 600dp (games excepted). Bubblewrap 1.25.0 targets API level 36, so on tablets and unfolded foldables your splash activity rotates freely regardless of orientation. Whether the browser's TWA activity honors the requested orientation follows the browser's own target API level. Design the web UI to work in both orientations; see Responsive & Adaptive Design. A temporary opt-out, android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY, stops working when you target API level 37.
System bar colors come from the APK (themeColor, themeColorDark, navigationColor and their dark variants), selected by the system's light or dark setting. They're build-time values, so a theme switcher inside your web app can't change them; pick colors that work with both of your web themes.
Notification delegation¶
Without delegation, web push notifications from your origin would appear as Chrome notifications, grouped under Chrome in system settings. With delegation, the browser hands each notification to your APK, which posts it under your app's identity and icon. Delegation is on by default in Bubblewrap (enableNotifications: true).
How the delegation works¶
The browser finds a service in your APK that handles the action android.support.customtabs.trusted.TRUSTED_WEB_ACTIVITY_SERVICE and that's associated with the verified origin, binds to it, and calls it through the TrustedWebActivityService API. Android Browser Helper's DelegationService extends androidx's TrustedWebActivityService, which implements the notification calls, and adds a persistent token store and the extra-command dispatcher:
onNotifyNotificationWithChannel(platformTag, platformId, notification, channelName)(inherited from androidx): posts the browser-built notification from your app, using the small icon from the service'sSMALL_ICONmetadata (Bubblewrap generates it frommonochromeIconUrl, or fromiconUrlif you have no monochrome icon).onAreNotificationsEnabled(channelName): reports whether the app, and that channel, may show notifications.onExtraCommand(...): the extension point that notification permission, location delegation and Play Billing use. You add handlers withregisterExtraCommandHandler().
The Android 13+ permission flow¶
On Android 13 (API level 33) and later, apps need the POST_NOTIFICATIONS runtime permission, and an app targeting API level 33+ decides when the system dialog appears. Bubblewrap adds the permission to the manifest when notifications are enabled. When your page calls Notification.requestPermission() inside a TWA with delegation:
sequenceDiagram
participant Page as Web page
participant Chrome
participant DS as DelegationService (your APK)
participant Sys as Android
Page->>Chrome: Notification.requestPermission()
Chrome->>DS: extra command checkNotificationPermission(channelName)
DS-->>Chrome: ALLOW, BLOCK or ASK
alt ASK
Chrome->>DS: getNotificationPermissionRequestPendingIntent
DS-->>Chrome: PendingIntent for NotificationPermissionRequestActivity
Chrome->>Sys: start activity, which requests POST_NOTIFICATIONS
Sys-->>Chrome: user choice
end
Chrome-->>Page: "granted" or "denied" The status is ALLOW when app notifications (and the channel) are enabled, BLOCK when they're disabled and the app has asked before, and ASK when they're disabled but the app has never requested the permission. The user's decision is the app's Android permission, visible and revocable in the app's system settings, rather than a Chrome site setting.
The web-side rules don't change: call Notification.requestPermission() from a user gesture, explain the value first, then subscribe with PushManager, exactly as on the web (Push Notifications, Notifications API, Permissions). An open issue in the Android Browser Helper repository (#563) reports cases where the page receives "granted" while the app's notification permission is still blocked, so check Notification.permission again after a push subscription and consider linking to the app's system notification settings from your UI if notifications never arrive.
Channels and high-priority notifications¶
On Android 8.0+, every notification belongs to a channel. The browser passes a channel name with each notification, and the channel is created on first use with an ID derived from that name (lowercased, spaces replaced by underscores, plus _channel_id) and IMPORTANCE_DEFAULT. Android Browser Helper 2.7.3 added an opt-in: add androidx.browser.trusted.USE_HIGH_PRI_NOTIFICATIONS metadata with the value true to your DelegationService declaration (the library reads it from the service's own metadata, not from <application>), and NotificationUtils.createNotificationChannel(context, channelName) creates channels with IMPORTANCE_HIGH, which allows heads-up notifications, whenever it's called with the service as its context. The repository's twa-notification-high-priority demo shows the setup.
Verify the result on a device before you rely on heads-up delivery. In the 2.7.3 sources the high-importance helper is used from Android Browser Helper's own code paths, while the androidx TrustedWebActivityService.onNotifyNotificationWithChannel() that actually posts each delegated notification still calls createNotificationChannel() with IMPORTANCE_DEFAULT. Android treats that call on an existing channel as an update, and an update may lower (never raise) the importance of a channel whose settings the user hasn't touched. Send a test push, then open the app's notification settings and check the channel's behavior.
Two Android rules limit what the flag can do. An app can't raise the importance of a channel that already exists, so users who installed an earlier version keep their default-importance channel until they change it in system settings or reinstall. And users can lower any channel's importance at any time. Use high priority for messaging and calls, not for marketing.
<service android:name=".DelegationService" android:exported="true">
<meta-data android:name="android.support.customtabs.trusted.SMALL_ICON"
android:resource="@drawable/ic_notification_icon" />
<meta-data android:name="androidx.browser.trusted.USE_HIGH_PRI_NOTIFICATIONS"
android:value="true" />
<intent-filter>
<action android:name="android.support.customtabs.trusted.TRUSTED_WEB_ACTIVITY_SERVICE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
Delegation support differs by browser: Android Browser Helper's browser-support table lists notification delegation for Chrome, Brave and Vivaldi, but not for Edge or Samsung Internet (see Browser support). On a browser without delegation, notifications are attributed to the browser.
Location delegation¶
With features.locationDelegation.enabled (the "Request geolocation permission?" prompt in init), Bubblewrap adds the com.google.androidbrowserhelper:locationdelegation module. The module declares ACCESS_FINE_LOCATION and ACCESS_COARSE_LOCATION, adds a PermissionRequestActivity, and registers a LocationDelegationExtraCommandHandler in DelegationService. When the page calls navigator.geolocation.getCurrentPosition() or watchPosition(), the browser sends extra commands (checkAndroidLocationPermission, startLocation, stopLocation) to your app, which asks for the Android location permission if needed and streams positions back from the platform location APIs (or from Google Play services when available).
The benefit is consistency: users see the standard Android location dialog attributed to your app, and the grant shows up in the app's permissions. Your JavaScript stays the same. Handle PERMISSION_DENIED and POSITION_UNAVAILABLE errors as usual, and request location in context, after a user action, because Google Play's policies on location permissions apply to your APK.
Google Play Billing with the Digital Goods API¶
Google Play's payments policy generally requires apps distributed on Play to sell in-app digital goods and subscriptions through Google Play Billing (with regional alternative-billing programs as exceptions). A TWA is an app on Play, so if your PWA sells digital content, the Play version must offer Play Billing. The web platform side is two APIs:
- The Digital Goods API (
window.getDigitalGoodsService()), a WICG draft that queries products and existing purchases from a store. It's Chromium-only, enabled by default since Chrome 101 on Android and Chrome 100 on ChromeOS, and only returns a working service when a store backend is connected, which for Play means a TWA with Android Browser Helper's billing module. - The Payment Request API with the payment method identifier
https://play.google.com/billing, which launches the Play purchase sheet.
Enabling billing in the Android app¶
In twa-manifest.json set "features": { "playBilling": { "enabled": true } } (or answer yes to "Include support for Play Billing?"), then run bubblewrap update and bubblewrap build. Bubblewrap 1.25.0 adds com.google.androidbrowserhelper:billing:1.2.0, a PaymentActivity handling org.chromium.intent.action.PAY with org.chromium.default_payment_method_name set to https://play.google.com/billing, a PaymentService handling org.chromium.intent.action.IS_READY_TO_PAY, and registers DigitalGoodsRequestHandler in DelegationService. Older guides, including Chrome's own billing guide, also tell you to enable alphaDependencies; with current Bubblewrap that's no longer needed, since 1.23.0 removed the alpha warnings for billing and the billing module is added whether or not alpha dependencies are on. Two build prerequisites do apply: enableNotifications must be true (the billing handler lives in DelegationService), and minSdkVersion must be at least 23 for billing 1.2.0.
Prerequisites on the Play side: a Play developer account linked to a payments profile, the products or subscriptions created in Play Console, and a release on at least an internal testing track, since billing only works for an app Play knows about. Use license testers to buy without being charged.
API surface¶
| Member | Returns | Notes |
|---|---|---|
window.getDigitalGoodsService(serviceProvider) | Promise<DigitalGoodsService> | Secure context only. Rejects with TypeError for an empty provider, NotAllowedError in a cross-origin frame or when payment permission is denied, OperationError when the provider is unsupported (for example, a normal Chrome tab) |
service.getDetails(itemIds) | Promise<ItemDetails[]> | TypeError for an empty array. Match results by itemId rather than by position |
service.listPurchases() | Promise<PurchaseDetails[]> | Current entitlements and active subscriptions: { itemId, purchaseToken } |
service.listPurchaseHistory() | Promise<PurchaseDetails[]> | Always [] with billing 1.2.0, because Play Billing Library 8 removed the underlying query |
service.consume(purchaseToken) | Promise<undefined> | Marks a consumable as used so it can be bought again. TypeError for an empty token |
ItemDetails has itemId, title, price (a PaymentCurrencyAmount with currency and value), and optionally type ("product" or "subscription"), description, iconURLs, subscriptionPeriod, freeTrialPeriod, introductoryPrice, introductoryPricePeriod and introductoryPriceCycles. Periods are ISO 8601 durations such as P1M.
Behavior changes in billing 1.2.0 (Play Billing Library 8)¶
Google Play requires Play Billing Library 8 or newer for new apps and app updates from August 31, 2026 (an extension to November 1, 2026 can be requested in Play Console). Android Browser Helper's billing module 1.2.0 is the upgrade path, and its migration guide lists two behavior changes that the web API can't express:
listPurchaseHistory()returns an empty list. Keep your own purchase history on the server, keyed by purchase token.- Subscriptions are flattened. Play subscriptions are hierarchical (subscription → base plans → offers) while
ItemDetailsis flat, sogetDetails()now returns only the first eligible base plan or offer for each subscription. The old "backward compatible" base plan designation is no longer honored. If you sell several base plans or offers for one subscription,getDetails()can't show them all.
A production purchase flow¶
// Google Play Billing through the Digital Goods API, with a web fallback.
const PLAY_BILLING = "https://play.google.com/billing";
let servicePromise = null;
/** Resolves to a DigitalGoodsService, or null outside a Play-installed TWA. */
export function getPlayBillingService() {
if (!servicePromise) {
servicePromise = (async () => {
if (!("getDigitalGoodsService" in window)) return null;
try {
return await window.getDigitalGoodsService(PLAY_BILLING);
} catch (err) {
// OperationError: no Play backend (normal browser tab, WebView fallback,
// or an APK without the billing module). Not an error for us.
console.info("Play Billing unavailable:", err.name);
return null;
}
})();
}
return servicePromise;
}
/** Loads localized prices for the storefront. */
export async function loadCatalog(itemIds) {
const service = await getPlayBillingService();
if (!service) return null; // caller renders the web checkout instead
const items = await service.getDetails(itemIds);
return items.map((item) => ({
id: item.itemId,
title: item.title,
description: item.description ?? "",
type: item.type ?? "product",
period: item.subscriptionPeriod ?? null,
// Format with Intl using the currency Play returned, not the user's locale currency.
price: new Intl.NumberFormat(navigator.language, {
style: "currency",
currency: item.price.currency,
}).format(Number(item.price.value)),
}));
}
/**
* Starts a purchase. Must run from a user gesture (click handler), because
* PaymentRequest.show() requires transient user activation.
*/
export async function purchase(itemId, { consumable = false } = {}) {
const service = await getPlayBillingService();
if (!service) throw new Error("Play Billing is not available in this context");
const request = new PaymentRequest(
[{ supportedMethods: PLAY_BILLING, data: { sku: itemId } }],
// Required by Payment Request; Play shows its own price, so the amount is ignored.
{ total: { label: "Total", amount: { currency: "USD", value: "0" } } }
);
let response;
try {
response = await request.show();
} catch (err) {
if (err.name === "AbortError") return { status: "cancelled" };
throw err;
}
const { purchaseToken } = response.details;
let result;
try {
// The server verifies the token with the Play Developer API, grants the
// entitlement (idempotently, keyed by token) and acknowledges the purchase.
// Unacknowledged purchases are refunded and revoked by Play after three days.
const res = await fetch("/api/play/purchases", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ itemId, purchaseToken }),
});
if (!res.ok) throw new Error(`Verification failed: HTTP ${res.status}`);
result = await res.json(); // { status: "granted" | "pending" | "invalid" | ... }
} catch (err) {
await response.complete("fail");
// The token is still valid: reconcilePurchases() retries on the next launch.
throw err;
}
if (result.status === "pending") {
// Cash or delayed payment: nothing to grant yet. Play updates the state later,
// and reconcilePurchases() or a Real-time Developer Notification completes it.
await response.complete("success");
return { status: "pending", purchaseToken };
}
if (result.status !== "granted") {
await response.complete("fail");
return { status: result.status, purchaseToken };
}
// Settle the PaymentResponse as soon as the entitlement exists; consuming is separate.
await response.complete("success");
if (consumable) {
try {
// Only after the server has granted the goods, and never for a pending purchase.
await service.consume(purchaseToken);
} catch (err) {
// Not fatal: the goods are granted. The purchase stays in listPurchases()
// until consumed, so reconcilePurchases() re-sends it and the server
// (idempotent per token) answers "granted" again; consume then.
console.warn("consume() failed, will retry on next launch:", err);
}
}
return { status: "purchased", purchaseToken };
}
/**
* Call on startup: re-sends every current purchase to the server so purchases
* interrupted by a crash, a lost connection or a pending payment are granted.
*/
export async function reconcilePurchases() {
const service = await getPlayBillingService();
if (!service) return [];
const purchases = await service.listPurchases();
const results = await Promise.allSettled(
purchases.map(async (p) => {
const res = await fetch("/api/play/purchases", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ itemId: p.itemId, purchaseToken: p.purchaseToken }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { status, consumable } = await res.json();
// The server knows which SKUs are consumable; finish interrupted consumptions.
if (status === "granted" && consumable) await service.consume(p.purchaseToken);
return { itemId: p.itemId, status };
})
);
return results
.filter((r) => r.status === "fulfilled" && r.value.status === "granted")
.map((r) => r.value.itemId);
}
The server never trusts the client's claim. It asks the Google Play Developer API about the token, checks that the token hasn't been used for another account, grants the entitlement and acknowledges:
import { google } from "googleapis";
const PACKAGE_NAME = "com.example.app.twa";
const auth = new google.auth.GoogleAuth({
scopes: ["https://www.googleapis.com/auth/androidpublisher"],
});
const publisher = google.androidpublisher({ version: "v3", auth });
/**
* Verifies and acknowledges a one-time product purchase.
* @param {{ userId: string, itemId: string, purchaseToken: string, db: object }} input
* @returns {Promise<{ status: string, consumable?: boolean }>} sent to the client as JSON
*/
export async function handleProductPurchase({ userId, itemId, purchaseToken, db }) {
const { data } = await publisher.purchases.products.get({
packageName: PACKAGE_NAME,
productId: itemId,
token: purchaseToken,
});
// purchaseState: 0 = purchased, 1 = canceled, 2 = pending.
if (data.purchaseState === 2) return { status: "pending" };
if (data.purchaseState !== 0) return { status: "invalid" };
// Bind the token to one account; replayed tokens from other accounts are rejected.
// claimPurchaseToken() is an atomic insert-if-absent that returns the owning user.
const owner = await db.claimPurchaseToken(purchaseToken, userId, itemId);
if (owner !== userId) return { status: "token-already-used" };
// Idempotent per token: reconcilePurchases() re-sends tokens on every launch, so a
// second call for the same token must not grant the goods twice.
await db.grantEntitlementOnce(purchaseToken, userId, itemId, { orderId: data.orderId });
// acknowledgementState: 0 = not yet acknowledged, 1 = acknowledged.
if (data.acknowledgementState === 0) {
await publisher.purchases.products.acknowledge({
packageName: PACKAGE_NAME,
productId: itemId,
token: purchaseToken,
requestBody: {},
});
}
// consumptionState: 0 = not yet consumed. Tells the client whether to call consume().
return { status: "granted", consumable: db.isConsumable(itemId) && data.consumptionState === 0 };
}
Subscriptions use purchases.subscriptionsv2.get (state in subscriptionState, for example SUBSCRIPTION_STATE_ACTIVE) and purchases.subscriptions.acknowledge, and you should also consume Real-time Developer Notifications to learn about renewals, cancellations and refunds that happen while the app is closed. Pending purchases (a cash payment at a store, for example) resolve later; that's why reconcilePurchases() runs on every launch.
To test a build you installed yourself (with bubblewrap install or adb install), enable chrome://flags/#enable-debug-for-store-billing in Chrome 101 or later on the device; the Chrome documentation notes the flag "is not required when the application is downloaded from the Play Store." Either way, the package and its products must exist in Play Console on at least an internal testing track, and the Google account on the device should be a license tester so purchases aren't charged.
Other integrations: shortcuts, sharing, files, protocols and messaging¶
Many manifest features have a native counterpart in the APK. Bubblewrap generates them from your web manifest; the web side keeps working as documented in each feature's page.
| Web feature | How the TWA implements it | Web page |
|---|---|---|
shortcuts | Static Android launcher shortcuts in res/xml/shortcuts.xml, at most 4, launching LauncherActivity with the shortcut URL | App Shortcuts |
share_target | An intent filter for SEND/SEND_MULTIPLE with the accepted MIME types, plus METADATA_SHARE_TARGET; the browser turns the shared data into the GET or POST request your manifest describes | Web Share Target |
file_handlers | Intent filters for the MIME types; content:// URIs are passed with setFileHandlingData() after checking the app has read/write URI permission, and the page receives them through launchQueue | File Handling |
protocol_handlers | Intent filters for the schemes, and a scheme → URL template map; web+tasks://x becomes the template with %s replaced by the percent-encoded link | Protocol Handlers & Launch Handling |
launch_handler client_mode | LAUNCH_HANDLER_CLIENT_MODE metadata passed with setLaunchHandlerClientMode() | Protocol Handlers & Launch Handling |
| App Links | The autoVerify VIEW intent filter for your host; links to your site from other apps open the TWA | This page |
postMessage between the app and the page¶
Since Chrome 115.0.5790.13, a native app can open a message channel to the page in its TWA or Custom Tab. The app binds to the Custom Tabs service, calls warmup(), creates a session, asks the browser to verify its claim on the source origin with session.validateRelationship(CustomTabsService.RELATION_USE_AS_ORIGIN, sourceOrigin, null) (the result arrives in onRelationshipValidationResult()), waits for onNavigationEvent(NAVIGATION_FINISHED), then calls session.requestPostMessageChannel(sourceOrigin, targetOrigin, extras); when onMessageChannelReady() fires it can postMessage(), and replies arrive in onPostMessage(). It requires androidx.browser 1.6.0-alpha02 or later, the androidx.browser.customtabs.PostMessageService declared in the manifest, and a delegate_permission/common.use_as_origin statement for the app in the site's assetlinks.json, so that the browser can report the app's origin as event.origin.
On the page, the first message delivers a MessagePort:
// Receives the MessagePort the Android app opens after the page loads.
const APP_ORIGIN = "https://app.example.com"; // the source origin the app validated
let port = null;
const handlers = new Map();
window.addEventListener("message", (event) => {
if (event.origin !== APP_ORIGIN || !event.ports?.length) return;
port = event.ports[0];
port.onmessage = ({ data }) => {
let message;
try {
message = typeof data === "string" ? JSON.parse(data) : data;
} catch {
return; // ignore malformed input from the native side
}
handlers.get(message.type)?.(message.payload);
};
port.postMessage(JSON.stringify({ type: "ready" }));
});
export function onNativeMessage(type, handler) {
handlers.set(type, handler);
}
export function sendToNative(type, payload) {
if (!port) return false; // not in an app that opened a channel
port.postMessage(JSON.stringify({ type, payload }));
return true;
}
Messages are strings on the Android side, so serialize to JSON. Treat everything from the channel as untrusted input, like any postMessage traffic.
Multiple origins in one TWA¶
If your app spans origins (a separate login or checkout host, a regional domain), list them in additionalTrustedOrigins. Bubblewrap then writes the ADDITIONAL_TRUSTED_ORIGINS string array, an extra asset_statements entry and an App Links intent filter for each. Every origin must serve its own assetlinks.json with the app's package and fingerprints; the Chrome documentation's rule is that "an application using Trusted Web Activities can have any number of validated domains, as long as Digital Asset Links are implemented for all of them." Origins you don't control, such as an identity provider, can't be verified, and the user sees the Custom Tabs toolbar while on them, which is the intended security signal.
When you configure the launch in code, pass the list with TrustedWebActivityIntentBuilder.setAdditionalTrustedOrigins(List<String>).
Updating a TWA¶
A TWA has two update channels, and only one needs Google Play:
| Change | Ship how | User impact |
|---|---|---|
| HTML, CSS, JavaScript, API behavior, service worker | Deploy the website | Picked up by the browser like any site; the service worker update flow applies |
Web manifest name, icons, colors, shortcuts, share target, file or protocol handlers | bubblewrap merge, bubblewrap update, bubblewrap build, upload to Play | Users get the change when Play updates the app |
| Signing fingerprints, additional origins | Update assetlinks.json on every origin first, then ship the APK | Deploy statements before the APK that needs them |
| Target API level, Android Browser Helper or billing version | Upgrade Bubblewrap, bubblewrap update, bubblewrap build, upload | Needed at least yearly to keep publishing updates |
bubblewrap update increments appVersionCode by one on each run (unless you pass --skipVersionUpgrade) and asks for a versionName; if the old versionName equaled the old code, it defaults the new name to the new code. bubblewrap play versionCheck compares your local version code with the highest one on Play, which helps when several machines build releases.
The yearly Play requirements are the main reason to touch the APK. From August 31, 2026, new apps and app updates must target API level 36 (Android 16), and existing apps must target at least API level 35 to stay available to new users on newer Android versions. Bubblewrap 1.25.0's template targets 36, so upgrading the CLI and running update is usually enough. Because update regenerates the Android project from templates, any manual edits to Gradle, Java or XML files are overwritten; keep manual customizations in a patch you re-apply, or move to a hand-maintained project.
For the web-side view of identity and updates (the manifest id, icon updates for browser-installed apps), see App Identity & Updates.
ChromeOS, Meta Quest and large screens¶
TWAs published on Google Play are also offered on Chromebooks that run Android apps. On ChromeOS the Android side runs in ARC (the Android runtime for ChromeOS), and Android Browser Helper contains ChromeOS-specific paths: it detects the org.chromium.arc system feature, launches the content through a Custom Tabs intent flagged to launch as a TWA (a code comment notes that "ARC++ does not support native TWAs at the moment"), and registers org.chromium.arc.payment_app as the verified provider for billing requests. The webManifestUrl in twa-manifest.json exists for this case; Bubblewrap's template describes it as "used by Chrome OS and Meta Quest to open the Web version of the PWA instead of the TWA, as it will probably give a better user experience for non-mobile devices."
Publishing specifics from Google's ChromeOS documentation:
bubblewrap init --chromeosonly(or<uses-feature android:name="org.chromium.arc" android:required="true"/>) restricts the package to ChromeOS devices.- If the store listing also has a phone app, the ChromeOS-only package's version code must be higher than the Android app's. Uploading a mobile package with a higher version code than a ChromeOS-only package that also runs on Chromebooks replaces it there.
- Play Billing through the Digital Goods API works on ChromeOS (Chrome 100+).
- Google doesn't recommend the "paid app" model for PWAs on Play, because the web content itself can't verify the purchase.
bubblewrap init --metaquest builds for Meta Quest headsets, with minSdkVersion 23, hand-tracking features and com.oculus.pwa.NAME, START_URL and SCOPE metadata. For the desktop-class form factors themselves, see Desktop Platforms.
TWA vs WebAPK vs WebView vs Custom Tab¶
Four ways a PWA can end up behind an Android icon, compared:
| Trusted Web Activity | WebAPK | WebView app | Custom Tab | |
|---|---|---|---|---|
| Created by | You, with Bubblewrap or Android Browser Helper | Chrome, when the user installs the PWA; Google generates and signs the APK | You | Any app that opens a link |
| Distribution | Google Play and other stores | Browser install only, no store listing | Stores | Not an app |
| Engine | The user's TWA-capable browser | Chrome | The system WebView | The user's browser |
| Browser UI | None when verified | None | None | Toolbar with URL |
| Storage and sign-in | Shared with the browser | Shared with Chrome | Separate, per app | Shared with the browser |
| Service workers, push | Yes; notifications can be delegated to the app | Yes, attributed to the WebAPK | Limited; no web push | Yes, attributed to the browser |
| Play Billing | Yes, Digital Goods API + Payment Request | No | Through native code | No |
| Native code | Possible (services, native screens) | None | Yes, plus a JavaScript bridge | No |
| Update of app shell | Play update | Chrome checks the manifest and re-mints | Play update | Not applicable |
| Ownership proof | Digital Asset Links | Installed from the origin itself | None | None |
Rules of thumb:
- You need a store listing, Play Billing, or native extension points (a native screen, a native SDK for something the web can't do): TWA.
- You want installation from the web with zero Android tooling: let Chrome mint a WebAPK; see Installation by Platform and Android.
- You ship both (common): the Play TWA and the WebAPK coexist. Declare the Play app in
related_applicationsso you can detect it from the browser, and remember that both use the same browser storage for your origin when Chrome is the TWA provider. - Your content must work without a browser, or you need deep native integration on every screen: a WebView-based or native app, which is a different architecture; see PWA vs Native vs Hybrid.
Browser support¶
TWA is an Android protocol, so "browser support" means which Android browsers implement the provider side. Android Browser Helper maintains this table in its repository:
| Browser | Trusted Web Activity | Splash screen | Notification delegation |
|---|---|---|---|
| Chrome | ✅ 72 | ✅ | ✅ |
| Edge | ✅ 45.05 | ✅ | ❌ |
| Samsung Internet | ✅ 13.0.2.9 | ❌ | ❌ |
| Brave | ✅ | ✅ | ✅ |
| Vivaldi | ✅ | ✅ | ✅ |
| Firefox | ⚠️1 | ❌ | ❌ |
| Opera, DuckDuckGo, Kiwi, UC Browser, Naver Whale, Yandex Browser | ❌ | ❌ | ❌ |
| Silk | ❌2 | ❌ | ❌ |
The Digital Goods API with Play Billing is Chromium-only in practice: enabled by default in Chrome 101 on Android and Chrome 100 on ChromeOS. postMessage to a TWA needs Chrome 115.0.5790.13 or later.
The table is maintained by hand in the Android Browser Helper repository ("We do our best to keep this up to date"), and some rows are years old, so test your app in the browsers your users actually have.
Support data as of September 2026. For live data, see the Android Browser Helper browser support table, Chrome Platform Status for the Digital Goods API, and MDN's Payment Request API page.
Debugging a Trusted Web Activity¶
Is it verified? Read the logs¶
Most TWA bugs are verification bugs, and the browser logs its decision. Connect the device with USB debugging and filter logcat:
# Which browser did Android Browser Helper pick, and in which mode?
adb logcat -v brief | grep -e TWAProviderPicker -e TWALauncherActivity
# Verification decisions in Chrome
adb logcat -v brief | grep -e OriginVerifier -e digital_asset_links
A message about a "statement failure matching fingerprint" means the site's statement doesn't list the certificate of the installed build. Found no TWA providers, using first Custom Tabs provider means no TWA-capable browser was found, so the app used the fallback strategy.
Check each side independently¶
# 1. The site: status, content type, no redirect
curl -sI https://app.example.com/.well-known/assetlinks.json
# 2. What Google parses from it
curl -s "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://app.example.com&relation=delegate_permission/common.handle_all_urls"
# 3. The certificate of the build actually installed on the device
adb shell pm path com.example.app.twa
adb pull <path-to-base.apk> installed.apk && apksigner verify --print-certs installed.apk
# 4. Android's own App Links verification (Android 12+)
adb shell pm verify-app-links --re-verify com.example.app.twa
adb shell pm get-app-links com.example.app.twa
pm get-app-links prints a state per domain: verified, none (not finished yet; wait a few minutes and query again), legacy_failure, or an error code of 1024 or higher specific to the device's verifier. A domain that is verified for App Links while the TWA still shows a URL bar points to the browser side, such as a certificate missing from the statement or a browser other than the one you tested.
Bypass verification during development¶
To test a build against a site where you can't deploy assetlinks.json yet (a staging host, a local tunnel), tell Chrome to skip verification for one URL:
- On the device, open
chrome://flagsand enable Enable command line on non-rooted devices, then relaunch Chrome. -
Write the flag to Chrome's command-line file. The first token must be an underscore:
-
Force-stop Chrome (or restart the device). The Chromium documentation notes that relaunching from
chrome://flags"might not be enough to trigger reading this file."
Remove the file when you're done (adb shell rm /data/local/tmp/chrome-command-line). Never ship a release that relies on this.
Inspect the page with DevTools¶
The TWA is a Chrome tab, so remote debugging works as usual: open chrome://inspect/#devices in desktop Chrome with the phone connected over USB, find the page under the device and click inspect. You get the full DevTools, including the Application panel for the service worker, Cache Storage and the manifest. See Browser DevTools for the workflow. To test the offline path end to end, including a cold launch from the launcher icon, put the device in airplane mode rather than relying only on DevTools network throttling.
Things that look like bugs but aren't¶
- A URL bar only on some navigations. The page navigated to an origin that isn't verified, often a login or payment provider. Expected.
- The Play build shows a URL bar, sideloaded builds don't. The Play App Signing fingerprint (or one of the hybrid signing fingerprints) is missing.
- Nothing changes after fixing
assetlinks.json. Google's API advertises a cache lifetime of about an hour, and browsers keep their own verification state. Wait, then reinstall the app or clear the browser's data on a test device. - The app opens in Samsung Internet or Edge. The user's default browser supports TWAs; that's the provider-selection order working as designed.
Common pitfalls¶
- Listing only the local signing key. Add the Play App Signing key fingerprint, and every key the Play app signing page shows if your app uses hybrid signing.
- Redirecting
/.well-known/assetlinks.json. HTTP→HTTPS, apex→www, trailing-slash and locale redirects all break verification. Serve HTTP 200 directly on every origin you open. - Serving the SPA's
index.htmlinstead of the JSON. A catch-all route or a deploy tool that drops dot-directories returns HTML with status 200. Check the body, not only the status. - Wrong origin in the statement.
https://example.comdoesn't coverhttps://www.example.com. Every origin needs its own file and its ownasset_statementsentry. - Editing generated Android files and running
bubblewrap update. The update regenerates them. Put changes intwa-manifest.jsonor keep a patch. - Changing the package name. It's permanent on Play and appears in
assetlinks.json, analytics and billing records. - Forgetting the yearly target API bump. Play blocks updates that target an API level below the current requirement (36 from August 31, 2026).
- Shipping Play Billing with billing 1.1.0 after August 31, 2026. Upgrade to 1.2.0, raise
minSdkVersionto 23 so the manifest merge succeeds, and stop relying onlistPurchaseHistory(). - Selling digital goods on Play through your web checkout. Detect the TWA context and switch to Play Billing, or the app risks policy enforcement.
- Assuming Chrome. The TWA runs in the user's default TWA-capable browser. Test in Samsung Internet and Edge, or pin a browser with
LAUNCHING_BROWSERknowing that users without it get a dialog. - Relying on
display-modeto detect the TWA. It can't distinguish a TWA from a WebAPK; use a launch parameter. - Losing the keystore. Without Play App Signing you can never update the app again; with it, you face an upload key reset.
Further reading¶
On this site
- Publishing to App Stores: store listings, policies, Microsoft Store and App Store options
- Android: WebAPKs, Chrome and Samsung Internet install behavior
- Installation by Platform and Detecting Installed Apps: how installation differs across browsers and how to detect it
- PWABuilder: a GUI that packages TWAs with Bubblewrap
- Payments: Payment Request, Payment Handler and web checkout
- Push Notifications: the push pipeline that delegated notifications use
- Offline UX & Fallbacks: meeting the offline and error-page bar inside an app
- Splash Screens & Theming: colors and icons across platforms
External references
- Trusted Web Activity overview and quick start with Bubblewrap (Chrome for Developers)
- Integration guide and Android concepts for web developers
- Multi-origin Trusted Web Activities and postMessage for TWA
- Receive payments via Google Play Billing and the Play Billing Library 8 migration guide
- Digital Goods API draft (WICG)
- Bubblewrap and the Bubblewrap CLI README
- Android Browser Helper
- Digital Asset Links: getting started and creating a statement list
- Custom Tabs overview
- Verify Android App Links
- Use Play App Signing and Target API level requirements (Play Console Help)
- Android 16 behavior changes and Notification runtime permission
- List your PWA in Google Play (ChromeOS)
- Changes to quality criteria for PWAs using Trusted Web Activity (Chromium blog, 2020; enforcement since removed)
- Run Chromium with flags