diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index 95275ca45..2ea735435 100644
--- a/.github/badges/branches.svg
+++ b/.github/badges/branches.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c7acaf69..0a0ae00f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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`).
diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt
index 3aa487c56..3ceca48ab 100644
--- a/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt
+++ b/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt
@@ -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
}
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt
new file mode 100644
index 000000000..e2124bb2e
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt
@@ -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()
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt
index c0cad2da9..13b2b4130 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt
@@ -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
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt
index 8e858ae07..6c832ce4f 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt
@@ -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
@@ -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,
)
- }
}
},
)
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt
index 8e75bcf41..cab61190a 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt
@@ -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()
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt
index a520bfb30..3f496e9ea 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt
@@ -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 ->
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt
new file mode 100644
index 000000000..5f06e5284
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt
@@ -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)
+ }
+ }
+ }
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt
index c9a7665ea..281f42933 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt
@@ -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,
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt
index 6a95ac06d..d6b265535 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt
@@ -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") {