Skip to content

UI events for paywall component interactions#3287

Merged
MonikaMateska merged 32 commits into
mainfrom
monika/Ul-events/paywall-control-interaction
Apr 16, 2026
Merged

UI events for paywall component interactions#3287
MonikaMateska merged 32 commits into
mainfrom
monika/Ul-events/paywall-control-interaction

Conversation

@MonikaMateska

@MonikaMateska MonikaMateska commented Mar 28, 2026

Copy link
Copy Markdown
Member

Checklist

  • If applicable, unit tests
  • If applicable, create follow-up issues for purchases-ios and hybrids

Motivation

This PR is part of the “Posting UI Events to Integrations” initiative and focuses on enabling paywall_component_interaction on Android so UI behavior events can flow through the existing paywall event pipeline and be forwarded to integrations without app-side callback wiring.

It addresses the current gap where UI events exist on other platforms but are not consistently represented in Android’s paywall event payloads—including package selection, package-selection sheet lifecycle, and extended interaction metadata aligned with iOS. This is especially important for the upcoming Campaigns/Workflows/Checkpoints direction and for high-integration customers (e.g. Leadtech).

Resolves: PWENG-15

Description

This PR adds Android support for paywall_component_interaction and wires control interaction metadata through purchases + RevenueCatUI, aligned with the iOS wire format and semantics (same backend field names as iOS’s paywall component interaction map, e.g. origin_package_id, current_package_id, etc.).

What was added

  • Paywall event support for paywall_component_interaction (unchanged type string on Android; payload extended to match iOS).
  • Core interaction fields (backend / stored event / flush path):
    • component_type, component_name, component_value, component_url (optional)
  • Extended interaction fields (parity with iOS ComponentInteractionData / backend map):
    • origin_index, destination_index, default_index
    • origin_context_name, destination_context_name
    • origin_package_id, destination_package_id, default_package_id
    • origin_product_id, destination_product_id, default_product_id
    • current_package_id, resulting_package_id, current_product_id, resulting_product_id
  • Component type enum (wire-aligned), including:
    • tab, switch, carousel, button, text
    • package (selectable package row)
    • package_selection_sheet (bottom sheet open/close lifecycle)
  • Model + mapping
    • PaywallControlInteractionData expanded; PaywallEvent / PaywallStoredEvent round-trip
    • BackendEvent.Paywalls + BackendStoredEvent / PaywallStoredEvent mapping via PaywallControlInteractionData.toBackendControlFields()BackendPaywallControlFields
    • Request / serialization tests updated; cases for extended fields
  • Tracking API
    • PaywallViewModel.trackControlInteraction(PaywallControlInteractionData) as the primary entry point; 4-parameter overload builds a minimal PaywallControlInteractionData for legacy call sites
    • Purchases.track(PaywallEvent) unchanged as the sink
    • LocalPaywallControlInteractionTracker now takes a single PaywallControlInteractionData argument
  • Factories (RevenueCatUI, mirror iOS helpers): sheet open/close, package row selection, tier selection (paywallPackageSelectionSheetOpen / Close, paywallPackageRowSelection, paywallTierSelection)
  • Paywall JSON / style pipeline
    • Optional name on PackageComponentPackageComponentStyle.componentName for component_name on package rows when the dashboard provides it

UI coverage included

V2 (components paywall)

  • Tab control button
  • Tab control toggle (on / off)
  • Button actions (non-purchase only; purchase / web-checkout actions excluded)
  • Carousel page changes (user-initiated; auto-advance suppressed for analytics)
  • Text / markdown link taps (navigate_to_url + URL)
  • Selectable package row (package): component_value = destination package id; origin/destination/default package + product ids when applicable
  • Package selection sheet (package_selection_sheet):
    • Open when navigating to sheet: component_value = open; current_* = root selection at open time
    • Close on sheet dismiss (scrim, back, in-sheet navigate back): component_value = close; current_* = selection while sheet was open; resulting_* = root after resetToDefaultPackage()

V1 (template / legacy paywall)

  • All plans toggle
  • Restore purchases
  • Terms link
  • Privacy link
  • Tier selector (Template 7)—now with origin/destination package + product ids when tiers map to packages
  • Default / validation paywall restore (same payload as footer restore when Footer is not shown)

Semantics used

  • component_name
    • V2: builder name when available (including package components when JSON includes name)
    • V1: context-based names (all_plans_button, restore_button, terms_link, privacy_link, tier_selector)
  • component_value
    • Action-style discriminators where applicable (restore_purchases, navigate_to_terms, navigate_to_privacy_policy, toggle_all_plans, navigate_to_url, open / close for package sheet, etc.)
    • Toggle uses on / off
    • Tier selector uses tier display name when non-blank (else empty string per existing helper)
    • Carousel uses 0-based logical page index as string
    • Package row: destination package identifier
  • Extended fields
    • Populated for package, package_selection_sheet, and tier flows as on iOS (origin/destination/default package+product; sheet current/resulting pair on dismiss)

Note

Medium Risk
Adds a new paywall event type and threads many new optional analytics fields through serialization, backend event mapping, and UI interaction points; mistakes could change event payloads or increase event volume but core purchase logic is largely untouched.

Overview
Adds support for a new paywall event type, PaywallEventType.COMPONENT_INTERACTION, including a typed payload (PaywallComponentInteractionData) and backend wire fields (component metadata, indices/context names, and package/product lifecycle identifiers). These fields are plumbed through BackendEvent.Paywalls, stored-event conversion (PaywallStoredEvent/BackendStoredEvent), and request serialization, with new/updated tests verifying round-trip encoding and backend mapping.

Extends the paywall components data model to carry optional dashboard name values (e.g., ButtonComponent, CarouselComponent, PackageComponent, StackComponent, TabsComponent, TextComponent) and threads those into UI styles. RevenueCatUI now emits component-interaction tracking for V2 components (buttons, tabs/toggles, carousel page changes with auto-advance suppression, package selection rows, sheet open/close, and text link taps) and adds comparable tracking calls for key legacy/V1 paywall actions; paywall impression tracking is also gated behind LaunchedEffect keys to avoid redundant calls.

Reviewed by Cursor Bugbot for commit b2e6c69. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment on lines +120 to +135
LaunchedEffect(pagerState, pageCount, style.componentName, controlInteractionTracker) {
var previousPage = pagerState.currentPage
snapshotFlow { pagerState.currentPage }.collect { page ->
if (page != previousPage) {
if (skipProgrammaticPageTracking.getAndSet(false)) {
// Auto-advance scroll; do not emit control interaction (parity with iOS).
} else {
val logicalPage = page % pageCount
controlInteractionTracker.track(
componentType = PaywallControlType.CAROUSEL,
componentName = style.componentName,
componentValue = logicalPage.toString(),
componentUrl = null,
)
}
previousPage = page

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had a bug in non-looping carousels with auto-advance turned on.
The code always computed the next page as (current + 1) mod page count, which is correct when the carousel loops but wrong when it does not. On the last page it would wrap back to the first page and keep animating forever instead of staying put.
Single-page carousels had the same kind of problem: modulo math still produced a “next” page when there was nowhere real to go. The fix is to only schedule the next auto-advance page when there actually is one, for non-loop mode that means stop once you are on the last page (and bail out for empty or single-page carousels).
Looping behavior is unchanged; we still just increment the page index there.

@MonikaMateska MonikaMateska marked this pull request as ready for review March 28, 2026 16:23
@MonikaMateska MonikaMateska requested review from a team as code owners March 28, 2026 16:23

@tonidero tonidero left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking really great! Left a few initial comments but I think it's close!

@MonikaMateska MonikaMateska changed the title UI events for paywall control interactions UI events for paywall component interactions Apr 8, 2026
@MonikaMateska MonikaMateska requested a review from a team as a code owner April 13, 2026 11:25
MonikaMateska and others added 7 commits April 13, 2026 13:34
… paywalls (#3336)

<!-- Thank you for contributing to Purchases! Before pressing the
"Create Pull Request" button, please provide the following: -->

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

### Motivation
Paywall component interaction analytics did not consistently record taps
on purchase CTAs for Paywalls V2 (component JSON) or v1 template
paywalls. Product and backend need a purchase_button interaction type
with stable component_value, package/product identifiers, and URL
semantics aligned with how checkout is opened (in-app vs web / browser
mode).
Resolves: PWENG-32

### Description

- **V2 / component paywalls:** On purchase-related
**`ButtonComponentStyle`** actions, emit
**`PaywallComponentType.PURCHASE_BUTTON`** via
**`paywallPurchaseButtonAction`**, using
**`packageForPurchaseButtonInteraction`** for package/product IDs and
**`purchaseButtonInteractionComponentUrl`** (built on
**`resolveWebCheckoutUrlForInteraction`**) for **`component_url`**
(external browser = fully resolved checkout URL; in-app / deep link =
package/offering WPL + custom URL fallback as implemented).
- **Style factory:** Wire **`PurchaseButtonComponent.name`** into
**`ButtonComponentStyle.componentName`** where applicable.
- **Legacy templates:** Track the same interaction shape from
**`PurchaseButton`** and the validation-warning **`DefaultPaywallView`**
purchase path, using **`PaywallLegacyComponentInteraction`** constants
for stable **`component_name`** / **`component_value`**.
- **Serialization / backend:** Extend **`PaywallComponentType`** with
**`purchase_button`** and keep backend flattening in sync; fix
**`PaywallEventSerializationTests`** to cast
**`BackendStoredEvent.Paywalls`** before reading **`event`**.
- **Detekt:** Collapse **`paywallCarouselPageChange`** parameters into
**`CarouselPageChangeInteraction`** (with
**`@Suppress("LongParameterList")`** on the data class constructor if
needed).


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes paywall analytics emission and URL resolution for
purchase-related buttons across both component (V2) and legacy paywalls,
which could affect event payloads and downstream reporting. No
purchase/entitlement logic is modified, but incorrect URL/package
resolution could misattribute interactions.
> 
> **Overview**
> Adds a new paywall component interaction type,
`PaywallComponentType.PURCHASE_BUTTON`, and wires backend
flattening/serialization to emit the `purchase_button` wire value.
> 
> Updates RevenueCatUI to **track purchase CTA taps** for both component
paywalls (purchase-related `ButtonComponentStyle` actions) and legacy
templates (including the validation-warning/default paywall), emitting a
standardized `purchase_button` interaction with stable
`component_value`, optional `component_name`,
`currentPackageIdentifier`/`currentProductIdentifier`, and consistent
`component_url` semantics.
> 
> Refactors web-checkout URL construction into a shared resolver
(`resolveWebCheckoutUrlForInteraction`) and adjusts carousel page-change
tracking to use a `CarouselPageChangeInteraction` container to reduce
parameter noise; tests are updated/added to validate the new
purchase-button interaction shape and stored-event casting.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
02014ea. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Alexander Repty <alex.repty@revenuecat.com>
@MonikaMateska MonikaMateska requested a review from tonidero April 14, 2026 15:16

@tonidero tonidero left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still reviewing but looking good so far! Just some thoughts. Will finish tomorrow 🙇

@tonidero tonidero left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like some tests are failing. But I think the changes here makes sense! Amazing job!! Complex feature touching many things 🙌

currentPackage = currentPackage,
state = state,
)
componentInteractionTracker.track(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Not for this PR]
Could be very interesting to have a project with a paywall with all these components and then add some E2E integration tests, either using instrumentation tests, or maestro tests, that verifies the events are sent with the expected properties. We could potentially use the debug event internal API.

This could prove very valuable as a fully E2E tests for these analytics. But as I said, doesn't need to be part of this PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I've added this backlog ticket so we can introduce the e2e tests

fun clear() {
shouldSkipNextPageChange = false
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-advance flag may suppress user carousel tracking

Low Severity

ProgrammaticPageTrackingFlag is a plain mutable holder shared between two coroutines (auto-advance LaunchedEffect and snapshotFlow page-change collector). If a user swipe interrupts an auto-advance animation, the currentPage snapshot change from the user gesture can fire before the CancellationException catch block calls clear(), causing the one-shot flag to be consumed by the user-initiated page change — silently dropping that user interaction event.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 848b970. Configure here.

@emerge-tools

emerge-tools Bot commented Apr 16, 2026

Copy link
Copy Markdown

📸 Snapshot Test

21 modified, 567 unchanged

Name Added Removed Modified Renamed Unchanged Errored Approval
TestPurchasesUIAndroidCompatibility
com.revenuecat.testpurchasesuiandroidcompatibility
0 0 9 0 322 0 ⏳ Needs approval
TestPurchasesUIAndroidCompatibility Paparazzi
com.revenuecat.testpurchasesuiandroidcompatibility.paparazzi
0 0 12 0 245 0 ⏳ Needs approval

🛸 Powered by Emerge Tools

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 69c91fc. Configure here.

@JZDesign JZDesign left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥

@MonikaMateska MonikaMateska enabled auto-merge April 16, 2026 14:38
@MonikaMateska MonikaMateska added this pull request to the merge queue Apr 16, 2026
@codecov

codecov Bot commented Apr 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.23664% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 79.39%. Comparing base (30e7757) to head (b2e6c69).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...venuecat/purchases/paywalls/events/PaywallEvent.kt 98.27% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3287      +/-   ##
==========================================
+ Coverage   79.21%   79.39%   +0.18%     
==========================================
  Files         354      354              
  Lines       14121    14252     +131     
  Branches     1948     1950       +2     
==========================================
+ Hits        11186    11316     +130     
  Misses       2133     2133              
- Partials      802      803       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Merged via the queue into main with commit edf1dc9 Apr 16, 2026
37 of 39 checks passed
@MonikaMateska MonikaMateska deleted the monika/Ul-events/paywall-control-interaction branch April 16, 2026 15:26
matteinn pushed a commit to matteinn/purchases-android that referenced this pull request Apr 28, 2026
**This is an automatic release.**

## RevenueCat SDK
### 🐞 Bugfixes
* fix: move Google BillingClient connection off the main thread (RevenueCat#3369)
via Toni Rico (@tonidero)
* [EXTERNAL] fix(google): guard showInAppMessages against BillingClient
runtime crashes (RevenueCat#3367) by @matteinn (RevenueCat#3368) via Monika Mateska
(@MonikaMateska)

## RevenueCatUI SDK
### Paywallv2
#### 🐞 Bugfixes
* Add Workflows network layer (RevenueCat#3300) via Cesar de la Vega (@vegaro)

### 🔄 Other Changes
* Fix `revenuecat.useWorkflowsEndpoint` compiler flag (RevenueCat#3374) via Cesar
de la Vega (@vegaro)
* Create paywall from workflow response. Add `USE_WORKFLOWS_ENDPOINT`
BuildConfig (RevenueCat#3350) via Cesar de la Vega (@vegaro)
* Refactor: Remove unnecessary lint suppressions (RevenueCat#3373) via cursor[bot]
(@cursor[bot])
* Bump fastlane-plugin-revenuecat_internal from `a1eed48` to `b822f01`
(RevenueCat#3371) via dependabot[bot] (@dependabot[bot])
* Bump fastlane from 2.232.2 to 2.233.0 (RevenueCat#3370) via dependabot[bot]
(@dependabot[bot])
* Attempt to fix `AssertionError` "ms is denormalized" in
`QueryPurchasesUseCaseTest` (RevenueCat#3361) via Cesar de la Vega (@vegaro)
* Update baseline profiles (RevenueCat#3296) via Jaewoong Eum (@skydoves)
* fix: reduce precision for flaky HeaderDirectHeroImage snapshot (RevenueCat#3362)
via Cesar de la Vega (@vegaro)
* Fix test failures reported twice (RevenueCat#3360) via Cesar de la Vega
(@vegaro)
* refactor: extract `updateStateFromOffering` in `PaywallViewModel`
(RevenueCat#3359) via Cesar de la Vega (@vegaro)
* [Fix] Include parent tabs component_name in tab-control switch
interaction events (RevenueCat#3358) via Monika Mateska (@MonikaMateska)
* Refactor: Remove unnecessary lint suppressions (RevenueCat#3348) via cursor[bot]
(@cursor[bot])
* fix: always upload CI test results even when tests fail (RevenueCat#3357) via
Cesar de la Vega (@vegaro)
* refactor: extract `RevenueCatDialogScaffold` (RevenueCat#3355) via Cesar de la
Vega (@vegaro)
* Fix Slack notifications for nightly integration tests (RevenueCat#3354) via Toni
Rico (@tonidero)
* UI events for paywall component interactions (RevenueCat#3287) via Monika
Mateska (@MonikaMateska)
* Bump fastlane-plugin-revenuecat_internal from `20911d1` to `a1eed48`
(RevenueCat#3351) via dependabot[bot] (@dependabot[bot])

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Primarily a version bump and release automation updates (docs
deploy/redirect and changelog); no functional library code changes
beyond updating embedded version constants.
> 
> **Overview**
> Cuts the `10.2.1` release by updating version references across the
repo (Gradle `VERSION_NAME`, internal `frameworkVersion`, sample/test
app dependency pins, and `.version`).
> 
> Updates the docs release pipeline and website redirect to publish and
point at `10.2.1`, and refreshes `CHANGELOG.md`/`CHANGELOG.latest.md`
with the 10.2.1 release notes.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
a0a325b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants