Skip to content

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_statements resource, 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/json and 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 by twa-manifest.json; bubblewrap build produces 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_NOTIFICATIONS runtime 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 minSdkVersion 23; 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:

  1. 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.)
  2. Otherwise, the first browser with any Custom Tabs service → CUSTOM_TAB.
  3. Otherwise, any browser that handles http VIEW 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:

AndroidManifest.xml (only needed without Android Browser Helper)
<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 (in androidx.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, setOriginalLaunchUrl and setLaunchHandlerClientMode. The corresponding intent extras use the androidx.browser.trusted.extra. prefix (for example androidx.browser.trusted.extra.DISPLAY_MODE).
  • Display modes are classes, not strings. TrustedWebActivityDisplayMode has DefaultMode, ImmersiveMode(isSticky, layoutInDisplayCutoutMode), MinimalUiMode, BrowserMode, TabbedMode and WindowControlsOverlayMode implementations; the browser decides which it supports.
  • Relationship validation has two relations. CustomTabsService.RELATION_HANDLE_ALL_URLS (value 2) corresponds to the delegate_permission/common.handle_all_urls statement that TWAs rely on. RELATION_USE_AS_ORIGIN (value 1) corresponds to delegate_permission/common.use_as_origin, needed for the postMessage channel.

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 additionalTrustedOrigins and verified with its own assetlinks.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.

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:

app/src/main/AndroidManifest.xml (excerpt)
<application ...>
    <meta-data
        android:name="asset_statements"
        android:resource="@string/assetStatements" />
</application>
app/src/main/res/values/strings.xml
<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."

https://app.example.com/.well-known/assetlinks.json
[
  {
    "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.

/etc/nginx/conf.d/assetlinks.conf
# 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";
}
server/assetlinks.js
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;
firebase.json
{
  "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.

Getting fingerprints from the command line
# 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:

Managing fingerprints with Bubblewrap
# 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:

Querying the Digital Asset Links API
# 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":

scripts/check-assetlinks.mjs
// 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

Terminal
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:

twa-manifest.json
{
  "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

Terminal
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

Terminal
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.

app/build.gradle (excerpt)
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:

app/src/main/AndroidManifest.xml
<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>
app/src/main/res/xml/filepaths.xml
<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():

app/src/main/java/com/example/app/twa/DelegationService.java
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().

app/src/main/java/com/example/app/twa/LauncherActivity.java
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. LauncherActivity always relaunches itself with FLAG_ACTIVITY_NEW_TASK (clearing FLAG_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 new LauncherActivity finishes 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, LauncherActivity calls finish() 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 in onRestart() 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:

HelpActivity.java (excerpt)
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 the getLaunchingUrl() override above). A startUrl parameter is only on launches from the icon: App Links arrive with their own URL, and notification clicks are handled by your service worker, not by LauncherActivity. The getLaunchingUrl() override also tags App Link launches, because they pass through LauncherActivity. Persist the flag on first sight either way.
  • document.referrer, which is android-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 firstRunFlag feature, which appends a query parameter (named by queryParameterName) with true on the app's first launch and false afterwards.
src/twa-context.js
// 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:

  1. LauncherActivity (with a translucent theme) shows an ImageView with SPLASH_IMAGE_DRAWABLE centered on SPLASH_SCREEN_BACKGROUND_COLOR, and colors the system bars to match what the TWA will show.
  2. If the chosen browser supports splash screens (TrustedWebUtils.areSplashScreensSupported() for version V1), the library writes the bitmap to files/twa_splash/ and grants the browser read access through the FileProvider.
  3. The TWA intent carries the splash parameters: version, background color, scale type, optional transformation matrix and KEY_FADE_OUT_DURATION_MS.
  4. 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's SMALL_ICON metadata (Bubblewrap generates it from monochromeIconUrl, or from iconUrl if 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 with registerExtraCommandHandler().

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.

AndroidManifest.xml (DelegationService with high-priority channels)
<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 ItemDetails is flat, so getDetails() 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

src/billing.js
// 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:

server/play-purchases.js
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:

src/native-bridge.js
// 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_applications so 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:

Terminal
# 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

Terminal
# 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:

  1. On the device, open chrome://flags and enable Enable command line on non-rooted devices, then relaunch Chrome.
  2. Write the flag to Chrome's command-line file. The first token must be an underscore:

    Terminal
    adb shell "echo '_ --disable-digital-asset-link-verification-for-url=\"https://staging.example.com\"' > /data/local/tmp/chrome-command-line"
    
  3. 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

  1. 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.
  2. 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.
  3. Serving the SPA's index.html instead 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.
  4. Wrong origin in the statement. https://example.com doesn't cover https://www.example.com. Every origin needs its own file and its own asset_statements entry.
  5. Editing generated Android files and running bubblewrap update. The update regenerates them. Put changes in twa-manifest.json or keep a patch.
  6. Changing the package name. It's permanent on Play and appears in assetlinks.json, analytics and billing records.
  7. Forgetting the yearly target API bump. Play blocks updates that target an API level below the current requirement (36 from August 31, 2026).
  8. Shipping Play Billing with billing 1.1.0 after August 31, 2026. Upgrade to 1.2.0, raise minSdkVersion to 23 so the manifest merge succeeds, and stop relying on listPurchaseHistory().
  9. Selling digital goods on Play through your web checkout. Detect the TWA context and switch to Play Billing, or the app risks policy enforcement.
  10. 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_BROWSER knowing that users without it get a dialog.
  11. Relying on display-mode to detect the TWA. It can't distinguish a TWA from a WebAPK; use a launch parameter.
  12. 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

External references


  1. Listed as supported in "Preview Nightly" only; don't rely on Firefox as a TWA provider. ↩

  2. The table notes that Silk "seems to advertise support for Trusted Web Activity, but the app opens the content in a Custom Tab." ↩