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 @@ -branches34.8% \ No newline at end of file +branches35.2% \ 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 @@ -coverage43.9% \ No newline at end of file +coverage44.7% \ 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