Skip to content
Open
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 @@ -39,6 +39,9 @@ public void onRestoreError(@NonNull PurchasesError error) {}

@Override
public void onRestoreCompleted(@NonNull CustomerInfo customerInfo) {}

@Override
public void onWebCheckoutOpened() {}
};
}
}
1 change: 1 addition & 0 deletions ui/revenuecatui/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ package com.revenuecat.purchases.ui.revenuecatui {
method public default void onRestoreError(com.revenuecat.purchases.PurchasesError error);
method public default void onRestoreInitiated(com.revenuecat.purchases.ui.revenuecatui.utils.Resumable resume);
method public default void onRestoreStarted();
method public default void onWebCheckoutOpened();
}

@androidx.compose.runtime.Immutable @dev.drewhamilton.poko.Poko public final class PaywallOptions {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@file:OptIn(InternalRevenueCatAPI::class)
@file:Suppress("TooManyFunctions")

package com.revenuecat.purchases.ui.revenuecatui

Expand Down Expand Up @@ -388,19 +389,7 @@ private fun rememberPaywallActionHandler(viewModel: PaywallViewModel): suspend (
)
}

is PaywallAction.External.LaunchWebCheckout -> {
val url = viewModel.getWebCheckoutUrl(action)
if (url == null) {
Logger.e("Web checkout URL cannot be found, not launching web checkout.")
} else {
viewModel.invalidateCustomerInfoCache()
context.handleUrlDestination(url, action.openMethod)
if (action.autoDismiss) {
Logger.d("Auto-dismissing paywall after launching web checkout.")
viewModel.closePaywall()
}
}
}
is PaywallAction.External.LaunchWebCheckout -> handleLaunchWebCheckout(context, viewModel, action)

is PaywallAction.External.NavigateBack -> {
if (!viewModel.handleBackNavigation()) {
Expand All @@ -427,19 +416,43 @@ private fun rememberPaywallActionHandler(viewModel: PaywallViewModel): suspend (
}
}

private fun Context.handleUrlDestination(url: String, method: ButtonComponent.UrlMethod) {
private fun handleLaunchWebCheckout(
context: Context,
viewModel: PaywallViewModel,
action: PaywallAction.External.LaunchWebCheckout,
) {
val url = viewModel.getWebCheckoutUrl(action)
if (url == null) {
Logger.e("Web checkout URL cannot be found, not launching web checkout.")
return
}
viewModel.invalidateCustomerInfoCache()
val opened = context.handleUrlDestination(url, action.openMethod)
if (opened) {
viewModel.notifyWebCheckoutOpened()
}
if (action.autoDismiss) {
Logger.d("Auto-dismissing paywall after launching web checkout.")
viewModel.closePaywall()
}
}

/**
* @return whether the URL was actually opened.
*/
private fun Context.handleUrlDestination(url: String, method: ButtonComponent.UrlMethod): Boolean {
val openingMethod = when (method) {
ButtonComponent.UrlMethod.IN_APP_BROWSER -> URLOpeningMethod.IN_APP_BROWSER
ButtonComponent.UrlMethod.EXTERNAL_BROWSER -> URLOpeningMethod.EXTERNAL_BROWSER
ButtonComponent.UrlMethod.DEEP_LINK -> URLOpeningMethod.DEEP_LINK
ButtonComponent.UrlMethod.UNKNOWN -> {
// Buttons like this should be hidden, so this log should never be shown.
Logger.e("Ignoring button click with unknown open method for URL: '$url'. This is a bug in the SDK.")
return
return false
}
}

URLOpener.openURL(this, url, openingMethod)
return URLOpener.openURL(this, url, openingMethod)
}

private fun Modifier.screenModeBackground(isInFullScreenMode: Boolean, backgroundColor: Color): Modifier = this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ private class LoadingViewModel(
return null
}

override fun notifyWebCheckoutOpened() {
// no-op
}

override fun invalidateCustomerInfoCache() {
// no-op
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,11 @@ public interface PaywallListener {
public fun onRestoreStarted() {}
public fun onRestoreCompleted(customerInfo: CustomerInfo) {}
public fun onRestoreError(error: PurchasesError) {}

/**
* Called when the user taps a web checkout CTA and the external payment URL was opened.
* This is distinct from cancellation: the user has not cancelled, they left the app to
* complete payment externally.
*/
public fun onWebCheckoutOpened() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ internal class PaywallActivity : ComponentActivity() {
userListener?.onRestoreError(error)
setResult(RESULT_OK, createResultIntent(PaywallResult.Error(error)))
}

override fun onWebCheckoutOpened() {
userListener?.onWebCheckoutOpened()
}
}

val edgeToEdge = args?.edgeToEdge == true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ internal interface PaywallViewModel {
fun onTransitionComplete(transitionId: Int)

fun getWebCheckoutUrl(launchWebCheckout: PaywallAction.External.LaunchWebCheckout): String?
fun notifyWebCheckoutOpened()
fun invalidateCustomerInfoCache()

/**
Expand Down Expand Up @@ -414,6 +415,10 @@ internal class PaywallViewModelImpl(
return state.resolveWebCheckoutUrlForInteraction(launchWebCheckout)
}

override fun notifyWebCheckoutOpened() {
listener?.onWebCheckoutOpened()
}

override fun invalidateCustomerInfoCache() {
purchases.invalidateVirtualCurrenciesCache()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ internal class MockViewModel(
validationWarning: PaywallWarning? = null,
private val allowsPurchases: Boolean = false,
private val shouldErrorOnUnsupportedMethods: Boolean = true,
private val webCheckoutUrl: String? = null,
) : ViewModel(), PaywallViewModel {
override val resourceProvider: ResourceProvider
get() = MockResourceProvider()
Expand Down Expand Up @@ -619,7 +620,13 @@ internal class MockViewModel(
override fun getWebCheckoutUrl(launchWebCheckout: PaywallAction.External.LaunchWebCheckout): String? {
getWebCheckoutUrlCallCount++
getWebCheckoutUrlParams.add(launchWebCheckout)
return null
return webCheckoutUrl
}

var notifyWebCheckoutOpenedCallCount = 0
private set
override fun notifyWebCheckoutOpened() {
notifyWebCheckoutOpenedCallCount++
}

var invalidateCustomerInfoCacheCallCount = 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ internal enum class URLOpeningMethod {
}

internal object URLOpener {
@JvmSynthetic internal fun openURL(context: Context, url: String, method: URLOpeningMethod) {
/**
* @return whether the URL was actually opened.
*/
@JvmSynthetic internal fun openURL(context: Context, url: String, method: URLOpeningMethod): Boolean {
var opened = true

fun handleException(exception: Exception) {
opened = false
val message = if (exception is ActivityNotFoundException) {
context.getString(R.string.no_browser_cannot_open_link)
} else {
Expand Down Expand Up @@ -49,5 +55,7 @@ internal object URLOpener {
URLOpeningMethod.DEEP_LINK,
-> context.openUriOrElse(url, ::handleException)
}

return opened
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,194 @@ class PaywallActionTests {
assertEquals(1, viewModel.closePaywallCallCount)
}

@Test
fun `LaunchWebCheckout with autoDismiss notifies listener and closes paywall`(): Unit =
with(composeTestRule) {
// Arrange
val textColor = ColorScheme(ColorInfo.Hex(Color.Black.toArgb()))
val defaultLocale = LocaleId("en_US")
val localizationKey = LocalizationKey("web_checkout")
val localizationData = LocalizationData.Text("web checkout")
val localizations = nonEmptyMapOf(
defaultLocale to nonEmptyMapOf(localizationKey to localizationData),
)
val components = listOf(
PurchaseButtonComponent(
stack = StackComponent(
components = listOf(TextComponent(text = localizationKey, color = textColor)),
),
method = PurchaseButtonComponent.Method.WebCheckout(autoDismiss = true),
),
)
val offering = FakeOffering(components, localizations)
val viewModel = MockViewModel(
offering = offering,
allowsPurchases = true,
webCheckoutUrl = "https://checkout.example.com",
)
val options = PaywallOptions.Builder(dismissRequest = {}).setOffering(offering).build()

// Act
setContent { InternalPaywall(options, viewModel) }
// Buttons appear once in main content and once in sticky footer
onAllNodesWithText(localizationData.value)
.assertCountEquals(2)
.get(0)
.assertIsDisplayed()
.assertHasClickAction()
.performClick()
waitForIdle()

// Assert: listener notified once, paywall dismissed generically (no new result type)
assertEquals(1, viewModel.notifyWebCheckoutOpenedCallCount)
assertEquals(1, viewModel.closePaywallCallCount)
}

@Test
fun `LaunchWebCheckout without autoDismiss notifies listener but keeps paywall open`(): Unit =
with(composeTestRule) {
// Arrange
val textColor = ColorScheme(ColorInfo.Hex(Color.Black.toArgb()))
val defaultLocale = LocaleId("en_US")
val localizationKey = LocalizationKey("web_checkout")
val localizationData = LocalizationData.Text("web checkout")
val localizations = nonEmptyMapOf(
defaultLocale to nonEmptyMapOf(localizationKey to localizationData),
)
val components = listOf(
PurchaseButtonComponent(
stack = StackComponent(
components = listOf(TextComponent(text = localizationKey, color = textColor)),
),
method = PurchaseButtonComponent.Method.WebCheckout(autoDismiss = false),
),
)
val offering = FakeOffering(components, localizations)
val viewModel = MockViewModel(
offering = offering,
allowsPurchases = true,
webCheckoutUrl = "https://checkout.example.com",
)
val options = PaywallOptions.Builder(dismissRequest = {}).setOffering(offering).build()

// Act
setContent { InternalPaywall(options, viewModel) }
// Buttons appear once in main content and once in sticky footer
onAllNodesWithText(localizationData.value)
.assertCountEquals(2)
.get(0)
.assertIsDisplayed()
.assertHasClickAction()
.performClick()
waitForIdle()

// Assert: listener still notified, but the paywall is NOT dismissed
assertEquals(1, viewModel.notifyWebCheckoutOpenedCallCount)
assertEquals(0, viewModel.closePaywallCallCount)
}

@Test
fun `LaunchWebCheckout with unknown open method does not notify listener`(): Unit =
with(composeTestRule) {
// Arrange
val textColor = ColorScheme(ColorInfo.Hex(Color.Black.toArgb()))
val defaultLocale = LocaleId("en_US")
val localizationKey = LocalizationKey("web_checkout")
val localizationData = LocalizationData.Text("web checkout")
val localizations = nonEmptyMapOf(
defaultLocale to nonEmptyMapOf(localizationKey to localizationData),
)
val components = listOf(
PurchaseButtonComponent(
stack = StackComponent(
components = listOf(TextComponent(text = localizationKey, color = textColor)),
),
method = PurchaseButtonComponent.Method.WebCheckout(
autoDismiss = true,
openMethod = ButtonComponent.UrlMethod.UNKNOWN,
),
),
)
val offering = FakeOffering(components, localizations)
val viewModel = MockViewModel(
offering = offering,
allowsPurchases = true,
webCheckoutUrl = "https://checkout.example.com",
)
val options = PaywallOptions.Builder(dismissRequest = {}).setOffering(offering).build()

// Act
setContent { InternalPaywall(options, viewModel) }
// Buttons appear once in main content and once in sticky footer
onAllNodesWithText(localizationData.value)
.assertCountEquals(2)
.get(0)
.assertIsDisplayed()
.assertHasClickAction()
.performClick()
waitForIdle()

// Assert: no URL was opened for an unrecognized method, so the listener must not be
// told a web checkout was launched
assertEquals(0, viewModel.notifyWebCheckoutOpenedCallCount)
}

@Test
fun `LaunchWebCheckout with a recognized method that fails to actually open does not notify listener`(): Unit =
with(composeTestRule) {
// Arrange
val textColor = ColorScheme(ColorInfo.Hex(Color.Black.toArgb()))
val defaultLocale = LocaleId("en_US")
val localizationKey = LocalizationKey("web_checkout")
val localizationData = LocalizationData.Text("web checkout")
val localizations = nonEmptyMapOf(
defaultLocale to nonEmptyMapOf(localizationKey to localizationData),
)
val components = listOf(
PurchaseButtonComponent(
stack = StackComponent(
components = listOf(TextComponent(text = localizationKey, color = textColor)),
),
method = PurchaseButtonComponent.Method.WebCheckout(
autoDismiss = true,
openMethod = ButtonComponent.UrlMethod.DEEP_LINK,
),
),
)
val offering = FakeOffering(components, localizations)
val viewModel = MockViewModel(
offering = offering,
allowsPurchases = true,
// No app on the test device/Robolectric sandbox registers this custom scheme, so the
// real Android URL-opening machinery genuinely fails here (unlike the other tests'
// https:// URLs, which resolve successfully).
webCheckoutUrl = "com.revenuecat.nonexistent-test-scheme://checkout",
)
val options = PaywallOptions.Builder(dismissRequest = {}).setOffering(offering).build()

// Robolectric's default shadow accepts any startActivity call as "successful" regardless of
// whether a real component could handle it. Enable strict validation so this custom scheme
// genuinely throws ActivityNotFoundException, like it would on a real device.
org.robolectric.Shadows.shadowOf(
ApplicationProvider.getApplicationContext<android.app.Application>(),
).checkActivities(true)

// Act
setContent { InternalPaywall(options, viewModel) }
// Buttons appear once in main content and once in sticky footer
onAllNodesWithText(localizationData.value)
.assertCountEquals(2)
.get(0)
.assertIsDisplayed()
.assertHasClickAction()
.performClick()
waitForIdle()

// Assert: even though DEEP_LINK is a recognized method, the URL genuinely failed to open
// (no matching activity), so the listener must not be told a web checkout was launched
assertEquals(0, viewModel.notifyWebCheckoutOpenedCallCount)
}

@Suppress("TestFunctionName")
private fun FakeOffering(data: PaywallComponentsData): Offering =
Offering(
Expand Down