Skip to content
2 changes: 1 addition & 1 deletion .github/badges/branches.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw
- Renames `TestStoreProduct` to `ApiStoreProduct`, now shared between test mode and custom store products.

## Breaking Changes
- System back presses are now forwarded into the paywall as a `back_button_input` message instead of dismissing it directly: multi-page flows navigate back one page, and paywalls with nowhere to go back (root page, single page) close themselves through the standard manual-close path (`Declined`/`ManualClose`) — so single-page paywalls dismiss the same as before, from the app's perspective. When `reroute_back_button` is enabled in Paywall settings, the `PaywallOptions.onBackPressed` app callback keeps first refusal before the press is forwarded. Paywalls built on runtimes that predate `back_button_input` will ignore the press; make sure paywalls are re-published on a current runtime.
- Removes the deprecated `SuperwallBillingFlowParams.Builder.setSkuDetails(SkuDetails)`. Billing Library 9 removes `SkuDetails` entirely, so this method can no longer exist. Use `setProductDetailsParamsList(...)` with `ProductDetails` instead.
- Removes the unused internal `com.superwall.sdk.billing.SWProduct`, which wrapped the now-removed `SkuDetails`.
- Internal purchase-history queries now resolve current purchases via `QueryPurchasesParams` — Billing Library 9 removes the purchase-history APIs (`queryPurchaseHistoryAsync`, `QueryPurchaseHistoryParams`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ class PaywallOptions() {
/**
* This will be invoked when `reroute_back_button` is enabled in Paywall settings.
* You can use this to react to back button presses with custom logic.
* Return `true` if the dismiss has been consumed, `false` if you prefer to fall back to SDK logic.
* Return `true` if the press has been consumed. Return `false` to fall back to SDK
* logic: the press is forwarded into the paywall as a `back_button_input` message,
* and the paywall either navigates its flow back one page or closes itself.
*/
var onBackPressed: ((PaywallInfo?) -> Boolean)? = null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.superwall.sdk.paywall.view

import com.superwall.sdk.models.paywall.Paywall

/**
* Decides whether the host app's `PaywallOptions.onBackPressed` callback
* consumes a system back press. The callback is only consulted when
* `reroute_back_button` is enabled in Paywall settings and a callback is set.
*
* A press the app doesn't consume is forwarded into the paywall as a
* `back_button_input` message: the paywall navigates its flow back one page
* when possible, and otherwise posts `close` to dismiss through the
* standard manual-close path.
*/
internal fun isBackPressConsumedByApp(
rerouteBackButton: Paywall.ToggleMode?,
consumedByApp: () -> Boolean,
): Boolean = rerouteBackButton == Paywall.ToggleMode.ENABLED && consumedByApp()
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,19 @@ class PaywallView(

//endregion

//region Back button

/**
* Forwards a system back press into the paywall (`back_button_input`).
* The paywall either navigates its flow back one page or posts `close`,
* which dismisses via the standard manual-close path.
*/
fun backButtonPressed() {
webView.messageHandler.handle(PaywallMessage.BackButtonPressed)
}

//endregion

//region Misc

// Android-specific
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ import com.superwall.sdk.misc.isLightColor
import com.superwall.sdk.misc.onError
import com.superwall.sdk.misc.readableOverlayColor
import com.superwall.sdk.models.paywall.LocalNotification
import com.superwall.sdk.models.paywall.Paywall
import com.superwall.sdk.models.paywall.PaywallPresentationStyle
import com.superwall.sdk.network.JsonFactory
import com.superwall.sdk.paywall.presentation.PaywallCloseReason
Expand Down Expand Up @@ -297,20 +296,26 @@ class SuperwallPaywallActivity : AppCompatActivity() {
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val shouldConsumeDismiss =
if (paywallView()?.state?.paywall?.rerouteBackButton == Paywall.ToggleMode.ENABLED) {
Superwall.instance.options.paywalls.onBackPressed
?.let { it(paywallView()?.info) }
?: false
} else {
false
}
if (!shouldConsumeDismiss) {
view.dismiss(
val paywallView = paywallView()
val consumedByApp =
isBackPressConsumedByApp(
rerouteBackButton = paywallView?.state?.paywall?.rerouteBackButton,
consumedByApp = {
Superwall.instance.options.paywalls.onBackPressed
?.let { it(paywallView?.info) }
?: false
},
)
if (consumedByApp) {
return
}
// The paywall handles the press (navigate back or close); native
// dismissal only remains for the edge where no view is attached.
paywallView?.backButtonPressed()
?: view.dismiss(
result = PaywallResult.Declined(),
closeReason = PaywallCloseReason.ManualClose,
)
}
}
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ sealed class PaywallMessage {

object PaywallClose : PaywallMessage()

/**
* SDK-originated: forwards a system back press into the paywall as
* `back_button_input`. The paywall navigates its flow back one page or
* posts `close` to dismiss.
*/
object BackButtonPressed : PaywallMessage()

object TransactionStart : PaywallMessage()

object TransactionAbandon : PaywallMessage()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ class PaywallMessageHandler(
}
}

is PaywallMessage.BackButtonPressed ->
ioScope.launch {
pass(eventName = "back_button_input", paywall = paywall)
}

is PaywallMessage.Custom -> handleCustomEvent(message.data)
is PaywallMessage.CustomPlacement -> handleCustomPlacement(message.name, message.params)
is PaywallMessage.RestoreFailed ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.superwall.sdk.paywall.view

import com.superwall.sdk.Given
import com.superwall.sdk.Then
import com.superwall.sdk.When
import com.superwall.sdk.models.paywall.Paywall
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class BackPressBehaviorTest {
@Test
fun app_callback_is_not_consulted_when_reroute_is_disabled() {
Given("a paywall with reroute_back_button disabled and a consuming app callback") {
var callbackInvoked = false

When("the back button is pressed") {
val consumed =
isBackPressConsumedByApp(Paywall.ToggleMode.DISABLED) {
callbackInvoked = true
true
}

Then("the press is not consumed and the callback is never invoked") {
assertFalse(consumed)
assertFalse(callbackInvoked)
}
}
}
}

@Test
fun app_callback_is_not_consulted_when_reroute_is_unset() {
Given("a paywall without a reroute_back_button setting") {
When("the back button is pressed") {
val consumed = isBackPressConsumedByApp(null) { true }

Then("the press is not consumed") {
assertFalse(consumed)
}
}
}
}

@Test
fun app_callback_consumes_the_press_when_reroute_is_enabled() {
Given("a paywall with reroute_back_button enabled and a consuming app callback") {
When("the back button is pressed") {
val consumed = isBackPressConsumedByApp(Paywall.ToggleMode.ENABLED) { true }

Then("the press is consumed by the app") {
assertTrue(consumed)
}
}
}
}

@Test
fun press_is_not_consumed_when_reroute_is_enabled_and_app_declines() {
Given("a paywall with reroute_back_button enabled and a non-consuming app callback") {
When("the back button is pressed") {
val consumed = isBackPressConsumedByApp(Paywall.ToggleMode.ENABLED) { false }

Then("the press is not consumed and falls through to the paywall") {
assertFalse(consumed)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,37 @@ class PaywallMessageHandlerTest {
}
}

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun handler_backButtonPressed_passesBackButtonInputToWebView() =
runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(dispatcher)
val scope = CoroutineScope(dispatcher + Job())

try {
Given("a handler harness") {
val harness = buildHandlerHarness(scope)

When("BackButtonPressed is handled") {
harness.handler.handle(PaywallMessage.BackButtonPressed)
advanceUntilIdle()

Then("a back_button_input message is passed into the webview") {
assertTrue(
"Expected back_button_input in evaluate calls: ${harness.webUI.evaluateCalls}",
harness.webUI.evaluateCalls.any {
it.contains("window.paywall.accept64") && it.contains("back_button_input")
},
)
}
}
}
} finally {
Dispatchers.resetMain()
}
}

private data class HandlerHarness(
val paywallView: PaywallView,
val handler: PaywallMessageHandler,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,21 @@ class PaywallViewTest {
}
}

@Test
fun backButtonPressed_forwardsToMessageHandler() {
Given("a paywall presented with the system back button pressed") {
every { messageHandler.handle(any()) } just Runs

When("the press is forwarded to the paywall") {
paywallView.backButtonPressed()

Then("the message handler receives BackButtonPressed") {
verify(exactly = 1) { messageHandler.handle(PaywallMessage.BackButtonPressed) }
}
}
}
}

@Test
fun destroyWebview_forwardsCall() {
Given("a PaywallView") {
Expand Down