SIGN IN SIGN UP

feat(paywalls): Preboot the WebView engine when a paywall has a web_view component (#3904)

- Calls `WebViewCompat.startUpWebView` when the current
offering/workflow actually contains a `web_view` component, so the first
render does not pay WebView startup on the UI thread.
- **Gated on the current offering's paywall containing a `web_view`, and
on RevenueCatUI being on the classpath.**
- Worth 42 to 104 ms of first-paint time on its own, and more on slower
hardware: Pixel 7a 42 to 70 ms, Mi 9T Pro a consistent 102 to 104 ms,
since it moves a fixed amount of work off the UI thread.
- **Its value changes once cache warming (#3897) lands**, because a
warming WebView does everything startup does and more. See the note
below rather than reading the figures above as additive.
- **#3938 later widens the trigger.** It moves preboot inside
`PaywallAssetWarming.warmWebViewUrls`, so preboot stops having call
sites of its own and inherits warming's scope: the current offering,
every placement target, and the placement fallback. Until then a paywall
reached only through a placement pays engine startup at display time.
- Bumps `androidx.webkit` 1.12.1 -> 1.16.0, required by
`startUpWebView`.

### Checklist
- [x] If applicable, unit tests
- [ ] If applicable, create follow-up issues for `purchases-ios` and
hybrids

<details><summary>Agent description</summary>

### Motivation

WebView startup otherwise happens on first use and blocks the UI thread.
Measured at ~190 ms for the first `web_view` render, dropping to ~112 ms
once startup has already run.

`startUpWebView` has no iOS equivalent, so this is strictly cheaper than
the pooled-instance approach the iOS plan takes for the same step: no
held WebView, no pool, no policy decisions.

### How this relates to cache warming (#3897)

Worth being explicit, since #3897 sits directly on top of this branch
and changes what preboot is for.

- Constructing any WebView does everything `startUpWebView` does, plus
per-instance setup. Pixel 7a, cold install: construction costs 85 ms
with no or late preboot, 22 ms with early preboot, and 3 ms for a second
WebView in the same process.
- So once warming runs, it has already loaded the provider by the time a
paywall renders, and preboot's first-paint saving is absorbed rather
than added to.
- What preboot still buys is making the *warmer's* own first
construction cheaper on the main thread: ~37 ms of reported UI-thread
time plus a 22 ms construction, against 85 ms if the warmer loads the
provider itself. That work now lands during app startup rather than at
paywall display, which is where it matters.
- It is no longer the only warming available on older System WebViews.
That was true while #3897 required WebView M137+ for
`PRERENDER_WITH_URL`; #3897 now warms with `loadUrl` and has no version
floor.
- Preboot remains the whole benefit for any paywall where warming does
not run at all: an unresolvable url, a WebView without secure messaging,
or a paywall whose urls were never queued.

### Description

- `PaywallWebViewStartUp` (RevenueCatUI) wraps
`WebViewCompat.startUpWebView`, reached from core through
`PaywallAssetWarmerImpl.prebootWebView`, the seam implementation added
in #3913.
- Loads exactly one profile: the paywall profile where `MULTI_PROFILE`
is supported, `Default` otherwise, mirroring `applyPaywallProfile`.
Naming a set opts every unnamed profile out, and named profiles have
resources allocated during startup, so loading both would waste memory
on every device.
- Everything, including the `MULTI_PROFILE` feature check, runs on the
startup executor. `isFeatureSupported` loads the WebView provider, which
is the exact work this API exists to defer, and the caller here is the
SDK's single-threaded dispatcher, so anything done inline serialises
ahead of every other queued SDK job. **`touches no webkit API on the
calling thread` is the regression gate for this.**
- The executor wraps the delegate so a throw inside a submitted task
cannot escape, and it catches `Throwable` rather than `Exception` on
purpose: androidx runs provider class loading in there, and a missing or
mid-update WebView package surfaces as `NoClassDefFoundError` /
`UnsatisfiedLinkError` / `ExceptionInInitializerError`. An uncaught
throw on a `Dispatchers.IO` thread kills the host app, so `Exception`
alone would leave the crash it exists to prevent. Covered by `the
background executor swallows an Error from the task it runs`.
- Runs once per process. Startup that was attempted and failed is not
retried: androidx says further WebView calls on such a device are likely
to throw or crash. A rejected submission is retryable, since startup was
never attempted.
- Preboot's own UI-thread cost measured at 37 ms median, reported by
androidx and paid on a background thread. The intent extra used to take
that measurement is not part of this PR; #3897 carries the prewarm
harness.

### Known limitations

- The preboot trigger sits at two call sites (offerings and workflows)
rather than behind the single component that consumes
`PaywallComponentAssets`. Each new asset kind is then a two-site edit.
#3938 moves it behind the warming seam, so this is transitional.
- Warming wants a third method on the same seam. If a per-capability
interface starts to feel wrong, `PurchasesService` is the alternative
discovery hook, at the cost of a mutable registry in core and lifecycle
ordering.
- The display path constructs its WebView without the `Throwable` guard
this path uses, so a mid-update WebView package is handled
inconsistently between preboot, warming and display. Called out rather
than fixed here, since changing display behaviour is a separate
decision.

</details>

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches paywall preload and WebView initialization on a background
thread; failures are swallowed but dependency and seam API changes
affect all apps with RevenueCatUI on the classpath.
> 
> **Overview**
> Preloads the WebView engine **before** the first paywall `web_view`
paints, so startup work does not land on the UI thread at render time.
**`androidx.webkit` is bumped to 1.16.0** for
`WebViewCompat.startUpWebView`.
> 
> Core exposes **`PaywallAssetWarmer.prebootWebView`** and
**`PaywallAssetWarming.prebootWebView`** (best-effort, same
ServiceLoader seam as image warming). **Offerings V2** and **workflow**
asset prewarm paths call preboot only when `collectAssets()` finds at
least one `web_view` in the component tree.
> 
> RevenueCatUI adds **`PaywallWebViewStartUp`**, which runs startup on a
background executor (no WebKit calls on the caller thread), loads the
same profile the paywall WebView uses, and runs **once per process**
with guarded error handling. **`PaywallAssetWarmerImpl`** delegates
preboot there.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
e062e4eb3a65902379dd60c565155922da4c7519. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Á
Álvaro Brey committed
5c5979e588c0c605d2b1fe0a7393c06f721784b6
Parent: b2d7cf2
Committed by GitHub <noreply@github.com> on 8/14/2026, 9:24:08 AM