Clear paywall web view storage when the user logs out or switches (#4075)
- Clears the paywall WebView profile's browsing data when an identified user leaves: `logOut`, `switchUser`, and `logIn` away from an already identified user. - Keeps the data when an anonymous user signs in. That transition is the middle of a multipage paywall or workflow flow, so the storage belongs to the same customer and dropping it would break the flow they are in. - Headline caveat: on System WebViews without `DELETE_BROWSING_DATA` the fallback clears cookies and `WebStorage.deleteAllData` only, so that profile's network cache, IndexedDB and CacheStorage survive. Those devices get less than the guarantee the first bullet implies. - Goes further than iOS on one path: `purchases-ios#7470` clears on `logOut` and the customEntitlementComputation `switchUser`, so `logIn(A)` then `logIn(B)` keeps A's data there. This clears it. - Resets `PaywallWebViewPrewarmer`'s warmed set after a successful clear, since the deleted data includes the network cache that set is a memo of. ### Checklist - [x] If applicable, unit tests - [ ] If applicable, create follow-up issues for `purchases-ios` and hybrids <details><summary>Agent description</summary> ### Motivation [PWENG-221](https://linear.app/revenuecat/issue/PWENG-221/android-clear-the-paywall-webview-profile-on-identity-change). Paywall WebViews have had an isolated `com.revenuecat.paywall` profile since #3816, but nothing ever cleared it: no `CookieManager`, `WebStorage` or `ServiceWorkerController` reference existed anywhere in the repo. So one user's paywall cookies and DOM storage survived into the next user's session. Isolated from the host app, not isolated between users. The policy is narrower than "on identity change", and comes from [Jake's comment on purchases-ios#7470](https://github.com/RevenueCat/purchases-ios/pull/7470#discussion_r3847188423): clear on log out or on a switch from user A to user B, not on anonymous to known, because most multipage flows are an anonymous customer starting a flow, getting identified, then continuing it. Clearing there would destroy the warm cache mid-conversion. ### Description - Adds `clearWebViewStorage(context)` to the existing `PaywallAssetWarmer` `ServiceLoader` SPI, which is the only push channel from `:purchases` to `:ui:revenuecatui` and already carries the "return promptly, do not throw" contract with `runCatching` isolation on the core side. - `clearPaywallProfileStorage()` calls `WebStorageCompat.deleteBrowsingData` on `Profile.getWebStorage()`. Scoped to our profile, so the host app's `Default` profile is untouched. - Hooks two sites in `IdentityManager`: `logIn`'s backend-success block, and `resetAndSaveUserID`, which is the sole funnel for `logOut` and `switchUser` including the customEntitlementComputation flavor's public `switchUser`. - Guard is `oldAppUserID != newAppUserID && !isUserIDAnonymous(oldAppUserID)`, so the helper owns the whole rule rather than depending on `PurchasesOrchestrator`'s same-id guards upstream. - `getProfile`, not `getOrCreateProfile`: an app that never rendered a `web_view` paywall has nothing to clear, and should not get a profile created on disk in order to wipe it. Not visible in the diff: - `deleteBrowsingData` is documented as deleting "network cache, cookies, and any JavaScript-readable storage", which is the same outcome iOS gets from `WKWebsiteDataStore.remove(forIdentifier:)`. Verified against the `androidx.webkit` 1.16.0 sources, not the docs site. - `DELETE_BROWSING_DATA` is negotiated separately from `MULTI_PROFILE`, so a device can have the isolated profile without the delete API. Hence the fallback branch rather than a single call. - androidx documents the deletion as non-atomic: it "may also delete data that is stored during deletion". That is why the prewarmer reset runs in the done callback instead of inline, so a re-warm cannot start into a deletion that is still running. - Everything touched is `@InternalRevenueCatAPI` or internal, and those members are excluded from the metalava dumps, so `api-*.txt` is unchanged. - The reset is wired into the `deleteBrowsingData` branch only. The fallback does not touch the network cache, so `warmedUrls` stays accurate there. ### Regression gates - `login from an anonymous user keeps the paywall web view storage` in `IdentityManagerTests`: the test that fails if the anonymity exemption is ever dropped, which is the whole policy. - `falls back to cookies and web storage when deleting browsing data is unsupported` in `WebViewProfileTest`, for the older-WebView branch. - `warms a url again once the cache it was warmed into is cleared` in `PaywallWebViewPrewarmerTest`, for the stale-memo bug. **Limitations:** - Pre-113 System WebViews have no isolated profile at all: paywalls fall back to the host app's `Default` profile, which is not ours to wipe, so nothing is cleared there. Logged at debug. - Clearing runs even with a paywall on screen, so a live page loses its cookies underneath it. Logging out with a paywall visible is not a flow we support, and the alternative costs the store-rotation machinery below. - The first qualifying identity change in a process can pay the WebView provider load on a main-thread frame, since `WebViewFeature.isFeatureSupported` is what triggers that load. It lands even on apps whose paywalls contain no `web_view` component, which get no benefit from the clear. Gating on "this process already touched the paywall WebView" would skip it but would be wrong: the profile is persistent, so a previous process's data has to be clearable. - `IdentityManager.configure()` with an explicit app user ID that differs from the cached one still clears nothing. It never captures the old id, so no cache is dropped on that path today, not just this one. Worth its own issue; it is the same leak for an app that manages sign-in by reconfiguring. **Rejected:** - `ProfileStore.deleteProfile`: throws both for a profile any WebView is live on and for one that `getOrCreateProfile` has loaded, and `applyPaywallProfile` always loads it. Unusable here. - Rotating profile names with a delete-on-next-start sweep, which is what iOS does with data store identifiers. Needs a persisted generation counter plus a startup sweep, and `deleteBrowsingData` reaches the same end state in one call. - `WebView.clearCache(true)`: clears the host app's WebView cache too, so it breaks the isolation the profile exists for. - Clearing only cookies and DOM storage to preserve the prewarmed bundles. `WebStorage.deleteAllData` documents only Web SQL and Web Storage, so the network cache, IndexedDB and CacheStorage would keep the previous user's data. The bundle is server-authored, so which stores it uses is not ours to assume. - Hooking `PurchasesOrchestrator` instead: three call sites rather than two, and a future identity path added inside `IdentityManager` would silently miss the clear. </details>
Á
Álvaro Brey committed
c673fb768635e56d51968802c091276dc042ce18
Parent: 0a50b9a
Committed by GitHub <noreply@github.com>
on 8/28/2026, 4:14:23 PM