diff --git a/api-tester/src/defaults/java/com/revenuecat/apitester/java/revenuecatui/PaywallListenerAPI.java b/api-tester/src/defaults/java/com/revenuecat/apitester/java/revenuecatui/PaywallListenerAPI.java index 8a11fb54b1..8acd60117a 100644 --- a/api-tester/src/defaults/java/com/revenuecat/apitester/java/revenuecatui/PaywallListenerAPI.java +++ b/api-tester/src/defaults/java/com/revenuecat/apitester/java/revenuecatui/PaywallListenerAPI.java @@ -39,6 +39,9 @@ public void onRestoreError(@NonNull PurchasesError error) {} @Override public void onRestoreCompleted(@NonNull CustomerInfo customerInfo) {} + + @Override + public void onWebCheckoutOpened() {} }; } } diff --git a/ui/revenuecatui/api.txt b/ui/revenuecatui/api.txt index 692de54744..4fd0997f26 100644 --- a/ui/revenuecatui/api.txt +++ b/ui/revenuecatui/api.txt @@ -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 { diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt index 51f80e20c1..53cb26f30c 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/InternalPaywall.kt @@ -1,4 +1,5 @@ @file:OptIn(InternalRevenueCatAPI::class) +@file:Suppress("TooManyFunctions") package com.revenuecat.purchases.ui.revenuecatui @@ -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()) { @@ -427,7 +416,31 @@ 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 @@ -435,11 +448,11 @@ private fun Context.handleUrlDestination(url: String, method: ButtonComponent.Ur 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 diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt index 458bd7bd56..c8725e8a72 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/LoadingPaywall.kt @@ -214,6 +214,10 @@ private class LoadingViewModel( return null } + override fun notifyWebCheckoutOpened() { + // no-op + } + override fun invalidateCustomerInfoCache() { // no-op } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallListener.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallListener.kt index 9997f53fca..07808260ac 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallListener.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallListener.kt @@ -39,4 +39,10 @@ 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. + * Distinct from cancellation: the user has not cancelled, they left to pay externally. + */ + public fun onWebCheckoutOpened() {} } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivity.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivity.kt index 17e74cf335..2c0734c485 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivity.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/activity/PaywallActivity.kt @@ -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 diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index 806033323b..8e5a149c46 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -139,6 +139,7 @@ internal interface PaywallViewModel { fun onTransitionComplete(transitionId: Int) fun getWebCheckoutUrl(launchWebCheckout: PaywallAction.External.LaunchWebCheckout): String? + fun notifyWebCheckoutOpened() fun invalidateCustomerInfoCache() /** @@ -414,6 +415,10 @@ internal class PaywallViewModelImpl( return state.resolveWebCheckoutUrlForInteraction(launchWebCheckout) } + override fun notifyWebCheckoutOpened() { + listener?.onWebCheckoutOpened() + } + override fun invalidateCustomerInfoCache() { purchases.invalidateVirtualCurrenciesCache() } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt index 88734b5e65..5e8b11b51f 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/testdata/TestData.kt @@ -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() @@ -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 diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/utils/URLOpener.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/utils/URLOpener.kt index f91776b78f..22540319bc 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/utils/URLOpener.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/utils/URLOpener.kt @@ -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 { @@ -49,5 +55,7 @@ internal object URLOpener { URLOpeningMethod.DEEP_LINK, -> context.openUriOrElse(url, ::handleException) } + + return opened } } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt index ffd16d6283..ae3166a141 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PaywallActionTests.kt @@ -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(), + ).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(