diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af462de..cac52c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,10 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 2.7.0 -## 2.6.9 +### Enhancements +- Enables paywall post-purchase action execution instead of dismissing ## Deprecations - Deprecated `paywallWebviewLoad_timeout` - this event was causing confusion due to it's naming, leading to it being deprecated diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageParsingTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageParsingTest.kt index e72484fc..60552404 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageParsingTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageParsingTest.kt @@ -339,8 +339,8 @@ class PaywallMessageParsingTest { // region Purchase @Test - fun parse_purchase_returns_Purchase_with_product_info() { - Given("a JSON with purchase event") { + fun parse_purchase_returns_Purchase_with_product_info_and_default_shouldDismiss() { + Given("a JSON with purchase event without should_dismiss") { val json = """ { @@ -360,13 +360,86 @@ class PaywallMessageParsingTest { When("parsed") { val result = parseWrappedPaywallMessages(json) - Then("it returns Purchase with correct product and productId") { + Then("it returns Purchase with correct product, productId, and shouldDismiss defaults to false") { assertTrue(result.isSuccess) val messages = result.getOrThrow().payload.messages assertEquals(1, messages.size) val message = messages[0] as PaywallMessage.Purchase assertEquals("primary", message.product) assertEquals("com.app.subscription.monthly", message.productId) + assertEquals(true, message.shouldDismiss) + } + } + } + } + + @Test + fun parse_purchase_with_should_dismiss_true_returns_Purchase_with_shouldDismiss_true() { + Given("a JSON with purchase event with should_dismiss true") { + val json = + """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "purchase", + "product": "primary", + "product_identifier": "com.app.subscription.monthly", + "should_dismiss": true + } + ] + } + } + """.trimIndent() + + When("parsed") { + val result = parseWrappedPaywallMessages(json) + + Then("it returns Purchase with shouldDismiss true") { + assertTrue(result.isSuccess) + val messages = result.getOrThrow().payload.messages + assertEquals(1, messages.size) + val message = messages[0] as PaywallMessage.Purchase + assertEquals("primary", message.product) + assertEquals("com.app.subscription.monthly", message.productId) + assertEquals(true, message.shouldDismiss) + } + } + } + } + + @Test + fun parse_purchase_with_should_dismiss_false_returns_Purchase_with_shouldDismiss_false() { + Given("a JSON with purchase event with explicit should_dismiss false") { + val json = + """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "purchase", + "product": "secondary", + "product_identifier": "com.app.subscription.yearly", + "should_dismiss": false + } + ] + } + } + """.trimIndent() + + When("parsed") { + val result = parseWrappedPaywallMessages(json) + + Then("it returns Purchase with shouldDismiss false") { + assertTrue(result.isSuccess) + val messages = result.getOrThrow().payload.messages + assertEquals(1, messages.size) + val message = messages[0] as PaywallMessage.Purchase + assertEquals("secondary", message.product) + assertEquals("com.app.subscription.yearly", message.productId) + assertEquals(false, message.shouldDismiss) } } } 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 d8d0c1c1..658cdb20 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 @@ -36,6 +36,7 @@ import com.superwall.sdk.storage.EventsQueue import com.superwall.sdk.storage.Storage import com.superwall.sdk.store.InternalPurchaseController import com.superwall.sdk.store.StoreManager +import com.superwall.sdk.store.abstractions.product.BasePlanType import com.superwall.sdk.store.abstractions.product.OfferType import com.superwall.sdk.store.abstractions.product.RawStoreProduct import com.superwall.sdk.store.abstractions.product.StoreProduct @@ -77,7 +78,7 @@ class TransactionManagerTest { RawStoreProduct( playProduct, "product1", - "basePlan", + BasePlanType.from("basePlan"), OfferType.Auto, ) diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index ff7a45d9..4a576d7a 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -990,6 +990,7 @@ class Superwall( TransactionManager.PurchaseSource.ExternalPurchase( StoreProduct(RawStoreProduct.from(product)), ), + shouldDismiss = true, ) }.toResult() @@ -1012,6 +1013,7 @@ class Superwall( TransactionManager.PurchaseSource.ExternalPurchase( product, ), + shouldDismiss = true, ) }.toResult() @@ -1035,6 +1037,7 @@ class Superwall( TransactionManager.PurchaseSource.ExternalPurchase( it, ), + shouldDismiss = true, ) } ?: throw IllegalArgumentException("Product with id $productId not found") }.toResult() @@ -1281,6 +1284,7 @@ class Superwall( paywallEvent.productId, paywallView.controller.state, ), + shouldDismiss = paywallEvent.shouldDismiss, ) } finally { // Ensure the task is cleared once the purchase is complete or if an error occurs 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 297ec2d7..b6994357 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -546,6 +546,7 @@ class DependencyContainer( return@TransactionManager } + paywallView.webView.messageHandler.handle(PaywallMessage.TransactionComplete(id)) // Schedule fallback notifications from the paywall config in case the paywall // hasn't been updated to send the ScheduleNotification message dynamically. // If the paywall sends a ScheduleNotification message, it will cancel and 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 dc9a8d38..a202c3fb 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 @@ -8,6 +8,7 @@ import com.superwall.sdk.permissions.PermissionType import com.superwall.sdk.storage.core_data.convertFromJsonElement import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray @@ -64,6 +65,7 @@ sealed class PaywallMessage { data class Purchase( val product: String, val productId: String, + val shouldDismiss: Boolean, ) : PaywallMessage() data class Custom( @@ -81,6 +83,10 @@ sealed class PaywallMessage { object TransactionStart : PaywallMessage() + data class TransactionComplete( + val productIdentifier: String, + ) : PaywallMessage() + data class TrialStarted( val trialEndDate: Long?, val productIdentifier: String, @@ -163,6 +169,7 @@ private fun parsePaywallMessage(json: JsonObject): PaywallMessage { PaywallMessage.Purchase( json["product"]!!.jsonPrimitive.content, json["product_identifier"]!!.jsonPrimitive.content, + json["should_dismiss"]?.jsonPrimitive?.booleanOrNull ?: true, ) "custom" -> PaywallMessage.Custom(json["data"]!!.jsonPrimitive.content) 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 a919c1e7..cb737943 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 @@ -162,7 +162,12 @@ class PaywallMessageHandler( is PaywallMessage.OpenDeepLink -> openDeepLink(message.url.toString()) is PaywallMessage.Restore -> restorePurchases() - is PaywallMessage.Purchase -> purchaseProduct(withId = message.productId) + is PaywallMessage.Purchase -> + purchaseProduct( + withId = message.productId, + shouldDismiss = message.shouldDismiss, + ) + is PaywallMessage.PaywallOpen -> { if (messageHandler?.state?.paywall?.paywalljsVersion == null) { queue.offer(message) @@ -203,6 +208,16 @@ class PaywallMessageHandler( setAttributes(message.data) } + is PaywallMessage.TransactionComplete -> { + ioScope.launch { + pass( + SuperwallEvents.TransactionComplete.rawName, + paywall, + mapOf("product_identifier" to message.productIdentifier), + ) + } + } + is PaywallMessage.TrialStarted -> { ioScope.launch { pass( @@ -460,10 +475,13 @@ class PaywallMessageHandler( messageHandler?.eventDidOccur(PaywallWebEvent.InitiateRestore) } - private fun purchaseProduct(withId: String) { + private fun purchaseProduct( + withId: String, + shouldDismiss: Boolean, + ) { detectHiddenPaywallEvent("purchase") hapticFeedback() - messageHandler?.eventDidOccur(PaywallWebEvent.InitiatePurchase(withId)) + messageHandler?.eventDidOccur(PaywallWebEvent.InitiatePurchase(withId, shouldDismiss)) } private fun handleCustomEvent(customEvent: String) { @@ -559,6 +577,7 @@ class PaywallMessageHandler( ), ) } + PermissionStatus.DENIED, PermissionStatus.UNSUPPORTED -> { track( InternalSuperwallEvent.Permission( diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt index 71b0ca9f..ad4dba4b 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt @@ -15,6 +15,7 @@ sealed class PaywallWebEvent { @SerialName("initiate_purchase") data class InitiatePurchase( val productId: String, + val shouldDismiss: Boolean, ) : PaywallWebEvent() object InitiateRestore : PaywallWebEvent() 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 9b776590..3a08474c 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 @@ -197,6 +197,7 @@ class TransactionManager( PurchaseSource.ObserverMode(product), product.hasFreeTrial, purchase, + shouldDismiss = true, // gets ignored later on, ) } } @@ -270,7 +271,10 @@ class TransactionManager( storage.write(PurchasingProductdIds, remainingTransactions.toSet()) } - suspend fun purchase(purchaseSource: PurchaseSource): PurchaseResult { + suspend fun purchase( + purchaseSource: PurchaseSource, + shouldDismiss: Boolean = true, + ): PurchaseResult { val product = when (purchaseSource) { is PurchaseSource.Internal -> @@ -318,7 +322,7 @@ class TransactionManager( when (result) { is PurchaseResult.Purchased -> { - didPurchase(product, purchaseSource, isEligibleForTrial && product.hasFreeTrial) + didPurchase(product, purchaseSource, isEligibleForTrial && product.hasFreeTrial, shouldDismiss = shouldDismiss) } is PurchaseResult.Failed -> { @@ -568,6 +572,7 @@ class TransactionManager( purchaseSource: PurchaseSource, didStartFreeTrial: Boolean, purchase: Purchase? = null, + shouldDismiss: Boolean, ) { when (purchaseSource) { is PurchaseSource.Internal -> { @@ -592,7 +597,7 @@ class TransactionManager( trackTransactionDidSucceed(transaction, product, purchaseSource, didStartFreeTrial) - if (factory.makeSuperwallOptions().paywalls.automaticallyDismiss) { + if (shouldDismiss && factory.makeSuperwallOptions().paywalls.automaticallyDismiss) { dismiss( purchaseSource.paywallInfo.cacheKey, PaywallResult.Purchased(product.fullIdentifier), 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 147396cd..667fb6df 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 @@ -124,7 +124,7 @@ class PaywallMessageHandlerTest { When("purchase message is handled") { val productId = "com.example.product" - harness.handler.handle(PaywallMessage.Purchase("Monthly Plan", productId)) + harness.handler.handle(PaywallMessage.Purchase("Monthly Plan", productId, shouldDismiss = true)) latch.await(500, TimeUnit.MILLISECONDS) Then("InitiatePurchase event is delivered with product id") { diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageHandlerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageHandlerTest.kt index 615ac5a5..bb8db945 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageHandlerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageHandlerTest.kt @@ -235,7 +235,7 @@ class PaywallMessageHandlerTest { handler.handle(PaywallMessage.OpenUrlInBrowser(URI("https://example.com/ext"))) handler.handle(PaywallMessage.OpenDeepLink(URI("myapp://path"))) handler.handle(PaywallMessage.Restore) - handler.handle(PaywallMessage.Purchase(product = "primary", productId = "p1")) + handler.handle(PaywallMessage.Purchase(product = "primary", productId = "p1", shouldDismiss = true)) handler.handle(PaywallMessage.RequestReview(PaywallMessage.RequestReview.Type.INAPP)) handler.handle(PaywallMessage.Close) advanceUntilIdle() @@ -499,6 +499,103 @@ class PaywallMessageHandlerTest { } } + @Test + fun purchase_with_shouldDismiss_true_emits_InitiatePurchase_with_shouldDismiss_true() = + runTest { + Given("a handler and recording events delegate") { + val paywall = Paywall.stub() + val state = PaywallViewState(paywall = paywall, locale = "en-US") + val delegate = + object : FakeDelegate(state) { + val events = mutableListOf() + + override fun eventDidOccur(paywallWebEvent: PaywallWebEvent) { + events.add(paywallWebEvent) + } + } + val handler = createHandler() + handler.messageHandler = delegate + + When("Purchase message with shouldDismiss=true arrives") { + handler.handle(PaywallMessage.Purchase(product = "primary", productId = "product123", shouldDismiss = true)) + + Then("delegate receives InitiatePurchase event with shouldDismiss=true") { + val purchaseEvent = delegate.events.filterIsInstance().firstOrNull() + assertNotNull(purchaseEvent) + assertEquals("product123", purchaseEvent!!.productId) + assertEquals(true, purchaseEvent.shouldDismiss) + } + } + } + } + + @Test + fun purchase_with_shouldDismiss_false_emits_InitiatePurchase_with_shouldDismiss_false() = + runTest { + Given("a handler and recording events delegate") { + val paywall = Paywall.stub() + val state = PaywallViewState(paywall = paywall, locale = "en-US") + val delegate = + object : FakeDelegate(state) { + val events = mutableListOf() + + override fun eventDidOccur(paywallWebEvent: PaywallWebEvent) { + events.add(paywallWebEvent) + } + } + val handler = createHandler() + handler.messageHandler = delegate + + When("Purchase message with shouldDismiss=false arrives") { + handler.handle(PaywallMessage.Purchase(product = "secondary", productId = "product456", shouldDismiss = false)) + + Then("delegate receives InitiatePurchase event with shouldDismiss=false") { + val purchaseEvent = delegate.events.filterIsInstance().firstOrNull() + assertNotNull(purchaseEvent) + assertEquals("product456", purchaseEvent!!.productId) + assertEquals(false, purchaseEvent.shouldDismiss) + } + } + } + } + + @Test + fun transactionComplete_emits_event_to_webview() = + runTest { + Given("a ready handler and delegate") { + val paywall = Paywall.stub() + val state = PaywallViewState(paywall = paywall, locale = "en-US") + val delegate = + object : FakeDelegate(state) { + val evals = mutableListOf() + + override fun evaluate( + code: String, + resultCallback: ((String?) -> Unit)?, + ) { + evals.add(code) + resultCallback?.invoke(null) + } + } + val handler = createHandler() + handler.messageHandler = delegate + + When("OnReady is handled then TransactionComplete arrives") { + handler.handle(PaywallMessage.OnReady("5.0.0")) + advanceUntilIdle() + handler.handle(PaywallMessage.TransactionComplete("com.app.product.monthly")) + advanceUntilIdle() + + Then("webview receives a transaction_complete event JSON with product_identifier") { + val anyTransactionComplete = delegate.evals.any { it.contains("transaction_complete") } + assert(anyTransactionComplete) { "Expected transaction_complete event in webview" } + val anyProductId = delegate.evals.any { it.contains("com.app.product.monthly") } + assert(anyProductId) { "Expected product_identifier in transaction_complete event" } + } + } + } + } + @Test fun parseWrappedPaywallMessages_parses_user_attribute_updated() { Given("a JSON string with user_attribute_updated event") {