Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import com.revenuecat.purchases.PurchasesException
import com.revenuecat.purchases.common.workflows.PublishedWorkflow
import com.revenuecat.purchases.common.workflows.WorkflowDataResult
import com.revenuecat.purchases.common.workflows.WorkflowStep
import com.revenuecat.purchases.common.workflows.WorkflowTriggerAction
import com.revenuecat.purchases.common.workflows.WorkflowTriggerType
import com.revenuecat.purchases.common.workflows.events.WorkflowEvent
import com.revenuecat.purchases.models.SubscriptionOption
import com.revenuecat.purchases.paywalls.components.common.ProductChangeConfig
import com.revenuecat.purchases.paywalls.events.ExitOfferType
Expand Down Expand Up @@ -185,6 +187,7 @@ internal class PaywallViewModelImpl(
private val _actionError: MutableState<PurchasesError?> = mutableStateOf(null)
private val _purchaseCompleted: MutableState<Boolean> = mutableStateOf(false)
private val _workflowState: MutableState<WorkflowPaywallUiState?> = mutableStateOf(null)
private var workflowTraceId: String = UUID.randomUUID().toString()
private val _lastLocaleList = MutableStateFlow(getCurrentLocaleList())
private val _colorScheme = MutableStateFlow(colorScheme)

Expand All @@ -207,6 +210,12 @@ internal class PaywallViewModelImpl(
private var preWarmJob: Job? = null
private var transitionIdCounter: Int = 0

private enum class WorkflowStepEntryReason(val value: String) {
START("start"),
FORWARD("forward"),
BACK("back"),
}

private sealed interface ExitOfferData {
val preloadRequested: Boolean

Expand Down Expand Up @@ -307,6 +316,7 @@ internal class PaywallViewModelImpl(

override fun closePaywall(result: PaywallResult?) {
Logger.d("Paywalls: Close paywall initiated")
trackCurrentWorkflowStepCompleted()
trackPaywallClose()
val exitOffering = if (!_purchaseCompleted.value && shouldTriggerExitOfferForCurrentStep) {
preloadedExitOffering
Expand Down Expand Up @@ -514,6 +524,7 @@ internal class PaywallViewModelImpl(
if (!it(customerInfo)) {
_purchaseCompleted.value = true
Logger.d("Dismissing paywall after restore since display condition has not been met")
trackCurrentWorkflowStepCompleted()
options.dismissRequest()
}
}
Expand Down Expand Up @@ -675,6 +686,7 @@ internal class PaywallViewModelImpl(
_purchaseCompleted.value = true
listener?.onPurchaseCompleted(purchaseResult.customerInfo, purchaseResult.storeTransaction)
Logger.d("Dismissing paywall after purchase")
trackCurrentWorkflowStepCompleted()
options.dismissRequest()
}
else -> {
Expand Down Expand Up @@ -718,7 +730,7 @@ internal class PaywallViewModelImpl(
}

private suspend fun updateStateFromOffering(offeringSelection: OfferingSelection) {
if (updateStateFromWorkflowEndpointIfNeeded(offeringSelection)) {
if (startWorkflowPresentationFromEndpointIfNeeded(offeringSelection)) {
return
}

Expand All @@ -740,7 +752,7 @@ internal class PaywallViewModelImpl(
updatePaywallState(currentOffering)
}

private suspend fun updateStateFromWorkflowEndpointIfNeeded(offeringSelection: OfferingSelection): Boolean {
private suspend fun startWorkflowPresentationFromEndpointIfNeeded(offeringSelection: OfferingSelection): Boolean {

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.

Renamed this one and updateStateFromWorkflow becuase I found the naming confusing

var updatedFromWorkflow = false
if (useWorkflowsEndpoint) {
val workflowParams = when (offeringSelection) {
Expand All @@ -755,7 +767,7 @@ internal class PaywallViewModelImpl(
coroutineScope {
val fetchResultDeferred = async { purchases.awaitGetWorkflow(offeringId) }
val offeringsDeferred = async { purchases.awaitOfferings() }
applyWorkflowState(
startWorkflowPresentation(
fetchResultDeferred.await(),
offeringsDeferred.await(),
presentedOfferingContext,
Expand Down Expand Up @@ -824,17 +836,17 @@ internal class PaywallViewModelImpl(
}
}

internal fun updateStateFromWorkflow(
internal fun startWorkflowPresentationFromResult(
fetchResult: WorkflowDataResult,
offerings: Offerings,
presentedOfferingContext: PresentedOfferingContext?,
) {
cancelStateUpdate()
applyWorkflowState(fetchResult, offerings, presentedOfferingContext)
startWorkflowPresentation(fetchResult, offerings, presentedOfferingContext)
}

@Suppress("ReturnCount")
private fun applyWorkflowState(
private fun startWorkflowPresentation(
fetchResult: WorkflowDataResult,
offerings: Offerings,
presentedOfferingContext: PresentedOfferingContext?,
Expand All @@ -849,6 +861,10 @@ internal class PaywallViewModelImpl(
return
}

// Close the lifecycle of any step the user was already on before starting a new presentation.
// Uses the old currentWorkflowResult and _workflowState before either is mutated below.
trackCurrentWorkflowStepCompleted()

currentWorkflowResult = fetchResult
currentWorkflowOfferings = offerings
currentWorkflowPresentedOfferingContext = presentedOfferingContext
Expand All @@ -868,7 +884,13 @@ internal class PaywallViewModelImpl(
if (stepWithPackagesId != null && workflow.steps[stepWithPackagesId] == null) {
Logger.w("Workflow singleStepFallbackId '$stepWithPackagesId' not found in steps")
}
buildWorkflowStates(workflow, offerings, presentedOfferingContext, currentStep = initialStep)
buildWorkflowStates(
workflow = workflow,
offerings = offerings,
presentedOfferingContext = presentedOfferingContext,
currentStep = initialStep,
isNewWorkflowImpression = true,
)
Comment thread
cursor[bot] marked this conversation as resolved.
}

/**
Expand All @@ -886,6 +908,7 @@ internal class PaywallViewModelImpl(
offerings = offerings,
presentedOfferingContext = currentWorkflowPresentedOfferingContext,
currentStep = currentStep,
isNewWorkflowImpression = false,
)
}

Expand All @@ -899,19 +922,36 @@ internal class PaywallViewModelImpl(
offerings: Offerings,
presentedOfferingContext: PresentedOfferingContext?,
currentStep: WorkflowStep,
isNewWorkflowImpression: Boolean,
) {
preWarmJob?.cancel()
workflowStepStateCache.clear()
_workflowState.value = null
if (isNewWorkflowImpression) {
workflowTraceId = UUID.randomUUID().toString()
}

// Pre-compute the package step so its default package is available in cache
// for early packageless steps to use as context.
val stepWithPackages = workflow.singleStepFallbackId?.let { workflow.steps[it] }
if (stepWithPackages != null && stepWithPackages.id != currentStep.id) {
buildStateFromStep(stepWithPackages, workflow, offerings, presentedOfferingContext)
buildStateFromStep(
stepWithPackages,
workflow,
offerings,
presentedOfferingContext,
shouldApplyState = false,
)
}

buildStateFromStep(currentStep, workflow, offerings, presentedOfferingContext)
if (isNewWorkflowImpression && _workflowState.value != null) {
trackWorkflowStepStarted(
step = currentStep,
fromStepId = null,
entryReason = WorkflowStepEntryReason.START,
)
}
Comment thread
cursor[bot] marked this conversation as resolved.
preWarmWorkflowStepCache(workflow, offerings, presentedOfferingContext)
}

Expand All @@ -922,6 +962,7 @@ internal class PaywallViewModelImpl(
presentedOfferingContext: PresentedOfferingContext?,
fromStepId: String? = null,
navigationDirection: NavigationDirection? = null,
shouldApplyState: Boolean = true,
) {
val cached = workflowStepStateCache[step.id]
val newState = cached ?: computeStateForStep(step, workflow, offerings, presentedOfferingContext)
Expand All @@ -938,6 +979,7 @@ internal class PaywallViewModelImpl(
newState.setDefaultPackage(defaultPackage)
}
}
if (!shouldApplyState) return
val pendingTransition = if (fromStepId != null && navigationDirection != null) {
WorkflowPendingTransition(
fromStepId = fromStepId,
Expand All @@ -951,6 +993,11 @@ internal class PaywallViewModelImpl(
// sees the workflow branch and the correct step, not the single-page branch.
// On error, clear workflowState so the UI falls through to the normal error path rather
// than entering workflow mode with a currentStepId absent from stepStates.
if (newState !is PaywallState.Loaded.Components) {
currentWorkflowStep?.let { currentStep ->
trackWorkflowStepCompleted(step = currentStep, toStepId = null)
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.
_workflowState.value = if (newState is PaywallState.Loaded.Components) {
WorkflowPaywallUiState(
currentStepId = step.id,
Expand Down Expand Up @@ -1042,7 +1089,8 @@ internal class PaywallViewModelImpl(
Logger.e("Cannot navigate to step '${candidate.id}': $error")
return
}
val fromStepId = navigator.currentStep?.id
val fromStep = navigator.currentStep
val fromStepId = fromStep?.id
// triggerAction repeats the same lookup as peekTriggerStep. It should not return null
// given the peek succeeded, but guard anyway to avoid a hard crash.
val newStep = navigator.triggerAction(componentId, triggerType) ?: run {
Expand All @@ -1057,6 +1105,11 @@ internal class PaywallViewModelImpl(
fromStepId = fromStepId,
navigationDirection = NavigationDirection.FORWARD,
)
trackWorkflowStepNavigation(
fromStep = fromStep,
toStep = newStep,
entryReason = WorkflowStepEntryReason.FORWARD,
)
}

@Suppress("ReturnCount")
Expand All @@ -1070,7 +1123,8 @@ internal class PaywallViewModelImpl(
Logger.e("Cannot navigate back to step '${candidate.id}': $error")
return false
}
val fromStepId = navigator.currentStep?.id
val fromStep = navigator.currentStep
val fromStepId = fromStep?.id
// navigateBack should not return null given canNavigateBack is true, but guard to be safe.
val newStep = navigator.navigateBack() ?: run {
Logger.e("navigateBack returned null after canNavigateBack was true — this is a bug")
Expand All @@ -1084,9 +1138,94 @@ internal class PaywallViewModelImpl(
fromStepId = fromStepId,
navigationDirection = NavigationDirection.BACKWARD,
)
trackWorkflowStepNavigation(
fromStep = fromStep,
toStep = newStep,
entryReason = WorkflowStepEntryReason.BACK,
)
return true
}

private fun trackWorkflowStepNavigation(
fromStep: WorkflowStep?,
toStep: WorkflowStep,
entryReason: WorkflowStepEntryReason,
) {
// If _workflowState is null after buildStateFromStep, an error occurred and
// StepCompleted was already fired inside buildStateFromStep.
if (_workflowState.value == null) return

fromStep?.let { from ->
trackWorkflowStepCompleted(step = from, toStepId = toStep.id)
}
Comment thread
vegaro marked this conversation as resolved.
trackWorkflowStepStarted(
step = toStep,
fromStepId = fromStep?.id,
entryReason = entryReason,
)
}

private fun trackWorkflowStepStarted(
step: WorkflowStep,
fromStepId: String?,
entryReason: WorkflowStepEntryReason,
) {
val workflowResult = currentWorkflowResult ?: return
val workflow = workflowResult.workflow
purchases.track(
WorkflowEvent.StepStarted(
creationData = WorkflowEvent.CreationData(UUID.randomUUID(), Date()),
workflowId = workflow.id,
stepId = step.id,
traceId = workflowTraceId,
fromStepId = fromStepId,
entryReason = entryReason.value,
isFirstStep = step.id == workflow.initialStepId,
isLastStep = isTerminalStep(workflow, step.id),
),
)
}

private fun trackWorkflowStepCompleted(step: WorkflowStep, toStepId: String?) {
val workflowResult = currentWorkflowResult ?: return
val workflow = workflowResult.workflow
purchases.track(
WorkflowEvent.StepCompleted(
creationData = WorkflowEvent.CreationData(UUID.randomUUID(), Date()),
workflowId = workflow.id,
stepId = step.id,
traceId = workflowTraceId,
toStepId = toStepId,
isFirstStep = step.id == workflow.initialStepId,
isLastStep = isTerminalStep(workflow, step.id),
),
)
}

private fun isTerminalStep(workflow: PublishedWorkflow, stepId: String): Boolean {
val step = workflow.steps[stepId] ?: return false
return step.triggerActions.values.none { it is WorkflowTriggerAction.Step }
}

private val currentWorkflowStep: WorkflowStep?
get() {
val stepId = _workflowState.value?.currentStepId ?: return null
return currentWorkflowResult?.workflow?.steps?.get(stepId)
}

/**
* Fires [WorkflowEvent.StepCompleted] for the step the user is currently on, if any. No-op for
* non-workflow paywalls. Used when the current step is left without navigating to another one.
* Called directly on dismiss paths that intentionally do not emit a paywall close event (a
* successful purchase, and the REVENUECAT restore-dismiss), and from [closePaywall] (which
* additionally emits the close event). This keeps paywall_close behavior identical to non-workflow.
*/
private fun trackCurrentWorkflowStepCompleted() {
currentWorkflowStep?.let { step ->
trackWorkflowStepCompleted(step = step, toStepId = null)
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

@Suppress("ReturnCount")
private fun validateStep(step: WorkflowStep, workflow: PublishedWorkflow, offerings: Offerings): String? {
val screenId = step.screenId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,31 @@ class PaywallViewModelTest {
assertThat(dismissInvoked).isTrue
}

@Test
fun `handlePackagePurchase success does not track CLOSE event`(): Unit = runBlocking {
// A successful purchase dismisses the paywall but must not be counted as a
// user-initiated close. Regression test: the dismiss path must not emit CLOSE.
val offering = Offering(
identifier = "offering-id",
serverDescription = "description",
metadata = emptyMap(),
availablePackages = listOf(TestData.Packages.monthly, TestData.Packages.annual),
paywallComponents = Offering.PaywallComponents(UiConfig(), emptyPaywallComponentsData),
)
val model = create(offering = offering)
val state = model.state.value as PaywallState.Loaded.Components
state.update(TestData.Packages.monthly.identifier)
model.trackPaywallImpressionIfNeeded()
coEvery {
purchases.awaitPurchase(any())
} returns PurchaseResult(mockk<StoreTransaction>(), customerInfo)

model.handlePackagePurchase(activity, pkg = null)

assertThat(dismissInvoked).isTrue
verifyNoEventsOfTypeTracked(PaywallEventType.CLOSE)
}

@Test
fun `handlePackagePurchase purchases provided package`(): Unit = runBlocking {
// Arrange
Expand Down Expand Up @@ -1184,6 +1209,32 @@ class PaywallViewModelTest {
assertThat(dismissInvoked).isTrue()
}

@Test
fun `handleRestorePurchases dismiss does not change paywall_close behavior`(): Unit = runBlocking {
// The workflow wiring must not alter paywall_close. On the REVENUECAT path, a restore that
// dismisses the paywall did not emit a close event before workflows, and must not now.
val offering = Offering(
identifier = "offering-id",
serverDescription = "description",
metadata = emptyMap(),
availablePackages = listOf(TestData.Packages.monthly, TestData.Packages.annual),
paywallComponents = Offering.PaywallComponents(UiConfig(), emptyPaywallComponentsData),
)
val model = create(
offering = offering,
shouldDisplayBlock = { false },
)
model.trackPaywallImpressionIfNeeded()
coEvery {
purchases.awaitRestore()
} returns customerInfo

model.handleRestorePurchases()

assertThat(dismissInvoked).isTrue
verifyNoEventsOfTypeTracked(PaywallEventType.CLOSE)
}

@Test
fun `restorePurchases does not call onDismiss if shouldDisplayBlock condition true`() {
val model = create {
Expand Down
Loading