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 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") {