diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index b53e2eae5..cf88d0552 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/.github/badges/jacoco.svg b/.github/badges/jacoco.svg
index e1c798d88..b9dcd7843 100644
--- a/.github/badges/jacoco.svg
+++ b/.github/badges/jacoco.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.github/workflows/preload-benchmark.yml b/.github/workflows/preload-benchmark.yml
index a1f972a0b..32f606cf1 100644
--- a/.github/workflows/preload-benchmark.yml
+++ b/.github/workflows/preload-benchmark.yml
@@ -1,4 +1,5 @@
-# Paywall preload benchmark — runs on PRs into main or develop.
+# Paywall preload benchmark — opt-in only. Runs on PRs into main or develop
+# that carry the `release` or `benchmark` label, or on manual dispatch.
#
# Measures the time from Superwall.preloadAllPaywalls() until every paywall for
# the dev app's embedded API key reaches PaywallLoadingState.Ready, on 3
@@ -19,14 +20,16 @@
name: Paywall Preload Benchmark
on:
- # PRs into main (release) and develop (feature) both run the benchmark, so
- # preload regressions surface before they land anywhere.
+ # PRs into main (release) and develop (feature) run the benchmark only when
+ # opted in with the `release` or `benchmark` label — it burns 3 emulators for
+ # up to 40 minutes, so it's not worth paying on every PR push. `labeled` is in
+ # the trigger list so adding the label to an open PR starts the run.
pull_request:
branches: [ main, develop ]
- # Merges re-run the benchmark AND roll the baseline forward automatically,
- # so release PRs always gate against the latest mainline numbers.
- push:
- branches: [ main, develop ]
+ types: [ opened, synchronize, reopened, labeled ]
+ # No push trigger: a merge into main/develop carries no labels, so it could
+ # never satisfy the opt-in. Roll the baseline forward with a manual dispatch
+ # (update_baseline: true) off the branch you want to become the new mainline.
workflow_dispatch:
inputs:
update_baseline:
@@ -43,6 +46,13 @@ jobs:
build-apks:
runs-on: ubuntu-latest
timeout-minutes: 20
+ # Opt-in guard: PRs need the `release` or `benchmark` label; manual
+ # dispatches always run. The downstream jobs `needs` this one, so they skip
+ # with it.
+ if: >-
+ github.event_name == 'workflow_dispatch' ||
+ contains(github.event.pull_request.labels.*.name, 'release') ||
+ contains(github.event.pull_request.labels.*.name, 'benchmark')
steps:
- name: Checkout code
@@ -254,7 +264,7 @@ jobs:
--config .benchmark/config.json \
--report .benchmark/REPORT.md \
--normalized-out .benchmark/results \
- ${{ (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.update_baseline)) && '--update-baseline' || '' }}
+ ${{ (github.event_name == 'workflow_dispatch' && inputs.update_baseline) && '--update-baseline' || '' }}
echo "exit_code=$?" >> "$GITHUB_OUTPUT"
- name: Publish report to job summary
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c8880d382..05dc905d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub.
+## 2.7.22
+
+## Enhancements
+- Always notify backend of purchased entitlements
+- Adds a 5 second timeout to resolving latest purchases, allowing non-play store purchases to resolve
+
+## Fixes
+- Fix a dead buy button when re-presenting a cached paywall in the same session after a purchase, for both `register()` and embedded paywalls (`getPaywall`/`getPaywallView`). Per-presentation transient state (loading spinner and presentation-prepared flag) is now reset on each new presentation, so it no longer leaks from a previous presentation that was stopped without a finishing teardown.
+
## 2.7.21
## Enhancements
diff --git a/app/src/androidTest/java/com/example/superapp/test/PaywallHostFragment.kt b/app/src/androidTest/java/com/example/superapp/test/PaywallHostFragment.kt
new file mode 100644
index 000000000..a06ac463e
--- /dev/null
+++ b/app/src/androidTest/java/com/example/superapp/test/PaywallHostFragment.kt
@@ -0,0 +1,82 @@
+package com.example.superapp.test
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import com.superwall.sdk.paywall.presentation.get_paywall.builder.PaywallBuilder
+import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult
+import com.superwall.sdk.paywall.view.PaywallView
+import com.superwall.sdk.paywall.view.delegate.PaywallViewCallback
+import kotlinx.coroutines.launch
+
+/**
+ * Minimal test-host screen that EMBEDS a paywall inside a [Fragment] via the public
+ * embed API ([PaywallBuilder] -> [com.superwall.sdk.Superwall.getPaywall]), NOT via
+ * [com.superwall.sdk.paywall.presentation.register].
+ */
+class PaywallHostFragment : Fragment() {
+ // Parameterised so the whole flow keys off a single placement constant.
+ var placement: String = RepresentTests.CONSUMABLE_REBUY_PLACEMENT
+
+ private lateinit var container: FrameLayout
+ private var paywallView: PaywallView? = null
+
+ // Minimal embed delegate. The fragment host stays on screen so the test can
+ // re-embed the same paywall, so we intentionally do NOT finish the activity here.
+ private val delegate =
+ object : PaywallViewCallback {
+ override fun onFinished(
+ paywall: PaywallView,
+ result: PaywallResult,
+ shouldDismiss: Boolean,
+ ) {
+ // no-op: keep the host alive for re-embedding.
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View = FrameLayout(requireContext()).also { this.container = it }
+
+ // NOTE: no auto-present on view-create. The SDK's config fetch is gated on the app
+ // being in the FOREGROUND (Network.getConfig -> awaitUntilAppInForeground), so the
+ // test must launch this host first, await Configured, and only then call [present].
+
+ /**
+ * Obtains the embedded paywall via the public [PaywallBuilder]/`getPaywall` API and
+ * attaches it into the fragment's container. On the second call the SDK reuses the
+ * CACHED `PaywallView` (via `PaywallManager.getPaywallView`'s cache-hit branch),
+ * which is precisely the path whose stale `LoadingPurchase` overlay used to swallow
+ * the buy tap before PR #434.
+ */
+ fun present() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ embedPaywall()
+ }
+ }
+
+ private suspend fun embedPaywall() {
+ // Detach any previously-embedded instance before re-embedding the (cached) view.
+ paywallView?.let { (it.parent as? ViewGroup)?.removeView(it) }
+
+ PaywallBuilder(placement)
+ .delegate(delegate)
+ .activity(requireActivity())
+ .build()
+ .onSuccess { view ->
+ paywallView = view
+ // `build()` already ran getPaywall() (cache-hit reset) + beforeViewCreated();
+ // ensure the view isn't still parented, attach it, then drive onViewCreated()
+ // as PaywallComposable does.
+ (view.parent as? ViewGroup)?.removeView(view)
+ container.addView(view)
+ view.onViewCreated()
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/example/superapp/test/RepresentTests.kt b/app/src/androidTest/java/com/example/superapp/test/RepresentTests.kt
new file mode 100644
index 000000000..8a38b561a
--- /dev/null
+++ b/app/src/androidTest/java/com/example/superapp/test/RepresentTests.kt
@@ -0,0 +1,333 @@
+package com.example.superapp.test
+
+import android.app.Application
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+import com.example.superapp.utils.awaitUntilWebviewAppears
+import com.superwall.sdk.Superwall
+import com.superwall.sdk.analytics.superwall.SuperwallEvent
+import com.superwall.sdk.analytics.superwall.SuperwallEventInfo
+import com.superwall.sdk.config.models.ConfigurationStatus
+import com.superwall.sdk.config.options.PaywallOptions
+import com.superwall.sdk.config.options.SuperwallOptions
+import com.superwall.sdk.delegate.SuperwallDelegate
+import com.superwall.sdk.logger.LogLevel
+import com.superwall.sdk.models.entitlements.SubscriptionStatus
+import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState
+import com.superwall.sdk.store.testmode.TestModeBehavior
+import com.superwall.superapp.Keys
+import com.superwall.superapp.test.PaywallHostActivity
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeoutOrNull
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+@RunWith(AndroidJUnit4::class)
+class RepresentTests {
+ companion object {
+ /**
+ * Placement that must present a NON-gated paywall backed by a CONSUMABLE
+ * product (one that grants NO persistent entitlement), so the same paywall
+ * can be re-presented and re-bought within a single session.
+ *
+ * `non_recurring_product` is the closest existing placement (see
+ * `UITestHandler.testAndroid20Info`, "Non-recurring product purchase"). If it
+ * turns out to be gated / to grant a blocking entitlement, point this constant
+ * at a dedicated consumable re-buy placement instead.
+ */
+ const val CONSUMABLE_REBUY_PLACEMENT = "non_recurring_product"
+
+ /**
+ * The paywall's primary purchase CTA label (WebView text). Must be DISTINCT
+ * from the test-mode activation modal's "Continue" button, otherwise the buy
+ * tap is ambiguous. Update to match the configured paywall's button text.
+ */
+ const val BUY_BUTTON_TEXT = "Continue"
+
+ // Test-mode purchase drawer confirm button (set in TestModePurchaseDrawer).
+ private const val CONFIRM_PURCHASE_TEXT = "Confirm Purchase"
+
+ // Unique substring of the test-mode activation modal (its title row reads
+ // "✏️ Test Mode Active"), used to detect the modal without colliding with
+ // paywall/webview text.
+ private const val TEST_MODE_MODAL_HINT = "Test Mode Active"
+
+ private val EVENT_TIMEOUT = 30.seconds
+ private val UI_TIMEOUT_MS = 30_000L
+ }
+
+ // Live event stream fed by the Superwall delegate installed in [setup].
+ private val events = MutableSharedFlow(replay = 64, extraBufferCapacity = 256)
+
+ @Before
+ fun setup() {
+ Superwall.configure(
+ getInstrumentation().targetContext.applicationContext as Application,
+ Keys.CONSTANT_API_KEY,
+ options =
+ SuperwallOptions().apply {
+ logging.level = LogLevel.debug
+ // Billing is unavailable on CI emulators — ALWAYS routes purchases
+ // through the in-app test-mode drawer instead of Google Play.
+ testModeBehavior = TestModeBehavior.ALWAYS
+ paywalls =
+ PaywallOptions().apply {
+ shouldPreload = false
+ }
+ },
+ )
+ Superwall.instance.delegate =
+ object : SuperwallDelegate {
+ override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
+ events.tryEmit(eventInfo.event)
+ }
+ }
+ }
+
+ /**
+ * Test 1 — repro + fix, end-to-end (embedded path).
+ *
+ * Embed → buy → drawer → Confirm → complete → re-embed the SAME placement →
+ * tap buy AGAIN. Pre-fix the second tap is dead (stale overlay swallows it) so the
+ * drawer never reappears; post-fix the purchase flow is re-invoked because the
+ * cache-hit reset in `PaywallManager` cleared the stale `LoadingPurchase` overlay.
+ */
+ @Test
+ fun test_represent_after_consumable_purchase_reinvokes_purchase() =
+ runBlocking {
+ // Launch the host FIRST: the SDK only fetches config once the app is in the
+ // foreground (Network.getConfig gates on awaitUntilAppInForeground), so waiting
+ // for Configured before showing an activity deadlocks.
+ val scenario = launchHost()
+ awaitConfigured()
+
+ // First embedded presentation.
+ scenario.presentPaywall()
+ dismissTestModeActivationModalIfPresent()
+ assertTrue("First paywall never appeared", awaitUntilWebviewAppears())
+ delay(1.seconds)
+
+ // First purchase.
+ tapText(BUY_BUTTON_TEXT)
+ assertTrue(
+ "Purchase drawer did not appear on the FIRST buy tap",
+ awaitTextAppears(CONFIRM_PURCHASE_TEXT),
+ )
+ val firstComplete = awaitEventAsync { it is SuperwallEvent.TransactionComplete }
+ tapText(CONFIRM_PURCHASE_TEXT)
+ assertNotNull("First purchase never completed", firstComplete.await())
+ // The embedded paywall isn't dismissed (no full-screen activity to finish);
+ // just wait for the test-mode drawer to close so the re-tap detects a FRESH drawer.
+ awaitTextDisappears(CONFIRM_PURCHASE_TEXT)
+ delay(1.seconds)
+
+ // Re-obtain/re-attach the SAME placement via the embedded API (reuses the cached
+ // PaywallView through PaywallManager.getPaywallView's cache-hit reset).
+ scenario.presentPaywall()
+ dismissTestModeActivationModalIfPresent()
+ assertTrue("Re-presented paywall never appeared", awaitUntilWebviewAppears())
+ delay(1.seconds)
+
+ // Tap buy AGAIN — dead pre-fix, alive post-fix (PR #434).
+ tapText(BUY_BUTTON_TEXT)
+ assertTrue(
+ "Re-presented paywall's buy tap did NOT re-invoke the purchase flow — " +
+ "stale transient presentation state regression (PR #434)",
+ awaitTextAppears(CONFIRM_PURCHASE_TEXT),
+ )
+ }
+
+ /**
+ * Test 2 — public-API state assertion (analog of the internal-reset unit test).
+ *
+ * After purchasing and re-embedding, the re-presented paywall must NOT be stuck in
+ * `LoadingPurchase` (which is what showed the tap-swallowing overlay). `:app:` can't
+ * see the internal reset API, so we assert via the public `loadingState` getter.
+ */
+ @Test
+ fun test_represent_resets_loading_state() =
+ runBlocking {
+ // Foreground-first, same as test 1 (config fetch is gated on foreground).
+ val scenario = launchHost()
+ awaitConfigured()
+
+ scenario.presentPaywall()
+ dismissTestModeActivationModalIfPresent()
+ assertTrue("First paywall never appeared", awaitUntilWebviewAppears())
+ delay(1.seconds)
+
+ // Purchase once so a LoadingPurchase state exists to (previously) leak.
+ tapText(BUY_BUTTON_TEXT)
+ assertTrue(
+ "Purchase drawer did not appear on the buy tap",
+ awaitTextAppears(CONFIRM_PURCHASE_TEXT),
+ )
+ val complete = awaitEventAsync { it is SuperwallEvent.TransactionComplete }
+ tapText(CONFIRM_PURCHASE_TEXT)
+ assertNotNull("Purchase never completed", complete.await())
+ awaitTextDisappears(CONFIRM_PURCHASE_TEXT)
+ delay(1.seconds)
+
+ // Re-obtain/re-attach the cached embedded paywall (exercises the cache-hit reset).
+ scenario.presentPaywall()
+ dismissTestModeActivationModalIfPresent()
+ assertTrue("Re-presented paywall never appeared", awaitUntilWebviewAppears())
+ delay(1.seconds)
+
+ val paywallView = Superwall.instance.paywallView
+ assertNotNull("No paywallView after re-present", paywallView)
+ val loadingState = paywallView!!.loadingState
+ // Post-fix `resetTransientPresentationState()` puts LoadingPurchase back to Ready,
+ // so the tap-swallowing overlay is not shown.
+ assertTrue(
+ "Re-presented paywall carried a stale LoadingPurchase state (PR #434); was $loadingState",
+ loadingState !is PaywallLoadingState.LoadingPurchase,
+ )
+ }
+
+ // region helpers
+
+ private suspend fun awaitConfigured() {
+ // Force a DEFINITE entitlement status up front. On CI emulators Google Play Billing
+ // is unavailable (developer error 5), so the status otherwise stays `Unknown` and
+ // paywall presentation is SKIPPED with `subscription_status_timeout` (the status
+ // stayed "unknown" for >5s) — including the implicit app_install attempt that fires
+ // during configure. `Inactive` means "no entitlements" so the non-gated paywall
+ // still presents. Mirrors every paywall-presenting test in `UITestHandler`.
+ Superwall.instance.setSubscriptionStatus(SubscriptionStatus.Inactive)
+ // Bounded wait: a hang here must fail loudly with the actual state, not stall the
+ // suite forever (configurationStateListener never emits Configured if config load
+ // fails or wedges — `first {}` would otherwise suspend indefinitely).
+ val configured =
+ withTimeoutOrNull(EVENT_TIMEOUT) {
+ Superwall.instance.configurationStateListener.first { it is ConfigurationStatus.Configured }
+ }
+ assertNotNull(
+ "Superwall never reached Configured within $EVENT_TIMEOUT; " +
+ "last state = ${Superwall.instance.configurationState}",
+ configured,
+ )
+ }
+
+ /**
+ * Launches the debug-source-set host activity (it must live in the app APK so
+ * ActivityScenario can launch it into the app's process — see the KDoc on
+ * [PaywallHostActivity]) and commits [PaywallHostFragment] into its container,
+ * keeping all test logic in this source set.
+ */
+ private fun launchHost(): ActivityScenario {
+ val scenario = ActivityScenario.launch(PaywallHostActivity::class.java)
+ scenario.onActivity {
+ it.supportFragmentManager
+ .beginTransaction()
+ .replace(PaywallHostActivity.CONTAINER_ID, PaywallHostFragment())
+ .commitNow()
+ }
+ return scenario
+ }
+
+ private fun ActivityScenario.presentPaywall() {
+ onActivity {
+ val fragment =
+ it.supportFragmentManager.findFragmentById(PaywallHostActivity.CONTAINER_ID)
+ (fragment as PaywallHostFragment).present()
+ }
+ }
+
+ private fun device() = UiDevice.getInstance(getInstrumentation())
+
+ /**
+ * The ALWAYS test-mode activation modal auto-presents once (per just-activated
+ * session) and is non-cancelable (TestModeModal sets cancelable=false). Its
+ * "Continue" button sits BELOW THE FOLD of the collapsed bottom sheet, so after
+ * detecting the modal by its title, swipe the sheet up until the button is on
+ * screen, then tap it. Best-effort: absent modal is not an error.
+ */
+ private suspend fun dismissTestModeActivationModalIfPresent() {
+ val d = device()
+ val modal = d.wait(Until.findObject(By.textContains(TEST_MODE_MODAL_HINT)), 10_000)
+ if (modal != null) {
+ var continueButton = d.findObject(By.text("Continue"))
+ var swipes = 0
+ while (continueButton == null && swipes < 6) {
+ d.swipe(
+ d.displayWidth / 2,
+ (d.displayHeight * 0.85).toInt(),
+ d.displayWidth / 2,
+ (d.displayHeight * 0.25).toInt(),
+ 15,
+ )
+ d.waitForIdle()
+ continueButton = d.findObject(By.text("Continue"))
+ swipes++
+ }
+ assertNotNull("Test-mode modal is up but its Continue button never appeared", continueButton)
+ continueButton!!.click()
+ d.waitForIdle()
+ delay(500.milliseconds)
+ }
+ }
+
+ private fun awaitTextAppears(
+ text: String,
+ timeoutMs: Long = UI_TIMEOUT_MS,
+ ): Boolean = device().wait(Until.findObject(By.textContains(text)), timeoutMs) != null
+
+ /**
+ * Waits for [text] (webview or native) and taps it. Uses the By/Until API — the
+ * legacy [UiSelector]-based [clickButtonWith] takes a one-shot accessibility
+ * snapshot that can miss webview content. On failure, dumps the accessibility
+ * hierarchy to logcat so the actual on-screen tree is visible in CI logs.
+ */
+ private fun tapText(
+ text: String,
+ timeoutMs: Long = UI_TIMEOUT_MS,
+ ) {
+ val obj = device().wait(Until.findObject(By.textContains(text)), timeoutMs)
+ if (obj == null) {
+ val dump = java.io.ByteArrayOutputStream()
+ device().dumpWindowHierarchy(dump)
+ println("!! tapText(\"$text\") FAILED. Accessibility hierarchy:\n$dump")
+ }
+ assertNotNull("Tap target \"$text\" never appeared within ${timeoutMs}ms", obj)
+ obj!!.click()
+ }
+
+ /**
+ * Waits until [text] is no longer on screen (best-effort, bounded by [timeoutMs]).
+ * Used after confirming a test-mode purchase so the subsequent buy re-tap detects a
+ * FRESH drawer rather than the lingering previous one. The embedded paywall itself is
+ * never dismissed (there's no full-screen activity to finish), so we can't wait on the
+ * webview disappearing.
+ */
+ private fun awaitTextDisappears(
+ text: String,
+ timeoutMs: Long = UI_TIMEOUT_MS,
+ ): Boolean = device().wait(Until.gone(By.textContains(text)), timeoutMs) ?: false
+
+ /**
+ * Subscribe to [events] BEFORE the triggering action (avoids the emit/collect race),
+ * returning a Deferred that resolves to the matching event or null on timeout.
+ */
+ private fun CoroutineScope.awaitEventAsync(predicate: (SuperwallEvent) -> Boolean) =
+ async(Dispatchers.IO) {
+ withTimeoutOrNull(EVENT_TIMEOUT) { events.first(predicate) }
+ }
+
+ // endregion
+}
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
index fe90dc87a..018c29c4b 100644
--- a/app/src/debug/AndroidManifest.xml
+++ b/app/src/debug/AndroidManifest.xml
@@ -27,5 +27,12 @@
android:name="com.superwall.superapp.benchmark.BenchmarkForegroundActivity"
android:exported="false"
android:theme="@android:style/Theme.Material.Light" />
+
+
+
diff --git a/app/src/debug/java/com/superwall/superapp/test/PaywallHostActivity.kt b/app/src/debug/java/com/superwall/superapp/test/PaywallHostActivity.kt
new file mode 100644
index 000000000..f09d9f5d7
--- /dev/null
+++ b/app/src/debug/java/com/superwall/superapp/test/PaywallHostActivity.kt
@@ -0,0 +1,26 @@
+package com.superwall.superapp.test
+
+import android.os.Bundle
+import android.view.View
+import android.widget.FrameLayout
+import androidx.fragment.app.FragmentActivity
+
+/**
+ * Bare fragment host for the `RepresentTests` androidTest suite. Debug-source-set only —
+ * ships in no release build and pulls in no test libraries. Like
+ * [com.superwall.superapp.benchmark.BenchmarkForegroundActivity], it must live in the app
+ * (not the test APK) so ActivityScenario can launch it into the app's process.
+ *
+ * The activity is deliberately empty: the test commits its own fragment into
+ * [CONTAINER_ID], keeping all test logic in the androidTest source set.
+ */
+class PaywallHostActivity : FragmentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(FrameLayout(this).apply { id = CONTAINER_ID })
+ }
+
+ companion object {
+ val CONTAINER_ID = View.generateViewId()
+ }
+}
diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/PaywallViewDismissTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/PaywallViewDismissTest.kt
index 10a16a422..63f6309ea 100644
--- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/PaywallViewDismissTest.kt
+++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/PaywallViewDismissTest.kt
@@ -16,6 +16,7 @@ import com.superwall.sdk.paywall.presentation.internal.PresentationRequestType
import com.superwall.sdk.paywall.presentation.internal.request.PresentationInfo
import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult
import com.superwall.sdk.paywall.presentation.internal.state.PaywallState
+import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState
import com.superwall.sdk.paywall.view.delegate.PaywallViewCallback
import com.superwall.sdk.paywall.view.delegate.PaywallViewDelegateAdapter
import kotlinx.coroutines.Dispatchers
@@ -157,6 +158,60 @@ class PaywallViewDismissTest {
}
}
+ @Test
+ fun dismiss_purchased_resets_loading_spinner_for_embedded_paywall() =
+ runBlocking {
+ val finished = kotlinx.coroutines.CompletableDeferred()
+ // Mirrors the tab-bar / embedded host: onFinished is a no-op, so the view stays
+ // on screen after the purchase and is NEVER torn down (destroy() is not called).
+ val callback =
+ object : PaywallViewCallback {
+ override fun onFinished(
+ paywall: PaywallView,
+ result: PaywallResult,
+ shouldDismiss: Boolean,
+ ) {
+ finished.complete(Unit)
+ }
+ }
+ retainedCallback = callback
+ val delegate = PaywallViewDelegateAdapter(callback)
+ val view = makeView(delegate)
+
+ val publisher = MutableSharedFlow(replay = 1, extraBufferCapacity = 1)
+ val request = makeRequest()
+ Given("an embedded paywall showing the purchase spinner") {
+ withContext(Dispatchers.Main) {
+ view.set(request, publisher, null)
+ view.onViewCreated()
+ // Buy tap sets this in production (Superwall.kt InitiatePurchase).
+ view.updateState(
+ PaywallViewState.Updates.SetLoadingState(PaywallLoadingState.LoadingPurchase),
+ )
+ }
+
+ When("the purchase completes and the SDK auto-dismisses") {
+ withContext(Dispatchers.Main) {
+ view.dismiss(
+ result = PaywallResult.Purchased(productId = "product1"),
+ closeReason = PaywallCloseReason.SystemLogic,
+ )
+ }
+
+ // Wait for the dismissal hand-off (onFinished, shouldDismiss=true).
+ withContext(Dispatchers.IO) {
+ withTimeout(3000) { finished.await() }
+ }
+
+ Then("the loading spinner is reset to Ready even though the host kept the view") {
+ // No destroy()/teardown here: the host (tab-bar embed) leaves the view
+ // on screen. Pre-fix the LoadingPurchase spinner leaked and spun forever.
+ assertEquals(PaywallLoadingState.Ready, view.loadingState)
+ }
+ }
+ }
+ }
+
@Test
fun dismiss_declined_for_next_paywall_does_not_clear_publisher() =
runBlocking {
diff --git a/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
index 1f3b7665f..cca618f7a 100644
--- a/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
+++ b/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
@@ -134,6 +134,7 @@ class TransactionManagerTest {
),
)
coEvery { queryAllPurchases() } returns emptyList()
+ every { purchaseResults } returns MutableStateFlow(null)
}
private var activityProvider =
mockk {
diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
index c453767c0..886f5eca7 100644
--- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
+++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
@@ -592,6 +592,9 @@ class DependencyContainer(
showRestoreDialogForWeb = {
showWebRestoreSuccesful()
},
+ notifyBackendOfReceipts = {
+ reedemer.redeem(WebPaywallRedeemer.RedeemType.Existing)
+ },
entitlementsById = {
entitlements.byProductId(it)
},
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt
index 33dbb0a1f..bba42bb30 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt
@@ -80,6 +80,19 @@ class PaywallManager(
if (!isPreloading) {
view.callback = delegate
view.updateState(PaywallViewState.Updates.MergePaywall(it))
+ // A cached view is being handed back for a new presentation. Clear any
+ // per-presentation transient state (a stale LoadingPurchase/ManualLoading
+ // spinner that swallows taps, and a stale presentationDidFinishPrepare)
+ // leaked by a previous presentation that stopped without a finishing
+ // teardown. This covers both the register()/full-screen path and the
+ // embedded getPaywallView/getPaywall path, which both funnel through here.
+ // Guarded by !isPreloading so preloading never resets a live view, and by
+ // isForPresentation because getPresentationResult() (a pure query API)
+ // also fetches through here — a result check while this paywall is
+ // on screen mid-purchase must not wipe its live spinner or prepare flags.
+ if (isForPresentation) {
+ view.resetTransientPresentationState()
+ }
}
return@mapAsync view
}
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 ca5c6d93b..c0cad2da9 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
@@ -477,6 +477,26 @@ class PaywallView(
controller.updateState(ResetPresentationPreparations)
}
+ // A cached view can be re-presented after a previous presentation was stopped without a
+ // finishing teardown - the purchase billing sheet (or backgrounding) triggers a non-finishing
+ // onStop, which since #431 intentionally no longer tears the view down. That leaks
+ // per-presentation transient state into the next present: a stale LoadingPurchase/ManualLoading
+ // spinner swallows taps, and a stale presentationDidFinishPrepare makes onViewCreated()
+ // early-return without re-wiring the view. Called from PaywallManager.getPaywallView's cache-hit
+ // branch when a cached view is handed back for a new presentation, so every new presentation -
+ // full-screen register() and embedded getPaywallView/getPaywall alike - gets a clean slate.
+ // Safe because that branch only runs for a genuinely new presentation request - never on
+ // resume-same-instance (that goes through onResume -> onViewCreated) nor during an in-flight
+ // purchase.
+ internal fun resetTransientPresentationState() {
+ if (loadingState is PaywallLoadingState.LoadingPurchase ||
+ loadingState is PaywallLoadingState.ManualLoading
+ ) {
+ controller.updateState(SetLoadingState(PaywallLoadingState.Ready))
+ }
+ resetPresentationPreparations()
+ }
+
internal fun dismiss(
result: PaywallResult,
closeReason: PaywallCloseReason,
@@ -536,6 +556,15 @@ class PaywallView(
}
callback?.let {
+ // A callback-backed (embedded) paywall delegates teardown to its host, which
+ // may legitimately keep the view on screen (e.g. a tab-bar embed). The purchase
+ // spinner is otherwise only reset in the full destroy() teardown, so without
+ // this an embedded paywall that stays visible after a purchase spins forever.
+ if (loadingState is PaywallLoadingState.LoadingPurchase ||
+ loadingState is PaywallLoadingState.ManualLoading
+ ) {
+ controller.updateState(SetLoadingState(PaywallLoadingState.Ready))
+ }
controller.updateState(CallbackInvoked)
it.onFinished(
paywall = this,
diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt
index 53257fa29..30f19d13e 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt
@@ -55,6 +55,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap
class TransactionManager(
@@ -79,6 +80,7 @@ class TransactionManager(
Superwall.instance.entitlements.web
},
private val showRestoreDialogForWeb: suspend () -> Unit,
+ private val notifyBackendOfReceipts: suspend () -> Unit = {},
private val refreshReceipt: () -> Unit,
private val updateState: (cacheKey: String, update: PaywallViewState.Updates) -> Unit,
private val notifyOfTransactionComplete: suspend (paywallCacheKey: String, trialEndDate: Long?, productId: String) -> Unit,
@@ -437,21 +439,14 @@ class TransactionManager(
product: StoreProduct? = null,
purchaseSource: PurchaseSource,
) {
- val purchasingCoordinator = factory.makeTransactionVerifier()
- var transaction: StoreTransaction?
- val restoreType: RestoreType
-
- if (product != null) {
- // Product exists so much have been via a purchase of a specific product.
- transaction =
- purchasingCoordinator.getLatestTransaction(
- factory = factory,
- )
- restoreType = RestoreType.ViaPurchase(transaction)
- } else {
- // Otherwise it was a generic restore.
- restoreType = RestoreType.ViaRestore
- }
+ val restoreType: RestoreType =
+ if (product != null) {
+ // Product exists so much have been via a purchase of a specific product.
+ RestoreType.ViaPurchase(awaitLatestTransaction())
+ } else {
+ // Otherwise it was a generic restore.
+ RestoreType.ViaRestore
+ }
val trackedEvent =
InternalSuperwallEvent.Transaction(
@@ -478,6 +473,8 @@ class TransactionManager(
PaywallResult.Restored(),
)
}
+
+ ioScope.launchWithTracking { notifyBackendOfReceipts() }
}
private fun trackFailure(
@@ -558,6 +555,10 @@ class TransactionManager(
val isObserved =
source is PurchaseSource.ObserverMode
+ // The billing flow retains the last Play result indefinitely; clear it so this
+ // purchase can't be resolved against a stale transaction from an earlier one.
+ storeManager.billing.purchaseResults.value = null
+
when (source) {
is PurchaseSource.Internal -> {
ioScope.launch {
@@ -649,14 +650,12 @@ class TransactionManager(
)
}
- val transactionVerifier = factory.makeTransactionVerifier()
- val transaction =
- transactionVerifier.getLatestTransaction(
- factory = factory,
- )
+ val transaction = awaitLatestTransaction()
storeManager.loadPurchasedProducts(allEntitlementsByProductId())
+ ioScope.launchWithTracking { notifyBackendOfReceipts() }
+
trackTransactionDidSucceed(transaction, product, purchaseSource, didStartFreeTrial)
if (shouldDismiss && factory.makeSuperwallOptions().paywalls.automaticallyDismiss) {
@@ -677,24 +676,20 @@ class TransactionManager(
message = "Transaction Succeeded",
info = mapOf("product_id" to product.fullIdentifier),
)
- val transactionVerifier = factory.makeTransactionVerifier()
val transaction =
if (purchaseSource is PurchaseSource.ExternalPurchase) {
- transactionVerifier.getLatestTransaction(
- factory = factory,
- )
if (purchase != null) {
factory.makeStoreTransaction(purchase)
} else {
- transactionVerifier.getLatestTransaction(
- factory = factory,
- )
+ awaitLatestTransaction()
}
} else {
null
}
storeManager.loadPurchasedProducts(allEntitlementsByProductId())
+ ioScope.launchWithTracking { notifyBackendOfReceipts() }
+
trackTransactionDidSucceed(transaction, product, purchaseSource, didStartFreeTrial)
}
}
@@ -988,6 +983,21 @@ class TransactionManager(
)
}
+ // With an external purchase controller the purchase may not go through Google Play
+ // at all (e.g. Galaxy Store), in which case the billing client never emits a result
+ // and an unbounded wait would hang the purchase flow forever. The transaction only
+ // enriches tracking events, so bound the wait and fall back to null.
+ private suspend fun awaitLatestTransaction(): StoreTransaction? {
+ val transactionVerifier = factory.makeTransactionVerifier()
+ return if (factory.makeHasExternalPurchaseController()) {
+ withTimeoutOrNull(TRANSACTION_LOOKUP_TIMEOUT_MS) {
+ transactionVerifier.getLatestTransaction(factory)
+ }
+ } else {
+ transactionVerifier.getLatestTransaction(factory)
+ }
+ }
+
private suspend fun trackTransactionDidSucceed(
transaction: StoreTransaction?,
product: StoreProduct,
@@ -1092,4 +1102,8 @@ class TransactionManager(
info = info,
error = error,
)
+
+ private companion object {
+ const val TRANSACTION_LOOKUP_TIMEOUT_MS = 5_000L
+ }
}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerTest.kt
index 5e5a7fd73..3e5a4667e 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerTest.kt
@@ -159,6 +159,89 @@ class PaywallManagerTest {
verify { mockView.updateState(any()) }
}
+ @Test
+ fun test_getPaywallView_resetsTransientState_onCacheHitForNewPresentation() =
+ runTest {
+ // A cached view handed back for a new presentation (register() or embedded
+ // getPaywallView/getPaywall) must have its per-presentation transient state cleared
+ // so a stale LoadingPurchase spinner / presentationDidFinishPrepare flag from a
+ // previous non-finishing stop can't dead-button the buy button on re-present.
+ val paywall =
+ mockk {
+ every { identifier } returns "test_paywall"
+ }
+ val request =
+ mockk {
+ every { isDebuggerLaunched } returns false
+ }
+ val mockView =
+ mockk(relaxed = true) {
+ every { loadingState } returns PaywallLoadingState.Unknown
+ }
+ val delegate = mockk()
+
+ coEvery { paywallRequestManager.getPaywall(any(), any()) } returns Either.Success(paywall)
+ every { cache.getPaywallView(any()) } returns mockView
+ every { mockView.callback = any() } just Runs
+ every { mockView.updateState(any()) } just Runs
+ every { mockView.resetTransientPresentationState() } just Runs
+
+ paywallManager.getPaywallView(request, isForPresentation = true, isPreloading = false, delegate)
+
+ verify { mockView.resetTransientPresentationState() }
+ }
+
+ @Test
+ fun test_getPaywallView_doesNotResetTransientState_forPresentationResultChecks() =
+ runTest {
+ // getPresentationResult() (a pure query API) fetches through getPaywallView with
+ // isPreloading = false but isForPresentation = false. A result check must never
+ // reset a live cached view's transient state (e.g. a spinner mid-purchase).
+ val paywall =
+ mockk {
+ every { identifier } returns "test_paywall"
+ }
+ val request =
+ mockk {
+ every { isDebuggerLaunched } returns false
+ }
+ val mockView = mockk(relaxed = true)
+ val delegate = mockk()
+
+ coEvery { paywallRequestManager.getPaywall(any(), any()) } returns Either.Success(paywall)
+ every { cache.getPaywallView(any()) } returns mockView
+ every { mockView.callback = any() } just Runs
+ every { mockView.updateState(any()) } just Runs
+
+ paywallManager.getPaywallView(request, isForPresentation = false, isPreloading = false, delegate)
+
+ verify(exactly = 0) { mockView.resetTransientPresentationState() }
+ }
+
+ @Test
+ fun test_getPaywallView_doesNotResetTransientState_whenPreloading() =
+ runTest {
+ // Preloading must never reset a (possibly live) cached view's transient state.
+ val paywall =
+ mockk {
+ every { identifier } returns "test_paywall"
+ }
+ val request =
+ mockk {
+ every { isDebuggerLaunched } returns false
+ }
+ val mockView = mockk(relaxed = true)
+
+ coEvery { paywallRequestManager.getPaywall(any(), any()) } returns Either.Success(paywall)
+ every { cache.getPaywallView(any()) } returns mockView
+
+ paywallManager.getPaywallView(request, isForPresentation = false, isPreloading = true, null)
+
+ verify(exactly = 0) { mockView.resetTransientPresentationState() }
+ verify(exactly = 0) { mockView.callback = any() }
+ verify(exactly = 0) { mockView.updateState(any()) }
+ }
+
@Test
fun test_getPaywallView_skipsCache_whenDebuggerLaunched() =
runTest {
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 dd4daf0f1..6a95ac06d 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
@@ -928,6 +928,54 @@ class PaywallViewTest {
}
}
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun resetTransientPresentationState_clearsStalePurchaseSpinnerAndPrepareFlag_soRepresentReWires() =
+ runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ Dispatchers.setMain(dispatcher)
+ try {
+ Given("a cached view left with stale transient state after a non-finishing stop mid-purchase") {
+ val view = makePaywallView(cache = null)
+ // A finished presentation whose purchase spinner never got torn down (#431):
+ view.controller.updateState(PaywallViewState.Updates.SetPresentedAndFinished)
+ view.controller.updateState(
+ PaywallViewState.Updates.SetLoadingState(PaywallLoadingState.LoadingPurchase),
+ )
+ assertTrue(
+ "Precondition: stale LoadingPurchase spinner",
+ view.state.loadingState is PaywallLoadingState.LoadingPurchase,
+ )
+ assertTrue(
+ "Precondition: stale presentationDidFinishPrepare",
+ view.state.presentationDidFinishPrepare,
+ )
+
+ When("the cached view is re-presented (PaywallManager resets transient state on cache-hit)") {
+ view.resetTransientPresentationState()
+ advanceUntilIdle()
+
+ Then("the spinner is cleared and onViewCreated() will re-wire instead of early-returning") {
+ assertTrue(
+ "Stale purchase spinner should be reset to Ready",
+ view.state.loadingState is PaywallLoadingState.Ready,
+ )
+ assertFalse(
+ "presentationDidFinishPrepare must be reset so onViewCreated() re-wires",
+ view.state.presentationDidFinishPrepare,
+ )
+ assertTrue(
+ "presentationWillPrepare must be re-armed for the new presentation",
+ view.state.presentationWillPrepare,
+ )
+ }
+ }
+ }
+ } finally {
+ Dispatchers.resetMain()
+ }
+ }
+
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun destroyed_whenFinishingAfterDismiss_emitsTerminalPaywallCloseWithReason_andClearsActiveKey() =
diff --git a/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
index 961570a67..5f57ed892 100644
--- a/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
@@ -11,6 +11,7 @@ import com.superwall.sdk.When
import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent
import com.superwall.sdk.delegate.InternalPurchaseResult
+import com.superwall.sdk.delegate.PurchaseResult
import com.superwall.sdk.delegate.RestorationResult
import com.superwall.sdk.delegate.subscription_controller.PurchaseController
import com.superwall.sdk.misc.ActivityProvider
@@ -39,6 +40,7 @@ import io.mockk.mockkObject
import io.mockk.unmockkObject
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.After
@@ -473,6 +475,58 @@ class TransactionManagerTest {
// endregion
+ // region non-Play purchase controller regression (Galaxy Store)
+
+ @Test
+ fun purchase_internalWithExternalControllerAndNoPlayResult_completesWithoutHanging() =
+ runTest {
+ Given("a paywall purchase resolved by an external purchase controller while Google Play never emits a result") {
+ // Reproduces the Galaxy Store hang: the customer's PurchaseController completes
+ // the purchase outside Google Play, so the SDK's billing client never fires
+ // onPurchasesUpdated and getLatestTransaction() used to suspend forever.
+ val productId = "product1"
+ val rawProduct =
+ mockk(relaxed = true) {
+ every { fullIdentifier } returns productId
+ every { underlyingProductDetails } returns mockProductDetails(productId)
+ every { hasFreeTrial } returns false
+ }
+ val product = mockStoreProduct(productId, rawProduct)
+ every { storeManager.getProductFromCache(productId) } returns product
+ coEvery {
+ storeManager.purchaseController.purchase(any(), any(), any(), any())
+ } returns PurchaseResult.Purchased()
+ every { factory.makeHasExternalPurchaseController() } returns true
+ every { factory.makeTransactionVerifier() } returns
+ mockk {
+ coEvery { getLatestTransaction(any()) } coAnswers { awaitCancellation() }
+ }
+
+ When("the purchase is made from a paywall") {
+ val result =
+ transactionManager.purchase(
+ TransactionManager.PurchaseSource.Internal(
+ productId,
+ mockk(relaxed = true),
+ ),
+ )
+
+ Then("the purchase resolves and the transaction completes without a Play transaction attached") {
+ assertTrue(result is PurchaseResult.Purchased)
+ val completed =
+ trackedEvents
+ .filterIsInstance()
+ .map { it.state }
+ .filterIsInstance()
+ assertTrue(completed.isNotEmpty())
+ assertTrue(completed.all { it.transaction == null })
+ }
+ }
+ }
+ }
+
+ // endregion
+
private fun mockStoreProduct(
productId: String,
rawProduct: RawStoreProduct? = null,
diff --git a/version.env b/version.env
index 3965c508c..e457ae8fd 100644
--- a/version.env
+++ b/version.env
@@ -1 +1 @@
-SUPERWALL_VERSION=2.7.21
+SUPERWALL_VERSION=2.7.22