From d342606e53790831694db2919f629c672fe2389d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 11:10:44 +0000 Subject: [PATCH 1/8] feat: forward re-routed back presses into the paywall as back_button_input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When reroute_back_button is enabled in Paywall settings, a system back press previously did nothing unless the host app set the PaywallOptions.onBackPressed callback — otherwise the paywall dismissed as if the setting were off. Back presses the app callback doesn't consume are now injected into the webview as the paywall-js back_button_input message. The paywall either navigates its flow back one page or posts the existing close message, which dismisses through the standard manual-close path (Declined / ManualClose), so back-at-root behaves exactly like a native dismissal. The press/consume decision is extracted into backPressBehavior() and unit tested; the JS bridge payload is covered in PaywallViewTest. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- CHANGELOG.md | 3 + .../sdk/config/options/PaywallOptions.kt | 4 +- .../sdk/paywall/view/BackPressBehavior.kt | 33 +++++++++ .../superwall/sdk/paywall/view/PaywallView.kt | 30 +++++++- .../paywall/view/SuperwallPaywallActivity.kt | 30 ++++---- .../webview/messaging/BackButtonInputEvent.kt | 19 +++++ .../sdk/paywall/view/BackPressBehaviorTest.kt | 70 +++++++++++++++++++ .../sdk/paywall/view/PaywallViewTest.kt | 25 +++++++ 8 files changed, 198 insertions(+), 16 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c7acaf69..af1055d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ Google Play Billing Library 9 requires Android 6.0 (API 23), so the SDK's `minSd ## 2.7.23 +## Enhancements +- When `reroute_back_button` is enabled in Paywall settings, system back presses are now forwarded into the paywall as a `back_button_input` message: the paywall navigates its flow back one page when possible, and otherwise closes itself through the standard manual-close path. The `PaywallOptions.onBackPressed` app callback keeps first refusal; forwarding happens only when it is unset or returns `false`. + ## Fixes - Fix paywall inputs silently dropping all non-Latin keyboard text (Cyrillic, Korean, Thai, emoji, etc.). The webview no longer replaces Chromium's `InputConnection` with a dummy one unless `isGameControllerEnabled` is set, restoring the full IME pipeline (composition, autocorrect, swipe typing, autofill) for everyone else. - Ensure that errors thrown when user lacks billing do not propagate to the app 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..b9efc6954 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt @@ -0,0 +1,33 @@ +package com.superwall.sdk.paywall.view + +import com.superwall.sdk.models.paywall.Paywall + +internal enum class BackPressBehavior { + /** The host app's `onBackPressed` callback consumed the press. */ + CONSUMED_BY_APP, + + /** Forward the press into the paywall as a `back_button_input` message. */ + FORWARD_TO_PAYWALL, + + /** Dismiss the paywall, matching a manual close. */ + DISMISS, +} + +/** + * Decides what a system back press does while a paywall is presented. + * + * With `reroute_back_button` enabled in Paywall settings, the host app's + * `PaywallOptions.onBackPressed` callback gets first refusal; if it doesn't + * consume the press, the press is forwarded into the paywall, which either + * navigates its flow back one page or posts `close` to dismiss. With the + * setting disabled the paywall dismisses immediately. + */ +internal fun backPressBehavior( + rerouteBackButton: Paywall.ToggleMode?, + consumedByApp: () -> Boolean, +): BackPressBehavior = + when { + rerouteBackButton != Paywall.ToggleMode.ENABLED -> BackPressBehavior.DISMISS + consumedByApp() -> BackPressBehavior.CONSUMED_BY_APP + else -> BackPressBehavior.FORWARD_TO_PAYWALL + } 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..e0f8c1aa3 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 @@ -67,6 +67,7 @@ import com.superwall.sdk.paywall.view.webview.PaywallWebUI import com.superwall.sdk.paywall.view.webview.SWWebView import com.superwall.sdk.paywall.view.webview.SendPaywallMessages import com.superwall.sdk.paywall.view.webview.WebviewError +import com.superwall.sdk.paywall.view.webview.messaging.BackButtonInputEvent import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessage import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandlerDelegate import com.superwall.sdk.paywall.view.webview.messaging.PaywallStateDelegate @@ -139,7 +140,7 @@ class PaywallView( private companion object { private val mainScope: MainScope = MainScope() private val ioScope: IOScope = IOScope() - private val gameControllerJson by lazy { + private val webEventJson by lazy { Json { encodeDefaults = true namingStrategy = JsonNamingStrategy.SnakeCase @@ -1091,7 +1092,7 @@ class PaywallView( override fun gameControllerEventOccured(event: GameControllerEvent) { val payload = try { - gameControllerJson.encodeToString(event) + webEventJson.encodeToString(event) } catch (e: Throwable) { null } @@ -1105,6 +1106,31 @@ 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. Called when + * `reroute_back_button` is enabled in Paywall settings. + */ + fun backButtonPressed() { + val payload = + try { + webEventJson.encodeToString(BackButtonInputEvent(pressed = true)) + } catch (e: Throwable) { + null + } ?: return + webView.evaluate("window.paywall.accept([$payload])", null) + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.paywallView, + message = "Back button press forwarded to paywall: $payload", + ) + } + +//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..6f4911f36 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,19 +296,24 @@ 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( - result = PaywallResult.Declined(), - closeReason = PaywallCloseReason.ManualClose, + val paywallView = paywallView() + val behavior = + backPressBehavior( + rerouteBackButton = paywallView?.state?.paywall?.rerouteBackButton, + consumedByApp = { + Superwall.instance.options.paywalls.onBackPressed + ?.let { it(paywallView?.info) } + ?: false + }, ) + when (behavior) { + BackPressBehavior.CONSUMED_BY_APP -> Unit + BackPressBehavior.FORWARD_TO_PAYWALL -> paywallView?.backButtonPressed() + BackPressBehavior.DISMISS -> + view.dismiss( + result = PaywallResult.Declined(), + closeReason = PaywallCloseReason.ManualClose, + ) } } }, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt new file mode 100644 index 000000000..7aa20a737 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt @@ -0,0 +1,19 @@ +package com.superwall.sdk.paywall.view.webview.messaging + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Message injected into the paywall webview when the system back button is + * re-routed to the paywall (`reroute_back_button` enabled in Paywall settings). + * + * The paywall responds by either navigating its flow back one page or posting + * the `close` message, which dismisses via the standard manual-close path. + */ +@Serializable +data class BackButtonInputEvent( + @SerialName("event_name") + val eventName: String = "back_button_input", + @SerialName("pressed") + val pressed: Boolean, +) 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..0999c71d6 --- /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.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class BackPressBehaviorTest { + @Test + fun dismisses_when_reroute_is_disabled() { + Given("a paywall with reroute_back_button disabled") { + var callbackInvoked = false + + When("the back button is pressed") { + val behavior = + backPressBehavior(Paywall.ToggleMode.DISABLED) { + callbackInvoked = true + true + } + + Then("the paywall dismisses without consulting the app callback") { + assertEquals(BackPressBehavior.DISMISS, behavior) + assertFalse(callbackInvoked) + } + } + } + } + + @Test + fun dismisses_when_reroute_is_unset() { + Given("a paywall without a reroute_back_button setting") { + When("the back button is pressed") { + val behavior = backPressBehavior(null) { true } + + Then("the paywall dismisses") { + assertEquals(BackPressBehavior.DISMISS, behavior) + } + } + } + } + + @Test + fun app_callback_gets_first_refusal_when_reroute_is_enabled() { + Given("a paywall with reroute_back_button enabled and a consuming app callback") { + When("the back button is pressed") { + val behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { true } + + Then("the press is consumed by the app") { + assertEquals(BackPressBehavior.CONSUMED_BY_APP, behavior) + } + } + } + } + + @Test + fun forwards_to_paywall_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 behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { false } + + Then("the press is forwarded into the paywall") { + assertEquals(BackPressBehavior.FORWARD_TO_PAYWALL, behavior) + } + } + } + } +} 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..78029a03f 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 @@ -21,6 +21,7 @@ import com.superwall.sdk.paywall.view.delegate.PaywallViewEventCallback import com.superwall.sdk.paywall.view.webview.PaywallUIDelegate import com.superwall.sdk.paywall.view.webview.PaywallWebUI import com.superwall.sdk.paywall.view.webview.SendPaywallMessages +import com.superwall.sdk.paywall.view.webview.messaging.BackButtonInputEvent import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessage import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandler import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent @@ -237,6 +238,30 @@ class PaywallViewTest { } } + @Test + fun backButtonPressed_evaluatesJavascriptBridge() { + Given("a paywall that re-routes the system back button") { + When("a back press is forwarded to the paywall") { + paywallView.backButtonPressed() + + Then("the WebUI receives a back_button_input press") { + val expectedPayload = + Json { + encodeDefaults = true + namingStrategy = JsonNamingStrategy.SnakeCase + }.encodeToString( + BackButtonInputEvent.serializer(), + BackButtonInputEvent(pressed = true), + ) + assertEquals( + listOf("window.paywall.accept([$expectedPayload])"), + fakeWebUI.evaluateCalls, + ) + } + } + } + } + @Test fun destroyWebview_forwardsCall() { Given("a PaywallView") { From cec47f3807c807c3ce1083b8aed821331eb8ae96 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 11:29:26 +0000 Subject: [PATCH 2/8] feat: forward back presses to the paywall by default, gated by capability probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-page flow paywalls were closing entirely on system back instead of navigating back a page. Forwarding is now the default for every paywall, not just those with reroute_back_button enabled — that setting now only controls whether the PaywallOptions.onBackPressed app callback gets first refusal. Because published paywalls pin their built runtime, blindly forwarding would strand users on paywalls whose runtime lacks the back_button_input consumer. The SDK therefore probes the webview for window.paywall.supportsBackButtonInput first; when absent (older runtime, still loading, crashed webview) it falls back to the native dismiss, preserving today's behavior exactly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- CHANGELOG.md | 2 +- .../sdk/config/options/PaywallOptions.kt | 3 +- .../sdk/paywall/view/BackPressBehavior.kt | 34 ++++--------- .../superwall/sdk/paywall/view/PaywallView.kt | 49 ++++++++++++------- .../paywall/view/SuperwallPaywallActivity.kt | 21 ++++---- .../sdk/paywall/view/BackPressBehaviorTest.kt | 36 +++++++------- .../sdk/paywall/view/PaywallViewTest.kt | 45 ++++++++++++++--- 7 files changed, 112 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af1055d19..764d695d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ Google Play Billing Library 9 requires Android 6.0 (API 23), so the SDK's `minSd ## 2.7.23 ## Enhancements -- When `reroute_back_button` is enabled in Paywall settings, system back presses are now forwarded into the paywall as a `back_button_input` message: the paywall navigates its flow back one page when possible, and otherwise closes itself through the standard manual-close path. The `PaywallOptions.onBackPressed` app callback keeps first refusal; forwarding happens only when it is unset or returns `false`. +- System back presses are now forwarded into the paywall as a `back_button_input` message: multi-page flows navigate back one page instead of dismissing the whole paywall, and paywalls with nowhere to go back close themselves through the standard manual-close path (`Declined`/`ManualClose`), so single-page paywalls dismiss exactly as before. The SDK first probes the webview for `window.paywall.supportsBackButtonInput`; paywalls built on older runtimes (or still loading / crashed) keep the previous native dismiss. When `reroute_back_button` is enabled in Paywall settings, the `PaywallOptions.onBackPressed` app callback keeps first refusal before the press is forwarded. ## Fixes - Fix paywall inputs silently dropping all non-Latin keyboard text (Cyrillic, Korean, Thai, emoji, etc.). The webview no longer replaces Chromium's `InputConnection` with a dummy one unless `isGameControllerEnabled` is set, restoring the full IME pipeline (composition, autocorrect, swipe typing, autofill) for everyone else. 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 3ceca48ab..68d130998 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 @@ -162,7 +162,8 @@ class PaywallOptions() { * You can use this to react to back button presses with custom 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. + * and the paywall either navigates its flow back one page or closes itself. If the + * paywall's runtime can't handle the message, the paywall dismisses natively. */ 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 index b9efc6954..28aa2ed6b 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt @@ -2,32 +2,18 @@ package com.superwall.sdk.paywall.view import com.superwall.sdk.models.paywall.Paywall -internal enum class BackPressBehavior { - /** The host app's `onBackPressed` callback consumed the press. */ - CONSUMED_BY_APP, - - /** Forward the press into the paywall as a `back_button_input` message. */ - FORWARD_TO_PAYWALL, - - /** Dismiss the paywall, matching a manual close. */ - DISMISS, -} - /** - * Decides what a system back press does while a paywall is presented. + * 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. * - * With `reroute_back_button` enabled in Paywall settings, the host app's - * `PaywallOptions.onBackPressed` callback gets first refusal; if it doesn't - * consume the press, the press is forwarded into the paywall, which either - * navigates its flow back one page or posts `close` to dismiss. With the - * setting disabled the paywall dismisses immediately. + * 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. Paywalls that can't + * handle the message (older runtimes, loading/crashed webviews) fall back to + * the SDK's native dismiss — see `PaywallView.backButtonPressed`. */ -internal fun backPressBehavior( +internal fun isBackPressConsumedByApp( rerouteBackButton: Paywall.ToggleMode?, consumedByApp: () -> Boolean, -): BackPressBehavior = - when { - rerouteBackButton != Paywall.ToggleMode.ENABLED -> BackPressBehavior.DISMISS - consumedByApp() -> BackPressBehavior.CONSUMED_BY_APP - else -> BackPressBehavior.FORWARD_TO_PAYWALL - } +): 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 e0f8c1aa3..41d5f0299 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 @@ -140,6 +140,8 @@ class PaywallView( private companion object { private val mainScope: MainScope = MainScope() private val ioScope: IOScope = IOScope() + private const val SUPPORTS_BACK_BUTTON_INPUT_PROBE = + "typeof window.paywall !== 'undefined' && window.paywall.supportsBackButtonInput === true" private val webEventJson by lazy { Json { encodeDefaults = true @@ -1109,24 +1111,37 @@ class PaywallView( //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. Called when - * `reroute_back_button` is enabled in Paywall settings. + * Handles a system back press. Probes the webview for the paywall-js + * `supportsBackButtonInput` capability: when present, the press is + * forwarded into the paywall as a `back_button_input` message and the + * paywall either navigates its flow back one page or posts `close`, + * which dismisses via the standard manual-close path. When the paywall + * can't handle it (older runtime, still loading, crashed webview), + * [onUnhandled] is invoked so the caller dismisses natively instead. */ - fun backButtonPressed() { - val payload = - try { - webEventJson.encodeToString(BackButtonInputEvent(pressed = true)) - } catch (e: Throwable) { - null - } ?: return - webView.evaluate("window.paywall.accept([$payload])", null) - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.paywallView, - message = "Back button press forwarded to paywall: $payload", - ) + fun backButtonPressed(onUnhandled: () -> Unit) { + webView.evaluate(SUPPORTS_BACK_BUTTON_INPUT_PROBE) { result -> + if (result?.trim() != "true") { + onUnhandled() + return@evaluate + } + val payload = + try { + webEventJson.encodeToString(BackButtonInputEvent(pressed = true)) + } catch (e: Throwable) { + null + } + if (payload == null) { + onUnhandled() + return@evaluate + } + webView.evaluate("window.paywall.accept([$payload])", null) + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.paywallView, + message = "Back button press forwarded to paywall: $payload", + ) + } } //endregion 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 6f4911f36..adf5edb70 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 @@ -297,8 +297,8 @@ class SuperwallPaywallActivity : AppCompatActivity() { object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { val paywallView = paywallView() - val behavior = - backPressBehavior( + val consumedByApp = + isBackPressConsumedByApp( rerouteBackButton = paywallView?.state?.paywall?.rerouteBackButton, consumedByApp = { Superwall.instance.options.paywalls.onBackPressed @@ -306,15 +306,16 @@ class SuperwallPaywallActivity : AppCompatActivity() { ?: false }, ) - when (behavior) { - BackPressBehavior.CONSUMED_BY_APP -> Unit - BackPressBehavior.FORWARD_TO_PAYWALL -> paywallView?.backButtonPressed() - BackPressBehavior.DISMISS -> - view.dismiss( - result = PaywallResult.Declined(), - closeReason = PaywallCloseReason.ManualClose, - ) + if (consumedByApp) { + return + } + val dismiss = { + view.dismiss( + result = PaywallResult.Declined(), + closeReason = PaywallCloseReason.ManualClose, + ) } + paywallView?.backButtonPressed(onUnhandled = dismiss) ?: dismiss() } }, ) 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 index 0999c71d6..5f06e5284 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt @@ -4,25 +4,25 @@ 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.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class BackPressBehaviorTest { @Test - fun dismisses_when_reroute_is_disabled() { - Given("a paywall with reroute_back_button disabled") { + 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 behavior = - backPressBehavior(Paywall.ToggleMode.DISABLED) { + val consumed = + isBackPressConsumedByApp(Paywall.ToggleMode.DISABLED) { callbackInvoked = true true } - Then("the paywall dismisses without consulting the app callback") { - assertEquals(BackPressBehavior.DISMISS, behavior) + Then("the press is not consumed and the callback is never invoked") { + assertFalse(consumed) assertFalse(callbackInvoked) } } @@ -30,39 +30,39 @@ class BackPressBehaviorTest { } @Test - fun dismisses_when_reroute_is_unset() { + 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 behavior = backPressBehavior(null) { true } + val consumed = isBackPressConsumedByApp(null) { true } - Then("the paywall dismisses") { - assertEquals(BackPressBehavior.DISMISS, behavior) + Then("the press is not consumed") { + assertFalse(consumed) } } } } @Test - fun app_callback_gets_first_refusal_when_reroute_is_enabled() { + 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 behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { true } + val consumed = isBackPressConsumedByApp(Paywall.ToggleMode.ENABLED) { true } Then("the press is consumed by the app") { - assertEquals(BackPressBehavior.CONSUMED_BY_APP, behavior) + assertTrue(consumed) } } } } @Test - fun forwards_to_paywall_when_reroute_is_enabled_and_app_declines() { + 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 behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { false } + val consumed = isBackPressConsumedByApp(Paywall.ToggleMode.ENABLED) { false } - Then("the press is forwarded into the paywall") { - assertEquals(BackPressBehavior.FORWARD_TO_PAYWALL, behavior) + 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/PaywallViewTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt index 78029a03f..42e656a0b 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 @@ -239,12 +239,15 @@ class PaywallViewTest { } @Test - fun backButtonPressed_evaluatesJavascriptBridge() { - Given("a paywall that re-routes the system back button") { - When("a back press is forwarded to the paywall") { - paywallView.backButtonPressed() + fun backButtonPressed_forwardsToPaywallWhenSupported() { + Given("a paywall whose runtime supports back_button_input") { + fakeWebUI.evaluateResult = "true" + var unhandled = false - Then("the WebUI receives a back_button_input press") { + When("a back press occurs") { + paywallView.backButtonPressed(onUnhandled = { unhandled = true }) + + Then("the WebUI is probed then receives a back_button_input press") { val expectedPayload = Json { encodeDefaults = true @@ -254,9 +257,36 @@ class PaywallViewTest { BackButtonInputEvent(pressed = true), ) assertEquals( - listOf("window.paywall.accept([$expectedPayload])"), + listOf( + "typeof window.paywall !== 'undefined' && " + + "window.paywall.supportsBackButtonInput === true", + "window.paywall.accept([$expectedPayload])", + ), + fakeWebUI.evaluateCalls, + ) + assertFalse(unhandled) + } + } + } + } + + @Test + fun backButtonPressed_fallsBackWhenRuntimeLacksSupport() { + Given("a paywall whose runtime does not support back_button_input") { + var unhandled = false + + When("a back press occurs") { + paywallView.backButtonPressed(onUnhandled = { unhandled = true }) + + Then("nothing is injected and the press is reported as unhandled") { + assertEquals( + listOf( + "typeof window.paywall !== 'undefined' && " + + "window.paywall.supportsBackButtonInput === true", + ), fakeWebUI.evaluateCalls, ) + assertTrue(unhandled) } } } @@ -534,6 +564,7 @@ class PaywallViewTest { var destroyed: Boolean = false var setupLatch: CountDownLatch? = null val evaluateCalls = mutableListOf() + var evaluateResult: String? = null private val view = View(context) override fun onView(perform: View.() -> Unit) { @@ -570,7 +601,7 @@ class PaywallViewTest { resultCallback: ((String?) -> Unit)?, ) { evaluateCalls += code - resultCallback?.invoke(null) + resultCallback?.invoke(evaluateResult) } override fun destroyView() { From d7a538c87a2418b5b166cdde6ff41055a34ff0e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 12:55:18 +0000 Subject: [PATCH 3/8] revert to opt-in: only forward back presses when reroute_back_button is ENABLED Forwarding by default changes implied behavior for existing multi-page paywalls, so gate it behind the existing per-paywall dashboard setting while the default story is decided (possibly a new setting). DISABLED and unset paywalls dismiss on back exactly as before. The capability probe stays: even ENABLED paywalls on older runtimes keep the native dismiss. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- CHANGELOG.md | 2 +- .../sdk/config/options/PaywallOptions.kt | 1 + .../sdk/paywall/view/BackPressBehavior.kt | 39 ++++++++++++++----- .../paywall/view/SuperwallPaywallActivity.kt | 14 ++++--- .../sdk/paywall/view/BackPressBehaviorTest.kt | 34 ++++++++-------- 5 files changed, 56 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 764d695d4..5083e19dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ Google Play Billing Library 9 requires Android 6.0 (API 23), so the SDK's `minSd ## 2.7.23 ## Enhancements -- System back presses are now forwarded into the paywall as a `back_button_input` message: multi-page flows navigate back one page instead of dismissing the whole paywall, and paywalls with nowhere to go back close themselves through the standard manual-close path (`Declined`/`ManualClose`), so single-page paywalls dismiss exactly as before. The SDK first probes the webview for `window.paywall.supportsBackButtonInput`; paywalls built on older runtimes (or still loading / crashed) keep the previous native dismiss. When `reroute_back_button` is enabled in Paywall settings, the `PaywallOptions.onBackPressed` app callback keeps first refusal before the press is forwarded. +- When `reroute_back_button` is enabled in Paywall settings, system back presses are now forwarded into the paywall as a `back_button_input` message: multi-page flows navigate back one page instead of dismissing the whole paywall, and paywalls with nowhere to go back close themselves through the standard manual-close path (`Declined`/`ManualClose`). The SDK first probes the webview for `window.paywall.supportsBackButtonInput`; paywalls built on older runtimes (or still loading / crashed) keep the previous native dismiss. The `PaywallOptions.onBackPressed` app callback keeps first refusal before the press is forwarded. Paywalls with the setting disabled (the default) are unaffected and dismiss on back exactly as before. ## Fixes - Fix paywall inputs silently dropping all non-Latin keyboard text (Cyrillic, Korean, Thai, emoji, etc.). The webview no longer replaces Chromium's `InputConnection` with a dummy one unless `isGameControllerEnabled` is set, restoring the full IME pipeline (composition, autocorrect, swipe typing, autofill) for everyone else. 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 68d130998..88e12e1ff 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 @@ -164,6 +164,7 @@ class PaywallOptions() { * 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. If the * paywall's runtime can't handle the message, the paywall dismisses natively. + * Paywalls with `reroute_back_button` disabled dismiss on back press directly. */ 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 index 28aa2ed6b..512a235ab 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt @@ -2,18 +2,37 @@ package com.superwall.sdk.paywall.view import com.superwall.sdk.models.paywall.Paywall +internal enum class BackPressBehavior { + /** The host app's `onBackPressed` callback consumed the press. */ + CONSUMED_BY_APP, + + /** Forward the press into the paywall as a `back_button_input` message. */ + FORWARD_TO_PAYWALL, + + /** Dismiss the paywall, matching a manual close. */ + DISMISS, +} + /** - * 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. + * Decides what a system back press does while a paywall is presented. + * + * With `reroute_back_button` enabled in Paywall settings, the host app's + * `PaywallOptions.onBackPressed` callback gets first refusal; a press it + * doesn't consume is forwarded into the paywall as a `back_button_input` + * message, and the paywall either navigates its flow back one page or posts + * `close` to dismiss. Paywalls that can't handle the message (older + * runtimes, loading/crashed webviews) fall back to the SDK's native dismiss + * — see `PaywallView.backButtonPressed`. * - * 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. Paywalls that can't - * handle the message (older runtimes, loading/crashed webviews) fall back to - * the SDK's native dismiss — see `PaywallView.backButtonPressed`. + * With the setting disabled (the default) the paywall dismisses + * immediately, unchanged from previous SDK versions. */ -internal fun isBackPressConsumedByApp( +internal fun backPressBehavior( rerouteBackButton: Paywall.ToggleMode?, consumedByApp: () -> Boolean, -): Boolean = rerouteBackButton == Paywall.ToggleMode.ENABLED && consumedByApp() +): BackPressBehavior = + when { + rerouteBackButton != Paywall.ToggleMode.ENABLED -> BackPressBehavior.DISMISS + consumedByApp() -> BackPressBehavior.CONSUMED_BY_APP + else -> BackPressBehavior.FORWARD_TO_PAYWALL + } 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 adf5edb70..4e1a6e110 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 @@ -297,8 +297,8 @@ class SuperwallPaywallActivity : AppCompatActivity() { object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { val paywallView = paywallView() - val consumedByApp = - isBackPressConsumedByApp( + val behavior = + backPressBehavior( rerouteBackButton = paywallView?.state?.paywall?.rerouteBackButton, consumedByApp = { Superwall.instance.options.paywalls.onBackPressed @@ -306,16 +306,18 @@ class SuperwallPaywallActivity : AppCompatActivity() { ?: false }, ) - if (consumedByApp) { - return - } val dismiss = { view.dismiss( result = PaywallResult.Declined(), closeReason = PaywallCloseReason.ManualClose, ) } - paywallView?.backButtonPressed(onUnhandled = dismiss) ?: dismiss() + when (behavior) { + BackPressBehavior.CONSUMED_BY_APP -> Unit + BackPressBehavior.FORWARD_TO_PAYWALL -> + paywallView?.backButtonPressed(onUnhandled = dismiss) ?: dismiss() + BackPressBehavior.DISMISS -> dismiss() + } } }, ) 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 index 5f06e5284..0b93fe77a 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt @@ -4,25 +4,25 @@ 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.assertEquals 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() { + fun dismisses_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) { + val behavior = + backPressBehavior(Paywall.ToggleMode.DISABLED) { callbackInvoked = true true } - Then("the press is not consumed and the callback is never invoked") { - assertFalse(consumed) + Then("the paywall dismisses without consulting the app callback") { + assertEquals(BackPressBehavior.DISMISS, behavior) assertFalse(callbackInvoked) } } @@ -30,39 +30,39 @@ class BackPressBehaviorTest { } @Test - fun app_callback_is_not_consulted_when_reroute_is_unset() { + fun dismisses_when_reroute_is_unset() { Given("a paywall without a reroute_back_button setting") { When("the back button is pressed") { - val consumed = isBackPressConsumedByApp(null) { true } + val behavior = backPressBehavior(null) { true } - Then("the press is not consumed") { - assertFalse(consumed) + Then("the paywall dismisses") { + assertEquals(BackPressBehavior.DISMISS, behavior) } } } } @Test - fun app_callback_consumes_the_press_when_reroute_is_enabled() { + fun app_callback_gets_first_refusal_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 } + val behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { true } Then("the press is consumed by the app") { - assertTrue(consumed) + assertEquals(BackPressBehavior.CONSUMED_BY_APP, behavior) } } } } @Test - fun press_is_not_consumed_when_reroute_is_enabled_and_app_declines() { + fun forwards_to_paywall_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 } + val behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { false } - Then("the press is not consumed and falls through to the paywall") { - assertFalse(consumed) + Then("the press is forwarded into the paywall") { + assertEquals(BackPressBehavior.FORWARD_TO_PAYWALL, behavior) } } } From d8a2c31c3af2d3597bc087c48bff77a79a36876f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 11:39:22 +0000 Subject: [PATCH 4/8] forward back presses unconditionally; drop capability probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team decision: this ships as an accepted breaking change. Every system back press the app callback doesn't consume is forwarded into the paywall as back_button_input — the paywall navigates its flow back one page or posts close (Declined/ManualClose) at the root. The reroute_back_button setting now only controls whether the PaywallOptions.onBackPressed callback is consulted first. Native dismissal remains only when no PaywallView is attached to the activity. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- CHANGELOG.md | 4 +- .../sdk/config/options/PaywallOptions.kt | 4 +- .../sdk/paywall/view/BackPressBehavior.kt | 38 ++++----------- .../superwall/sdk/paywall/view/PaywallView.kt | 48 +++++++------------ .../paywall/view/SuperwallPaywallActivity.kt | 20 ++++---- .../sdk/paywall/view/BackPressBehaviorTest.kt | 34 ++++++------- .../sdk/paywall/view/PaywallViewTest.kt | 45 +++-------------- 7 files changed, 61 insertions(+), 132 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5083e19dc..90e448f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,8 @@ Google Play Billing Library 9 requires Android 6.0 (API 23), so the SDK's `minSd ## 2.7.23 -## Enhancements -- When `reroute_back_button` is enabled in Paywall settings, system back presses are now forwarded into the paywall as a `back_button_input` message: multi-page flows navigate back one page instead of dismissing the whole paywall, and paywalls with nowhere to go back close themselves through the standard manual-close path (`Declined`/`ManualClose`). The SDK first probes the webview for `window.paywall.supportsBackButtonInput`; paywalls built on older runtimes (or still loading / crashed) keep the previous native dismiss. The `PaywallOptions.onBackPressed` app callback keeps first refusal before the press is forwarded. Paywalls with the setting disabled (the default) are unaffected and dismiss on back exactly as before. +## 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. ## Fixes - Fix paywall inputs silently dropping all non-Latin keyboard text (Cyrillic, Korean, Thai, emoji, etc.). The webview no longer replaces Chromium's `InputConnection` with a dummy one unless `isGameControllerEnabled` is set, restoring the full IME pipeline (composition, autocorrect, swipe typing, autofill) for everyone else. 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 88e12e1ff..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 @@ -162,9 +162,7 @@ class PaywallOptions() { * You can use this to react to back button presses with custom 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. If the - * paywall's runtime can't handle the message, the paywall dismisses natively. - * Paywalls with `reroute_back_button` disabled dismiss on back press directly. + * 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 index 512a235ab..e2124bb2e 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt @@ -2,37 +2,17 @@ package com.superwall.sdk.paywall.view import com.superwall.sdk.models.paywall.Paywall -internal enum class BackPressBehavior { - /** The host app's `onBackPressed` callback consumed the press. */ - CONSUMED_BY_APP, - - /** Forward the press into the paywall as a `back_button_input` message. */ - FORWARD_TO_PAYWALL, - - /** Dismiss the paywall, matching a manual close. */ - DISMISS, -} - /** - * Decides what a system back press does while a paywall is presented. - * - * With `reroute_back_button` enabled in Paywall settings, the host app's - * `PaywallOptions.onBackPressed` callback gets first refusal; a press it - * doesn't consume is forwarded into the paywall as a `back_button_input` - * message, and the paywall either navigates its flow back one page or posts - * `close` to dismiss. Paywalls that can't handle the message (older - * runtimes, loading/crashed webviews) fall back to the SDK's native dismiss - * — see `PaywallView.backButtonPressed`. + * 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. * - * With the setting disabled (the default) the paywall dismisses - * immediately, unchanged from previous SDK versions. + * 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 backPressBehavior( +internal fun isBackPressConsumedByApp( rerouteBackButton: Paywall.ToggleMode?, consumedByApp: () -> Boolean, -): BackPressBehavior = - when { - rerouteBackButton != Paywall.ToggleMode.ENABLED -> BackPressBehavior.DISMISS - consumedByApp() -> BackPressBehavior.CONSUMED_BY_APP - else -> BackPressBehavior.FORWARD_TO_PAYWALL - } +): 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 41d5f0299..c38fcaba8 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 @@ -140,8 +140,6 @@ class PaywallView( private companion object { private val mainScope: MainScope = MainScope() private val ioScope: IOScope = IOScope() - private const val SUPPORTS_BACK_BUTTON_INPUT_PROBE = - "typeof window.paywall !== 'undefined' && window.paywall.supportsBackButtonInput === true" private val webEventJson by lazy { Json { encodeDefaults = true @@ -1111,37 +1109,23 @@ class PaywallView( //region Back button /** - * Handles a system back press. Probes the webview for the paywall-js - * `supportsBackButtonInput` capability: when present, the press is - * forwarded into the paywall as a `back_button_input` message and the - * paywall either navigates its flow back one page or posts `close`, - * which dismisses via the standard manual-close path. When the paywall - * can't handle it (older runtime, still loading, crashed webview), - * [onUnhandled] is invoked so the caller dismisses natively instead. + * 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(onUnhandled: () -> Unit) { - webView.evaluate(SUPPORTS_BACK_BUTTON_INPUT_PROBE) { result -> - if (result?.trim() != "true") { - onUnhandled() - return@evaluate - } - val payload = - try { - webEventJson.encodeToString(BackButtonInputEvent(pressed = true)) - } catch (e: Throwable) { - null - } - if (payload == null) { - onUnhandled() - return@evaluate - } - webView.evaluate("window.paywall.accept([$payload])", null) - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.paywallView, - message = "Back button press forwarded to paywall: $payload", - ) - } + fun backButtonPressed() { + val payload = + try { + webEventJson.encodeToString(BackButtonInputEvent(pressed = true)) + } catch (e: Throwable) { + null + } ?: return + webView.evaluate("window.paywall.accept([$payload])", null) + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.paywallView, + message = "Back button press forwarded to paywall: $payload", + ) } //endregion 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 4e1a6e110..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 @@ -297,8 +297,8 @@ class SuperwallPaywallActivity : AppCompatActivity() { object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { val paywallView = paywallView() - val behavior = - backPressBehavior( + val consumedByApp = + isBackPressConsumedByApp( rerouteBackButton = paywallView?.state?.paywall?.rerouteBackButton, consumedByApp = { Superwall.instance.options.paywalls.onBackPressed @@ -306,18 +306,16 @@ class SuperwallPaywallActivity : AppCompatActivity() { ?: false }, ) - val dismiss = { - view.dismiss( + 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, ) - } - when (behavior) { - BackPressBehavior.CONSUMED_BY_APP -> Unit - BackPressBehavior.FORWARD_TO_PAYWALL -> - paywallView?.backButtonPressed(onUnhandled = dismiss) ?: dismiss() - BackPressBehavior.DISMISS -> dismiss() - } } }, ) 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 index 0b93fe77a..5f06e5284 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt @@ -4,25 +4,25 @@ 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.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class BackPressBehaviorTest { @Test - fun dismisses_when_reroute_is_disabled() { + 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 behavior = - backPressBehavior(Paywall.ToggleMode.DISABLED) { + val consumed = + isBackPressConsumedByApp(Paywall.ToggleMode.DISABLED) { callbackInvoked = true true } - Then("the paywall dismisses without consulting the app callback") { - assertEquals(BackPressBehavior.DISMISS, behavior) + Then("the press is not consumed and the callback is never invoked") { + assertFalse(consumed) assertFalse(callbackInvoked) } } @@ -30,39 +30,39 @@ class BackPressBehaviorTest { } @Test - fun dismisses_when_reroute_is_unset() { + 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 behavior = backPressBehavior(null) { true } + val consumed = isBackPressConsumedByApp(null) { true } - Then("the paywall dismisses") { - assertEquals(BackPressBehavior.DISMISS, behavior) + Then("the press is not consumed") { + assertFalse(consumed) } } } } @Test - fun app_callback_gets_first_refusal_when_reroute_is_enabled() { + 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 behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { true } + val consumed = isBackPressConsumedByApp(Paywall.ToggleMode.ENABLED) { true } Then("the press is consumed by the app") { - assertEquals(BackPressBehavior.CONSUMED_BY_APP, behavior) + assertTrue(consumed) } } } } @Test - fun forwards_to_paywall_when_reroute_is_enabled_and_app_declines() { + 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 behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { false } + val consumed = isBackPressConsumedByApp(Paywall.ToggleMode.ENABLED) { false } - Then("the press is forwarded into the paywall") { - assertEquals(BackPressBehavior.FORWARD_TO_PAYWALL, behavior) + 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/PaywallViewTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt index 42e656a0b..d98135761 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 @@ -239,15 +239,12 @@ class PaywallViewTest { } @Test - fun backButtonPressed_forwardsToPaywallWhenSupported() { - Given("a paywall whose runtime supports back_button_input") { - fakeWebUI.evaluateResult = "true" - var unhandled = false + fun backButtonPressed_evaluatesJavascriptBridge() { + Given("a paywall presented with the system back button pressed") { + When("the press is forwarded to the paywall") { + paywallView.backButtonPressed() - When("a back press occurs") { - paywallView.backButtonPressed(onUnhandled = { unhandled = true }) - - Then("the WebUI is probed then receives a back_button_input press") { + Then("the WebUI receives a back_button_input press") { val expectedPayload = Json { encodeDefaults = true @@ -257,36 +254,9 @@ class PaywallViewTest { BackButtonInputEvent(pressed = true), ) assertEquals( - listOf( - "typeof window.paywall !== 'undefined' && " + - "window.paywall.supportsBackButtonInput === true", - "window.paywall.accept([$expectedPayload])", - ), - fakeWebUI.evaluateCalls, - ) - assertFalse(unhandled) - } - } - } - } - - @Test - fun backButtonPressed_fallsBackWhenRuntimeLacksSupport() { - Given("a paywall whose runtime does not support back_button_input") { - var unhandled = false - - When("a back press occurs") { - paywallView.backButtonPressed(onUnhandled = { unhandled = true }) - - Then("nothing is injected and the press is reported as unhandled") { - assertEquals( - listOf( - "typeof window.paywall !== 'undefined' && " + - "window.paywall.supportsBackButtonInput === true", - ), + listOf("window.paywall.accept([$expectedPayload])"), fakeWebUI.evaluateCalls, ) - assertTrue(unhandled) } } } @@ -564,7 +534,6 @@ class PaywallViewTest { var destroyed: Boolean = false var setupLatch: CountDownLatch? = null val evaluateCalls = mutableListOf() - var evaluateResult: String? = null private val view = View(context) override fun onView(perform: View.() -> Unit) { @@ -601,7 +570,7 @@ class PaywallViewTest { resultCallback: ((String?) -> Unit)?, ) { evaluateCalls += code - resultCallback?.invoke(evaluateResult) + resultCallback?.invoke(null) } override fun destroyView() { From 2e4192f3f37dea1ec851e658311751cdd4012fba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 14:05:07 +0000 Subject: [PATCH 5/8] drop pressed field from BackButtonInputEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message's arrival is the signal — only presses are ever sent, so the boolean carried no information. Matches the simplified paywall-js schema. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- .../main/java/com/superwall/sdk/paywall/view/PaywallView.kt | 2 +- .../paywall/view/webview/messaging/BackButtonInputEvent.kt | 4 +--- .../java/com/superwall/sdk/paywall/view/PaywallViewTest.kt | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) 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 c38fcaba8..fa36a6ccc 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 @@ -1116,7 +1116,7 @@ class PaywallView( fun backButtonPressed() { val payload = try { - webEventJson.encodeToString(BackButtonInputEvent(pressed = true)) + webEventJson.encodeToString(BackButtonInputEvent()) } catch (e: Throwable) { null } ?: return diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt index 7aa20a737..4bc7deb79 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable /** * Message injected into the paywall webview when the system back button is - * re-routed to the paywall (`reroute_back_button` enabled in Paywall settings). + * pressed while a paywall is presented. * * The paywall responds by either navigating its flow back one page or posting * the `close` message, which dismisses via the standard manual-close path. @@ -14,6 +14,4 @@ import kotlinx.serialization.Serializable data class BackButtonInputEvent( @SerialName("event_name") val eventName: String = "back_button_input", - @SerialName("pressed") - val pressed: Boolean, ) 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 d98135761..550b8633b 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 @@ -244,14 +244,14 @@ class PaywallViewTest { When("the press is forwarded to the paywall") { paywallView.backButtonPressed() - Then("the WebUI receives a back_button_input press") { + Then("the WebUI receives a back_button_input message") { val expectedPayload = Json { encodeDefaults = true namingStrategy = JsonNamingStrategy.SnakeCase }.encodeToString( BackButtonInputEvent.serializer(), - BackButtonInputEvent(pressed = true), + BackButtonInputEvent(), ) assertEquals( listOf("window.paywall.accept([$expectedPayload])"), From 8cc6d5f89679adae8098f62d95319622539b5922 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 14:46:11 +0000 Subject: [PATCH 6/8] move back-button changelog entry into 2.8.0 after rebase The Unreleased section this entry was written under became 2.7.23 on develop; the change ships with 2.8.0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e448f56..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`). @@ -29,9 +30,6 @@ Google Play Billing Library 9 requires Android 6.0 (API 23), so the SDK's `minSd ## 2.7.23 -## 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. - ## Fixes - Fix paywall inputs silently dropping all non-Latin keyboard text (Cyrillic, Korean, Thai, emoji, etc.). The webview no longer replaces Chromium's `InputConnection` with a dummy one unless `isGameControllerEnabled` is set, restoring the full IME pipeline (composition, autocorrect, swipe typing, autofill) for everyone else. - Ensure that errors thrown when user lacks billing do not propagate to the app From dcc8e352d61a96fc306dd2f0a2c1f67ff70f9e5d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 16:51:01 +0000 Subject: [PATCH 7/8] route back press through PaywallMessageHandler Adds PaywallMessage.BackButtonPressed and handles it via the standard pass()/accept64 pipeline instead of a hand-rolled evaluate in PaywallView, reusing the established main-thread dispatch, error logging, and encoding. Removes BackButtonInputEvent and reverts the gameControllerJson rename it motivated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- .../superwall/sdk/paywall/view/PaywallView.kt | 18 ++--------- .../webview/messaging/BackButtonInputEvent.kt | 17 ---------- .../view/webview/messaging/PaywallMessage.kt | 7 +++++ .../messaging/PaywallMessageHandler.kt | 5 +++ .../paywall/view/PaywallMessageHandlerTest.kt | 31 +++++++++++++++++++ .../sdk/paywall/view/PaywallViewTest.kt | 20 +++--------- 6 files changed, 51 insertions(+), 47 deletions(-) delete mode 100644 superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt 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 fa36a6ccc..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 @@ -67,7 +67,6 @@ import com.superwall.sdk.paywall.view.webview.PaywallWebUI import com.superwall.sdk.paywall.view.webview.SWWebView import com.superwall.sdk.paywall.view.webview.SendPaywallMessages import com.superwall.sdk.paywall.view.webview.WebviewError -import com.superwall.sdk.paywall.view.webview.messaging.BackButtonInputEvent import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessage import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandlerDelegate import com.superwall.sdk.paywall.view.webview.messaging.PaywallStateDelegate @@ -140,7 +139,7 @@ class PaywallView( private companion object { private val mainScope: MainScope = MainScope() private val ioScope: IOScope = IOScope() - private val webEventJson by lazy { + private val gameControllerJson by lazy { Json { encodeDefaults = true namingStrategy = JsonNamingStrategy.SnakeCase @@ -1092,7 +1091,7 @@ class PaywallView( override fun gameControllerEventOccured(event: GameControllerEvent) { val payload = try { - webEventJson.encodeToString(event) + gameControllerJson.encodeToString(event) } catch (e: Throwable) { null } @@ -1114,18 +1113,7 @@ class PaywallView( * which dismisses via the standard manual-close path. */ fun backButtonPressed() { - val payload = - try { - webEventJson.encodeToString(BackButtonInputEvent()) - } catch (e: Throwable) { - null - } ?: return - webView.evaluate("window.paywall.accept([$payload])", null) - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.paywallView, - message = "Back button press forwarded to paywall: $payload", - ) + webView.messageHandler.handle(PaywallMessage.BackButtonPressed) } //endregion diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt deleted file mode 100644 index 4bc7deb79..000000000 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.superwall.sdk.paywall.view.webview.messaging - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Message injected into the paywall webview when the system back button is - * pressed while a paywall is presented. - * - * The paywall responds by either navigating its flow back one page or posting - * the `close` message, which dismisses via the standard manual-close path. - */ -@Serializable -data class BackButtonInputEvent( - @SerialName("event_name") - val eventName: String = "back_button_input", -) 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/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 550b8633b..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 @@ -21,7 +21,6 @@ import com.superwall.sdk.paywall.view.delegate.PaywallViewEventCallback import com.superwall.sdk.paywall.view.webview.PaywallUIDelegate import com.superwall.sdk.paywall.view.webview.PaywallWebUI import com.superwall.sdk.paywall.view.webview.SendPaywallMessages -import com.superwall.sdk.paywall.view.webview.messaging.BackButtonInputEvent import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessage import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandler import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent @@ -239,24 +238,15 @@ class PaywallViewTest { } @Test - fun backButtonPressed_evaluatesJavascriptBridge() { + 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 WebUI receives a back_button_input message") { - val expectedPayload = - Json { - encodeDefaults = true - namingStrategy = JsonNamingStrategy.SnakeCase - }.encodeToString( - BackButtonInputEvent.serializer(), - BackButtonInputEvent(), - ) - assertEquals( - listOf("window.paywall.accept([$expectedPayload])"), - fakeWebUI.evaluateCalls, - ) + Then("the message handler receives BackButtonPressed") { + verify(exactly = 1) { messageHandler.handle(PaywallMessage.BackButtonPressed) } } } } From 37b081b71c5740262c4d072317476a58339bae6b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 28 Jul 2026 17:09:03 +0000 Subject: [PATCH 8/8] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 @@ -branches36.2% \ No newline at end of file +branches36.3% \ No newline at end of file