Skip to content

UI events for paywall component interactions#3287

Open
MonikaMateska wants to merge 8 commits intomainfrom
monika/Ul-events/paywall-control-interaction
Open

UI events for paywall component interactions#3287
MonikaMateska wants to merge 8 commits intomainfrom
monika/Ul-events/paywall-control-interaction

Conversation

@MonikaMateska
Copy link
Copy Markdown
Member

@MonikaMateska MonikaMateska commented Mar 28, 2026

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_component_interaction event type and threads many new optional fields through paywall event serialization, storage, and flush to backend; mistakes could drop or mis-shape analytics payloads. UI wiring spans multiple components (tabs/carousel/buttons/text/package selection), increasing the chance of noisy or missing event emission.

Overview
Adds first-class support for paywall component interaction analytics on Android by introducing PaywallEventType.COMPONENT_INTERACTION with a typed PaywallComponentInteractionData payload (including extended index/context/package/product metadata and legacy JSON aliasing via @JsonNames("controlInteraction")).

Extends BackendEvent.Paywalls and both stored-event conversion paths (PaywallEvent.toBackendStoredEvent, PaywallStoredEvent.toBackendEvent) to flatten and send these component fields (component_*, origin/destination/default_*, current/resulting_*).

Wires RevenueCatUI to emit these interactions for V2 components (non-purchase buttons, carousel page changes excluding auto-advance, tab buttons/toggles, markdown link taps, package row selection, package-selection sheet open/close) and for legacy templates (footer actions + Template 7 tier selection), using a new composition-local PaywallComponentInteractionTracker and helper factories; adds optional dashboard name propagation into component models/styles and updates/expands serialization/unit tests accordingly.

Reviewed by Cursor Bugbot for commit 022e6b5. 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
*/
@JvmSynthetic
internal fun tierSelectorControlInteractionValue(tier: TemplateConfiguration.TierInfo): String =
tier.name.takeUnless { it.isBlank() } ?: ""
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.

Tier selector fallback uses empty string instead of tier ID

Medium Severity

tierSelectorControlInteractionValue falls back to an empty string "" when the tier name is blank, but the PR specification states it should fall back to tier.id. The expression tier.name.takeUnless { it.isBlank() } ?: "" discards the tier identifier entirely. The corresponding tests also assert the incorrect empty-string behavior.

Additional Locations (1)
Fix in Cursor Fix in Web

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 could consider fullbacking to the tier id here. Would appreciate any thoughts.

Copy link
Copy Markdown
Contributor

@tonidero tonidero left a comment

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!

}

@InternalRevenueCatAPI
internal fun PaywallControlType.toWireString(): String = when (this) {
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.

Hmm since PaywallControlType is serializable, should we instead just serialize the enum value? (Alternatively, not sure if we just want to make the type not serializable and keep this. Just keeping both means we need to keep serialization logic in both places.)

Comment on lines 152 to 153
myActionInProgress = true
state.update(actionInProgress = true)
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.

Very nitpicky... but should we set this to true before doing anything else to minimize chances of 2 successive clicks making it through?

)

val coroutineScope = rememberCoroutineScope()
val controlInteractionTracker = LocalPaywallControlInteractionTracker.current
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.

Hmm so we've been trying to minimize usages of this pattern, mostly because implicit dependencies like this are more error prone when considering how to keep scope in consideration and that you need to remember to mock it the values as well... (This might be something we should add to the AGENTS.md 😅)

I believe the alternative would be to pass the data we want to track up the action handlers when interacting, which is indeed a lot more plumbing... (though maybe in true MVVM, we should be handling all these interactions from the view model but that's trickier 😅)

So I guess I'm not too opposed to keep it like this. Thoughts @JayShortway @vegaro?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yea we should avoid this as much as possible imo, as it is error prone and can lead to crashes if not provided. In fact, detekt failed on this:

CompositionLocals are implicit dependencies and creating new ones should be avoided.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

definitely a good idea to add to AGENTS.md!

is ButtonComponentStyle.Action.WebProductSelection,
is ButtonComponentStyle.Action.CustomWebCheckout,
-> true
is ButtonComponentStyle.Action.RestorePurchases,
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.

Hmm should Restore Purchases emit paywall_control_interaction? I guess it's kinda purchase related (though it doesn't perform a purchase, just restores them...). Just doubting because, if what we want to know is whether a restore purchases happened, wouldn't we also want to know it for purchase related items like starting to purchase a package, or navigating to a web checkout? (I'm not familiar with the specs so I might be missing something 😅)

}
}

val skipProgrammaticPageTracking = remember { AtomicBoolean(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.

Just to check, is this reset to false if the user manually moves to previous pages in the carousel?

Also worth checking what happens (if you haven't already) with slow manual scrolling and back, like moving > 50% to the next page but moving back before releasing.

pagerState: PagerState,
shouldLoop: Boolean,
pageCount: Int,
skipProgrammaticPageTracking: AtomicBoolean,
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.

Hmm so this is using AtomicBoolean as a way to pass a shared mutable state... Would it be worth to pass a mutable state directly? Though I guess it's not updating UI based on this value... So it's probably ok as is.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is it atomic though? Also, isn't a regular boolean all the "mutable" state we need? Compose will recompose the thing if the boolean changed.

checked = checked,
onCheckedChange = { state.update(selectedTabIndex = if (it) 1 else 0) },
onCheckedChange = {
state.update(selectedTabIndex = if (it) 1 else 0)
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.

Similar nitpick as before, I would wait to update the state until after the tracking

textAlign = textState.textAlign,
style = textStyle,
)
}
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.

Would be good to add some UI tests that create a dummy component of these types and make sure the LocalPaywallControlInteractionTracker or viewModel method is called with the correct params. I think that's the kinda thing that's easy to break 😅 . For example, for this one, it could be a test in TextComponentViewTests I think.

@MonikaMateska MonikaMateska changed the title UI events for paywall control interactions UI events for paywall component interactions Apr 8, 2026
@@ -1,4 +1,5 @@
@file:JvmSynthetic
@file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class)
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.

FQN in @file:OptIn instead of using imports

Low Severity

Several new/modified files use @file:OptIn(com.revenuecat.purchases.InternalRevenueCatAPI::class) with a fully-qualified reference instead of importing InternalRevenueCatAPI and using @file:OptIn(InternalRevenueCatAPI::class). The existing codebase (e.g. VideoView.kt) already follows the import pattern. This violates the rule requiring FQN imports over inline fully-qualified references.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by learned rule: Use FQN imports, not inline fully-qualified references

Reviewed by Cursor Bugbot for commit bdd1a74. Configure here.

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

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 3 total unresolved issues (including 2 from previous reviews).

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 022e6b5. Configure here.

componentName = style.componentName,
componentValue = if (it) "on" else "off",
),
)
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.

Toggle tracks interaction after state update, unlike other components

Low Severity

In TabControlToggleView, state.update(selectedTabIndex = ...) is called before componentInteractionTracker.track(...). Every other component in this PR (TabControlButtonView, PackageComponentView, ButtonComponentView) tracks the interaction before updating state. This inconsistency means the UI state has already mutated by the time the tracking call fires, which is the opposite of the intended order. The PR reviewer explicitly flagged this: "I would wait to update the state until after the tracking."

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 022e6b5. Configure here.

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.

4 participants