diff --git a/.github/workflows/build+test+deploy.yml b/.github/workflows/build+test+deploy.yml index 2512c788..4b8bd1ec 100644 --- a/.github/workflows/build+test+deploy.yml +++ b/.github/workflows/build+test+deploy.yml @@ -91,7 +91,15 @@ jobs: GPG_SIGNING_KEY_PASSPHRASE: ${{ secrets.GPG_SIGNING_KEY_PASSPHRASE }} GPG_SIGNING_KEY_ID: ${{ secrets.GPG_SIGNING_KEY_ID }} run: | - ./gradlew publish \ + ./gradlew :superwall:publish \ + -Paws_access_key_id=$AWS_ACCESS_KEY_ID \ + -Paws_secret_access_key=$AWS_SECRET_ACCESS_KEY \ + -PsonatypeUsername=$SONATYPE_USERNAME \ + -PsonatypePassword=$SONATYPE_PASSWORD \ + -Pgpg_signing_key_passphrase=$GPG_SIGNING_KEY_PASSPHRASE \ + -Pgpg_signing_key_id=$GPG_SIGNING_KEY_ID + + ./gradlew :superwall-compose:publish \ -Paws_access_key_id=$AWS_ACCESS_KEY_ID \ -Paws_secret_access_key=$AWS_SECRET_ACCESS_KEY \ -PsonatypeUsername=$SONATYPE_USERNAME \ @@ -143,7 +151,7 @@ jobs: GPG_SIGNING_KEY_PASSPHRASE: ${{ secrets.GPG_SIGNING_KEY_PASSPHRASE }} GPG_SIGNING_KEY_ID: ${{ secrets.GPG_SIGNING_KEY_ID }} run: | - ./gradlew publish \ + ./gradlew :superwall:publish \ -Paws_access_key_id=$AWS_ACCESS_KEY_ID \ -Paws_secret_access_key=$AWS_SECRET_ACCESS_KEY \ -PsonatypeUsername=$SONATYPE_USERNAME \ diff --git a/CHANGELOG.md b/CHANGELOG.md index d406e1fb..d747376f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 2.0.0-Alpha + +- Removes `PaywallComposable` and Jetpack Compose support from main SDK +- Adds `Superwall-Compose` module for Jetpack Compose support: + - You can find it at `com.superwall.sdk:superwall-compose:2.0.0-alpha` +- Adds consumer proguard rules to enable consumer minification +- Removed methods previously marked as Deprecated + ## 1.5.0 ### Enhancements @@ -14,8 +22,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ### Fixes -- Fixes concurrency issues with subscriptions triggered in Cordova apps - +- Fixes concurrency issues with subscriptions triggered in Cordova apps ## 1.5.0-beta.2 ## Enhancements diff --git a/README.md b/README.md index 51f8f004..4e6f91b9 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ The preferred installation method is with [Gradle](https://superwall.com/docs/in diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d91aa857..5e919a92 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,7 +11,7 @@ android { defaultConfig { applicationId = "com.superwall.superapp" - minSdk = 26 + minSdk = 22 targetSdk = 34 versionCode = 2 versionName = "1.0.0" @@ -80,6 +80,7 @@ dependencies { // Superwall implementation(project(":superwall")) + implementation(project(":superwall-compose")) // Test testImplementation(libs.junit) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro deleted file mode 100644 index 481bb434..00000000 --- a/app/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/superapp/test/FlowScreenshotTestExecutor.kt b/app/src/androidTest/java/com/example/superapp/test/FlowScreenshotTestExecutor.kt index 94b1c8c5..b86f76aa 100644 --- a/app/src/androidTest/java/com/example/superapp/test/FlowScreenshotTestExecutor.kt +++ b/app/src/androidTest/java/com/example/superapp/test/FlowScreenshotTestExecutor.kt @@ -1,6 +1,7 @@ @file:Suppress("ktlint:standard:no-empty-file") import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.FlakyTest import com.dropbox.dropshots.Dropshots import com.dropbox.dropshots.ThresholdValidator import com.example.superapp.utils.CustomComparator @@ -32,7 +33,7 @@ class FlowScreenshotTestExecutor { val mainScope = CoroutineScope(Dispatchers.Main) - /*@Test + @Test @FlakyTest fun test_paywall_reappers_with_video() = with(dropshots) { @@ -44,12 +45,13 @@ class FlowScreenshotTestExecutor { delayFor(500.milliseconds) } step("second_paywall") { + it.waitFor { it is SuperwallEvent.PaywallOpen } awaitUntilWebviewAppears() delayFor(1.seconds) } } } -*/ + @Test fun test_paywall_presents_regardless_of_subscription() = with(dropshots) { @@ -65,7 +67,7 @@ class FlowScreenshotTestExecutor { Superwall.instance.paywallView ?.webView ?.scrollBy(0, 300) ?: kotlin.run { - throw IllegalStateException("No viewcontroller found") + throw IllegalStateException("No view found") } }.await() // We delay a bit to ensure the button is visible @@ -76,7 +78,7 @@ class FlowScreenshotTestExecutor { Superwall.instance.paywallView ?.webView ?.scrollTo(0, 0) ?: kotlin.run { - throw IllegalStateException("No viewcontroller found") + throw IllegalStateException("No view found") } }.await() // We delay a bit to ensure scroll has finished @@ -123,16 +125,21 @@ class FlowScreenshotTestExecutor { } } } +} + + /* + + Commented out due to inability to re-record tests until Firebase Android Studio plugin is fixed @Test fun test_paywall_presents_then_dismisses_without_reappearing() = with(dropshots) { screenshotFlow(UITestHandler.test14Info) { step { - it.waitFor { it is SuperwallEvent.PaywallWebviewLoadComplete } + it.waitFor { it is SuperwallEvent.ShimmerViewComplete } awaitUntilShimmerDisappears() awaitUntilWebviewAppears() - delayFor(100.milliseconds) + delayFor(300.milliseconds) mainScope .async { // We scroll a bit to display the button @@ -146,7 +153,7 @@ class FlowScreenshotTestExecutor { } }.await() // We delay a bit to ensure the button is visible - delayFor(100.milliseconds) + delayFor(300.milliseconds) // We scroll back to the top mainScope .async { @@ -159,10 +166,7 @@ class FlowScreenshotTestExecutor { // We delay a bit to ensure scroll has finished delayFor(500.milliseconds) } - - step { - delayFor(10.seconds) - } } } } + */ diff --git a/app/src/androidTest/java/com/example/superapp/test/PresentationRuleTests.kt b/app/src/androidTest/java/com/example/superapp/test/PresentationRuleTests.kt index ebc3adb9..74286b1b 100644 --- a/app/src/androidTest/java/com/example/superapp/test/PresentationRuleTests.kt +++ b/app/src/androidTest/java/com/example/superapp/test/PresentationRuleTests.kt @@ -73,7 +73,7 @@ class PresentationRuleTests { with(dropshots) { screenshotFlow(UITestHandler.test32Info) { step("") { - it.waitFor { it is SuperwallEvent.SubscriptionStatusDidChange } + it.waitFor { it is SuperwallEvent.EntitlementStatusDidChange } delayFor(1.seconds) } } diff --git a/app/src/androidTest/java/com/example/superapp/test/SimpleScreenshotTestExecutor.kt b/app/src/androidTest/java/com/example/superapp/test/SimpleScreenshotTestExecutor.kt index afa32b74..bdda61fe 100644 --- a/app/src/androidTest/java/com/example/superapp/test/SimpleScreenshotTestExecutor.kt +++ b/app/src/androidTest/java/com/example/superapp/test/SimpleScreenshotTestExecutor.kt @@ -14,7 +14,6 @@ import com.example.superapp.utils.screenshotFlow import com.example.superapp.utils.waitFor import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.superwall.SuperwallEvent -import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.superapp.test.UITestHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -144,19 +143,21 @@ class SimpleScreenshotTestExecutor { it.waitFor { it is SuperwallEvent.PaywallPresentationRequest } } } - Superwall.instance.setSubscriptionStatus(SubscriptionStatus.INACTIVE) } - @Test - fun test_paywall_doesnt_present_without_showing_alert_after_dismiss() = - with(dropshots) { - screenshotFlow(UITestHandler.test26Info) { - step("") { - it.waitFor { it is SuperwallEvent.PaywallPresentationRequest } - awaitUntilDialogAppears() - } - } - } + /* + * Commented out temporarily since Firebase remote lab connection is broken and recording tests is not possible + * @Test + * fun test_paywall_presents_without_showing_alert_after_dismiss() = + * with(dropshots) { + * screenshotFlow(UITestHandler.test26Info) { + * step("") { + * it.waitFor { it is SuperwallEvent.PaywallPresentationRequest } + * awaitUntilDialogAppears() + * } + * } + * } + * */ @Test fun test_paywall_doesnt_present_calls_feature_block() = diff --git a/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt b/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt index 016aa2b2..65b6c672 100644 --- a/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt +++ b/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt @@ -17,7 +17,8 @@ import androidx.test.uiautomator.Until import com.dropbox.dropshots.Dropshots import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.superwall.SuperwallEvent -import com.superwall.sdk.paywall.vc.ShimmerView +import com.superwall.sdk.config.models.ConfigurationStatus +import com.superwall.sdk.paywall.view.ShimmerView import com.superwall.superapp.MainActivity import com.superwall.superapp.test.UITestInfo import kotlinx.coroutines.CoroutineScope @@ -112,6 +113,7 @@ fun Dropshots.screenshotFlow( } runTest(timeout = 5.minutes) { + Superwall.instance.configurationStateListener.first { it is ConfigurationStatus.Configured } try { flow.steps.forEach { if (!testReady.value) { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9151da8c..68dcff70 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,7 +51,7 @@ diff --git a/app/src/main/java/com/superwall/superapp/ComposeActivity.kt b/app/src/main/java/com/superwall/superapp/ComposeActivity.kt index cd22b18c..2185f751 100644 --- a/app/src/main/java/com/superwall/superapp/ComposeActivity.kt +++ b/app/src/main/java/com/superwall/superapp/ComposeActivity.kt @@ -25,11 +25,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import com.superwall.sdk.composable.PaywallComposable +import com.superwall.sdk.compose.PaywallComposable import com.superwall.sdk.paywall.presentation.internal.request.PaywallOverrides import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult -import com.superwall.sdk.paywall.vc.PaywallView -import com.superwall.sdk.paywall.vc.delegate.PaywallViewCallback +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.delegate.PaywallViewCallback import com.superwall.superapp.ui.theme.MyApplicationTheme class ComposeActivity : diff --git a/app/src/main/java/com/superwall/superapp/MainApplication.kt b/app/src/main/java/com/superwall/superapp/MainApplication.kt index f5e431b6..d30e853e 100644 --- a/app/src/main/java/com/superwall/superapp/MainApplication.kt +++ b/app/src/main/java/com/superwall/superapp/MainApplication.kt @@ -13,6 +13,7 @@ import com.superwall.sdk.delegate.SuperwallDelegate import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.paywall.presentation.register +import com.superwall.superapp.purchase.RevenueCatPurchaseController import kotlinx.coroutines.flow.MutableSharedFlow import java.lang.ref.WeakReference @@ -62,7 +63,7 @@ class MainApplication : .build(), ) - configureWithAutomaticInitialization() + configureWithObserverMode() // configureWithRevenueCatInitialization() } @@ -86,6 +87,25 @@ class MainApplication : // Superwall.instance.options.isGameControllerEnabled = true } + fun configureWithObserverMode() { + Superwall.configure( + this@MainApplication, + CONSTANT_API_KEY, + options = + SuperwallOptions().apply { + shouldObservePurchases = true + paywalls = + PaywallOptions().apply { + shouldPreload = false + } + }, + ) + Superwall.instance.delegate = this@MainApplication + + // Make sure we enable the game controller + // Superwall.instance.options.isGameControllerEnabled = true + } + fun configureWithRevenueCatInitialization() { val purchaseController = RevenueCatPurchaseController(this) diff --git a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt index df91cc49..b31053b9 100644 --- a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt +++ b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt @@ -1,4 +1,4 @@ -package com.superwall.superapp +package com.superwall.superapp.purchase import android.app.Activity import android.content.Context @@ -24,8 +24,9 @@ import com.revenuecat.purchases.purchaseWith import com.superwall.sdk.Superwall import com.superwall.sdk.delegate.PurchaseResult import com.superwall.sdk.delegate.RestorationResult -import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.delegate.subscription_controller.PurchaseController +import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.entitlements.EntitlementStatus import kotlinx.coroutines.CompletableDeferred // Extension function to convert callback to suspend function @@ -126,9 +127,16 @@ class RevenueCatPurchaseController( // Refetch the customer info on load Purchases.sharedInstance.getCustomerInfoWith { if (hasAnyActiveEntitlements(it)) { - setSubscriptionStatus(SubscriptionStatus.ACTIVE) + setEntitlementStatus( + EntitlementStatus.Active( + it.entitlements.active + .map { + Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL) + }.toSet(), + ), + ) } else { - setSubscriptionStatus(SubscriptionStatus.INACTIVE) + setEntitlementStatus(EntitlementStatus.Inactive) } } } @@ -138,9 +146,16 @@ class RevenueCatPurchaseController( */ override fun onReceived(customerInfo: CustomerInfo) { if (hasAnyActiveEntitlements(customerInfo)) { - setSubscriptionStatus(SubscriptionStatus.ACTIVE) + setEntitlementStatus( + EntitlementStatus.Active( + customerInfo.entitlements.active + .map { + Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL) + }.toSet(), + ), + ) } else { - setSubscriptionStatus(SubscriptionStatus.INACTIVE) + setEntitlementStatus(EntitlementStatus.Inactive) } } @@ -275,9 +290,9 @@ class RevenueCatPurchaseController( return entitlements.isNotEmpty() } - private fun setSubscriptionStatus(subscriptionStatus: SubscriptionStatus) { + private fun setEntitlementStatus(entitlementStatus: EntitlementStatus) { if (Superwall.initialized) { - Superwall.instance.setSubscriptionStatus(subscriptionStatus) + Superwall.instance.setEntitlementStatus(entitlementStatus) } } } diff --git a/app/src/main/java/com/superwall/superapp/test/PurchaseMockBuilder.kt b/app/src/main/java/com/superwall/superapp/test/PurchaseMockBuilder.kt new file mode 100644 index 00000000..8a6c4a83 --- /dev/null +++ b/app/src/main/java/com/superwall/superapp/test/PurchaseMockBuilder.kt @@ -0,0 +1,105 @@ +package com.superwall.superapp.test + +import com.android.billingclient.api.Purchase +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +class PurchaseMockBuilder { + private val purchaseJson = JSONObject() + + @Throws(JSONException::class) + fun setPurchaseState(state: Int): PurchaseMockBuilder { + purchaseJson.put("purchaseState", if (state == 2) 4 else state) + return this + } + + @Throws(JSONException::class) + fun setPurchaseTime(time: Long): PurchaseMockBuilder { + purchaseJson.put("purchaseTime", time) + return this + } + + @Throws(JSONException::class) + fun setOrderId(orderId: String?): PurchaseMockBuilder { + purchaseJson.put("orderId", orderId) + return this + } + + @Throws(JSONException::class) + fun setProductId(productId: String?): PurchaseMockBuilder { + val productIds = JSONArray() + productIds.put(productId) + purchaseJson.put("productIds", productIds) + // For backward compatibility + purchaseJson.put("productId", productId) + return this + } + + @Throws(JSONException::class) + fun setQuantity(quantity: Int): PurchaseMockBuilder { + purchaseJson.put("quantity", quantity) + return this + } + + @Throws(JSONException::class) + fun setPurchaseToken(token: String?): PurchaseMockBuilder { + purchaseJson.put("token", token) + purchaseJson.put("purchaseToken", token) + return this + } + + @Throws(JSONException::class) + fun setPackageName(packageName: String?): PurchaseMockBuilder { + purchaseJson.put("packageName", packageName) + return this + } + + @Throws(JSONException::class) + fun setDeveloperPayload(payload: String?): PurchaseMockBuilder { + purchaseJson.put("developerPayload", payload) + return this + } + + @Throws(JSONException::class) + fun setAcknowledged(acknowledged: Boolean): PurchaseMockBuilder { + purchaseJson.put("acknowledged", acknowledged) + return this + } + + @Throws(JSONException::class) + fun setAutoRenewing(autoRenewing: Boolean): PurchaseMockBuilder { + purchaseJson.put("autoRenewing", autoRenewing) + return this + } + + @Throws(JSONException::class) + fun setAccountIdentifiers( + obfuscatedAccountId: String?, + obfuscatedProfileId: String?, + ): PurchaseMockBuilder { + purchaseJson.put("obfuscatedAccountId", obfuscatedAccountId) + purchaseJson.put("obfuscatedProfileId", obfuscatedProfileId) + return this + } + + @Throws(JSONException::class) + fun build(): Purchase = Purchase(purchaseJson.toString(), "dummy-signature") + + companion object { + @Throws(JSONException::class) + fun createDefaultPurchase(id: String = "premium_subscription"): Purchase = + PurchaseMockBuilder() + .setPurchaseState(Purchase.PurchaseState.PURCHASED) + .setPurchaseTime(System.currentTimeMillis()) + .setOrderId("GPA.1234-5678-9012-34567") + .setProductId(id) + .setQuantity(1) + .setPurchaseToken("opaque-token-up-to-1950-characters") + .setPackageName("com.example.app") + .setDeveloperPayload("") + .setAcknowledged(true) + .setAutoRenewing(true) + .build() + } +} diff --git a/app/src/main/java/com/superwall/superapp/test/UITestActivity.kt b/app/src/main/java/com/superwall/superapp/test/UITestActivity.kt index 3b723491..fafa3dd7 100644 --- a/app/src/main/java/com/superwall/superapp/test/UITestActivity.kt +++ b/app/src/main/java/com/superwall/superapp/test/UITestActivity.kt @@ -35,6 +35,7 @@ import com.superwall.superapp.test.UITestHandler.tests import com.superwall.superapp.ui.theme.MyApplicationTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.filterNotNull @@ -52,6 +53,7 @@ class UITestInfo( val test: suspend Context.() -> Unit = { val scope = CoroutineScope(Dispatchers.IO) + delay(100) Superwall.instance.delegate = object : SuperwallDelegate { override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) { diff --git a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt index 5dafb43a..80fe7165 100644 --- a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt +++ b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt @@ -7,17 +7,18 @@ import android.util.Log import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.superwall.SuperwallEvent import com.superwall.sdk.analytics.superwall.SuperwallEvent.DeepLink -import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.identity.identify import com.superwall.sdk.identity.setUserAttributes import com.superwall.sdk.misc.AlertControllerFactory +import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.paywall.presentation.PaywallPresentationHandler import com.superwall.sdk.paywall.presentation.dismiss import com.superwall.sdk.paywall.presentation.get_paywall.getPaywall import com.superwall.sdk.paywall.presentation.get_presentation_result.getPresentationResult import com.superwall.sdk.paywall.presentation.register import com.superwall.sdk.paywall.presentation.result.PresentationResult -import com.superwall.sdk.paywall.vc.SuperwallPaywallActivity +import com.superwall.sdk.paywall.view.SuperwallPaywallActivity import com.superwall.sdk.view.fatalAssert import com.superwall.superapp.ComposeActivity import kotlinx.coroutines.CoroutineScope @@ -178,9 +179,9 @@ object UITestHandler { "Sets subs status to active, paywall should present regardless of this," + " then it sets the status back to inactive.", test = { scope, events -> - Superwall.instance.setSubscriptionStatus(SubscriptionStatus.ACTIVE) + Superwall.instance.setEntitlementStatus(EntitlementStatus.Active(setOf(Entitlement("test")))) Superwall.instance.register(event = "present_always") - Superwall.instance.setSubscriptionStatus(SubscriptionStatus.INACTIVE) + Superwall.instance.setEntitlementStatus(EntitlementStatus.Inactive) }, ) var test10Info = @@ -202,20 +203,22 @@ object UITestHandler { "8 seconds and present again without any name. Then it should present again" + " with the name Sawyer.", test = { scope, events -> - + Superwall.instance.setEntitlementStatus(EntitlementStatus.Inactive) Superwall.instance.setUserAttributes(mapOf("first_name" to "Claire")) + Superwall.instance.setEntitlementStatus(EntitlementStatus.Inactive) Superwall.instance.register(event = "present_data") - events.first { it is SuperwallEvent.PaywallWebviewLoadComplete } + events.first { it is SuperwallEvent.ShimmerViewComplete } // Dismiss any view controllers - delay(8.seconds) + delay(4.seconds) - // Dismiss any view controllers + // Dismiss any views Superwall.instance.dismiss() Superwall.instance.setUserAttributes(mapOf("first_name" to null)) + Superwall.instance.setEntitlementStatus(EntitlementStatus.Inactive) Superwall.instance.register(event = "present_data") events.first { it is SuperwallEvent.PaywallOpen } delay(10.seconds) - // Dismiss any view controllers + // Dismiss any views Superwall.instance.dismiss() Superwall.instance.setUserAttributes(mapOf("first_name" to "Sawyer")) Superwall.instance.register(event = "present_data") @@ -245,11 +248,11 @@ object UITestHandler { test = { scope, events -> // Show a paywall Superwall.instance.register(event = "present_always") - events.first { it is SuperwallEvent.PaywallWebviewLoadComplete } + events.first { it is SuperwallEvent.ShimmerViewComplete } delay(8000) - // Dismiss any view controllers + // Dismiss any views Superwall.instance.dismiss() }, ) @@ -271,7 +274,7 @@ object UITestHandler { delay(5000) - // Dismiss any view controllers + // Dismiss any views Superwall.instance.dismiss() Superwall.instance.register(event = "present_always") @@ -280,7 +283,7 @@ object UITestHandler { delay(5000) - // Dismiss any view controllers + // Dismiss any views Superwall.instance.dismiss() var handler = PaywallPresentationHandler() @@ -333,7 +336,7 @@ object UITestHandler { delay(8000) - // Dismiss any view controllers + // Dismiss any views Superwall.instance.dismiss() // Set identity @@ -347,7 +350,7 @@ object UITestHandler { delay(8000) - // Dismiss any view controllers + // Dismiss any views Superwall.instance.dismiss() // Present paywall @@ -369,14 +372,14 @@ object UITestHandler { // Create a mock paywall view val delegate = MockPaywallViewDelegate() - // Get the paywall view controller instance - val viewController = + // Get the paywall view instance + val view = Superwall.instance.getPaywall(event = "present_urls", delegate = delegate) // Present using the convenience `SuperwallPaywallActivity` activity and verify test case. SuperwallPaywallActivity.startWithView( context = this@UITestInfo, - view = viewController.getOrThrow(), + view = view.getOrThrow(), ) }, ) @@ -397,14 +400,14 @@ object UITestHandler { delay(8000) - // Dismiss any view controllers + // Dismiss any views Superwall.instance.dismiss() Superwall.instance.getPresentationResult(event = "present_and_rule_user") delay(8000) - // Dismiss any view controllers + // Dismiss any views Superwall.instance.dismiss() // Show a paywall @@ -412,7 +415,7 @@ object UITestHandler { delay(8000) - // Dismiss any view controllers + // Dismiss any views Superwall.instance.dismiss() // Set identity @@ -472,15 +475,13 @@ object UITestHandler { "4s later.", test = { scope, events -> // Set user as subscribed - Superwall.instance.setSubscriptionStatus(SubscriptionStatus.ACTIVE) - + Superwall.instance.setEntitlementStatus(EntitlementStatus.Active(setOf(Entitlement("pro")))) // Register event - paywall shouldn't appear. Superwall.instance.register(event = "register_nongated_paywall") scope.launch { - - events.first { it is SuperwallEvent.SubscriptionStatusDidChange } + events.first { it is SuperwallEvent.EntitlementStatusDidChange } delay(4000) - Superwall.instance.setSubscriptionStatus(SubscriptionStatus.INACTIVE) + Superwall.instance.setEntitlementStatus(EntitlementStatus.Inactive) } }, ) @@ -490,16 +491,12 @@ object UITestHandler { "Tapping the button shouldn't present a paywall. These register calls don't " + "have a feature gate. Differs from iOS in that there is no purchase taking place.", test = { scope, events -> - var currentSubscriptionStatus = Superwall.instance.subscriptionStatus.value - - Superwall.instance.setSubscriptionStatus(SubscriptionStatus.ACTIVE) - + Superwall.instance.setEntitlementStatus(EntitlementStatus.Active(setOf(Entitlement("pro")))) // Try to present paywall again Superwall.instance.register(event = "register_nongated_paywall") scope.launch { delay(4000) - Superwall.instance.setSubscriptionStatus(currentSubscriptionStatus) } }, ) @@ -509,7 +506,8 @@ object UITestHandler { "Registers an event with a gating handler. The paywall should display, you should " + "NOT see an alert when you close the paywall.", test = { scope, events -> - Superwall.instance.register(event = "register_gated_paywalls") { + Superwall.instance.setEntitlementStatus(EntitlementStatus.Inactive) + Superwall.instance.register(event = "register_gated_paywall") { val alertController = AlertControllerFactory.make( context = this, @@ -527,10 +525,7 @@ object UITestHandler { "Tapping the button shouldn't present the paywall but should launch the " + "feature block - an alert should present.", test = { scope, events -> - var currentSubscriptionStatus = Superwall.instance.subscriptionStatus.value - - Superwall.instance.setSubscriptionStatus(SubscriptionStatus.ACTIVE) - + Superwall.instance.setEntitlementStatus("pro") Superwall.instance.register(event = "register_gated_paywall") { val alertController = AlertControllerFactory.make( @@ -541,8 +536,8 @@ object UITestHandler { ) alertController.show() } - delay(8000) - Superwall.instance.setSubscriptionStatus(currentSubscriptionStatus) + delay(1000) + Superwall.instance.setEntitlementStatus(EntitlementStatus.Inactive) }, ) var test28Info = @@ -550,9 +545,8 @@ object UITestHandler { 28, "Should print out \"Paywall(experiment...)\".", test = { scope, events -> - + Superwall.instance.setEntitlementStatus(EntitlementStatus.Inactive) scope.launch { - val result = Superwall.instance.getPresentationResult("present_data") val resOrNull = result.getOrNull() fatalAssert( @@ -622,15 +616,15 @@ object UITestHandler { "This sets the subscription status active, prints out \"userIsSubscribed\" " + "and then returns subscription status to inactive.", test = { scope, events -> - Superwall.instance.setSubscriptionStatus(SubscriptionStatus.ACTIVE) + Superwall.instance.setEntitlementStatus(EntitlementStatus.Active(setOf(Entitlement("test")))) scope.launch { val result = Superwall.instance.getPresentationResult("present_data") fatalAssert( - result.getOrNull() is PresentationResult.UserIsSubscribed, + result.getOrNull() is PresentationResult.NoRuleMatch, "UserIsSubscribed expected, received $result", ) println("!!! TEST 32 !!! $result") - Superwall.instance.setSubscriptionStatus(SubscriptionStatus.INACTIVE) + Superwall.instance.setEntitlementStatus(EntitlementStatus.Inactive) } }, ) @@ -668,13 +662,13 @@ object UITestHandler { "the result type `purchased` is printed to the console. The paywall should dismiss." + " After doing this, try test 37", test = { scope, events -> - // Create a mock paywall view controller + // Create a mock paywall view val delegate = MockPaywallViewDelegate() delegate.paywallViewFinished { paywallView, paywallResult, shouldDismiss -> println("!!! TEST 35 !!! Result: $paywallResult, shouldDismiss: $shouldDismiss, paywallVc: $paywallView") } - // Get the paywall view controller instance + // Get the paywall view instance val view = Superwall.instance.getPaywall(event = "present_data", delegate = delegate) @@ -688,18 +682,21 @@ object UITestHandler { "Close the paywall and check that after the purchase has finished \" " + "\"the result type \"declined\" is printed to the console. The paywall should close.", test = { scope, events -> - // Create a mock paywall view controller + // Create a mock paywall view val delegate = MockPaywallViewDelegate() delegate.paywallViewFinished { paywallView, paywallResult, shouldDismiss -> println("!!! TEST 36 !!! Result: $paywallResult, shouldDismiss: $shouldDismiss, paywallVc: $paywallView") } - // Get the paywall view controller instance - val viewController = + // Get the paywall view instance + val view = Superwall.instance.getPaywall(event = "present_data", delegate = delegate) // Present using the convenience `SuperwallPaywallActivity` activity and verify test case. - SuperwallPaywallActivity.startWithView(context = this, view = viewController.getOrThrow()) + SuperwallPaywallActivity.startWithView( + context = this, + view = view.getOrThrow(), + ) }, ) var test37Info = @@ -709,18 +706,18 @@ object UITestHandler { "paywall and tap \"restore\". The paywall should dismiss and the the console should" + "print the paywallResult as \"restored\".", test = { scope, events -> - // Create a mock paywall view controller + // Create a mock paywall view val delegate = MockPaywallViewDelegate() delegate.paywallViewFinished { paywallView, paywallResult, shouldDismiss -> println("!!! TEST 37 !!! Result: $paywallResult, shouldDismiss: $shouldDismiss, paywallVc: $paywallView") } - // Get the paywall view controller instance - val viewController = + // Get the paywall view instance + val view = Superwall.instance.getPaywall(event = "restore", delegate = delegate) // Present using the convenience `SuperwallPaywallActivity` activity and verify test case. - SuperwallPaywallActivity.startWithView(context = this, view = viewController.getOrThrow()) + SuperwallPaywallActivity.startWithView(context = this, view = view.getOrThrow()) }, ) @@ -731,11 +728,11 @@ object UITestHandler { subscribed: Boolean, gated: Boolean, ) { - val currentSubscriptionStatus = Superwall.instance.subscriptionStatus.value + val currentSubscriptionStatus = Superwall.instance.entitlements.status.value if (subscribed) { // Set user subscribed - Superwall.instance.setSubscriptionStatus(SubscriptionStatus.ACTIVE) + Superwall.instance.setEntitlementStatus(EntitlementStatus.Active(setOf(Entitlement("test")))) } // Determine gating event @@ -766,7 +763,7 @@ object UITestHandler { if (subscribed) { // Reset status - Superwall.instance.setSubscriptionStatus(currentSubscriptionStatus) + Superwall.instance.setEntitlementStatus(currentSubscriptionStatus) } } @@ -1042,18 +1039,18 @@ object UITestHandler { "Don't have an active subscription, present paywall, tap restore. Check " + "the \"No Subscription Found\" alert pops up.", test = { scope, events -> - // Create a mock paywall view controller + // Create a mock paywall view val delegate = MockPaywallViewDelegate() delegate.paywallViewFinished { paywallView, paywallResult, shouldDismiss -> println("!!! TEST 37 !!! Result: $paywallResult, shouldDismiss: $shouldDismiss, paywallVc: $paywallView") } - // Get the paywall view controller instance - val viewController = + // Get the paywall view instance + val view = Superwall.instance.getPaywall(event = "restore", delegate = delegate) // Present using the convenience `SuperwallPaywallActivity` activity and verify test case. - SuperwallPaywallActivity.startWithView(context = this, view = viewController.getOrThrow()) + SuperwallPaywallActivity.startWithView(context = this, view = view.getOrThrow()) }, ) var test64Info = @@ -1256,7 +1253,7 @@ object UITestHandler { } } - // Create a mock paywall view controller + // Create a mock paywall view val paywallDelegate = MockPaywallViewDelegate() paywallDelegate.paywallViewFinished { paywallView, paywallResult, shouldDismiss -> println("!!! TEST 70 !!! Result: $paywallResult, shouldDismiss: $shouldDismiss, paywallVc: $paywallView") @@ -1267,15 +1264,15 @@ object UITestHandler { } } - // Get the paywall view controller instance - val viewController = + // Get the paywall view instance + val view = Superwall.instance.getPaywall( event = "show_survey_with_other", delegate = paywallDelegate, ) // Present using the convenience `SuperwallPaywallActivity` activity and verify test case. - SuperwallPaywallActivity.startWithView(context = this, view = viewController.getOrThrow()) + SuperwallPaywallActivity.startWithView(context = this, view = view.getOrThrow()) }, ) var test71Info = @@ -1352,7 +1349,7 @@ object UITestHandler { "show. Tap the close button. The paywall will close and the console will print " + "\"!!! TEST 74 !!! SurveyClose\".", test = { scope, events -> - Superwall.instance.setSubscriptionStatus(SubscriptionStatus.INACTIVE) + Superwall.instance.setEntitlementStatus(EntitlementStatus.Inactive) // Create a mock Superwall delegate val delegate = MockSuperwallDelegate() @@ -1532,6 +1529,77 @@ object UITestHandler { }, ) + var testAndroid100Info = + UITestInfo( + 100, + "Entitlements test: Tap launch button. Paywall should display when user has no entitlements.", + testCaseType = TestCaseType.Android, + test = { scope, events -> + Superwall.instance.setEntitlementStatus(EntitlementStatus.Inactive) + Superwall.instance.register(event = "entitlements_test_basic") + }, + ) + + var testAndroid101Info = + UITestInfo( + 101, + "Entitlements test: Tap launch button. Paywall should not display since user has the entitlement `basic`. Dialog should show.", + testCaseType = TestCaseType.Android, + test = { scope, events -> + Superwall.instance.setEntitlementStatus(EntitlementStatus.Active(setOf(Entitlement("basic")))) + Superwall.instance.register(event = "entitlements_test_basic") { + val alertController = + AlertControllerFactory.make( + context = this, + title = "Feature Launched", + message = "The feature block was called", + actionTitle = "Ok", + ) + alertController.show() + } + }, + ) + + var testAndroid102Info = + UITestInfo( + 102, + "Entitlements test: Tap launch button. Paywall should display when user has no `pro` entitlements.", + testCaseType = TestCaseType.Android, + test = { scope, events -> + Superwall.instance.setEntitlementStatus("basic") + Superwall.instance.register(event = "entitlements_test_pro") { + val alertController = + AlertControllerFactory.make( + context = this, + title = "Feature Launched", + message = "The feature block was called", + actionTitle = "Ok", + ) + alertController.show() + } + }, + ) + + var testAndroid103Info = + UITestInfo( + 103, + "Entitlements test: Tap launch button. Paywall should not display when user has `pro` entitlements. Dialog should show.", + testCaseType = TestCaseType.Android, + test = { scope, events -> + Superwall.instance.setEntitlementStatus("pro") + Superwall.instance.register(event = "entitlements_test_pro") { + val alertController = + AlertControllerFactory.make( + context = this, + title = "Feature Launched", + message = "The feature block was called", + actionTitle = "Ok", + ) + alertController.show() + } + }, + ) + val tests = listOf( UITestHandler.test0Info, @@ -1609,5 +1677,9 @@ object UITestHandler { UITestHandler.testAndroid21Info, UITestHandler.testAndroid22Info, UITestHandler.testAndroid23Info, + UITestHandler.testAndroid100Info, + UITestHandler.testAndroid101Info, + UITestHandler.testAndroid102Info, + UITestHandler.testAndroid103Info, ) } diff --git a/app/src/main/java/com/superwall/superapp/test/UITestMocks.kt b/app/src/main/java/com/superwall/superapp/test/UITestMocks.kt index a25356aa..cbd39d1f 100644 --- a/app/src/main/java/com/superwall/superapp/test/UITestMocks.kt +++ b/app/src/main/java/com/superwall/superapp/test/UITestMocks.kt @@ -3,21 +3,12 @@ package com.superwall.superapp.test import com.superwall.sdk.analytics.superwall.SuperwallEventInfo import com.superwall.sdk.delegate.SuperwallDelegate import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult -import com.superwall.sdk.paywall.vc.PaywallView -import com.superwall.sdk.paywall.vc.delegate.PaywallViewCallback - -@Deprecated("Will be removed in the upcoming versions, use MockPaywallViewDelegate instead") -typealias MockPaywallViewControllerDelegate = MockPaywallViewDelegate +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.delegate.PaywallViewCallback class MockPaywallViewDelegate : PaywallViewCallback { private var paywallViewFinished: ((PaywallView, PaywallResult, Boolean) -> Unit)? = null - override fun didFinish( - paywall: PaywallView, - result: PaywallResult, - shouldDismiss: Boolean, - ) = onFinished(paywall, result, shouldDismiss) - override fun onFinished( paywall: PaywallView, result: PaywallResult, @@ -29,11 +20,6 @@ class MockPaywallViewDelegate : PaywallViewCallback { } } - @Deprecated("Will be removed in the upcoming versions, use paywallViewFinished instead") - fun paywallViewControllerDidFinish(handler: (PaywallView, PaywallResult, Boolean) -> Unit) { - paywallViewFinished(handler) - } - fun paywallViewFinished(handler: (PaywallView, PaywallResult, Boolean) -> Unit) { paywallViewFinished = handler } diff --git a/build.gradle.kts b/build.gradle.kts index 6628d878..1bda8ebf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.androidApplication) apply false alias(libs.plugins.kotlinAndroid) apply false alias(libs.plugins.serialization) apply false + alias(libs.plugins.androidLibrary) apply false } true diff --git a/consumer-rules.pro b/consumer-rules.pro new file mode 100644 index 00000000..5b17c7b4 --- /dev/null +++ b/consumer-rules.pro @@ -0,0 +1,64 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keep class com.superwall.** { *; } +-keep class androidx.lifecycle.DefaultLifecycleObserver +-keep class com.google.gson.reflect.TypeToken +-keep class * extends com.google.gson.reflect.TypeToken +-keep public class * implements java.lang.reflect.Type + +# Keep `Companion` object fields of serializable classes. +# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; +} + +# Keep `serializer()` on companion objects (both default and named) of serializable classes. +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep `INSTANCE.serializer()` of serializable objects. +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault + +# Don't print notes about potential mistakes or omissions in the configuration for kotlinx-serialization classes +# See also https://github.com/Kotlin/kotlinx.serialization/issues/1900 +-dontnote kotlinx.serialization.** + +# Serialization core uses `java.lang.ClassValue` for caching inside these specified classes. +# If kotlinx-serialization-cbor is in the classpath, it gets picked as a serialization strategy. +# Don't warn about these two things. +-dontwarn java.lang.ClassValue +-dontwarn org.jetbrains.annotations.ReadOnly diff --git a/example/app/build.gradle.kts b/example/app/build.gradle.kts index 13f7735c..42a3d876 100644 --- a/example/app/build.gradle.kts +++ b/example/app/build.gradle.kts @@ -9,7 +9,7 @@ android { defaultConfig { applicationId = "com.superwall.superapp" - minSdk = 26 + minSdk = 22 targetSdk = 34 versionCode = 1 versionName = "1.0.0" diff --git a/example/app/src/main/AndroidManifest.xml b/example/app/src/main/AndroidManifest.xml index 8f00d143..a5ea957e 100644 --- a/example/app/src/main/AndroidManifest.xml +++ b/example/app/src/main/AndroidManifest.xml @@ -32,7 +32,7 @@ android:label="Home Activity" android:theme="@style/Theme.SuperwallExampleApp" /> diff --git a/example/app/src/main/java/com/superwall/exampleapp/HomeActivity.kt b/example/app/src/main/java/com/superwall/exampleapp/HomeActivity.kt index 864f8b2e..b1b431ef 100644 --- a/example/app/src/main/java/com/superwall/exampleapp/HomeActivity.kt +++ b/example/app/src/main/java/com/superwall/exampleapp/HomeActivity.kt @@ -46,7 +46,7 @@ class HomeActivity : ComponentActivity() { val subscriptionStatus by Superwall.instance.subscriptionStatus.collectAsState() SuperwallExampleAppTheme { HomeScreen( - subscriptionStatus = subscriptionStatus, + entitlementStatus = subscriptionStatus, onLogOutClicked = { finish() }, @@ -58,12 +58,12 @@ class HomeActivity : ComponentActivity() { @Composable fun HomeScreen( - subscriptionStatus: SubscriptionStatus, + entitlementStatus: SubscriptionStatus, onLogOutClicked: () -> Unit, ) { val context = LocalContext.current val subscriptionText = - when (subscriptionStatus) { + when (entitlementStatus) { SubscriptionStatus.UNKNOWN -> "Loading subscription status." SubscriptionStatus.ACTIVE -> "You currently have an active subscription. Therefore, the " + diff --git a/gradle.properties b/gradle.properties index b9720b96..04c1b241 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,4 +21,7 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.nonFinalResIds=false \ No newline at end of file +android.nonFinalResIds=false +android.enableR8.debugMode=true +android.enableR8.fullMode=true +android.debug.obsoleteApi=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 28f1ae45..dd245c86 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,6 @@ billing_version = "6.1.0" browser_version = "1.5.0" gradle_plugin_version = "8.4.1" -javascriptengineVersion = "1.0.0-beta01" jna_version = "5.14.0@aar" kotlinxCoroutinesGuavaVersion = "1.8.1" leakcanaryAndroidVersion = "2.14" @@ -21,7 +20,7 @@ lifecycle_runtime_ktx_version = "2.8.1" junit_version = "4.13.2" kotlinx_coroutines_test_version = "1.8.1" room_runtime_version = "2.6.1" -supercel_version = "0.1.16" +supercel_version = "0.1.17" test_ext_junit_version = "1.2.1" espresso_core_version = "3.6.1" test_runner_version = "1.6.1" @@ -39,8 +38,6 @@ dropshot_version = "0.4.2" # SQL -javascriptengine = { module = "androidx.javascriptengine:javascriptengine", version.ref = "javascriptengineVersion" } -jna = { module = "net.java.dev.jna:jna", version.ref = "jna_version" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroidVersion" } orchestrator = { module = "androidx.test:orchestrator", version.ref = "orchestratorVersion" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room_runtime_version" } @@ -106,3 +103,4 @@ androidApplication = { id = "com.android.application", version.ref = "gradle_plu kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization_version" } dropshot = { id = "com.dropbox.dropshots", version.ref = "dropshot_version" } +androidLibrary = { id = "com.android.library", version.ref = "gradle_plugin_version" } \ No newline at end of file diff --git a/superwall/proguard-rules.pro b/proguard-rules.pro similarity index 100% rename from superwall/proguard-rules.pro rename to proguard-rules.pro diff --git a/settings.gradle b/settings.gradle index 41b07a90..4df8d6cb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,3 +17,4 @@ include ':app' include ':superwall' include 'example:app' project(':example:app').projectDir = new File('example/app') +include ':superwall-compose' diff --git a/superwall-compose/.gitignore b/superwall-compose/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/superwall-compose/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/superwall-compose/build.gradle.kts b/superwall-compose/build.gradle.kts new file mode 100644 index 00000000..c371146b --- /dev/null +++ b/superwall-compose/build.gradle.kts @@ -0,0 +1,169 @@ +import groovy.json.JsonBuilder +import java.io.ByteArrayOutputStream +import java.text.SimpleDateFormat +import java.util.Date + +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) + id("maven-publish") + id("signing") +} + +version = "1.2.4" + +android { + namespace = "com.superwall.sdk.composable" + compileSdk = 34 + + defaultConfig { + minSdk = 22 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("../consumer-rules.pro") + + val gitSha = + project + .exec { + commandLine("git", "rev-parse", "--short", "HEAD") + standardOutput = ByteArrayOutputStream() + }.toString() + .trim() + buildConfigField("String", "GIT_SHA", "\"${gitSha}\"") + + val currentTime = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'").format(Date()) + buildConfigField("String", "BUILD_TIME", "\"${currentTime}\"") + + buildConfigField("String", "SDK_VERSION", "\"${version}\"") + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "../proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + buildConfig = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.0" + } + publishing { + singleVariant("release") { + withSourcesJar() + } + } +} + +publishing { + publications { + register("release") { + groupId = "com.superwall.sdk" + artifactId = "superwall-compose" + version = version + + pom { + name.set("Superwall Compose") + description.set("Remotely configure paywalls without shipping app updates - Jetpack Compose support") + url.set("https://superwall.com") + + licenses { + license { + name.set("MIT License") + url.set("https://github.com/superwall/Superwall-Android?tab=MIT-1-ov-file#") + } + } + developers { + developer { + id.set("ianrumac") + name.set("Ian Rumac") + email.set("ian@superwall.com") + } + } + scm { + connection.set("scm:git:git@github.com:superwall/Superwall-Android.git") + developerConnection.set("scm:git:ssh://github.com:superwall/Superwall-Android.git") + url.set("scm:git:https://github.com/superwall/Superwall-Android.git") + } + } + + afterEvaluate { + from(components["release"]) + } + } + } + + repositories { + mavenLocal() + + // Allow us to publish to S3 if we have the credentials + // but also allow us to publish locally if we don't + val awsAccessKeyId: String? by extra + val awsSecretAccessKey: String? by extra + val sonatypeUsername: String? by extra + val sonatypePassword: String? by extra + if (awsAccessKeyId != null && awsSecretAccessKey != null) { + maven { + url = uri("s3://mvn.superwall.com/release") + credentials(AwsCredentials::class.java) { + accessKey = awsAccessKeyId + secretKey = awsSecretAccessKey + } + } + } + + if (sonatypeUsername != null && sonatypePassword != null) { + maven { + url = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") + credentials(PasswordCredentials::class.java) { + username = sonatypeUsername + password = sonatypePassword + } + } + } + } +} + +signing { + sign(publishing.publications["release"]) +} + +tasks.register("generateBuildInfo") { + doLast { + var buildInfo = mapOf("version" to version) + val jsonOutput = JsonBuilder(buildInfo).toPrettyString() + val outputFile = File("${project.buildDir}/version.json") + outputFile.writeText(jsonOutput) + } +} + +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.core.ktx) + implementation(libs.appcompat) + implementation(libs.material) + + // Compose + implementation(libs.ui) + implementation(libs.ui.graphics) + implementation(libs.ui.tooling.preview) + implementation(libs.material3) + implementation(project(":superwall")) + + testImplementation(libs.junit) + androidTestImplementation(libs.test.ext.junit) + androidTestImplementation(libs.espresso.core) +} diff --git a/superwall-compose/src/androidTest/java/com/superwall/sdk/compose/ExampleInstrumentedTest.kt b/superwall-compose/src/androidTest/java/com/superwall/sdk/compose/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..cb1468b9 --- /dev/null +++ b/superwall-compose/src/androidTest/java/com/superwall/sdk/compose/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.superwall.sdk.compose + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.superwall.superwall.compose.test", appContext.packageName) + } +} diff --git a/superwall-compose/src/main/AndroidManifest.xml b/superwall-compose/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/superwall-compose/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/composable/PaywallComposable.kt b/superwall-compose/src/main/java/com/superwall/sdk/compose/PaywallComposable.kt similarity index 93% rename from superwall/src/main/java/com/superwall/sdk/composable/PaywallComposable.kt rename to superwall-compose/src/main/java/com/superwall/sdk/compose/PaywallComposable.kt index 36333e28..62400e09 100644 --- a/superwall/src/main/java/com/superwall/sdk/composable/PaywallComposable.kt +++ b/superwall-compose/src/main/java/com/superwall/sdk/compose/PaywallComposable.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.composable +package com.superwall.sdk.compose import android.app.Activity import androidx.compose.foundation.layout.Arrangement @@ -19,10 +19,10 @@ import androidx.compose.ui.viewinterop.AndroidView import com.superwall.sdk.Superwall import com.superwall.sdk.paywall.presentation.get_paywall.getPaywall import com.superwall.sdk.paywall.presentation.internal.request.PaywallOverrides -import com.superwall.sdk.paywall.vc.LoadingView -import com.superwall.sdk.paywall.vc.PaywallView -import com.superwall.sdk.paywall.vc.ShimmerView -import com.superwall.sdk.paywall.vc.delegate.PaywallViewCallback +import com.superwall.sdk.paywall.view.LoadingView +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.ShimmerView +import com.superwall.sdk.paywall.view.delegate.PaywallViewCallback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/superwall-compose/src/test/java/com/superwall/sdk/compose/ExampleUnitTest.kt b/superwall-compose/src/test/java/com/superwall/sdk/compose/ExampleUnitTest.kt new file mode 100644 index 00000000..952aa22c --- /dev/null +++ b/superwall-compose/src/test/java/com/superwall/sdk/compose/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.superwall.sdk.compose + +import org.junit.Assert.* +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/superwall/build.gradle.kts b/superwall/build.gradle.kts index c1e1ee32..cc5143fd 100644 --- a/superwall/build.gradle.kts +++ b/superwall/build.gradle.kts @@ -18,19 +18,19 @@ plugins { id("com.android.library") kotlin("android") kotlin("kapt") - alias(libs.plugins.serialization) // Maven publishing + alias(libs.plugins.serialization) id("maven-publish") id("signing") } -version = "1.5.0" +version = "2.0.0-alpha.1" android { compileSdk = 34 namespace = "com.superwall.sdk" defaultConfig { - minSdkVersion(26) + minSdkVersion(22) targetSdkVersion(33) aarMetadata { @@ -40,8 +40,6 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true - consumerProguardFile("proguard-rules.pro") - val gitSha = project .exec { @@ -58,9 +56,10 @@ android { } buildTypes { - getByName("release") { + release { isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + consumerProguardFile("../proguard-rules.pro") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "../proguard-rules.pro") } } @@ -70,14 +69,9 @@ android { } buildFeatures { - compose = true buildConfig = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.0" - } - kotlinOptions { jvmTarget = "1.8" } @@ -188,7 +182,6 @@ dependencies { implementation(libs.room.runtime) implementation(libs.room.ktx) kapt(libs.room.compiler) - implementation(libs.javascriptengine) implementation(libs.kotlinx.coroutines.guava) implementation(libs.threetenbp) @@ -208,13 +201,6 @@ dependencies { // Coroutines implementation(libs.kotlinx.coroutines.core) - // Compose - implementation(platform(libs.compose.bom)) - implementation(libs.ui) - implementation(libs.ui.graphics) - implementation(libs.ui.tooling.preview) - implementation(libs.material3) - // Serialization implementation(libs.kotlinx.serialization.json) diff --git a/superwall/src/androidTest/java/com/superwall/sdk/ObserverModeTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/ObserverModeTest.kt new file mode 100644 index 00000000..d3761d96 --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/ObserverModeTest.kt @@ -0,0 +1,241 @@ +package com.superwall.sdk + +import Given +import Then +import When +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ProductDetails +import com.superwall.sdk.analytics.superwall.SuperwallEvent +import com.superwall.sdk.analytics.superwall.SuperwallEventInfo +import com.superwall.sdk.billing.BillingError +import com.superwall.sdk.config.options.PaywallOptions +import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.delegate.SuperwallDelegate +import com.superwall.sdk.dependencies.DependencyContainer +import com.superwall.sdk.store.PurchasingObserverState +import com.superwall.sdk.store.StoreKitManager +import com.superwall.sdk.store.abstractions.product.RawStoreProduct +import com.superwall.sdk.store.abstractions.product.SubscriptionPeriod +import com.superwall.sdk.store.transactions.TransactionManager +import com.superwall.sdk.utilities.PurchaseMockBuilder +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.spyk +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.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.math.BigDecimal +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class ObserverModeTest { + private lateinit var transactionManager: TransactionManager + private lateinit var storeKitManager: StoreKitManager + private lateinit var dependencyContainer: DependencyContainer + val mockPricingPhases = + mockk { + every { pricingPhaseList } returns + listOf( + mockk { + every { billingPeriod } returns "P1M" + every { priceAmountMicros } returns 999000L + every { priceCurrencyCode } returns "USD" + every { billingCycleCount } returns 1 + every { this@mockk.formattedPrice } returns "$9.99" + every { this@mockk.recurrenceMode } returns 0 + }, + ) + } + val mockSubscriptionOfferDetails = + mockk { + every { basePlanId } returns "test_base_plan" + every { offerId } returns "test_offer" + every { pricingPhases } returns mockPricingPhases + every { this@mockk.offerToken } returns "test_offer_token" + every { this@mockk.offerTags } returns listOf("test_offer_tag") + } + + val mockProductDetails = + mockk { + every { productId } returns "test_product" + every { productType } returns "subs" + every { subscriptionOfferDetails } returns listOf(mockSubscriptionOfferDetails) + every { this@mockk.oneTimePurchaseOfferDetails } returns null + every { this@mockk.name } returns "Test Product" + every { description } returns "Test Product Description" + } + val mockProduct: RawStoreProduct = + spyk(RawStoreProduct.from(mockProductDetails)) { + every { underlyingProductDetails } returns mockProductDetails + every { fullIdentifier } returns "test_product:test_base_plan:test_offer" + every { productIdentifier } returns "test_product" + every { hasFreeTrial } returns false + every { subscriptionPeriod } returns + SubscriptionPeriod( + 1, + SubscriptionPeriod.Unit.month, + ) + every { localizedSubscriptionPeriod } returns "1 month" + every { price } returns BigDecimal.valueOf(9.99) + every { localizedPrice } returns "$9.99" + every { period } returns "P1M" + } + + private lateinit var mockDelegate: MockDelegate + + val CONSTANT_API_KEY = "pk_0ff90006c5c2078e1ce832bd2343ba2f806ca510a0a1696a" + var configured = false + + @Before + fun setup() { + mockkObject(RawStoreProduct.Companion) + every { RawStoreProduct.from(any()) } returns mockProduct + if (!configured) { + Superwall.configure( + InstrumentationRegistry.getInstrumentation().context.applicationContext, + CONSTANT_API_KEY, + options = + SuperwallOptions().apply { + shouldObservePurchases = true + paywalls = + PaywallOptions().apply { + shouldPreload = false + } + }, + completion = { + configured = true + }, + ) + } + dependencyContainer = Superwall.instance.dependencyContainer + transactionManager = dependencyContainer.transactionManager + storeKitManager = dependencyContainer.storeKitManager + } + + @After + fun tearDown() { + Superwall.initialized = false + } + + @Test + fun test_observe_purchase_will_begin_with_controller() = + runTest { + setup() + Given("a configured Superwall instance with purchase observation enabled") { + mockDelegate = MockDelegate(this@runTest) + Superwall.instance.delegate = mockDelegate + + When("observing purchase will begin") { + Superwall.instance.observe( + PurchasingObserverState.PurchaseWillBegin(mockProductDetails), + ) + + Then("it should delegate to transaction manager and emit transaction start event") { + val event = + mockDelegate.events.first { + it is SuperwallEvent.TransactionStart + } + } + } + } + } + + @Test + fun test_observe_purchase_complete_with_controller() = + runTest { + setup() + Given("a configured Superwall instance and completed purchase") { + mockDelegate = MockDelegate(this@runTest) + Superwall.instance.delegate = mockDelegate + + When("observing purchase completion") { + Superwall.instance.observe( + PurchasingObserverState.PurchaseWillBegin(mockProductDetails), + ) + delayFor(1.seconds) + Superwall.instance.observe( + PurchasingObserverState.PurchaseResult( + result = + BillingResult + .newBuilder() + .setResponseCode(BillingClient.BillingResponseCode.OK) + .build(), + purchases = + listOf( + PurchaseMockBuilder.createDefaultPurchase( + "test_product:test_base_plan:test_offer", + ), + ), + ), + ) + + Then("it should handle successful purchase and emit transaction complete event") { + mockDelegate.events + .onEach { + Log.e("test", "event is $it") + }.first { + it is SuperwallEvent.TransactionComplete + } + } + } + } + } + + @Test + fun test_observe_purchase_failed_with_controller() = + runTest { + setup() + Given("a configured Superwall instance and failed purchase") { + mockDelegate = MockDelegate(this@runTest) + Superwall.instance.delegate = mockDelegate + + val error = BillingError.BillingNotAvailable("Test error") + + When("observing purchase failure") { + Superwall.instance.observe( + PurchasingObserverState.PurchaseError( + error = error, + product = mockProductDetails, + ), + ) + + Then("it should handle failure and emit transaction fail event") { + mockDelegate.events.first { + it is SuperwallEvent.TransactionFail + } + } + } + } + } +} + +class MockDelegate( + val scope: CoroutineScope, +) : SuperwallDelegate { + val events = MutableSharedFlow(extraBufferCapacity = 20) + + override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) { + Log.e("test", "handle event is ${eventInfo.event}") + scope.launch { + events.emit(eventInfo.event) + } + } +} + +suspend fun CoroutineScope.delayFor(duration: Duration) = + async(Dispatchers.IO) { + delay(duration) + }.await() diff --git a/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt index a72d8589..c6fd6df3 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.analytics.internal +import android.app.Application import androidx.test.platform.app.InstrumentationRegistry import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent @@ -33,7 +34,7 @@ class TrackingLogicTest { fun should_clean_up_attributes() = runTest { val ctx = InstrumentationRegistry.getInstrumentation().context - Superwall.configure(ctx, "pk_test_1234", null, null, null, null) + Superwall.configure(ctx.applicationContext as Application, "pk_test_1234", null, null, null, null) val deviceHelper = spyk( DeviceHelper( diff --git a/superwall/src/androidTest/java/com/superwall/sdk/billing/observer/SuperwallBillingFlowParamsTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/billing/observer/SuperwallBillingFlowParamsTest.kt new file mode 100644 index 00000000..736c8d3a --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/billing/observer/SuperwallBillingFlowParamsTest.kt @@ -0,0 +1,79 @@ +package com.superwall.sdk.billing.observer + +import com.android.billingclient.api.ProductDetails +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class SuperwallBillingFlowParamsTest { + @Test + fun test_builder_sets_all_parameters_correctly() { + // Mock ProductDetails + val mockProductDetails = mockk() + every { mockProductDetails.oneTimePurchaseOfferDetails } returns + mockk { + every { zza() } returns "test_offer_token" + } + every { mockProductDetails.productType } returns "subs" + every { mockProductDetails.zza() } returns "test_product_id" + + // Create ProductDetailsParams + val productDetailsParams = + SuperwallBillingFlowParams.ProductDetailsParams + .newBuilder() + .setOfferToken("test_offer_token") + .setProductDetails(mockProductDetails) + .build() + + // Create SubscriptionUpdateParams + val subscriptionUpdateParams = + SuperwallBillingFlowParams.SubscriptionUpdateParams + .newBuilder() + .setSubscriptionReplacementMode(SuperwallBillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION) + .setOriginalExternalTransactionId("external_transaction_id") + .build() + + // Build main params + val params = + SuperwallBillingFlowParams + .newBuilder() + .setIsOfferPersonalized(true) + .setObfuscatedAccountId("test_account_id") + .setObfuscatedProfileId("test_profile_id") + .setProductDetailsParamsList(listOf(productDetailsParams)) + .setSubscriptionUpdateParams(subscriptionUpdateParams) + .build() + + // Verify the built object + assertNotNull(params) + assertNotNull(params.toOriginal()) + assertEquals(1, params.productDetailsParams.size) + + // Verify ProductDetails + val storedProductDetails = params.productDetailsParams[0].details + assertEquals(mockProductDetails, storedProductDetails) + } + + @Test(expected = IllegalArgumentException::class) + fun test_product_details_params_builder_throws_when_product_details_is_missing() { + SuperwallBillingFlowParams.ProductDetailsParams + .newBuilder() + .setOfferToken("test_offer_token") + .build() + } + + @Test + fun test_subscription_update_params_replacement_modes() { + val params = + SuperwallBillingFlowParams.SubscriptionUpdateParams + .newBuilder() + .setSubscriptionReplacementMode(SuperwallBillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION) + .setOldPurchaseToken("test_token") + .build() + + assertNotNull(params) + assertNotNull(params.toOriginal()) + } +} diff --git a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt index 7bb77862..845e23a5 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt @@ -19,6 +19,7 @@ import com.superwall.sdk.models.assignment.Assignment import com.superwall.sdk.models.assignment.ConfirmableAssignment import com.superwall.sdk.models.config.Config import com.superwall.sdk.models.config.RawFeatureFlag +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.models.geo.GeoInfo import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.Trigger @@ -35,7 +36,10 @@ import com.superwall.sdk.storage.LatestGeoInfo import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.StorageMock -import com.superwall.sdk.store.StoreKitManager +import com.superwall.sdk.storage.StoredEntitlementStatus +import com.superwall.sdk.storage.StoredEntitlementsByProductId +import com.superwall.sdk.store.Entitlements +import com.superwall.sdk.store.StoreManager import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify @@ -65,7 +69,7 @@ class ConfigManagerUnderTest( private val storage: Storage, private val network: SuperwallAPI, private val paywallManager: PaywallManager, - private val storeKitManager: StoreKitManager, + private val storeManager: StoreManager, private val factory: Factory, private val deviceHelper: DeviceHelper, private val assignments: Assignments, @@ -76,7 +80,7 @@ class ConfigManagerUnderTest( storage = storage, network = network, paywallManager = paywallManager, - storeKitManager = storeKitManager, + storeManager = storeManager, factory = factory, deviceHelper = deviceHelper, options = SuperwallOptions(), @@ -84,6 +88,13 @@ class ConfigManagerUnderTest( paywallPreload = paywallPreload, ioScope = IOScope(ioScope.coroutineContext), track = {}, + entitlements = + Entitlements( + mockk(relaxUnitFun = true) { + every { read(StoredEntitlementStatus) } returns EntitlementStatus.Unknown + every { read(StoredEntitlementsByProductId) } returns emptyMap() + }, + ), ) { suspend fun setConfig(config: Config) { configState.emit(ConfigState.Retrieved(config)) @@ -134,7 +145,7 @@ class ConfigManagerTests { storage = storage, network = network, paywallManager = dependencyContainer.paywallManager, - storeKitManager = dependencyContainer.storeKitManager, + storeManager = dependencyContainer.storeManager, factory = dependencyContainer, deviceHelper = mockDeviceHelper, assignments = assignments, @@ -179,7 +190,7 @@ class ConfigManagerTests { storage = storage, network = network, paywallManager = dependencyContainer.paywallManager, - storeKitManager = dependencyContainer.storeKitManager, + storeManager = dependencyContainer.storeManager, factory = dependencyContainer, deviceHelper = mockDeviceHelper, assignments = assignments, @@ -223,7 +234,7 @@ class ConfigManagerTests { storage = storage, network = network, paywallManager = dependencyContainer.paywallManager, - storeKitManager = dependencyContainer.storeKitManager, + storeManager = dependencyContainer.storeManager, factory = dependencyContainer, deviceHelper = mockDeviceHelper, assignments = assignments, @@ -269,7 +280,7 @@ class ConfigManagerTests { storage = storage, network = network, paywallManager = dependencyContainer.paywallManager, - storeKitManager = dependencyContainer.storeKitManager, + storeManager = dependencyContainer.storeManager, factory = dependencyContainer, deviceHelper = mockDeviceHelper, assignments = assignmentStore, @@ -339,7 +350,7 @@ class ConfigManagerTests { Config.stub().copy( rawFeatureFlags = listOf( - RawFeatureFlag("enable_config_refresh", true), + RawFeatureFlag("enable_config_refresh_v2", true), ), ) @@ -364,7 +375,7 @@ class ConfigManagerTests { storage, mockNetwork, mockPaywallManager, - dependencyContainer.storeKitManager, + dependencyContainer.storeManager, mockContainer, mockDeviceHelper, assignments = assignments, @@ -412,7 +423,7 @@ class ConfigManagerTests { Config.stub().copy( rawFeatureFlags = listOf( - RawFeatureFlag("enable_config_refresh", true), + RawFeatureFlag("enable_config_refresh_v2", true), ), ) @@ -437,7 +448,7 @@ class ConfigManagerTests { storage, mockNetwork, mockPaywallManager, - dependencyContainer.storeKitManager, + dependencyContainer.storeManager, mockContainer, mockDeviceHelper, assignments = assignments, @@ -475,7 +486,7 @@ class ConfigManagerTests { every { resetCache() } just Runs } private val storeKit = - mockk { + mockk { coEvery { products(any()) } returns emptySet() } private val preload = @@ -499,7 +510,7 @@ class ConfigManagerTests { val cachedConfig = Config.stub().copy( buildId = "cached", - rawFeatureFlags = listOf(RawFeatureFlag("enable_config_refresh", true)), + rawFeatureFlags = listOf(RawFeatureFlag("enable_config_refresh_v2", true)), ) val newConfig = Config.stub().copy(buildId = "not") @@ -539,7 +550,7 @@ class ConfigManagerTests { storage = storage, network = mockNetwork, paywallManager = mockContainer.paywallManager, - storeKitManager = mockContainer.storeKitManager, + storeManager = mockContainer.storeManager, factory = mockContainer, deviceHelper = mockDeviceHelper, assignments = assignmentStore, @@ -595,7 +606,7 @@ class ConfigManagerTests { storage = storage, network = mockNetwork, paywallManager = manager, - storeKitManager = storeKit, + storeManager = storeKit, factory = dependencyContainer, deviceHelper = mockDeviceHelper, assignments = assignmentStore, @@ -627,7 +638,7 @@ class ConfigManagerTests { buildId = "cached", rawFeatureFlags = listOf( - RawFeatureFlag("enable_config_refresh", true), + RawFeatureFlag("enable_config_refresh_v2", true), ), ) @@ -654,7 +665,7 @@ class ConfigManagerTests { storage = storage, network = mockNetwork, paywallManager = manager, - storeKitManager = storeKit, + storeManager = storeKit, factory = dependencyContainer, deviceHelper = mockDeviceHelper, assignments = assignmentStore, @@ -729,7 +740,7 @@ class ConfigManagerTests { storage = storage, network = mockNetwork, paywallManager = manager, - storeKitManager = storeKit, + storeManager = storeKit, factory = dependencyContainer, deviceHelper = mockDeviceHelper, assignments = assignmentStore, @@ -754,7 +765,7 @@ class ConfigManagerTests { val cachedConfig = Config.stub().copy( buildId = "cached", - rawFeatureFlags = listOf(RawFeatureFlag("enable_config_refresh", true)), + rawFeatureFlags = listOf(RawFeatureFlag("enable_config_refresh_v2", true)), ) val newConfig = Config.stub().copy(buildId = "not") val cachedGeo = GeoInfo.stub().copy(country = "cachedCountry") @@ -795,7 +806,7 @@ class ConfigManagerTests { storage = storage, network = mockNetwork, paywallManager = mockContainer.paywallManager, - storeKitManager = mockContainer.storeKitManager, + storeManager = mockContainer.storeManager, factory = mockContainer, deviceHelper = mockDeviceHelper, assignments = assignmentStore, diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorParamsInstrumentedTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorParamsInstrumentedTest.kt deleted file mode 100644 index 170513cd..00000000 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorParamsInstrumentedTest.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator - -import org.json.JSONObject -import org.junit.Test -import java.util.* - -class CombinedExpressionEvaluatorParamsTest { - @Test - fun expression_evaluator_params_test() { - val expected = """ - { - "expression": "user.id == '123'", - "values": { - "user": { - "id": "123", - "email": "test@gmail.com" - }, - "device": {}, - "params": { - "id": "567" - } - } - } - """ - - val jsonValues = JSONObject() - jsonValues.put("user", JSONObject(mapOf("id" to "123", "email" to "test@gmail.com"))) - jsonValues.put("device", JSONObject(emptyMap())) - jsonValues.put("params", JSONObject(mapOf("id" to "567"))) - - val liquidExpressionParams = - LiquidExpressionEvaluatorParams( - expression = "user.id == '123'", - values = jsonValues, - ) - - val jsonString = liquidExpressionParams.toJson() - println("!! jsonString: $jsonString") - - // Parse jsonString into a JSONObject - val parsedJson = JSONObject(jsonString) - - // Test top-level properties - assert(parsedJson.getString("expression") == "user.id == '123'") - - // Test nested properties - val values = parsedJson.getJSONObject("values") - - val user = values.getJSONObject("user") - assert(user.getString("id") == "123") - assert(user.getString("email") == "test@gmail.com") - - val device = values.getJSONObject("device") - assert(device.names() == null) // Check that device is empty - - val params = values.getJSONObject("params") - assert(params.getString("id") == "567") - - val base64String = liquidExpressionParams.toBase64Input() - // Try to base64 decode the string - val decodedString = Base64.getDecoder().decode(base64String) - // Parse the json - val parsedJson2 = JSONObject(String(decodedString, Charsets.UTF_8)) - - // Test top-level properties - assert(parsedJson2.getString("expression") == "user.id == '123'") - - // Test nested properties - val values2 = parsedJson2.getJSONObject("values") - - val user2 = values2.getJSONObject("user") - assert(user2.getString("id") == "123") - assert(user2.getString("email") == "test@gmail.com") - - val device2 = values2.getJSONObject("device") - assert(device2.names() == null) // Check that device2 is empty - - val params2 = values2.getJSONObject("params") - assert(params2.getString("id") == "567") - } - - @Test - fun javascript_expression_evaluator_params_test() { - val expected = """ - { - "expressionJS": "user.id == '123'", - "values": { - "user": { - "id": "123", - "email": "test@gmail.com" - }, - "device": {}, - "params": { - "id": "567" - } - } - } - """ - - val jsonValues = JSONObject() - jsonValues.put("user", JSONObject(mapOf("id" to "123", "email" to "test@gmail.com"))) - jsonValues.put("device", JSONObject(emptyMap())) - jsonValues.put("params", JSONObject(mapOf("id" to "567"))) - - val jsExpressionParams = - JavascriptExpressionEvaluatorParams( - expressionJs = "user.id == '123'", - values = jsonValues, - ) - - val jsonString = jsExpressionParams.toJson() - - // Parse jsonString into a JSONObject - val parsedJson = JSONObject(jsonString) - - // Test top-level properties - assert(parsedJson.getString("expressionJS") == "user.id == '123'") - - // Test nested properties - val values = parsedJson.getJSONObject("values") - - val user = values.getJSONObject("user") - assert(user.getString("id") == "123") - assert(user.getString("email") == "test@gmail.com") - - val device = values.getJSONObject("device") - assert(device.names() == null) // Check that device is empty - - val params = values.getJSONObject("params") - assert(params.getString("id") == "567") - - val base64String = jsExpressionParams.toBase64Input() - // Try to base64 decode the string - val decodedByteArray = Base64.getDecoder().decode(base64String) - val decodedString = String(decodedByteArray, Charsets.UTF_8) - // Parse the json - val parsedJson2 = JSONObject(decodedString) - - // Test top-level properties - assert(parsedJson2.getString("expressionJS") == "user.id == '123'") - - // Test nested properties - val values2 = parsedJson2.getJSONObject("values") - - val user2 = values2.getJSONObject("user") - assert(user2.getString("id") == "123") - assert(user2.getString("email") == "test@gmail.com") - - val device2 = values2.getJSONObject("device") - assert(device2.names() == null) // Check that device2 is empty - - val params2 = values2.getJSONObject("params") - assert(params2.getString("id") == "567") - } -} diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/JavascriptCombinedExpressionEvaluatorInstrumentedTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/JavascriptCombinedExpressionEvaluatorInstrumentedTest.kt deleted file mode 100644 index d71009b2..00000000 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/JavascriptCombinedExpressionEvaluatorInstrumentedTest.kt +++ /dev/null @@ -1,417 +0,0 @@ -package com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator - -import androidx.javascriptengine.JavaScriptSandbox -import androidx.test.platform.app.InstrumentationRegistry -import com.superwall.sdk.dependencies.RuleAttributesFactory -import com.superwall.sdk.misc.IOScope -import com.superwall.sdk.models.config.ComputedPropertyRequest -import com.superwall.sdk.models.events.EventData -import com.superwall.sdk.models.triggers.Experiment -import com.superwall.sdk.models.triggers.MatchedItem -import com.superwall.sdk.models.triggers.TriggerPreloadBehavior -import com.superwall.sdk.models.triggers.TriggerRule -import com.superwall.sdk.models.triggers.TriggerRuleOutcome -import com.superwall.sdk.models.triggers.UnmatchedRule -import com.superwall.sdk.models.triggers.VariantOption -import com.superwall.sdk.paywall.presentation.rule_logic.cel.SuperscriptEvaluator -import com.superwall.sdk.paywall.presentation.rule_logic.javascript.SandboxJavascriptEvaluator -import com.superwall.sdk.storage.LocalStorage -import com.superwall.sdk.storage.StorageMock -import com.superwall.sdk.storage.core_data.CoreDataManager -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.async -import kotlinx.coroutines.guava.await -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.ClassDiscriminatorMode -import kotlinx.serialization.json.Json -import org.junit.After -import org.junit.Before -import org.junit.Test -import java.util.Date - -class RuleAttributeFactoryBuilder : RuleAttributesFactory { - override suspend fun makeRuleAttributes( - event: EventData?, - computedPropertyRequests: List, - ): Map = - mapOf( - "user" to - mapOf( - "id" to "123", - "email" to "test@gmail.com", - ), - ) -} - -class JavascriptCombinedExpressionEvaluatorInstrumentedTest { - var sandbox: JavaScriptSandbox? = null - - @Before - fun setup() = - runBlocking { - sandbox = JavaScriptSandbox.createConnectedInstanceAsync(InstrumentationRegistry.getInstrumentation().targetContext).await() - } - - @After - fun tearDown() = - runBlocking { - sandbox?.killImmediatelyOnThread() - sandbox?.close() - sandbox = null - } - - private fun CoroutineScope.evaluatorFor(storage: LocalStorage) = - SandboxJavascriptEvaluator( - sandbox ?: error("Sandbox not initialized"), - storage = storage.coreDataManager, - ioScope = this, - ) - - private fun CoroutineScope.superscriptEval( - storage: CoreDataManager, - factoryBuilder: RuleAttributeFactoryBuilder, - ) = SuperscriptEvaluator( - storage = storage, - json = - Json { - classDiscriminatorMode = ClassDiscriminatorMode.ALL_JSON_OBJECTS - classDiscriminator = "type" - }, - factory = factoryBuilder, - ioScope = IOScope(this.coroutineContext), - ) - - @Test - fun test_happy_path_evaluator() = - runTest { - // get context - val context = InstrumentationRegistry.getInstrumentation().targetContext - val ruleAttributes = RuleAttributeFactoryBuilder() - val storage = StorageMock(context = context, coroutineScope = this@runTest) - - val expressionEvaluator = - CombinedExpressionEvaluator( - evaluator = - evaluatorFor( - storage = storage, - ), - storage = storage, - factory = ruleAttributes, - track = { - assert(it.jsExpressionResult == it.celExpressionResult) - }, - superscriptEvaluator = - superscriptEval( - storage.coreDataManager, - ruleAttributes, - ), - ) - - val rule = - TriggerRule( - experimentId = "1", - experimentGroupId = "2", - variants = - listOf( - VariantOption( - type = Experiment.Variant.VariantType.HOLDOUT, - id = "3", - percentage = 20, - paywallId = null, - ), - ), - expression = "user.id == '123'", - expressionJs = null, - preload = - TriggerRule.TriggerPreload( - behavior = TriggerPreloadBehavior.ALWAYS, - requiresReEvaluation = false, - ), - ) - - val result = - expressionEvaluator.evaluateExpression( - rule = rule, - eventData = - EventData( - name = "test", - parameters = mapOf("id" to "123"), - createdAt = Date(), - ), - ) - assertEquals(TriggerRuleOutcome.match(rule = rule), result) - } - - @Test - fun test_expression_evaluator_expression_js() = - runTest { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val ruleAttributes = RuleAttributeFactoryBuilder() - val storage = StorageMock(context = context, coroutineScope = this@runTest) - - val expressionEvaluator = - CombinedExpressionEvaluator( - evaluator = - evaluatorFor( - storage = storage, - ), - storage = storage, - factory = ruleAttributes, - track = { - // This is a JS only function - }, - superscriptEvaluator = - superscriptEval( - storage.coreDataManager, - ruleAttributes, - ), - ) - - val trueRule = - TriggerRule( - experimentId = "1", - experimentGroupId = "2", - variants = - listOf( - VariantOption( - type = Experiment.Variant.VariantType.HOLDOUT, - id = "3", - percentage = 20, - paywallId = null, - ), - ), - expression = null, - expressionJs = "function superwallEvaluator(){ return true }; superwallEvaluator", - preload = - TriggerRule.TriggerPreload( - behavior = TriggerPreloadBehavior.ALWAYS, - requiresReEvaluation = false, - ), - ) - - val falseRule = - TriggerRule( - experimentId = "1", - experimentGroupId = "2", - variants = - listOf( - VariantOption( - type = Experiment.Variant.VariantType.HOLDOUT, - id = "3", - percentage = 20, - paywallId = null, - ), - ), - expression = null, - expressionJs = "function superwallEvaluator(){ return false }; superwallEvaluator", - preload = - TriggerRule.TriggerPreload( - behavior = TriggerPreloadBehavior.ALWAYS, - requiresReEvaluation = false, - ), - ) - - var trueResult = - expressionEvaluator.evaluateExpression( - rule = trueRule, - eventData = - EventData( - name = "test", - parameters = mapOf("id" to "123"), - createdAt = Date(), - ), - ) - assert(trueResult == TriggerRuleOutcome.match(trueRule)) - - var falseResult = - expressionEvaluator.evaluateExpression( - rule = falseRule, - eventData = - EventData( - name = "test", - parameters = mapOf("id" to "123"), - createdAt = Date(), - ), - ) - - assert( - falseResult == - TriggerRuleOutcome.noMatch( - source = UnmatchedRule.Source.EXPRESSION, - experimentId = "1", - ), - ) - } - - @Test - fun multi_threaded() = - runTest { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val ruleAttributes = RuleAttributeFactoryBuilder() - val storage = StorageMock(context = context, coroutineScope = this@runTest) - - val expressionEvaluator: CombinedExpressionEvaluator = - CombinedExpressionEvaluator( - evaluator = - evaluatorFor( - storage = storage, - ), - storage = storage, - factory = ruleAttributes, - track = { - assert(it.jsExpressionResult == it.celExpressionResult) - }, - superscriptEvaluator = - superscriptEval( - storage.coreDataManager, - ruleAttributes, - ), - ) - - val trueRule = - TriggerRule( - experimentId = "1", - experimentGroupId = "2", - variants = - listOf( - VariantOption( - type = Experiment.Variant.VariantType.HOLDOUT, - id = "3", - percentage = 20, - paywallId = null, - ), - ), - expression = "user.id == '123'", - expressionJs = null, - preload = - TriggerRule.TriggerPreload( - behavior = TriggerPreloadBehavior.ALWAYS, - requiresReEvaluation = false, - ), - ) - - val falseRule = - TriggerRule( - experimentId = "1", - experimentGroupId = "2", - variants = - listOf( - VariantOption( - type = Experiment.Variant.VariantType.HOLDOUT, - id = "3", - percentage = 20, - paywallId = null, - ), - ), - expression = null, - expressionJs = "function() { return false; }", - preload = - TriggerRule.TriggerPreload( - behavior = TriggerPreloadBehavior.ALWAYS, - requiresReEvaluation = false, - ), - ) - - val trueResult = - async { - expressionEvaluator.evaluateExpression( - rule = trueRule, - eventData = - EventData( - name = "test", - parameters = mapOf("id" to "123"), - createdAt = Date(), - ), - ) - } - - val falseResult = - async { - expressionEvaluator.evaluateExpression( - rule = falseRule, - eventData = - EventData( - name = "test", - parameters = mapOf("id" to "123"), - createdAt = Date(), - ), - ) - } - - // Await all the results - val results = listOf(trueResult.await(), falseResult.await()) - val expectedResults = - listOf( - TriggerRuleOutcome.Match(matchedItem = MatchedItem(rule = trueRule)), - TriggerRuleOutcome.noMatch( - source = UnmatchedRule.Source.EXPRESSION, - experimentId = "1", - ), - ) - - assert(results == expectedResults) - } - - @Test - fun test_no_expression() = - runTest { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val ruleAttributes = RuleAttributeFactoryBuilder() - val storage = StorageMock(context = context, coroutineScope = this@runTest) - - val expressionEvaluator: CombinedExpressionEvaluator = - CombinedExpressionEvaluator( - evaluator = - evaluatorFor( - storage = storage, - ), - storage = storage, - factory = ruleAttributes, - track = { - assert(it.jsExpressionResult == it.celExpressionResult) - }, - superscriptEvaluator = - superscriptEval( - storage.coreDataManager, - ruleAttributes, - ), - ) - - val rule = - TriggerRule( - experimentId = "1", - experimentGroupId = "2", - variants = - listOf( - VariantOption( - type = Experiment.Variant.VariantType.HOLDOUT, - id = "3", - percentage = 20, - paywallId = null, - ), - ), - expression = null, - expressionJs = null, - preload = - TriggerRule.TriggerPreload( - behavior = TriggerPreloadBehavior.ALWAYS, - requiresReEvaluation = false, - ), - ) - - val result = - expressionEvaluator.evaluateExpression( - rule = rule, - eventData = - EventData( - name = "test", - parameters = mapOf("id" to "123"), - createdAt = Date(), - ), - ) - - assert(result == TriggerRuleOutcome.match(rule = rule)) - } -} diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvaluatorTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvaluatorTest.kt deleted file mode 100644 index 3bebc993..00000000 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvaluatorTest.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.superwall.sdk.paywall.presentation.rule_logic.javascript - -import android.webkit.WebView -import androidx.javascriptengine.JavaScriptSandbox -import androidx.javascriptengine.SandboxDeadException -import androidx.test.platform.app.InstrumentationRegistry -import com.superwall.sdk.misc.IOScope -import com.superwall.sdk.misc.MainScope -import com.superwall.sdk.models.triggers.TriggerRule -import com.superwall.sdk.storage.StorageMock -import io.mockk.every -import io.mockk.mockkStatic -import io.mockk.spyk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.guava.await -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class DefaultJavascriptEvaluatorTest { - fun ctx() = InstrumentationRegistry.getInstrumentation().targetContext - - @Test - fun evaulate_succesfully_with_sandbox() = - runTest { - val storage = StorageMock(ctx()) - mockkStatic(WebView::class) { - every { WebView.getCurrentWebViewPackage() } returns null - } - val evaulator = - DefaultJavascriptEvalutor( - IOScope(this.coroutineContext), - MainScope(this.coroutineContext), - ctx(), - storage = storage, - ) - evaulator.evaluate("console.assert(true);", TriggerRule.stub()) - evaulator.teardown() - } - - @Test - fun fail_evaluating_with_sandbox_and_fallback_is_used() = - runTest { - val storage = - StorageMock( - ctx(), - coroutineScope = this, - ) - - val sandbox = JavaScriptSandbox.createConnectedInstanceAsync(ctx()).await() - - val mockSand = - spyk(sandbox) { - every { createIsolate() } throws SandboxDeadException() - } - val evaulator = - DefaultJavascriptEvalutor( - IOScope(this.coroutineContext), - MainScope(), - ctx(), - storage = storage, - createSandbox = { - Result.success(sandbox) - }, - ) - launch(Dispatchers.IO) { - delay(100) - evaulator.evaluate("console.assert(true);", TriggerRule.stub()) - } - mockSand.killImmediatelyOnThread() - evaulator.teardown() - } -} diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/WebviewFallbackClientTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/WebviewFallbackClientTest.kt index feaf2781..848b8baf 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/WebviewFallbackClientTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/WebviewFallbackClientTest.kt @@ -13,12 +13,12 @@ import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.MainScope import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.paywall.PaywallWebviewUrl -import com.superwall.sdk.paywall.vc.web_view.DefaultWebviewClient -import com.superwall.sdk.paywall.vc.web_view.SWWebView -import com.superwall.sdk.paywall.vc.web_view.WebviewClientEvent -import com.superwall.sdk.paywall.vc.web_view.WebviewClientEvent.OnPageFinished -import com.superwall.sdk.paywall.vc.web_view.WebviewError -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallMessageHandler +import com.superwall.sdk.paywall.view.webview.DefaultWebviewClient +import com.superwall.sdk.paywall.view.webview.SWWebView +import com.superwall.sdk.paywall.view.webview.WebviewClientEvent +import com.superwall.sdk.paywall.view.webview.WebviewClientEvent.OnPageFinished +import com.superwall.sdk.paywall.view.webview.WebviewError +import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandler import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -91,6 +91,7 @@ class WebviewFallbackClientTest { Then("the loading is successful") { webview .waitForEvent(mainScope) { + println("Got event $it") it is OnPageFinished }.let { assert(it is OnPageFinished) diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/messaging/TestPaywallMessageHandlerDelegate.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/messaging/TestPaywallMessageHandlerDelegate.kt index f438bd0b..da8f18a9 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/messaging/TestPaywallMessageHandlerDelegate.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/messaging/TestPaywallMessageHandlerDelegate.kt @@ -5,9 +5,9 @@ import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.paywall.presentation.internal.PresentationRequest -import com.superwall.sdk.paywall.vc.delegate.PaywallLoadingState -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallMessageHandlerDelegate -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent +import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState +import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandlerDelegate +import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent class TestPaywallMessageHandlerDelegate( override val request: PresentationRequest? = null, @@ -26,14 +26,6 @@ class TestPaywallMessageHandlerDelegate( TODO("Not yet implemented") } - override fun presentSafariInApp(url: String) { - super.presentSafariInApp(url) - } - - override fun presentSafariExternal(url: String) { - super.presentSafariExternal(url) - } - override fun presentBrowserInApp(url: String) { TODO("Not yet implemented") } diff --git a/superwall/src/androidTest/java/com/superwall/sdk/storage/ConfigureSDKTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/storage/ConfigureSDKTest.kt index 69b4acfb..0024f69c 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/storage/ConfigureSDKTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/storage/ConfigureSDKTest.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.storage +import android.app.Application import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.superwall.sdk.Superwall @@ -18,7 +19,7 @@ class ConfigureSDKTest { runTest { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - val superwall = Superwall.configure(appContext, CONSTANT_API_KEY) + val superwall = Superwall.configure(appContext.applicationContext as Application, CONSTANT_API_KEY) val res = Superwall.hasInitialized.first() assertEquals(true, res) } diff --git a/superwall/src/androidTest/java/com/superwall/sdk/utilities/PurchaseMockBuilder.kt b/superwall/src/androidTest/java/com/superwall/sdk/utilities/PurchaseMockBuilder.kt new file mode 100644 index 00000000..16cb97ec --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/utilities/PurchaseMockBuilder.kt @@ -0,0 +1,91 @@ +package com.superwall.sdk.utilities + +import com.android.billingclient.api.Purchase +import org.json.JSONArray +import org.json.JSONObject + +class PurchaseMockBuilder { + private val purchaseJson = JSONObject() + + fun setPurchaseState(state: Int): PurchaseMockBuilder { + purchaseJson.put("purchaseState", if (state == 2) 4 else state) + return this + } + + fun setPurchaseTime(time: Long): PurchaseMockBuilder { + purchaseJson.put("purchaseTime", time) + return this + } + + fun setOrderId(orderId: String?): PurchaseMockBuilder { + purchaseJson.put("orderId", orderId) + return this + } + + fun setProductId(productId: String?): PurchaseMockBuilder { + val productIds = JSONArray() + productIds.put(productId) + purchaseJson.put("productIds", productIds) + // For backward compatibility + purchaseJson.put("productId", productId) + return this + } + + fun setQuantity(quantity: Int): PurchaseMockBuilder { + purchaseJson.put("quantity", quantity) + return this + } + + fun setPurchaseToken(token: String?): PurchaseMockBuilder { + purchaseJson.put("token", token) + purchaseJson.put("purchaseToken", token) + return this + } + + fun setPackageName(packageName: String?): PurchaseMockBuilder { + purchaseJson.put("packageName", packageName) + return this + } + + fun setDeveloperPayload(payload: String?): PurchaseMockBuilder { + purchaseJson.put("developerPayload", payload) + return this + } + + fun setAcknowledged(acknowledged: Boolean): PurchaseMockBuilder { + purchaseJson.put("acknowledged", acknowledged) + return this + } + + fun setAutoRenewing(autoRenewing: Boolean): PurchaseMockBuilder { + purchaseJson.put("autoRenewing", autoRenewing) + return this + } + + fun setAccountIdentifiers( + obfuscatedAccountId: String?, + obfuscatedProfileId: String?, + ): PurchaseMockBuilder { + purchaseJson.put("obfuscatedAccountId", obfuscatedAccountId) + purchaseJson.put("obfuscatedProfileId", obfuscatedProfileId) + return this + } + + fun build(): Purchase = Purchase(purchaseJson.toString(), "dummy-signature") + + companion object { + fun createDefaultPurchase(id: String): Purchase = + PurchaseMockBuilder() + .setPurchaseState(Purchase.PurchaseState.PURCHASED) + .setPurchaseTime(System.currentTimeMillis()) + .setOrderId("GPA.1234-5678-9012-34567") + .setProductId(id) + .setQuantity(1) + .setPurchaseToken("opaque-token-up-to-1950-characters") + .setPackageName("com.superwall.sdk") + .setDeveloperPayload("") + .setAcknowledged(true) + .setAutoRenewing(true) + .build() + } +} diff --git a/superwall/src/main/AndroidManifest.xml b/superwall/src/main/AndroidManifest.xml index a3e594af..8288424f 100644 --- a/superwall/src/main/AndroidManifest.xml +++ b/superwall/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ - + + \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index be3d937a..537e4ccd 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -4,13 +4,18 @@ import android.app.Application import android.content.Context import android.net.Uri import androidx.work.WorkManager +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.analytics.superwall.SuperwallEventInfo +import com.superwall.sdk.billing.toInternalResult import com.superwall.sdk.config.models.ConfigState import com.superwall.sdk.config.models.ConfigurationStatus import com.superwall.sdk.config.options.SuperwallOptions -import com.superwall.sdk.delegate.SubscriptionStatus +import com.superwall.sdk.delegate.InternalPurchaseResult +import com.superwall.sdk.delegate.PurchaseResult import com.superwall.sdk.delegate.SuperwallDelegate import com.superwall.sdk.delegate.SuperwallDelegateJava import com.superwall.sdk.delegate.subscription_controller.PurchaseController @@ -27,6 +32,8 @@ import com.superwall.sdk.misc.fold import com.superwall.sdk.misc.launchWithTracking import com.superwall.sdk.misc.toResult import com.superwall.sdk.models.assignment.ConfirmedAssignment +import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.models.events.EventData import com.superwall.sdk.network.device.InterfaceStyle import com.superwall.sdk.paywall.presentation.PaywallCloseReason @@ -37,19 +44,23 @@ import com.superwall.sdk.paywall.presentation.internal.confirmAssignment import com.superwall.sdk.paywall.presentation.internal.dismiss import com.superwall.sdk.paywall.presentation.internal.request.PresentationInfo import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult -import com.superwall.sdk.paywall.vc.PaywallView -import com.superwall.sdk.paywall.vc.SuperwallPaywallActivity -import com.superwall.sdk.paywall.vc.delegate.PaywallViewEventCallback -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.Closed -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.Custom -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.InitiatePurchase -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.InitiateRestore -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.OpenedDeepLink -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.OpenedURL -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.OpenedUrlInChrome -import com.superwall.sdk.storage.ActiveSubscriptionStatus -import com.superwall.sdk.store.ExternalNativePurchaseController +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.SuperwallPaywallActivity +import com.superwall.sdk.paywall.view.delegate.PaywallViewEventCallback +import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent +import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.Closed +import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.Custom +import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.InitiatePurchase +import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.InitiateRestore +import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedDeepLink +import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedURL +import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedUrlInChrome +import com.superwall.sdk.storage.StoredEntitlementStatus +import com.superwall.sdk.store.Entitlements +import com.superwall.sdk.store.PurchasingObserverState +import com.superwall.sdk.store.abstractions.product.RawStoreProduct +import com.superwall.sdk.store.abstractions.product.StoreProduct +import com.superwall.sdk.store.transactions.TransactionManager import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -59,9 +70,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -88,7 +100,11 @@ class Superwall( internal val presentationItems: PresentationItems = PresentationItems() private val _events: MutableSharedFlow = - MutableSharedFlow(0, extraBufferCapacity = 64 * 4, onBufferOverflow = BufferOverflow.SUSPEND) + MutableSharedFlow( + 0, + extraBufferCapacity = 64 * 4, + onBufferOverflow = BufferOverflow.SUSPEND, + ) /** * A flow emitting all Superwall events as an alternative to delegate. @@ -174,7 +190,8 @@ class Superwall( @JvmName("getDelegate") fun getJavaDelegate(): SuperwallDelegateJava? = dependencyContainer.delegateAdapter.javaDelegate - /** A published property that indicates the subscription status of the user. + /** + * Sets the entitlement status and updates the corresponding entitlement collections. * * If you're handling subscription-related logic yourself, you must set this * property whenever the subscription status of a user changes. @@ -183,23 +200,41 @@ class Superwall( * be synced with the user's purchases on device. * * Paywalls will not show until the subscription status has been established. - * On first install, it's value will default to [SubscriptionStatus.UNKNOWN]. Afterwards, it'll + * On first install, it's value will default to [EntitlementStatus.Unknown]. Afterwards, it'll * default to its cached value. * - * You can observe [subscriptionStatus] to get notified whenever the user's subscription status + * You can observe [entitlements.status] to get notified whenever the user's subscription status * changes. * * Otherwise, you can check the delegate function - * [SuperwallDelegate.subscriptionStatusDidChange] + * [SuperwallDelegate.entitlementStatusDidChange] * to receive a callback with the new value every time it changes. * * To learn more, see * [Purchases and Subscription Status](https://docs.superwall.com/docs/advanced-configuration). * - * @param subscriptionStatus The subscription status of the user. + * @param entitlementStatus The entitlement status of the user. */ - fun setSubscriptionStatus(subscriptionStatus: SubscriptionStatus) { - _subscriptionStatus.value = subscriptionStatus + fun setEntitlementStatus(entitlementStatus: EntitlementStatus) { + entitlements.setEntitlementStatus(entitlementStatus) + } + + /** + * Simplified version of [Superwall.setEntitlementStatus] that allows + * you to set the entitlements by passing in an array of strings. + * An empty list is treated as [EntitlementStatus.Inactive]. + * Example: + * `setEntitlementStatus("default", "pro")` equals `EntitlementStatus.Active(setOf(Entitlement("default"), Entitlement("pro")))` + * `setEntitlementStatus()` equals `EntitlementStatus.Inactive` + * + * @param entitlements A list of entitlements. + * */ + fun setEntitlementStatus(vararg entitlements: String) { + if (entitlements.isEmpty()) { + this.entitlements.setEntitlementStatus(EntitlementStatus.Inactive) + } else { + this.setEntitlementStatus(EntitlementStatus.Active(entitlements.map { Entitlement(it) }.toSet())) + } } /** @@ -236,16 +271,9 @@ class Superwall( return presentedPaywallInfo ?: presentationItems.paywallInfo } - protected var _subscriptionStatus: MutableStateFlow = - MutableStateFlow( - SubscriptionStatus.UNKNOWN, - ) - - /** - * A `StateFlow` of the subscription status of the user. Set this using - * [setSubscriptionStatus]. - */ - val subscriptionStatus: StateFlow get() = _subscriptionStatus + val entitlements: Entitlements by lazy { + dependencyContainer.entitlements + } /** * A property that indicates current configuration state of the SDK. @@ -264,6 +292,16 @@ class Superwall( } } + val configurationStateListener: Flow + get() = + dependencyContainer.configManager.configState.asSharedFlow().map { + when (it) { + is ConfigState.Retrieved -> ConfigurationStatus.Configured + is ConfigState.Failed -> ConfigurationStatus.Failed + else -> ConfigurationStatus.Pending + } + } + companion object { /** A variable that is only `true` if ``instance`` is available for use. * Gets set to `true` immediately after @@ -298,7 +336,7 @@ class Superwall( * [sign up for free](https://superwall.com/sign-up). * @param purchaseController An object that conforms to [PurchaseController]. You must * implement this to handle all subscription-related logic yourself. You'll need to also - * call [setSubscriptionStatus] every time the user's subscription status changes. You can + * call [setEntitlementStatus] every time the user's subscription status changes. You can * read more about that in * [Purchases and Subscription Status](https://docs.superwall.com/docs/advanced-configuration). * @param options An optional [SuperwallOptions] object which allows you to customise the @@ -326,9 +364,6 @@ class Superwall( completion?.invoke(Result.success(Unit)) return } - val purchaseController = - purchaseController - ?: ExternalNativePurchaseController(context = applicationContext) _instance = Superwall( context = applicationContext, @@ -361,25 +396,6 @@ class Superwall( ) }) } - - @Deprecated( - "This constructor is too ambiguous and will be removed in upcoming versions. Use Superwall.configure(Application, ...) instead.", - ) - fun configure( - applicationContext: Context, - apiKey: String, - purchaseController: PurchaseController? = null, - options: SuperwallOptions? = null, - activityProvider: ActivityProvider? = null, - completion: ((Result) -> Unit)? = null, - ) = configure( - applicationContext.applicationContext as Application, - apiKey, - purchaseController, - options, - activityProvider, - completion, - ) } private lateinit var _dependencyContainer: DependencyContainer @@ -410,10 +426,10 @@ class Superwall( throw e } - val cachedSubsStatus = - dependencyContainer.storage.read(ActiveSubscriptionStatus) - ?: SubscriptionStatus.UNKNOWN - setSubscriptionStatus(cachedSubsStatus) + val cachedEntitlementStatus = + dependencyContainer.storage.read(StoredEntitlementStatus) + ?: EntitlementStatus.Unknown + setEntitlementStatus(cachedEntitlementStatus) addListeners() @@ -448,13 +464,13 @@ class Superwall( // / Listens to config and the subscription status private fun addListeners() { ioScope.launchWithTracking { - subscriptionStatus // Removes duplicates by default + entitlements.status // Removes duplicates by default .drop(1) // Drops the first item .collect { newValue -> // Save and handle the new value - dependencyContainer.storage.write(ActiveSubscriptionStatus, newValue) - dependencyContainer.delegateAdapter.subscriptionStatusDidChange(newValue) - val event = InternalSuperwallEvent.SubscriptionStatusDidChange(newValue) + dependencyContainer.storage.write(StoredEntitlementStatus, newValue) + dependencyContainer.delegateAdapter.entitlementStatusDidChange(newValue) + val event = InternalSuperwallEvent.EntitlementStatusDidChange(newValue) track(event) } } @@ -683,6 +699,224 @@ class Superwall( } } + /** + * Initiates a purchase of `ProductDetails`. + * + * Use this function to purchase any `ProductDetails`, regardless of whether you + * have a paywall or not. Superwall will handle the purchase with `GooglePlayBilling` + * and return the `PurchaseResult`. You'll see the data associated with the + * purchase on the Superwall dashboard. + * + * @param product: The `ProductDetails` you wish to purchase. + * @return A ``PurchaseResult``. + * - Note: You do not need to finish the transaction yourself after this. + * ``Superwall`` will handle this for you. + */ + suspend fun purchase(product: ProductDetails): Result = + withErrorTracking { + dependencyContainer.transactionManager.purchase( + TransactionManager.PurchaseSource.ExternalPurchase( + StoreProduct(RawStoreProduct.from(product)), + ), + ) + }.toResult() + + /** + * Initiates a purchase of `StoreProduct`. + * + * Use this function to purchase any `StoreProduct`, regardless of whether you + * have a paywall or not. Superwall will handle the purchase with `GooglePlayBilling` + * and return the `PurchaseResult`. You'll see the data associated with the + * purchase on the Superwall dashboard. + * + * @param product: The `StoreProduct` you wish to purchase. + * @return A ``PurchaseResult``. + * - Note: You do not need to finish the transaction yourself after this. + * ``Superwall`` will handle this for you. + */ + suspend fun purchase(product: StoreProduct): Result = + withErrorTracking { + dependencyContainer.transactionManager.purchase( + TransactionManager.PurchaseSource.ExternalPurchase( + product, + ), + ) + }.toResult() + + /** + * Initiates a purchase of a product with the given `productId`. + * + * Use this function to purchase any product with a given product ID, regardless of whether you + * have a paywall or not. Superwall will handle the purchase with `GooglePlayBilling` + * and return the `PurchaseResult`. You'll see the data associated with the + * purchase on the Superwall dashboard. + * + * @param product: The `produdctId` you wish to purchase. + * @return A ``PurchaseResult``. + * - Note: You do not need to finish the transaction yourself after this. + * ``Superwall`` will handle this for you. + */ + suspend fun purchase(productId: String): Result = + withErrorTracking { + getProducts(productId).getOrThrow()[productId]?.let { + dependencyContainer.transactionManager.purchase( + TransactionManager.PurchaseSource.ExternalPurchase( + it, + ), + ) + } ?: throw IllegalArgumentException("Product with id $productId not found") + }.toResult() + + /** + * Given a list of product identifiers, returns a map of identifiers to `StoreProduct` objects. + * + * @param productIds: A list of full product identifiers. + * @return A map of product identifiers to `StoreProduct` objects. + */ + suspend fun getProducts(vararg productIds: String): Result> = + withErrorTracking { + dependencyContainer.storeManager.getProductsWithoutPaywall(productIds.toList()) + }.toResult() + + /** + * Initiates a purchase of a `StoreProduct` with a callback. + * + * Use this function to purchase any `StoreProduct`, regardless of whether you + * have a paywall or not. Superwall will handle the purchase with `GooglePlayBilling` + * and return the `PurchaseResult` in `onFinished`. You'll see the data associated with the + * purchase on the Superwall dashboard. + * + * @param product: The `StoreProduct` you wish to purchase. + * @param onFinished: A callback that will receive the `PurchaseResult`. + * - Note: You do not need to finish the transaction yourself after this. + * ``Superwall`` will handle this for you. + */ + + fun purchase( + product: StoreProduct, + onFinished: (Result) -> Unit, + ) { + ioScope.launch { + val res = + withErrorTracking { + dependencyContainer.transactionManager.purchase( + TransactionManager.PurchaseSource.ExternalPurchase( + product, + ), + ) + }.toResult() + onFinished(res) + } + } + + /** + * Observe purchases made without using Paywalls. + * + * This method allows you to track purchases that happen outside of Superwall's paywall flow. + * It handles different states of the purchase process including start, completion, and errors. + * + * Note: The `shouldObservePurchases` option must be enabled in SuperwallOptions for this to work. + * + * @param state The current state of the purchase to observe, can be: + * - PurchaseWillBegin: When a purchase flow is about to start + * - PurchaseResult: When a purchase completes successfully + * - PurchaseError: When a purchase fails with an error + */ + fun observe(state: PurchasingObserverState) { + ioScope.launchWithTracking { + if (!options.shouldObservePurchases) { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.superwallCore, + message = + "You are trying to observe purchases but the SuperwallOption shouldObservePurchases is " + + "false. Please set it to true to be able to observe purchases.", + ) + return@launchWithTracking + } + when (state) { + is PurchasingObserverState.PurchaseWillBegin -> { + val product = StoreProduct(RawStoreProduct.from(state.product)) + dependencyContainer.transactionManager.prepareToPurchase( + product, + source = TransactionManager.PurchaseSource.ObserverMode(product), + ) + } + + is PurchasingObserverState.PurchaseResult -> { + val result = (state.result to state.purchases).toInternalResult() + for (internalPurchaseResult in result) { + dependencyContainer.transactionManager.handle( + internalPurchaseResult, + state, + ) + } + } + + is PurchasingObserverState.PurchaseError -> { + dependencyContainer.transactionManager.handle( + InternalPurchaseResult.Failed(state.error), + state, + ) + } + } + } + } + + /** + * Convenience method to observe when a purchase flow begins. + * + * Call this method when a purchase is about to start to track the beginning of the transaction. + * This will trigger tracking of the Transaction Start event in Superwall's analytics. + * + * @param product The Google Play Billing ProductDetails for the product being purchased + */ + fun observePurchaseStart(product: ProductDetails) { + observe(PurchasingObserverState.PurchaseWillBegin(product)) + } + + /** + * Convenience method to observe purchase errors. + * + * Call this method when a purchase fails to track the failure in Superwall's analytics. + * This will trigger tracking of the Transaction Fail event. + * + * @param product The Google Play Billing ProductDetails for the product that failed to purchase + * @param error The error that caused the purchase to fail + */ + fun observePurchaseError( + product: ProductDetails, + error: Throwable, + ) { + observe(PurchasingObserverState.PurchaseError(product, error)) + } + + /** + * Convenience method to observe successful purchases. + * + * Call this method when a purchase completes successfully to track the completion in Superwall's analytics. + * This will trigger tracking of the Transaction Success event. + * + * @param billingResult The BillingResult from Google Play Billing containing the purchase response + * @param purchases List of completed Purchase objects from the transaction + */ + fun observePurchaseResult( + billingResult: BillingResult, + purchases: List, + ) { + observe(PurchasingObserverState.PurchaseResult(billingResult, purchases)) + } + + /** + * Restores purchases + * + * Use this function to restore purchases made by the user. + * */ + suspend fun restorePurchases() = + withErrorTracking { + dependencyContainer.transactionManager.tryToRestorePurchases(null) + }.toResult() + override suspend fun eventDidOccur( paywallEvent: PaywallWebEvent, paywallView: PaywallView, @@ -713,8 +947,10 @@ class Superwall( launch { try { dependencyContainer.transactionManager.purchase( - paywallEvent.productId, - paywallView, + TransactionManager.PurchaseSource.Internal( + paywallEvent.productId, + paywallView, + ), ) } finally { // Ensure the task is cleared once the purchase is complete or if an error occurs @@ -724,7 +960,7 @@ class Superwall( } is InitiateRestore -> { - dependencyContainer.transactionManager.tryToRestore(paywallView) + dependencyContainer.transactionManager.tryToRestorePurchases(paywallView) } is OpenedURL -> { diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt index 7fc0a687..bdc2a581 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt @@ -14,7 +14,7 @@ import com.superwall.sdk.paywall.presentation.dismissForNextPaywall import com.superwall.sdk.paywall.presentation.internal.PresentationRequestType import com.superwall.sdk.paywall.presentation.internal.internallyPresent import com.superwall.sdk.paywall.presentation.internal.operators.logErrors -import com.superwall.sdk.paywall.presentation.internal.operators.waitForSubsStatusAndConfig +import com.superwall.sdk.paywall.presentation.internal.operators.waitForEntitlementsAndConfig import com.superwall.sdk.paywall.presentation.internal.request.PresentationInfo import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import com.superwall.sdk.storage.DisableVerboseEvents @@ -24,9 +24,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.util.Date +/** + * Tracks an analytical event by sending it to the server and, for internal Superwall events, the delegate. + * + * @param event The event you want to track. + * @return TrackingResult The result of the tracking operation. + */ suspend fun Superwall.track(event: Trackable): Result { return withErrorTracking { // Wait for the SDK to be fully initialized @@ -106,6 +113,24 @@ suspend fun Superwall.track(event: Trackable): Result { }.toResult() } +/** + * Tracks an analytical event synchronously. + * Warning: This blocks the calling thread. + * @param event The event you want to track. + * @return TrackingResult The result of the tracking operation. + */ +fun Superwall.trackSync(event: Trackable): Result = + runBlocking { + track(event) + } + +/** + * Attempts to implicitly trigger a paywall for a given analytical event. + * + * @param event The tracked event. + * @param eventData The event data that could trigger a paywall. + */ + suspend fun Superwall.handleImplicitTrigger( event: Trackable, eventData: EventData, @@ -130,7 +155,7 @@ private suspend fun Superwall.internallyHandleImplicitTrigger( ) try { - waitForSubsStatusAndConfig(request, null) + waitForEntitlementsAndConfig(request, null) } catch (e: Throwable) { logErrors(request, e) return@withErrorTracking diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingLogic.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingLogic.kt index d57190c5..602b3f02 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingLogic.kt @@ -8,16 +8,16 @@ import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger import com.superwall.sdk.models.paywall.PaywallURL -import com.superwall.sdk.paywall.vc.PaywallView +import com.superwall.sdk.paywall.view.PaywallView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.threeten.bp.LocalDateTime +import org.threeten.bp.ZoneOffset import java.net.URI -import java.time.LocalDateTime -import java.time.ZoneOffset import java.util.* sealed class TrackingLogic { diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt index 0f547c73..15d7bb5d 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt @@ -7,18 +7,18 @@ import com.superwall.sdk.config.models.Survey import com.superwall.sdk.config.models.SurveyOption import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.config.options.toMap -import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.dependencies.ComputedPropertyRequestsFactory import com.superwall.sdk.dependencies.FeatureFlagsFactory import com.superwall.sdk.dependencies.RuleAttributesFactory +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.triggers.InternalTriggerResult import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatus import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatusReason import com.superwall.sdk.paywall.presentation.internal.PresentationRequestType -import com.superwall.sdk.paywall.vc.Survey.SurveyPresentationResult -import com.superwall.sdk.paywall.vc.web_view.WebviewError +import com.superwall.sdk.paywall.view.Survey.SurveyPresentationResult +import com.superwall.sdk.paywall.view.webview.WebviewError import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.abstractions.transactions.StoreTransaction import com.superwall.sdk.store.abstractions.transactions.StoreTransactionType @@ -255,13 +255,19 @@ sealed class InternalSuperwallEvent( } } - class SubscriptionStatusDidChange( - val subscriptionStatus: SubscriptionStatus, + class EntitlementStatusDidChange( + val entitlementStatus: EntitlementStatus, override var audienceFilterParams: HashMap = HashMap(), - ) : InternalSuperwallEvent(SuperwallEvent.SubscriptionStatusDidChange()) { + ) : InternalSuperwallEvent(SuperwallEvent.EntitlementStatusDidChange()) { override suspend fun getSuperwallParameters(): HashMap = hashMapOf( - "subscription_status" to subscriptionStatus.toString(), + "entitlement_status" to { + when (entitlementStatus) { + is EntitlementStatus.Active -> "active" + is EntitlementStatus.Inactive -> "inactive" + is EntitlementStatus.Unknown -> "unknown" + } + }, ) } @@ -434,7 +440,17 @@ sealed class InternalSuperwallEvent( val paywallInfo: PaywallInfo, val product: StoreProduct?, val model: StoreTransaction?, + val source: TransactionSource, + val isObserved: Boolean, ) : TrackableSuperwallEvent { + enum class TransactionSource( + val raw: String, + ) { + INTERNAL("SUPERWALL"), + OBSERVER("OBSERVER"), + EXTERNAL("APP"), + } + sealed class State { class Start( val product: StoreProduct, @@ -511,7 +527,7 @@ sealed class InternalSuperwallEvent( get() = superwallEvent.rawName override val canImplicitlyTriggerPaywall: Boolean - get() = superwallEvent.canImplicitlyTriggerPaywall + get() = if (isObserved) false else superwallEvent.canImplicitlyTriggerPaywall override suspend fun getSuperwallParameters(): HashMap { return when (state) { diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt index ec2be1f5..5895e678 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt @@ -7,7 +7,7 @@ import com.superwall.sdk.models.triggers.TriggerResult import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatus import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatusReason -import com.superwall.sdk.paywall.vc.web_view.WebviewError +import com.superwall.sdk.paywall.view.webview.WebviewError import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.abstractions.transactions.StoreTransactionType import com.superwall.sdk.store.transactions.RestoreType @@ -77,9 +77,9 @@ sealed class SuperwallEvent { } // / When the user's subscription status changes. - class SubscriptionStatusDidChange : SuperwallEvent() { + class EntitlementStatusDidChange : SuperwallEvent() { override val rawName: String - get() = "subscriptionStatus_didChange" + get() = "entitlementStatus_didChange" } // / Anytime the app leaves the foreground. diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt index 5a6dcead..ef6f346f 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt @@ -1,8 +1,5 @@ package com.superwall.sdk.analytics.superwall -@Deprecated("Will be removed in the upcoming versions, use SuperwallEvents instead") -typealias SuperwallEventObjc = SuperwallEvents - enum class SuperwallEvents( val rawName: String, ) { @@ -26,7 +23,7 @@ enum class SuperwallEvents( SubscriptionStart("subscription_start"), SurveyResponse("survey_response"), SurveyClose("survey_close"), - SubscriptionStatusDidChange("subscriptionStatus_didChange"), + EntitlementStatusDidChange("entitlementStatus_didChange"), FreeTrialStart("freeTrial_start"), UserAttributes("user_attributes"), NonRecurringProductPurchase("nonRecurringProduct_purchase"), diff --git a/superwall/src/main/java/com/superwall/sdk/billing/Billing.kt b/superwall/src/main/java/com/superwall/sdk/billing/Billing.kt new file mode 100644 index 00000000..fcae3c8f --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/billing/Billing.kt @@ -0,0 +1,18 @@ +package com.superwall.sdk.billing + +import com.android.billingclient.api.Purchase +import com.superwall.sdk.delegate.InternalPurchaseResult +import com.superwall.sdk.dependencies.StoreTransactionFactory +import com.superwall.sdk.store.abstractions.product.StoreProduct +import com.superwall.sdk.store.abstractions.transactions.StoreTransaction +import kotlinx.coroutines.flow.MutableStateFlow + +interface Billing { + val purchaseResults: MutableStateFlow + + suspend fun awaitGetProducts(identifiers: Set): Set + + suspend fun getLatestTransaction(factory: StoreTransactionFactory): StoreTransaction? + + suspend fun queryAllPurchases(): List +} diff --git a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt index fddd134f..fd2b5761 100644 --- a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt +++ b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt @@ -1,25 +1,30 @@ package com.superwall.sdk.billing import android.content.Context -import android.os.Handler -import android.os.Looper import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClient.ProductType import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingResult import com.android.billingclient.api.Purchase import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryPurchasesParams import com.superwall.sdk.delegate.InternalPurchaseResult +import com.superwall.sdk.dependencies.HasExternalPurchaseControllerFactory +import com.superwall.sdk.dependencies.HasInternalPurchaseControllerFactory +import com.superwall.sdk.dependencies.OptionsFactory import com.superwall.sdk.dependencies.StoreTransactionFactory import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.AppLifecycleObserver import com.superwall.sdk.misc.Either +import com.superwall.sdk.misc.IOScope import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.abstractions.transactions.StoreTransaction +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter @@ -37,14 +42,26 @@ internal const val RECONNECT_TIMER_MAX_TIME_MILLISECONDS = 16L * 1000L class GoogleBillingWrapper( val context: Context, - val mainHandler: Handler = Handler(Looper.getMainLooper()), + val ioScope: IOScope, val appLifecycleObserver: AppLifecycleObserver, + val factory: Factory, ) : PurchasesUpdatedListener, - BillingClientStateListener { + BillingClientStateListener, + Billing { companion object { private val productsCache = ConcurrentHashMap>() } + interface Factory : + HasExternalPurchaseControllerFactory, + HasInternalPurchaseControllerFactory, + OptionsFactory + + private val threadHandler = Handler(ioScope) + private val shouldFinishTransactions: Boolean + get() = + factory.makeHasInternalPurchaseController() + @get:Synchronized @set:Synchronized @Volatile @@ -61,33 +78,60 @@ class GoogleBillingWrapper( private var reconnectionAlreadyScheduled = false // Setup mutable state flow for purchase results - private val purchaseResults = MutableStateFlow(null) + override val purchaseResults = MutableStateFlow(null) - internal val IN_APP_BILLING_LESS_THAN_3_ERROR_MESSAGE = "Google Play In-app Billing API version is less than 3" + internal val IN_APP_BILLING_LESS_THAN_3_ERROR_MESSAGE = + "Google Play In-app Billing API version is less than 3" init { startConnectionOnMainThread() } + internal class Handler( + val scope: CoroutineScope, + ) { + fun post(action: () -> Unit) { + scope.launch { + action() + } + } + + fun postDelayed( + action: () -> Unit, + delayMilliseconds: Long, + ) { + scope.launch { + delay(delayMilliseconds) + action() + } + } + } + private fun executePendingRequests() { synchronized(this@GoogleBillingWrapper) { while (billingClient?.isReady == true) { serviceRequests.poll()?.let { (request, delayMilliseconds) -> if (delayMilliseconds != null) { - mainHandler.postDelayed( + threadHandler.postDelayed( { request(null) }, delayMilliseconds, ) } else { - mainHandler.post { request(null) } + threadHandler.post { request(null) } } } ?: break } } } + override suspend fun queryAllPurchases(): List { + val apps = billingClient?.queryType(ProductType.INAPP) ?: emptyList() + val subs = billingClient?.queryType(ProductType.SUBS) ?: emptyList() + return apps + subs + } + fun startConnectionOnMainThread(delayMilliseconds: Long = 0) { - mainHandler.postDelayed( + threadHandler.postDelayed( { startConnection() }, delayMilliseconds, ) @@ -141,7 +185,7 @@ class GoogleBillingWrapper( */ @JvmSynthetic @Throws(Throwable::class) - suspend fun awaitGetProducts(fullProductIds: Set): Set { + override suspend fun awaitGetProducts(fullProductIds: Set): Set { // Get the cached products. If any are a failure, we throw an error. val cachedProducts = fullProductIds @@ -160,7 +204,8 @@ class GoogleBillingWrapper( } // Determine which product IDs are not in cache - val missingFullProductIds = fullProductIds - cachedProducts.map { it.fullIdentifier }.toSet() + val missingFullProductIds = + fullProductIds - cachedProducts.map { it.fullIdentifier }.toSet() return suspendCoroutine { continuation -> getProducts( @@ -175,9 +220,12 @@ class GoogleBillingWrapper( } // Identify and handle missing products - missingFullProductIds.filterNot { it in foundProductIds }.forEach { fullProductId -> - productsCache[fullProductId] = Either.Failure(Exception("Failed to query product details for $fullProductId")) - } + missingFullProductIds + .filterNot { it in foundProductIds } + .forEach { fullProductId -> + productsCache[fullProductId] = + Either.Failure(Exception("Failed to query product details for $fullProductId")) + } // Combine cached products (now including the newly fetched ones) with the fetched products val allProducts = cachedProducts + storeProducts @@ -274,12 +322,7 @@ class GoogleBillingWrapper( } private fun dispatch(action: () -> Unit) { - if (Thread.currentThread() != Looper.getMainLooper().thread) { - val handler = mainHandler ?: Handler(Looper.getMainLooper()) - handler.post(action) - } else { - action() - } + threadHandler.post(action) } private fun queryProductDetailsAsync( @@ -362,7 +405,7 @@ class GoogleBillingWrapper( } override fun onBillingSetupFinished(billingResult: BillingResult) { - mainHandler.post { + threadHandler.post { when (billingResult.responseCode) { BillingClient.BillingResponseCode.OK -> { Logger.debug( @@ -411,7 +454,8 @@ class GoogleBillingWrapper( Logger.debug( LogLevel.error, LogScope.productsManager, - error.message ?: "Billing is not available in this device. ${billingResult.debugMessage}", + error.message + ?: "Billing is not available in this device. ${billingResult.debugMessage}", ) // The calls will fail with an error that will be surfaced. We want to surface these errors // Can't call executePendingRequests because it will not do anything since it checks for isReady() @@ -509,28 +553,44 @@ class GoogleBillingWrapper( } } - suspend fun getLatestTransaction(factory: StoreTransactionFactory): StoreTransaction? { + /** + * Get the latest transaction from the purchaseResults flow. + * + * @param factory StoreTransactionFactory to create the StoreTransaction + * @return StoreTransaction if the purchase was finished by Superwall, null otherwise + */ + override suspend fun getLatestTransaction(factory: StoreTransactionFactory): StoreTransaction? { // Get the latest from purchaseResults - purchaseResults.asStateFlow().filter { it != null }.first().let { purchaseResult -> - return when (purchaseResult) { - is InternalPurchaseResult.Purchased -> { - return factory.makeStoreTransaction(purchaseResult.purchase) - } - is InternalPurchaseResult.Cancelled -> { - null - } - else -> { - null + purchaseResults + .asStateFlow() + .filter { it != null } + .first() + .let { purchaseResult -> + return when (purchaseResult) { + is InternalPurchaseResult.Purchased -> { + if (shouldFinishTransactions) { + return factory.makeStoreTransaction(purchaseResult.purchase) + } else { + null + } + } + + is InternalPurchaseResult.Cancelled -> { + null + } + + else -> { + null + } } } - } } @Synchronized private fun sendErrorsToAllPendingRequests(error: BillingError) { while (true) { serviceRequests.poll()?.let { (serviceRequest, _) -> - mainHandler.post { + threadHandler.post { serviceRequest(error) } } ?: break @@ -538,7 +598,8 @@ class GoogleBillingWrapper( } private fun trackProductDetailsNotSupportedIfNeeded() { - val billingResult = billingClient?.isFeatureSupported(BillingClient.FeatureType.PRODUCT_DETAILS) + val billingResult = + billingClient?.isFeatureSupported(BillingClient.FeatureType.PRODUCT_DETAILS) if ( billingResult != null && billingResult.responseCode == BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED @@ -551,3 +612,41 @@ class GoogleBillingWrapper( } } } + +fun Pair?>.toInternalResult(): List { + val (result, purchases) = this + return if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + purchases.map { + InternalPurchaseResult.Purchased(it) + } + } else if (result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) { + listOf(InternalPurchaseResult.Cancelled) + } else { + listOf(InternalPurchaseResult.Failed(Exception(result.responseCode.toString()))) + } +} + +suspend fun BillingClient.queryType(type: String): List { + val deferred = CompletableDeferred>() + + val params = + QueryPurchasesParams + .newBuilder() + .setProductType(type) + .build() + + queryPurchasesAsync(params) { billingResult, purchasesList -> + if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.nativePurchaseController, + message = "Unable to query for purchases.", + ) + return@queryPurchasesAsync + } + + deferred.complete(purchasesList) + } + + return deferred.await() +} diff --git a/superwall/src/main/java/com/superwall/sdk/billing/QueryProductDetailsUseCase.kt b/superwall/src/main/java/com/superwall/sdk/billing/QueryProductDetailsUseCase.kt index dcc1fcae..859dd9b9 100644 --- a/superwall/src/main/java/com/superwall/sdk/billing/QueryProductDetailsUseCase.kt +++ b/superwall/src/main/java/com/superwall/sdk/billing/QueryProductDetailsUseCase.kt @@ -28,15 +28,18 @@ internal class QueryProductDetailsUseCase( val withConnectedClient: (BillingClient.() -> Unit) -> Unit, executeRequestOnUIThread: ExecuteRequestOnUIThreadFunction, ) : BillingClientUseCase>(useCaseParams, onError, executeRequestOnUIThread) { + private fun log(msg: String) = + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.productsManager, + message = msg, + ) + override fun executeAsync() { val nonEmptyProductIds = useCaseParams.subscriptionIds.filter { it.isNotEmpty() }.toSet() if (nonEmptyProductIds.isEmpty()) { - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.productsManager, - message = "productId list is empty, skipping queryProductDetailsAsync call", - ) + log("productId list is empty, skipping queryProductDetailsAsync call") onReceive(emptyList()) return } @@ -57,22 +60,10 @@ internal class QueryProductDetailsUseCase( * `StoreProduct`. */ override fun onOk(received: List) { - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.productsManager, - message = "Products request finished for ${useCaseParams.subscriptionIds.joinToString()}", - ) - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.productsManager, - message = "Retrieved productDetailsList: ${received.joinToString { it.toString() }}", - ) + log("Products request finished for ${useCaseParams.subscriptionIds.joinToString()}") + log("Retrieved productDetailsList: ${received.joinToString { it.toString() }}") received.takeUnless { it.isEmpty() }?.forEach { - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.productsManager, - message = "${it.productId} - $it", - ) + log("${it.productId} - $it") } val storeProducts = @@ -102,12 +93,9 @@ internal class QueryProductDetailsUseCase( val hasResponded = AtomicBoolean(false) billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList -> if (hasResponded.getAndSet(true)) { - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.productsManager, - message = - "BillingClient queryProductDetails has returned more than once, " + - "with result ${billingResult.responseCode}", + log( + "BillingClient queryProductDetails has returned more than once, " + + "with result ${billingResult.responseCode}", ) return@queryProductDetailsAsync } diff --git a/superwall/src/main/java/com/superwall/sdk/billing/observer/LaunchBillingFlowWithSuperwall.kt b/superwall/src/main/java/com/superwall/sdk/billing/observer/LaunchBillingFlowWithSuperwall.kt new file mode 100644 index 00000000..79386854 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/billing/observer/LaunchBillingFlowWithSuperwall.kt @@ -0,0 +1,71 @@ +package com.superwall.sdk.billing.observer + +import android.app.Activity +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingResult +import com.superwall.sdk.Superwall +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger +import com.superwall.sdk.store.PurchasingObserverState +import com.superwall.sdk.store.abstractions.product.RawStoreProduct + +/** + * Extension function for BillingClient that launches the Google Play billing flow while allowing Superwall to observe the purchase. + * + * This method acts as a proxy between your app's purchase flow and Google Play Billing, enabling Superwall to track the + * purchase lifecycle when observer mode is enabled. It wraps the standard [BillingClient.launchBillingFlow] method and adds + * purchase observation capabilities. + * + * The method will: + * 1. Check if Superwall SDK is initialized + * 2. Verify if purchase observation is enabled via [SuperwallOptions.shouldObservePurchases] + * 3. Notify Superwall before each product purchase begins + * 4. Launch the actual billing flow + * + * Purchase events can then be observed through [Superwall.delegate] or [Superwall.events], which will emit events like: + * - [SuperwallEvent.TransactionStart] when purchase begins + * - [SuperwallEvent.TransactionComplete] on successful purchase + * - [SuperwallEvent.TransactionFail] on purchase failure + * + * @param activity The activity that will host the billing flow + * @param params Wrapper around BillingFlowParams containing product details for the purchase + * @return BillingResult containing the response from launching the billing flow + * @throws IllegalStateException if Superwall SDK is not initialized + * + * @see SuperwallBillingFlowParams + * @see Superwall.observe + */ +fun BillingClient.launchBillingFlowWithSuperwall( + activity: Activity, + params: SuperwallBillingFlowParams, +): BillingResult { + if (Superwall.initialized.not()) { + throw IllegalStateException("Superwall SDK is not initialized") + } + if (Superwall.instance.options.shouldObservePurchases + .not() + ) { + Logger.debug( + LogLevel.error, + LogScope.superwallCore, + "Observer mode is not enabled. In order to observe purchases, please enable it in the SuperwallOptions by setting `shouldObservePurchases` to true.", + mapOf( + "method" to "launchBillingFlowWithSuperwall", + "products" to + params.productDetailsParams + .map { it.details.productId } + .joinToString(", "), + ), + ) + return launchBillingFlow(activity, params.toOriginal()) + } + + params.productDetailsParams.forEach { + val product = RawStoreProduct.from(it.details) + Superwall.instance.observe( + PurchasingObserverState.PurchaseWillBegin(product.underlyingProductDetails), + ) + } + return launchBillingFlow(activity, params.toOriginal()) +} diff --git a/superwall/src/main/java/com/superwall/sdk/billing/observer/SuperwallBillingFlowParams.kt b/superwall/src/main/java/com/superwall/sdk/billing/observer/SuperwallBillingFlowParams.kt new file mode 100644 index 00000000..2bcd6e72 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/billing/observer/SuperwallBillingFlowParams.kt @@ -0,0 +1,166 @@ +package com.superwall.sdk.billing.observer + +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.SkuDetails + +class SuperwallBillingFlowParams private constructor( + internal val params: BillingFlowParams, + internal val productDetailsParams: MutableList = mutableListOf(), +) { + companion object { + fun newBuilder(): Builder = Builder() + } + + fun toOriginal() = params + + class Builder { + private val builder = BillingFlowParams.newBuilder() + internal val productDetailsParams: MutableList = mutableListOf() + + fun setIsOfferPersonalized(isOfferPersonalized: Boolean): Builder = + apply { + builder.setIsOfferPersonalized(isOfferPersonalized) + } + + fun setObfuscatedAccountId(obfuscatedAccountId: String): Builder = + apply { + builder.setObfuscatedAccountId(obfuscatedAccountId) + } + + fun setObfuscatedProfileId(obfuscatedProfileId: String): Builder = + apply { + builder.setObfuscatedProfileId(obfuscatedProfileId) + } + + fun setProductDetailsParamsList(productDetailsParamsList: List): Builder = + apply { + productDetailsParams.addAll(productDetailsParamsList) + builder.setProductDetailsParamsList(productDetailsParamsList.map { it.toOriginal() }) + } + + @Deprecated("Use setProductDetailsParamsList instead") + fun setSkuDetails(skuDetails: SkuDetails): Builder = + apply { + builder.setSkuDetails(skuDetails) + } + + fun setSubscriptionUpdateParams(params: SubscriptionUpdateParams): Builder = + apply { + builder.setSubscriptionUpdateParams(params.toOriginal()) + } + + fun build(): SuperwallBillingFlowParams = SuperwallBillingFlowParams(builder.build(), productDetailsParams) + } + + class ProductDetailsParams private constructor( + private val params: BillingFlowParams.ProductDetailsParams, + internal val details: ProductDetails, + ) { + fun toOriginal() = params + + companion object { + fun newBuilder(): ProductDetailsParamsBuilder = ProductDetailsParamsBuilder() + } + + class ProductDetailsParamsBuilder { + private val builder = BillingFlowParams.ProductDetailsParams.newBuilder() + internal var details: ProductDetails? = null + + fun setOfferToken(offerToken: String): ProductDetailsParamsBuilder = + apply { + builder.setOfferToken(offerToken) + } + + fun setProductDetails(productDetails: ProductDetails): ProductDetailsParamsBuilder = + apply { + details = productDetails + builder.setProductDetails(productDetails) + } + + fun build(): ProductDetailsParams { + val details = + details ?: throw IllegalArgumentException("ProductDetails are required") + + return ProductDetailsParams( + builder.build(), + details, + ) + } + } + } + + class SubscriptionUpdateParams private constructor( + private val params: BillingFlowParams.SubscriptionUpdateParams, + ) { + fun toOriginal() = params + + companion object { + fun newBuilder(): SubscriptionUpdateParamsBuilder = SubscriptionUpdateParamsBuilder() + } + + class SubscriptionUpdateParamsBuilder { + private val builder = BillingFlowParams.SubscriptionUpdateParams.newBuilder() + + fun setOldPurchaseToken(purchaseToken: String): SubscriptionUpdateParamsBuilder = + apply { + builder.setOldPurchaseToken(purchaseToken) + } + + @Deprecated("Use setOldPurchaseToken instead") + fun setOldSkuPurchaseToken(purchaseToken: String): SubscriptionUpdateParamsBuilder = + apply { + builder.setOldSkuPurchaseToken(purchaseToken) + } + + fun setOriginalExternalTransactionId(externalTransactionId: String): SubscriptionUpdateParamsBuilder = + apply { + builder.setOriginalExternalTransactionId(externalTransactionId) + } + + @Deprecated("Use setSubscriptionReplacementMode instead") + fun setReplaceProrationMode(replaceSkusProrationMode: Int): SubscriptionUpdateParamsBuilder = + apply { + builder.setReplaceProrationMode(replaceSkusProrationMode) + } + + @Deprecated("Use setSubscriptionReplacementMode instead") + fun setReplaceSkusProrationMode(replaceSkusProrationMode: Int): SubscriptionUpdateParamsBuilder = + apply { + builder.setReplaceSkusProrationMode(replaceSkusProrationMode) + } + + fun setSubscriptionReplacementMode(subscriptionReplacementMode: Int): SubscriptionUpdateParamsBuilder = + apply { + builder.setSubscriptionReplacementMode(subscriptionReplacementMode) + } + + fun build(): SubscriptionUpdateParams = SubscriptionUpdateParams(builder.build()) + } + + @Retention(AnnotationRetention.SOURCE) + annotation class ReplacementMode { + companion object { + const val UNKNOWN_REPLACEMENT_MODE = 0 + const val WITH_TIME_PRORATION = 1 + const val CHARGE_PRORATED_PRICE = 2 + const val WITHOUT_PRORATION = 3 + const val CHARGE_FULL_PRICE = 5 + const val DEFERRED = 6 + } + } + } + + @Deprecated("Use SubscriptionUpdateParams.ReplacementMode instead") + @Retention(AnnotationRetention.SOURCE) + annotation class ProrationMode { + companion object { + const val UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY = 0 + const val IMMEDIATE_WITH_TIME_PRORATION = 1 + const val IMMEDIATE_AND_CHARGE_PRORATED_PRICE = 2 + const val IMMEDIATE_WITHOUT_PRORATION = 3 + const val DEFERRED = 4 + const val IMMEDIATE_AND_CHARGE_FULL_PRICE = 5 + } + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt index c1bddda0..b6ec50d0 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt @@ -8,6 +8,7 @@ import com.superwall.sdk.models.assignment.ConfirmableAssignment import com.superwall.sdk.models.config.Config import com.superwall.sdk.models.config.PreloadingDisabled import com.superwall.sdk.models.paywall.Paywall +import com.superwall.sdk.models.product.ProductItem import com.superwall.sdk.models.triggers.* import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluating import java.util.* @@ -260,6 +261,7 @@ object ConfigLogic { skippedExperimentIds.add(rule.experiment.id) } } + TriggerPreloadBehavior.ALWAYS -> {} TriggerPreloadBehavior.NEVER -> skippedExperimentIds.add(rule.experiment.id) } @@ -316,4 +318,7 @@ object ConfigLogic { val triggers = from return triggers.associateBy { it.eventName } } + + // Returns entitlements mapped by product ID + fun extractEntitlementsByProductId(from: List) = from.associate { it.fullProductId to it.entitlements } } diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt index 63acb629..baf2170c 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -9,6 +9,7 @@ import com.superwall.sdk.dependencies.DeviceHelperFactory import com.superwall.sdk.dependencies.DeviceInfoFactory import com.superwall.sdk.dependencies.RequestFactory import com.superwall.sdk.dependencies.RuleAttributesFactory +import com.superwall.sdk.dependencies.StoreTransactionFactory import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger @@ -28,12 +29,12 @@ import com.superwall.sdk.network.SuperwallAPI import com.superwall.sdk.network.awaitUntilNetworkExists import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.paywall.manager.PaywallManager -import com.superwall.sdk.paywall.presentation.rule_logic.javascript.JavascriptEvaluator import com.superwall.sdk.storage.DisableVerboseEvents import com.superwall.sdk.storage.LatestConfig import com.superwall.sdk.storage.LatestGeoInfo import com.superwall.sdk.storage.Storage -import com.superwall.sdk.store.StoreKitManager +import com.superwall.sdk.store.Entitlements +import com.superwall.sdk.store.StoreManager import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.Flow @@ -48,7 +49,8 @@ import java.util.concurrent.atomic.AtomicInteger // TODO: Re-enable those params open class ConfigManager( private val context: Context, - private val storeKitManager: StoreKitManager, + private val storeManager: StoreManager, + private val entitlements: Entitlements, private val storage: Storage, private val network: SuperwallAPI, private val deviceHelper: DeviceHelper, @@ -67,10 +69,10 @@ open class ConfigManager( DeviceInfoFactory, RuleAttributesFactory, DeviceHelperFactory, - JavascriptEvaluator.Factory + StoreTransactionFactory // The configuration of the Superwall dashboard - val configState = MutableStateFlow(ConfigState.None) + internal val configState = MutableStateFlow(ConfigState.None) // Convenience variable to access config val config: Config? @@ -143,6 +145,7 @@ open class ConfigManager( } } } catch (e: Throwable) { + e.printStackTrace() // If fetching config fails, default to the cached version // Note: Only a timeout exception is possible here oldConfig?.let { @@ -221,7 +224,7 @@ open class ConfigManager( if (options.paywalls.shouldPreload) { val productIds = it.paywalls.flatMap { it.productIds }.toSet() try { - storeKitManager.products(productIds) + storeManager.products(productIds) } catch (e: Throwable) { Logger.debug( logLevel = LogLevel.error, @@ -305,6 +308,12 @@ open class ConfigManager( } triggersByEventName = ConfigLogic.getTriggersByEventName(config.triggers) assignments.choosePaywallVariants(config.triggers) + ConfigLogic.extractEntitlementsByProductId(config.products).let { + entitlements.addEntitlementsByProductId(it) + } + ioScope.launch { + storeManager.loadPurchasedProducts() + } } // Preloading Paywalls @@ -373,7 +382,7 @@ open class ConfigManager( return } - var retryCount: AtomicInteger = AtomicInteger(0) + val retryCount: AtomicInteger = AtomicInteger(0) val startTime = System.currentTimeMillis() network .getConfig { diff --git a/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt index 1d95f133..49b6cde2 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt @@ -10,9 +10,9 @@ import com.superwall.sdk.models.paywall.CacheKey import com.superwall.sdk.models.paywall.PaywallIdentifier import com.superwall.sdk.models.triggers.Trigger import com.superwall.sdk.paywall.manager.PaywallManager -import com.superwall.sdk.paywall.presentation.rule_logic.javascript.JavascriptEvaluator +import com.superwall.sdk.paywall.presentation.rule_logic.javascript.RuleEvaluator import com.superwall.sdk.paywall.request.ResponseIdentifiers -import com.superwall.sdk.paywall.vc.web_view.webViewExists +import com.superwall.sdk.paywall.view.webview.webViewExists import com.superwall.sdk.storage.LocalStorage import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job @@ -29,7 +29,7 @@ class PaywallPreload( interface Factory : RequestFactory, RuleAttributesFactory, - JavascriptEvaluator.Factory + RuleEvaluator.Factory private var currentPreloadingTask: Job? = null @@ -101,7 +101,6 @@ class PaywallPreload( overrides = null, isDebuggerLaunched = false, presentationSourceType = null, - retryCount = 6, ) try { paywallManager.getPaywallView( diff --git a/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt b/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt index b6b981a4..4affe957 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt @@ -2,7 +2,7 @@ package com.superwall.sdk.config.models import com.superwall.sdk.models.config.Config -sealed class ConfigState { +internal sealed class ConfigState { object None : ConfigState() object Retrieving : ConfigState() @@ -18,7 +18,7 @@ sealed class ConfigState { ) : ConfigState() } -fun ConfigState.getConfig(): Config? = +internal fun ConfigState.getConfig(): Config? = when (this) { is ConfigState.Retrieved -> config else -> null diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt index 1193203a..12b3a2bf 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt @@ -12,6 +12,8 @@ class SuperwallOptions { // Configures the appearance and behavior of paywalls. var paywalls: PaywallOptions = PaywallOptions() + var shouldObservePurchases = false + // **WARNING**: The different network environments that the SDK should use. // Only use this enum to set ``SuperwallOptions/networkEnvironment-swift.property`` // if told so explicitly by the Superwall team. diff --git a/superwall/src/main/java/com/superwall/sdk/contrib/threeteen/AmountFormats.kt b/superwall/src/main/java/com/superwall/sdk/contrib/threeteen/AmountFormats.kt index af53fed4..8da3c253 100644 --- a/superwall/src/main/java/com/superwall/sdk/contrib/threeteen/AmountFormats.kt +++ b/superwall/src/main/java/com/superwall/sdk/contrib/threeteen/AmountFormats.kt @@ -3,14 +3,11 @@ package com.superwall.sdk.contrib.threeteen import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger +import org.threeten.bp.Duration import org.threeten.bp.Period -import java.time.Duration -import java.time.format.DateTimeParseException +import org.threeten.bp.format.DateTimeParseException import java.util.* -import java.util.function.Function -import java.util.function.IntPredicate import java.util.regex.Pattern -import java.util.stream.Stream /* * Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos @@ -55,6 +52,10 @@ import java.util.stream.Stream * This class is immutable and thread-safe. */ object AmountFormats { + fun interface IntPredicate { + fun test(value: Int): Boolean + } + /** * The number of days per week. */ @@ -172,7 +173,7 @@ object AmountFormats { * List of DurationUnit values ordered by longest suffix first. */ private val DURATION_UNITS = - Arrays.asList( + listOf( DurationUnit("ns", Duration.ofNanos(1)), DurationUnit("µs", Duration.ofNanos(1000)), // U+00B5 = micro symbol DurationUnit("μs", Duration.ofNanos(1000)), // U+03BC = Greek letter mu @@ -431,23 +432,23 @@ object AmountFormats { // consume the leading sign - or + if one is present. var sign = 1 var updatedText = consumePrefix(durationText, '-') - if (updatedText.isPresent) { + if (updatedText.isSuccess) { sign = -1 offset += 1 - durationText = updatedText.get() + durationText = updatedText.getOrNull()!! } else { updatedText = consumePrefix(durationText, '+') - if (updatedText.isPresent) { + if (updatedText.isSuccess) { offset += 1 } - durationText = updatedText.orElse(durationText) + durationText = updatedText.getOrNull() ?: durationText } // special case for a string of "0" if (durationText == "0") { return Duration.ZERO } // special case, empty string as an invalid duration. - if (durationText.length == 0) { + if (durationText.isEmpty()) { throw DateTimeParseException("Not a numeric value", original, 0) } var value = Duration.ZERO @@ -459,9 +460,9 @@ object AmountFormats { val leadingInt: DurationScalar = integerPart var fraction: DurationScalar = EMPTY_FRACTION val dot = consumePrefix(durationText, '.') - if (dot.isPresent) { + if (dot.isSuccess) { offset += 1 - durationText = dot.get() + durationText = dot.getOrNull()!! val fractionPart = consumeDurationFraction(durationText, original, offset) // update the remaining string and fraction. offset += durationText.length - fractionPart.remainingText().length @@ -469,14 +470,14 @@ object AmountFormats { fraction = fractionPart } val optUnit = findUnit(durationText) - if (!optUnit.isPresent) { + if (optUnit.isFailure) { throw DateTimeParseException( "Invalid duration unit", original, offset, ) } - val unit = optUnit.get() + val unit = optUnit.getOrNull()!! try { var unitValue = leadingInt.applyTo(unit) val fractionValue = fraction.applyTo(unit) @@ -591,27 +592,24 @@ object AmountFormats { } // find the duration unit at the beginning of the input text, if present. - private fun findUnit(text: CharSequence): Optional = + private fun findUnit(text: CharSequence): Result = DURATION_UNITS - .stream() - .sequential() - .filter { du: DurationUnit -> + .firstOrNull { du: DurationUnit -> du.prefixMatchesUnit( text, ) - }.findFirst() + }?.let { Result.success(it) } ?: Result.failure(Exception("No matching duration unit found")) // consume the indicated {@code prefix} if it exists at the beginning of the - // text, returning the - // remaining string if the prefix was consumed. + // text, returning the remaining string if the prefix was consumed. private fun consumePrefix( text: CharSequence, prefix: Char, - ): Optional = - if (text.length > 0 && text[0] == prefix) { - Optional.of(text.subSequence(1, text.length)) + ): Result = + if (text.isNotEmpty() && text[0] == prefix) { + Result.success(text.subSequence(1, text.length)) } else { - Optional.empty() + Result.failure(Exception("Prefix not found")) } // ------------------------------------------------------------------------- @@ -695,11 +693,10 @@ object AmountFormats { init { check(predicateStrs.size + 1 == text.size) { "Invalid word-based resource" } predicates = - Stream - .of(*predicateStrs) + predicateStrs .map { predicateStr -> - findPredicate(predicateStr) - }.toArray { size -> arrayOfNulls(size) } + findPredicate(predicateStr!!) + }.toTypedArray() this.text = text } @@ -740,7 +737,7 @@ object AmountFormats { // scale the unit by the input scalingFunction, returning a value if // one is produced, or an empty result when the operation results in an // arithmetic overflow. - fun scaleBy(scaleFunc: Function): Duration? = scaleFunc.apply(value) + fun scaleBy(scaleFunc: (Duration) -> Duration?): Duration? = scaleFunc(value) } // interface for computing a duration from a duration unit and a scalar. @@ -774,7 +771,7 @@ object AmountFormats { // data holder for the fractional floating point value of a duration // scalar. - internal class FractionScalarPart constructor( + internal class FractionScalarPart( private val value: Long, private val scale: Long, ) : DurationScalar { diff --git a/superwall/src/main/java/com/superwall/sdk/debug/DebugManager.kt b/superwall/src/main/java/com/superwall/sdk/debug/DebugManager.kt index 0dea405f..55ce6a9e 100644 --- a/superwall/src/main/java/com/superwall/sdk/debug/DebugManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/debug/DebugManager.kt @@ -80,28 +80,20 @@ class DebugManager( currentView, ) } else { - val newViewController = factory.makeDebugViewController(paywallDatabaseId) + val newView = factory.makeDebugView(paywallDatabaseId) DebugViewActivity.startWithView( context, - newViewController, + newView, ) - view = newViewController + view = newView } } @MainThread suspend fun closeDebugger(animated: Boolean) { - // suspend fun dismissViewController() { view?.encapsulatingActivity?.finish() view = null isDebuggerLaunched = false -// } -// -// -// viewController?.presentedViewController?.let { -// it.dismiss(animated) -// dismissViewController() -// } ?: dismissViewController() } } diff --git a/superwall/src/main/java/com/superwall/sdk/debug/DebugView.kt b/superwall/src/main/java/com/superwall/sdk/debug/DebugView.kt index ab87a2b3..dcc1e70d 100644 --- a/superwall/src/main/java/com/superwall/sdk/debug/DebugView.kt +++ b/superwall/src/main/java/com/superwall/sdk/debug/DebugView.kt @@ -31,7 +31,6 @@ import com.superwall.sdk.BuildConfig import com.superwall.sdk.R import com.superwall.sdk.Superwall import com.superwall.sdk.debug.localizations.SWLocalizationActivity -import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.dependencies.RequestFactory import com.superwall.sdk.dependencies.ViewFactory import com.superwall.sdk.logger.LogLevel @@ -39,6 +38,7 @@ import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.fold import com.superwall.sdk.misc.toResult +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.network.Network import com.superwall.sdk.paywall.manager.PaywallManager @@ -49,8 +49,8 @@ import com.superwall.sdk.paywall.presentation.internal.state.PaywallSkippedReaso import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import com.superwall.sdk.paywall.request.PaywallRequestManager import com.superwall.sdk.paywall.request.ResponseIdentifiers -import com.superwall.sdk.paywall.vc.ActivityEncapsulatable -import com.superwall.sdk.store.StoreKitManager +import com.superwall.sdk.paywall.view.ActivityEncapsulatable +import com.superwall.sdk.store.StoreManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -65,7 +65,7 @@ interface AppCompatActivityEncapsulatable { class DebugView( private val context: Context, - private val storeKitManager: StoreKitManager, + private val storeManager: StoreManager, private val network: Network, private val paywallRequestManager: PaywallRequestManager, private val paywallManager: PaywallManager, @@ -83,7 +83,7 @@ class DebugView( val style: Int = AlertDialog.BUTTON_POSITIVE, ) - // The full screen activity instance if this view controller has been presented in one. + // The full screen activity instance if this view has been presented in one. override var encapsulatingActivity: AppCompatActivity? = null internal var paywallDatabaseId: String? = null @@ -561,12 +561,11 @@ class DebugView( overrides = null, isDebuggerLaunched = true, presentationSourceType = null, - retryCount = 6, ) var paywall = paywallRequestManager.getPaywall(request).toResult().getOrThrow() val productVariables = - storeKitManager.getProductVariables( + storeManager.getProductVariables( paywall, request = request, ) @@ -737,7 +736,7 @@ class DebugView( try { val (productsById, _) = - storeKitManager.getProducts( + storeManager.getProducts( paywall = paywall, ) @@ -836,7 +835,7 @@ class DebugView( // bottomButton.setImageDrawable(null) // bottomButton.showLoading = true - val inactiveSubscriptionPublisher = MutableStateFlow(SubscriptionStatus.INACTIVE) + val inactiveSubscriptionPublisher = MutableStateFlow(EntitlementStatus.Inactive) val presentationRequest = factory.makePresentationRequest( @@ -847,7 +846,7 @@ class DebugView( paywallOverrides = null, presenter = encapsulatingActivity, isDebuggerLaunched = true, - subscriptionStatus = inactiveSubscriptionPublisher, + entitlementStatus = inactiveSubscriptionPublisher, isPaywallPresented = Superwall.instance.isPaywallPresented, type = PresentationRequestType.Presentation, ) diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/PurchaseResult.kt b/superwall/src/main/java/com/superwall/sdk/delegate/PurchaseResult.kt index bf8192d1..ac37af06 100644 --- a/superwall/src/main/java/com/superwall/sdk/delegate/PurchaseResult.kt +++ b/superwall/src/main/java/com/superwall/sdk/delegate/PurchaseResult.kt @@ -14,7 +14,7 @@ sealed class InternalPurchaseResult { object Pending : InternalPurchaseResult() data class Failed( - val error: Exception, + val error: Throwable, ) : InternalPurchaseResult() } diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegate.kt b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegate.kt index f80e2edc..38d8783c 100644 --- a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegate.kt +++ b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegate.kt @@ -2,11 +2,12 @@ package com.superwall.sdk.delegate import android.net.Uri import com.superwall.sdk.analytics.superwall.SuperwallEventInfo +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.paywall.presentation.PaywallInfo import java.net.URI interface SuperwallDelegate { - fun subscriptionStatusDidChange(to: SubscriptionStatus) {} + fun entitlementStatusDidChange(to: EntitlementStatus) {} fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {} diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt index f0aaa7a3..e8c93469 100644 --- a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt +++ b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt @@ -2,6 +2,7 @@ package com.superwall.sdk.delegate import android.net.Uri import com.superwall.sdk.analytics.superwall.SuperwallEventInfo +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.paywall.presentation.PaywallInfo import java.net.URI @@ -49,9 +50,9 @@ class SuperwallDelegateAdapter { ?: javaDelegate?.handleSuperwallEvent(eventInfo) } - fun subscriptionStatusDidChange(newValue: SubscriptionStatus) { - kotlinDelegate?.subscriptionStatusDidChange(newValue) - ?: javaDelegate?.subscriptionStatusDidChange(newValue) + fun entitlementStatusDidChange(newValue: EntitlementStatus) { + kotlinDelegate?.entitlementStatusDidChange(newValue) + ?: javaDelegate?.entitlementStatusDidChange(newValue) } fun handleLog( diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateJava.kt b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateJava.kt index 2cb84214..f659334f 100644 --- a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateJava.kt +++ b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateJava.kt @@ -2,6 +2,7 @@ package com.superwall.sdk.delegate import android.net.Uri import com.superwall.sdk.analytics.superwall.SuperwallEventInfo +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.paywall.presentation.PaywallInfo import java.net.URI @@ -22,7 +23,7 @@ interface SuperwallDelegateJava { fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {} - fun subscriptionStatusDidChange(newValue: SubscriptionStatus) {} + fun entitlementStatusDidChange(newValue: EntitlementStatus) {} fun handleLog( level: String, 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 5dc49901..2977d1fc 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -22,7 +22,6 @@ import com.superwall.sdk.config.PaywallPreload import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.debug.DebugManager import com.superwall.sdk.debug.DebugView -import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.delegate.SuperwallDelegateAdapter import com.superwall.sdk.delegate.subscription_controller.PurchaseController import com.superwall.sdk.identity.IdentityInfo @@ -34,6 +33,7 @@ import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.MainScope import com.superwall.sdk.models.config.ComputedPropertyRequest import com.superwall.sdk.models.config.FeatureFlags +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.product.ProductVariable @@ -51,31 +51,33 @@ import com.superwall.sdk.paywall.manager.PaywallManager import com.superwall.sdk.paywall.manager.PaywallViewCache import com.superwall.sdk.paywall.presentation.internal.PresentationRequest import com.superwall.sdk.paywall.presentation.internal.PresentationRequestType +import com.superwall.sdk.paywall.presentation.internal.dismiss import com.superwall.sdk.paywall.presentation.internal.request.PaywallOverrides import com.superwall.sdk.paywall.presentation.internal.request.PresentationInfo import com.superwall.sdk.paywall.presentation.rule_logic.cel.SuperscriptEvaluator import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.CombinedExpressionEvaluator import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluating -import com.superwall.sdk.paywall.presentation.rule_logic.javascript.DefaultJavascriptEvalutor -import com.superwall.sdk.paywall.presentation.rule_logic.javascript.JavascriptEvaluator +import com.superwall.sdk.paywall.presentation.rule_logic.javascript.RuleEvaluator import com.superwall.sdk.paywall.request.PaywallRequest import com.superwall.sdk.paywall.request.PaywallRequestManager import com.superwall.sdk.paywall.request.PaywallRequestManagerDepFactory import com.superwall.sdk.paywall.request.ResponseIdentifiers -import com.superwall.sdk.paywall.vc.PaywallView -import com.superwall.sdk.paywall.vc.SuperwallStoreOwner -import com.superwall.sdk.paywall.vc.ViewModelFactory -import com.superwall.sdk.paywall.vc.ViewStorageViewModel -import com.superwall.sdk.paywall.vc.delegate.PaywallViewDelegateAdapter -import com.superwall.sdk.paywall.vc.web_view.SWWebView -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallMessageHandler -import com.superwall.sdk.paywall.vc.web_view.templating.models.JsonVariables -import com.superwall.sdk.paywall.vc.web_view.templating.models.Variables -import com.superwall.sdk.paywall.vc.web_view.webViewExists +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.SuperwallStoreOwner +import com.superwall.sdk.paywall.view.ViewModelFactory +import com.superwall.sdk.paywall.view.ViewStorageViewModel +import com.superwall.sdk.paywall.view.delegate.PaywallViewDelegateAdapter +import com.superwall.sdk.paywall.view.webview.SWWebView +import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandler +import com.superwall.sdk.paywall.view.webview.templating.models.JsonVariables +import com.superwall.sdk.paywall.view.webview.templating.models.Variables +import com.superwall.sdk.paywall.view.webview.webViewExists import com.superwall.sdk.storage.EventsQueue import com.superwall.sdk.storage.LocalStorage +import com.superwall.sdk.store.AutomaticPurchaseController +import com.superwall.sdk.store.Entitlements import com.superwall.sdk.store.InternalPurchaseController -import com.superwall.sdk.store.StoreKitManager +import com.superwall.sdk.store.StoreManager import com.superwall.sdk.store.abstractions.transactions.GoogleBillingPurchaseTransaction import com.superwall.sdk.store.abstractions.transactions.StoreTransaction import com.superwall.sdk.store.transactions.TransactionManager @@ -88,7 +90,6 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.ClassDiscriminatorMode import kotlinx.serialization.json.Json import java.lang.ref.WeakReference -import java.util.Base64 import java.util.Date class DependencyContainer( @@ -118,12 +119,13 @@ class DependencyContainer( ConfigManager.Factory, AppSessionManager.Factory, DebugView.Factory, - JavascriptEvaluator.Factory, + RuleEvaluator.Factory, JsonFactory, ConfigAttributesFactory, PaywallPreload.Factory, ViewStoreFactory, - SuperwallScopeFactory { + SuperwallScopeFactory, + GoogleBillingWrapper.Factory { var network: Network override var api: Api override var deviceHelper: DeviceHelper @@ -138,9 +140,12 @@ class DependencyContainer( var debugManager: DebugManager var paywallManager: PaywallManager var paywallRequestManager: PaywallRequestManager - var storeKitManager: StoreKitManager + var storeManager: StoreManager val transactionManager: TransactionManager val googleBillingWrapper: GoogleBillingWrapper + + var entitlements: Entitlements + private val uiScope get() = mainScope() private val ioScope @@ -148,14 +153,6 @@ class DependencyContainer( private val evaluator by lazy { CombinedExpressionEvaluator( storage = storage, - factory = this, - evaluator = - DefaultJavascriptEvalutor( - ioScope = ioScope, - uiScope = uiScope, - context = context, - storage = storage, - ), superscriptEvaluator = SuperscriptEvaluator( json = @@ -167,10 +164,6 @@ class DependencyContainer( factory = this, ioScope = ioScope, ), - track = { - Superwall.instance.track(it) - }, - shouldTraceResults = makeFeatureFlags()?.enableCELLogging ?: false, ) } @@ -180,8 +173,6 @@ class DependencyContainer( internal val errorTracker: ErrorTracker init { - // TODO: Add delegate adapter - // For tracking when the app enters the background. uiScope.launch { ProcessLifecycleOwner.get().lifecycle.addObserver(appLifecycleObserver) @@ -203,18 +194,27 @@ class DependencyContainer( } googleBillingWrapper = - GoogleBillingWrapper(context, appLifecycleObserver = appLifecycleObserver) + GoogleBillingWrapper( + context, + ioScope, + appLifecycleObserver = appLifecycleObserver, + this, + ) + storage = + LocalStorage(context = context, ioScope = ioScope(), factory = this, json = json()) + entitlements = Entitlements(storage) var purchaseController = InternalPurchaseController( - kotlinPurchaseController = purchaseController, + kotlinPurchaseController = + purchaseController + ?: AutomaticPurchaseController(context, ioScope, entitlements), javaPurchaseController = null, context, ) - storeKitManager = StoreKitManager(context, purchaseController, googleBillingWrapper) + storeManager = StoreManager(purchaseController, googleBillingWrapper) delegateAdapter = SuperwallDelegateAdapter() - storage = LocalStorage(context = context, ioScope = ioScope(), factory = this, json = json()) val httpConnection = CustomHttpUrlConnection( json = json(), @@ -256,7 +256,7 @@ class DependencyContainer( errorTracker = ErrorTracker(scope = ioScope, cache = storage) paywallRequestManager = PaywallRequestManager( - storeKitManager = storeKitManager, + storeManager = storeManager, network = network, factory = this, ioScope = ioScope, @@ -294,7 +294,7 @@ class DependencyContainer( configManager = ConfigManager( context = context, - storeKitManager = storeKitManager, + storeManager = storeManager, storage = storage, network = network, options = options, @@ -303,11 +303,11 @@ class DependencyContainer( deviceHelper = deviceHelper, assignments = assignments, ioScope = ioScope, - paywallPreload = - paywallPreload, + paywallPreload = paywallPreload, track = { Superwall.instance.track(it) }, + entitlements = entitlements, ) eventsQueue = @@ -356,13 +356,19 @@ class DependencyContainer( transactionManager = TransactionManager( - storeKitManager = storeKitManager, + storeManager = storeManager, purchaseController = purchaseController, - sessionEventsManager, eventsQueue = eventsQueue, + storage = storage, activityProvider, factory = this, - context = context, + track = { + Superwall.instance.track(it) + }, + dismiss = { it, et -> + Superwall.instance.dismiss(it, et) + }, + ioScope = ioScope(), ) /** @@ -411,8 +417,8 @@ class DependencyContainer( "X-Bundle-ID" to deviceHelper.bundleId, "X-Low-Power-Mode" to deviceHelper.isLowPowerModeEnabled.toString(), "X-Is-Sandbox" to deviceHelper.isSandbox.toString(), - "X-Subscription-Status" to - Superwall.instance.subscriptionStatus.value + "X-Entitlement-Status" to + Superwall.instance.entitlements.status.value .toString(), "Content-Type" to "application/json", "X-Current-Time" to dateFormat(DateUtils.ISO_MILLIS).format(Date()), @@ -423,7 +429,6 @@ class DependencyContainer( } private val paywallJson = Json { encodeDefaults = true } - private val encoder = Base64.getEncoder() override suspend fun makePaywallView( paywall: Paywall, @@ -435,7 +440,6 @@ class DependencyContainer( sessionEventsManager = sessionEventsManager, factory = this@DependencyContainer, ioScope = ioScope, - encoder = encoder, json = paywallJson, mainScope = mainScope(), ) @@ -472,11 +476,11 @@ class DependencyContainer( return paywallView } - override fun makeDebugViewController(id: String?): DebugView { + override fun makeDebugView(id: String?): DebugView { val view = DebugView( context = context, - storeKitManager = storeKitManager, + storeManager = storeManager, network = network, paywallRequestManager = paywallRequestManager, paywallManager = paywallManager, @@ -517,7 +521,9 @@ class DependencyContainer( audienceFilterParams = HashMap(identityManager.userAttributes), ) - override fun makeHasExternalPurchaseController(): Boolean = storeKitManager.purchaseController.hasExternalPurchaseController + override fun makeHasExternalPurchaseController(): Boolean = storeManager.purchaseController.hasExternalPurchaseController + + override fun makeHasInternalPurchaseController(): Boolean = storeManager.purchaseController.hasInternalPurchaseController override suspend fun didUpdateAppSession(appSession: AppSession) { } @@ -531,7 +537,6 @@ class DependencyContainer( overrides: PaywallRequest.Overrides?, isDebuggerLaunched: Boolean, presentationSourceType: String?, - retryCount: Int, ): PaywallRequest = PaywallRequest( @@ -540,7 +545,7 @@ class DependencyContainer( overrides = overrides ?: PaywallRequest.Overrides(products = null, isFreeTrial = null), isDebuggerLaunched = isDebuggerLaunched, presentationSourceType = presentationSourceType, - retryCount = retryCount, + retryCount = 6, ) override fun makePresentationRequest( @@ -548,7 +553,7 @@ class DependencyContainer( paywallOverrides: PaywallOverrides?, presenter: Activity?, isDebuggerLaunched: Boolean?, - subscriptionStatus: StateFlow?, + entitlementStatus: StateFlow?, isPaywallPresented: Boolean, type: PresentationRequestType, ): PresentationRequest = @@ -560,7 +565,7 @@ class DependencyContainer( PresentationRequest.Flags( isDebuggerLaunched = isDebuggerLaunched ?: debugManager.isDebuggerLaunched, // TODO: (PresentationCritical) Fix subscription status - subscriptionStatus = subscriptionStatus ?: Superwall.instance.subscriptionStatus, + entitlements = entitlementStatus ?: Superwall.instance.entitlements.status, // subscriptionStatus = subscriptionStatus!!, isPaywallPresented = isPaywallPresented, type = type, @@ -642,6 +647,8 @@ class DependencyContainer( appSessionId = appSessionManager.appSession.id, ) + override suspend fun activeProductIds(): List = storeManager.receiptManager.purchases.toList() + override fun makeTransactionVerifier(): GoogleBillingWrapper = googleBillingWrapper override fun makeSuperwallOptions(): SuperwallOptions = configManager.options diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt index a1af19f0..d990c758 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt @@ -7,7 +7,6 @@ import com.superwall.sdk.billing.GoogleBillingWrapper import com.superwall.sdk.config.ConfigManager import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.debug.DebugView -import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.identity.IdentityInfo import com.superwall.sdk.identity.IdentityManager import com.superwall.sdk.misc.AppLifecycleObserver @@ -15,6 +14,7 @@ import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.MainScope import com.superwall.sdk.models.config.ComputedPropertyRequest import com.superwall.sdk.models.config.FeatureFlags +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.product.ProductVariable @@ -29,10 +29,10 @@ import com.superwall.sdk.paywall.presentation.internal.request.PaywallOverrides import com.superwall.sdk.paywall.presentation.internal.request.PresentationInfo import com.superwall.sdk.paywall.request.PaywallRequest import com.superwall.sdk.paywall.request.ResponseIdentifiers -import com.superwall.sdk.paywall.vc.PaywallView -import com.superwall.sdk.paywall.vc.ViewStorage -import com.superwall.sdk.paywall.vc.delegate.PaywallViewDelegateAdapter -import com.superwall.sdk.paywall.vc.web_view.templating.models.JsonVariables +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.ViewStorage +import com.superwall.sdk.paywall.view.delegate.PaywallViewDelegateAdapter +import com.superwall.sdk.paywall.view.webview.templating.models.JsonVariables import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.store.abstractions.transactions.StoreTransaction import kotlinx.coroutines.flow.StateFlow @@ -74,7 +74,6 @@ interface RequestFactory { overrides: PaywallRequest.Overrides?, isDebuggerLaunched: Boolean, presentationSourceType: String?, - retryCount: Int, ): PaywallRequest fun makePresentationRequest( @@ -82,7 +81,7 @@ interface RequestFactory { paywallOverrides: PaywallOverrides? = null, presenter: Activity? = null, isDebuggerLaunched: Boolean? = null, - subscriptionStatus: StateFlow? = null, + entitlementStatus: StateFlow? = null, isPaywallPresented: Boolean, type: PresentationRequestType, ): PresentationRequest @@ -127,6 +126,10 @@ interface HasExternalPurchaseControllerFactory { fun makeHasExternalPurchaseController(): Boolean } +interface HasInternalPurchaseControllerFactory { + fun makeHasInternalPurchaseController(): Boolean +} + interface ViewFactory { // NOTE: THIS MUST BE EXECUTED ON THE MAIN THREAD (no way to enforce in Kotlin) suspend fun makePaywallView( @@ -135,26 +138,8 @@ interface ViewFactory { delegate: PaywallViewDelegateAdapter?, ): PaywallView - fun makeDebugViewController(id: String?): DebugView -} - -// ViewControllerFactory & CacheFactory & DeviceInfoFactory, -// interface ViewControllerCacheDevice { -// suspend fun makePaywallViewController( -// paywall: Paywall, -// cache: PaywallViewControllerCache?, -// delegate: PaywallViewControllerDelegate? -// ): PaywallViewController -// -// // TODO: (Debug) -// // fun makeDebugViewController(id: String?): DebugViewController -// -// // Mark - device -// fun makeDeviceInfo(): DeviceInfo -// -// // Mark - cache -// fun makeCache(): PaywallViewControllerCache -// } + fun makeDebugView(id: String?): DebugView +} interface CacheFactory { fun makeCache(): PaywallViewCache @@ -181,6 +166,8 @@ interface ConfigManagerFactory { interface StoreTransactionFactory { suspend fun makeStoreTransaction(transaction: Purchase): StoreTransaction + + suspend fun activeProductIds(): List } interface OptionsFactory { diff --git a/superwall/src/main/java/com/superwall/sdk/game/GameControllerManager.kt b/superwall/src/main/java/com/superwall/sdk/game/GameControllerManager.kt index b83b2a0d..a96bb2bb 100644 --- a/superwall/src/main/java/com/superwall/sdk/game/GameControllerManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/game/GameControllerManager.kt @@ -4,7 +4,7 @@ import android.view.KeyEvent import android.view.MotionEvent interface GameControllerDelegate { - fun gameControllerEventDidOccur(event: GameControllerEvent) + fun gameControllerEventOccured(event: GameControllerEvent) } class GameControllerManager { @@ -39,7 +39,7 @@ class GameControllerManager { y = y.toDouble(), directional = directional, ) - delegate?.gameControllerEventDidOccur(event) + delegate?.gameControllerEventOccured(event) } fun dispatchKeyEvent(event: KeyEvent): Boolean { diff --git a/superwall/src/main/java/com/superwall/sdk/misc/Config+AwaitFirstValidConfig.kt b/superwall/src/main/java/com/superwall/sdk/misc/Config+AwaitFirstValidConfig.kt index a9c3b2ed..539c4698 100644 --- a/superwall/src/main/java/com/superwall/sdk/misc/Config+AwaitFirstValidConfig.kt +++ b/superwall/src/main/java/com/superwall/sdk/misc/Config+AwaitFirstValidConfig.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first -suspend fun Flow.awaitFirstValidConfig(): Config = +internal suspend fun Flow.awaitFirstValidConfig(): Config = try { filterIsInstance() .first() diff --git a/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt b/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt index b829106f..a406cefa 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt @@ -3,6 +3,7 @@ package com.superwall.sdk.models.config import com.superwall.sdk.models.SerializableEntity import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.postback.PostbackRequest +import com.superwall.sdk.models.product.ProductItem import com.superwall.sdk.models.triggers.Trigger import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -16,6 +17,7 @@ data class Config( var postback: PostbackRequest, @SerialName("appSessionTimeoutMs") var appSessionTimeout: Long, @SerialName("toggles") var rawFeatureFlags: List, + @SerialName("products") val products: List, @SerialName("disablePreload") var preloadingDisabled: PreloadingDisabled, @SerialName("localization") var localizationConfig: LocalizationConfig, var requestId: String? = null, @@ -72,6 +74,7 @@ data class Config( preloadingDisabled = PreloadingDisabled.stub(), localizationConfig = LocalizationConfig(locales = emptyList()), buildId = "stub-build-id", + products = emptyList(), ) } } diff --git a/superwall/src/main/java/com/superwall/sdk/models/entitlements/Entitlement.kt b/superwall/src/main/java/com/superwall/sdk/models/entitlements/Entitlement.kt new file mode 100644 index 00000000..927027bc --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/entitlements/Entitlement.kt @@ -0,0 +1,20 @@ +package com.superwall.sdk.models.entitlements + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Entitlement( + @SerialName("identifier") + val id: String, + @SerialName("type") + val type: Type = Type.SERVICE_LEVEL, +) { + @Serializable + enum class Type( + val raw: String, + ) { + @SerialName("SERVICE_LEVEL") + SERVICE_LEVEL("SERVICE_LEVEL"), + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/models/entitlements/EntitlementStatus.kt b/superwall/src/main/java/com/superwall/sdk/models/entitlements/EntitlementStatus.kt new file mode 100644 index 00000000..debcb163 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/entitlements/EntitlementStatus.kt @@ -0,0 +1,41 @@ +package com.superwall.sdk.models.entitlements + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents the status of a user's entitlements. + * + * This sealed class has three possible states: + * - [Unknown]: The initial state before any entitlement status is determined + * - [Inactive]: When the user has no active entitlements + * - [Active]: When the user has one or more active entitlements + */ +@Serializable +sealed class EntitlementStatus { + /** + * Represents an unknown entitlement status. + * This is the initial state before any entitlement status is determined. + */ + @Serializable + object Unknown : EntitlementStatus() + + /** + * Represents an inactive entitlement status. + * This state indicates the user has no active entitlements. + */ + @Serializable + object Inactive : EntitlementStatus() + + /** + * Represents an active entitlement status. + * This state indicates the user has one or more active entitlements. + * + * @property entitlements A Set of active [Entitlement] objects belonging to the user + */ + @Serializable + data class Active( + @SerialName("entitlements") + val entitlements: Set, + ) : EntitlementStatus() +} diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/LocalNotification.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/LocalNotification.kt index f0e978aa..69fe01d6 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/paywall/LocalNotification.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/LocalNotification.kt @@ -16,6 +16,8 @@ class LocalNotification( val type: LocalNotificationType, @SerialName("title") val title: String, + @SerialName("subtitle") + val subtitle: String? = null, @SerialName("body") val body: String, @SerialName("delay") diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt index d306eeeb..06efac26 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt @@ -10,10 +10,8 @@ import com.superwall.sdk.models.SerializableEntity import com.superwall.sdk.models.config.ComputedPropertyRequest import com.superwall.sdk.models.config.FeatureGatingBehavior import com.superwall.sdk.models.events.EventData -import com.superwall.sdk.models.product.Product import com.superwall.sdk.models.product.ProductItem import com.superwall.sdk.models.product.ProductItemsDeserializer -import com.superwall.sdk.models.product.ProductType import com.superwall.sdk.models.product.ProductVariable import com.superwall.sdk.models.serialization.DateSerializer import com.superwall.sdk.models.triggers.Experiment @@ -27,7 +25,9 @@ import java.util.* @JvmInline value class PaywallURL( val value: String, -) +) { + override fun toString(): String = value +} @Serializable data class Paywalls( @@ -64,7 +64,6 @@ data class Paywall( "Unknown or unsupported presentation style: $presentationStyle", ) }, - condition = PresentationCondition.valueOf(presentationCondition.uppercase()), delay = presentationDelay, ), @SerialName("background_color_hex") @@ -73,7 +72,7 @@ data class Paywall( val darkBackgroundColorHex: String? = null, // Declared as private to prevent direct access @kotlinx.serialization.Transient() - private var _products: List = emptyList(), + private var _products: List = emptyList(), @Serializable(with = ProductItemsDeserializer::class) @SerialName("products_v2") private var _productItems: List, @@ -131,11 +130,11 @@ data class Paywall( _productItems = value // Automatically update related properties when productItems is set productIds = value.map { it.fullProductId } - _products = makeProducts(value) // Assuming makeProducts is a function that generates products based on product items + _products = value // Assuming makeProducts is a function that generates products based on product items } // Public getter for products to allow access but not direct modification - val products: List + val products: List get() = _products val backgroundColor: Int by lazy { @@ -200,7 +199,6 @@ data class Paywall( url = url, products = products, productIds = productIds, - productItems = productItems, eventData = fromEvent, responseLoadStartTime = responseLoadingInfo.startAt, responseLoadCompleteTime = responseLoadingInfo.endAt, @@ -229,29 +227,6 @@ data class Paywall( ) companion object { - private fun makeProducts(productItems: List): List { - val output = mutableListOf() - - for (productItem in productItems) { - when (productItem.name) { - "primary" -> - output.add( - Product(type = ProductType.PRIMARY, id = productItem.fullProductId), - ) - "secondary" -> - output.add( - Product(type = ProductType.SECONDARY, id = productItem.fullProductId), - ) - "tertiary" -> - output.add( - Product(type = ProductType.TERTIARY, id = productItem.fullProductId), - ) - } - } - - return output - } - fun stub(): Paywall = Paywall( databaseId = "id", @@ -262,7 +237,6 @@ data class Paywall( presentation = PaywallPresentationInfo( PaywallPresentationStyle.MODAL, - PresentationCondition.CHECK_USER_SUBSCRIPTION, 300, ), presentationStyle = "MODAL", diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallPresentationInfo.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallPresentationInfo.kt index 2c522845..aaf8be6e 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallPresentationInfo.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallPresentationInfo.kt @@ -9,9 +9,6 @@ data class PaywallPresentationInfo( // The presentation style of the paywall @SerialName("style") val style: PaywallPresentationStyle, - // The condition for when a paywall should present. - @SerialName("condition") - val condition: PresentationCondition, // The delay in milliseconds before switching from the loading view to // the paywall view. @SerialName("delay") diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/PresentationCondition.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/PresentationCondition.kt deleted file mode 100644 index 833af4e9..00000000 --- a/superwall/src/main/java/com/superwall/sdk/models/paywall/PresentationCondition.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.superwall.sdk.models.paywall - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -enum class PresentationCondition { - @SerialName("ALWAYS") - ALWAYS, - - @SerialName("CHECK_USER_SUBSCRIPTION") - CHECK_USER_SUBSCRIPTION, -} diff --git a/superwall/src/main/java/com/superwall/sdk/models/product/Product.kt b/superwall/src/main/java/com/superwall/sdk/models/product/Product.kt index 997d4685..a0712370 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/product/Product.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/product/Product.kt @@ -1,19 +1,7 @@ package com.superwall.sdk.models.product -import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException -import kotlinx.serialization.Serializer -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive @Serializable enum class ProductType { @@ -30,13 +18,7 @@ enum class ProductType { override fun toString() = name.lowercase() } - -@Serializable(with = ProductSerializer::class) -data class Product( - val type: ProductType, - val id: String, -) - +/* @Serializer(forClass = Product::class) object ProductSerializer : KSerializer { override fun serialize( @@ -70,3 +52,5 @@ object ProductSerializer : KSerializer { return Product(type, id) } } +* + */ diff --git a/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt b/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt index f3c5e8f4..7ccac177 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.models.product +import com.superwall.sdk.models.entitlements.Entitlement import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -124,9 +125,13 @@ object PlayStoreProductSerializer : KSerializer { @Serializable(with = ProductItemSerializer::class) data class ProductItem( + // Note: This is used only by paywall as a reference to the object. Otherwise, it is empty. @SerialName("reference_name") val name: String, + @SerialName("store_product") val type: StoreProductType, + @SerialName("entitlements") + val entitlements: Set, ) { sealed class StoreProductType { data class PlayStore( @@ -168,10 +173,16 @@ object ProductItemSerializer : KSerializer { val jsonObject = jsonInput.decodeJsonElement().jsonObject // Extract fields using the expected names during deserialization - val name = jsonObject["reference_name"]?.jsonPrimitive?.content ?: throw SerializationException("Missing reference_name") + val name = jsonObject["reference_name"]?.jsonPrimitive?.content ?: "" val storeProductJsonObject = jsonObject["store_product"]?.jsonObject ?: throw SerializationException("Missing store_product") + val entitlements = + jsonObject["entitlements"] + ?.jsonArray + ?.map { + Json.decodeFromJsonElement(it) + }?.toSet() ?: emptySet() // Deserialize 'storeProduct' JSON object into the expected Kotlin data class val storeProduct = Json.decodeFromJsonElement(storeProductJsonObject) @@ -179,6 +190,7 @@ object ProductItemSerializer : KSerializer { return ProductItem( name = name, type = ProductItem.StoreProductType.PlayStore(storeProduct), + entitlements = entitlements, ) } } diff --git a/superwall/src/main/java/com/superwall/sdk/models/transactions/SavedTransaction.kt b/superwall/src/main/java/com/superwall/sdk/models/transactions/SavedTransaction.kt new file mode 100644 index 00000000..42fff803 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/transactions/SavedTransaction.kt @@ -0,0 +1,23 @@ +package com.superwall.sdk.models.transactions + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * The transaction to save to storage + * @param id The id of the transaction + * @param date The date of the transaction as unix epoch time + * @param hasExternalPurchaseController Whether the transaction has an external purchase controller + * @param isExternal Whether the transaction is external + **/ +@Serializable +class SavedTransaction( + @SerialName("id") + val id: String, + @SerialName("date") + val date: Long, + @SerialName("hasExternalPurchaseController") + val hasExternalPurchaseController: Boolean, + @SerialName("isExternal") + val isExternal: Boolean, +) diff --git a/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerResult.kt b/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerResult.kt index 1f8ee4aa..c2d0968e 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerResult.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerResult.kt @@ -39,7 +39,7 @@ sealed class TriggerResult { // An error occurred and the user will not be shown a paywall. // - // If the error code is `101`, it means that no view controller could be found to present on. Otherwise a network failure may have occurred. + // If the error code is `101`, it means that no view could be found to present on. Otherwise a network failure may have occurred. // // In these instances, consider falling back to a native paywall. @Serializable @@ -92,7 +92,7 @@ sealed class InternalTriggerResult { /** * An error occurred and the user will not be shown a paywall. * - * If the error code is `101`, it means that no view controller could be found to present on. Otherwise a network failure may have occurred. + * If the error code is `101`, it means that no view could be found to present on. Otherwise a network failure may have occurred. * * In these instances, consider falling back to a native paywall. */ diff --git a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt index 87ed3d6b..3ce1195e 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt @@ -15,10 +15,10 @@ import com.superwall.sdk.BuildConfig import com.superwall.sdk.Superwall import com.superwall.sdk.dependencies.IdentityInfoFactory import com.superwall.sdk.dependencies.LocaleIdentifierFactory +import com.superwall.sdk.dependencies.StoreTransactionFactory import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger -import com.superwall.sdk.misc.fold import com.superwall.sdk.misc.then import com.superwall.sdk.misc.toResult import com.superwall.sdk.models.config.ComputedPropertyRequest @@ -26,7 +26,7 @@ import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.geo.GeoInfo import com.superwall.sdk.network.JsonFactory import com.superwall.sdk.network.SuperwallAPI -import com.superwall.sdk.paywall.vc.web_view.templating.models.DeviceTemplate +import com.superwall.sdk.paywall.view.webview.templating.models.DeviceTemplate import com.superwall.sdk.storage.LastPaywallView import com.superwall.sdk.storage.LatestGeoInfo import com.superwall.sdk.storage.LocalStorage @@ -38,8 +38,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json +import org.threeten.bp.Duration +import org.threeten.bp.Instant import java.text.SimpleDateFormat -import java.time.Duration import java.util.Currency import java.util.Date import java.util.Locale @@ -62,7 +63,8 @@ class DeviceHelper( interface Factory : IdentityInfoFactory, LocaleIdentifierFactory, - JsonFactory + JsonFactory, + StoreTransactionFactory private val json = Json { @@ -76,78 +78,72 @@ class DeviceHelper( private val appInstallDate = Date(appInfo.firstInstallTime) fun daysSince(date: Date): Int { - val fromDate = date - val toDate = Date() - val fromInstant = fromDate.toInstant() - val toInstant = toDate.toInstant() - val duration = Duration.between(fromInstant, toInstant) + val fromDate = Instant.ofEpochMilli(date.time) + val toDate = Instant.now() + val duration = Duration.between(fromDate, toDate) return duration.toDays().toInt() } fun minutesSince(date: Date): Int { - val fromDate = date - val toDate = Date() - val fromInstant = fromDate.toInstant() - val toInstant = toDate.toInstant() - val duration = Duration.between(fromInstant, toInstant) + val fromDate = Instant.ofEpochMilli(date.time) + val toDate = Instant.now() + val duration = Duration.between(fromDate, toDate) return duration.toMinutes().toInt() } fun hoursSince(date: Date): Int { - val fromDate = date - val toDate = Date() - val fromInstant = fromDate.toInstant() - val toInstant = toDate.toInstant() - val duration = Duration.between(fromInstant, toInstant) + val fromDate = Instant.ofEpochMilli(date.time) + val toDate = Instant.now() + val duration = Duration.between(fromDate, toDate) return duration.toHours().toInt() } fun monthsSince(date: Date): Int { - val fromDate = date - val toDate = Date() - val fromInstant = fromDate.toInstant() - val toInstant = toDate.toInstant() - val duration = Duration.between(fromInstant, toInstant) + val fromDate = Instant.ofEpochMilli(date.time) + val toDate = Instant.now() + val duration = Duration.between(fromDate, toDate) return duration.toDays().toInt() / 30 } private val daysSinceInstall: Int get() { - val fromDate = appInstallDate - val toDate = Date() - val fromInstant = fromDate.toInstant() - val toInstant = toDate.toInstant() - val duration = Duration.between(fromInstant, toInstant) + val fromDate = Instant.ofEpochMilli(appInstallDate.time) + val toDate = Instant.now() + val duration = Duration.between(fromDate, toDate) return duration.toDays().toInt() } private val minutesSinceInstall: Int get() { - val fromDate = appInstallDate - val toDate = Date() - val fromInstant = fromDate.toInstant() - val toInstant = toDate.toInstant() - val duration = Duration.between(fromInstant, toInstant) + val fromDate = Instant.ofEpochMilli(appInstallDate.time) + val toDate = Instant.now() + val duration = Duration.between(fromDate, toDate) return duration.toMinutes().toInt() } private val daysSinceLastPaywallView: Int? get() { - val fromDate = storage.read(LastPaywallView) ?: return null - val toDate = Date() - val fromInstant = fromDate.toInstant() - val toInstant = toDate.toInstant() - val duration = Duration.between(fromInstant, toInstant) + val fromDate = + storage.read(LastPaywallView)?.let { + Instant + .ofEpochMilli(it.time) + } + ?: return null + val toDate = Instant.now() + val duration = Duration.between(fromDate, toDate) return duration.toDays().toInt() } private val minutesSinceLastPaywallView: Int? get() { - val fromDate = storage.read(LastPaywallView) ?: return null - val toDate = Date() - val fromInstant = fromDate.toInstant() - val toInstant = toDate.toInstant() - val duration = Duration.between(fromInstant, toInstant) + val fromDate = + storage.read(LastPaywallView)?.let { + Instant + .ofEpochMilli(it.time) + } + ?: return null + val toDate = Instant.now() + val duration = Duration.between(fromDate, toDate) return duration.toMinutes().toInt() } @@ -238,12 +234,20 @@ class DeviceHelper( return "" } - val networkCapabilities = - connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) - return when { - networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "Cellular" - networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "Wifi" - else -> "" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val networkCapabilities = + connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + return when { + networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "Cellular" + networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "Wifi" + else -> "" + } + } else { + when (connectivityManager.activeNetworkInfo?.type) { + ConnectivityManager.TYPE_MOBILE -> return "Cellular" + ConnectivityManager.TYPE_WIFI -> return "Wifi" + else -> return "" + } } } @@ -374,10 +378,10 @@ class DeviceHelper( val sdkVersion: String get() = BuildConfig.SDK_VERSION - val buildTime: String? + val buildTime: String get() = BuildConfig.BUILD_TIME - val gitSha: String? + val gitSha: String get() = BuildConfig.GIT_SHA suspend fun getDeviceAttributes( @@ -476,9 +480,13 @@ class DeviceHelper( utcDateTime = utcDateTimeString, localDateTime = localDateTimeString, isSandbox = isSandbox.toString(), - subscriptionStatus = - Superwall.instance.subscriptionStatus.value - .toString(), + activeEntitlements = + Superwall.instance.entitlements.active + .map { it.id }, + activeEntitlementsObject = + Superwall.instance.entitlements.active + .map { mapOf("identifier" to it.id, "type" to it.type.raw) }, + activeProducts = factory.activeProductIds(), isFirstAppOpen = isFirstAppOpen, sdkVersion = sdkVersion, sdkVersionPadded = sdkVersionPadded, 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 9e02137f..5e51cd4d 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 @@ -7,13 +7,12 @@ import com.superwall.sdk.dependencies.ViewFactory import com.superwall.sdk.misc.Either import com.superwall.sdk.misc.launchWithTracking import com.superwall.sdk.misc.mapAsync -import com.superwall.sdk.misc.toResult import com.superwall.sdk.models.paywall.PaywallIdentifier import com.superwall.sdk.paywall.request.PaywallRequest import com.superwall.sdk.paywall.request.PaywallRequestManager -import com.superwall.sdk.paywall.vc.PaywallView -import com.superwall.sdk.paywall.vc.delegate.PaywallLoadingState -import com.superwall.sdk.paywall.vc.delegate.PaywallViewDelegateAdapter +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState +import com.superwall.sdk.paywall.view.delegate.PaywallViewDelegateAdapter class PaywallManager( private val factory: PaywallManager.Factory, @@ -28,13 +27,6 @@ class PaywallManager( var currentView: PaywallView? = null get() = cache.activePaywallView - @Deprecated("Will be removed in the upcoming versions, use curentView instead") - var presentedViewController: PaywallView? - get() = currentView - set(value) { - currentView = value - } - private var _cache: PaywallViewCache? = null private val cache: PaywallViewCache @@ -51,11 +43,6 @@ class PaywallManager( return cache } - @Deprecated("Will be removed in the upcoming versions, use removePaywallView instead") - fun removePaywallViewController(forKey: String) { - removePaywallView(forKey) - } - fun removePaywallView(identifier: PaywallIdentifier) { cache.removePaywallView(identifier) } @@ -81,14 +68,6 @@ class PaywallManager( } } - @Deprecated("Will be removed in the upcoming versions, use getPaywallView instead") - suspend fun getPaywallViewController( - request: PaywallRequest, - isForPresentation: Boolean, - isPreloading: Boolean, - delegate: PaywallViewDelegateAdapter?, - ): Result = getPaywallView(request, isForPresentation, isPreloading, delegate).toResult() - suspend fun getPaywallView( request: PaywallRequest, isForPresentation: Boolean, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt index 2a3549e7..c80ac542 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt @@ -4,10 +4,10 @@ import android.content.Context import com.superwall.sdk.misc.ActivityProvider import com.superwall.sdk.models.paywall.PaywallIdentifier import com.superwall.sdk.network.device.DeviceHelper -import com.superwall.sdk.paywall.vc.LoadingView -import com.superwall.sdk.paywall.vc.PaywallView -import com.superwall.sdk.paywall.vc.ShimmerView -import com.superwall.sdk.paywall.vc.ViewStorage +import com.superwall.sdk.paywall.view.LoadingView +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.ShimmerView +import com.superwall.sdk.paywall.view.ViewStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -37,9 +37,6 @@ class PaywallViewCache( .map { it.key to it.value } .toMap() - @Deprecated("Will be removed in the upcoming versions in favor of `getPaywallViews`") - fun getAllPaywallViewControllers(): List = getAllPaywallViews() - fun getAllPaywallViews(): List = runBlocking(singleThreadContext) { store.all().filterIsInstance().toList() diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt index 90b11793..7b16ac0f 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt @@ -10,8 +10,8 @@ import com.superwall.sdk.models.config.FeatureGatingBehavior import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.LocalNotification import com.superwall.sdk.models.paywall.PaywallPresentationInfo +import com.superwall.sdk.models.paywall.PaywallPresentationStyle import com.superwall.sdk.models.paywall.PaywallURL -import com.superwall.sdk.models.product.Product import com.superwall.sdk.models.product.ProductItem import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.store.abstractions.product.StoreProduct @@ -27,14 +27,7 @@ data class PaywallInfo( val name: String, val url: PaywallURL, val experiment: Experiment?, - @Deprecated("This will always be an empty string and will be removed in the next major update of the SDK.") - val triggerSessionId: String = "", - @Deprecated( - message = "Use productItems because a paywall can support more than three products", - ReplaceWith("productsItems"), - ) - val products: List, - val productItems: List, + val products: List, val productIds: List, val presentedByEventWithName: String?, val presentedByEventWithId: String?, @@ -72,8 +65,7 @@ data class PaywallInfo( identifier: String, name: String, url: PaywallURL, - products: List, - productItems: List, + products: List, productIds: List, eventData: EventData?, responseLoadStartTime: Date?, @@ -111,7 +103,6 @@ data class PaywallInfo( experiment = experiment, paywalljsVersion = paywalljsVersion, products = products, - productItems = productItems, productIds = productIds, isFreeTrialAvailable = isFreeTrialAvailable, featureGatingBehavior = featureGatingBehavior, @@ -279,7 +270,7 @@ data class PaywallInfo( output["secondary_product_id"] = "" output["tertiary_product_id"] = "" - productItems.forEachIndexed { index, product -> + products.forEachIndexed { index, product -> when (index) { 0 -> output["primary_product_id"] = product.fullProductId 1 -> output["secondary_product_id"] = product.fullProductId @@ -292,7 +283,48 @@ data class PaywallInfo( return output.filter { (_, value) -> value != null } as MutableMap } - private companion object { + companion object { private val json = Json { } + + fun empty() = + PaywallInfo( + databaseId = "", + identifier = "", + name = "", + url = PaywallURL(""), + experiment = null, + products = emptyList(), + productIds = emptyList(), + presentedByEventWithName = null, + presentedByEventWithId = null, + presentedByEventAt = null, + presentedBy = "", + presentationSourceType = null, + responseLoadStartTime = null, + responseLoadCompleteTime = null, + responseLoadFailTime = null, + responseLoadDuration = null, + webViewLoadStartTime = null, + webViewLoadCompleteTime = null, + webViewLoadFailTime = null, + webViewLoadDuration = null, + productsLoadStartTime = null, + productsLoadCompleteTime = null, + productsLoadFailTime = null, + productsLoadDuration = null, + shimmerLoadStartTime = null, + shimmerLoadCompleteTime = null, + paywalljsVersion = null, + isFreeTrialAvailable = false, + featureGatingBehavior = FeatureGatingBehavior.NonGated, + closeReason = PaywallCloseReason.None, + localNotifications = emptyList(), + computedPropertyRequests = emptyList(), + surveys = emptyList(), + presentation = PaywallPresentationInfo(PaywallPresentationStyle.NONE, 0), + buildId = "", + cacheKey = "", + isScrollEnabled = true, + ) } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PublicPresentation.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PublicPresentation.kt index 6bc25e23..d45437cd 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PublicPresentation.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PublicPresentation.kt @@ -23,8 +23,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +/** + * Dismisses the presented paywall, if one exists. + */ suspend fun Superwall.dismiss() = withContext(Dispatchers.Main) { val completionSignal = CompletableDeferred() @@ -40,6 +44,19 @@ suspend fun Superwall.dismiss() = completionSignal.await() } +/** + * Dismisses the presented paywall synchronously, if one exists. + * Warning: This blocks the calling thread. + */ +fun Superwall.dismissSync() { + runBlocking { + dismiss() + } +} + +/** + * Dismisses the presented paywall, if it exists, in order to present a different one. + */ suspend fun Superwall.dismissForNextPaywall() = withContext(Dispatchers.Main) { val completionSignal = CompletableDeferred() @@ -58,6 +75,37 @@ suspend fun Superwall.dismissForNextPaywall() = completionSignal.await() } +/** + * Dismisses the presented paywall synchronously, if it exists, in order to present a different one. + * Warning: This blocks the calling thread. + */ +fun Superwall.dismissSyncForNextPaywall() = + runBlocking { + dismissForNextPaywall() + } + +/** + * Registers an event to access a feature. When the event is added to a campaign on the Superwall dashboard, it can show a paywall. + * + * This shows a paywall to the user when: An event you provide is added to a campaign on the [Superwall Dashboard](https://superwall.com/dashboard); + * the user matches a rule in the campaign; and the user doesn't have an active subscription. + * + * Before using this method, you'll first need to create a campaign and add the event to the campaign on the [Superwall Dashboard](https://superwall.com/dashboard). + * + * The paywall shown to the user is determined by the rules defined in the campaign. When a user is assigned a paywall within a rule, + * they will continue to see that paywall unless you remove the paywall from the rule or reset assignments to the paywall. + * + * @param event The name of the event you wish to register. + * @param params Optional parameters you'd like to pass with your event. These can be referenced within the rules of your campaign. + * Keys beginning with `$` are reserved for Superwall and will be dropped. Values can be any JSON encodable value, URLs or Dates. + * Arrays and dictionaries as values are not supported at this time, and will be dropped. Defaults to `null`. + * @param handler An optional handler whose functions provide status updates for a paywall. Defaults to `null`. + * @param feature A completion block containing a feature that you wish to paywall. Access to this block is remotely configurable via the + * [Superwall Dashboard](https://superwall.com/dashboard). If the paywall is set to _Non Gated_, this will be called when + * the paywall is dismissed or if the user is already paying. If the paywall is _Gated_, this will be called only if the user + * is already paying or if they begin paying. If no paywall is configured, this gets called immediately. This will not be called + * in the event of an error, which you can detect via the `handler`. + */ fun Superwall.register( event: String, params: Map? = null, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt index c9d6aa17..c0982770 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt @@ -8,19 +8,27 @@ import com.superwall.sdk.paywall.presentation.internal.getPaywallComponents import com.superwall.sdk.paywall.presentation.internal.operators.logErrors import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import com.superwall.sdk.paywall.presentation.rule_logic.RuleEvaluationOutcome -import com.superwall.sdk.paywall.vc.PaywallView +import com.superwall.sdk.paywall.view.PaywallView import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking data class PaywallComponents( val view: PaywallView, val presenter: Activity?, val rulesOutcome: RuleEvaluationOutcome, val debugInfo: Map, -) { - @Deprecated("Will be removed in the upcoming versions, use PaywallComponents.view instead") - val viewController: PaywallView = view -} +) +/** + * Gets a paywall to present, publishing [PaywallState] objects that provide updates on the lifecycle of the paywall. + * + * @param request A presentation request of type [PresentationRequest] to feed into a presentation pipeline. + * @param publisher A [MutableSharedFlow] that emits [PaywallState] objects. + * @return A [PaywallView] to present. + * @throws Throwable if an error occurs during the process. + */ @Throws(Throwable::class) internal suspend fun Superwall.getPaywall( request: PresentationRequest, @@ -37,3 +45,27 @@ internal suspend fun Superwall.getPaywall( logErrors(request, error = it) Either.Failure(it) }) + +/** + * Gets a paywall to present synchronously, providing updates on the lifecycle of the paywall through a callback. + * Warning: This blocks the calling thread until the paywall is returned. + * + * @param request A presentation request of type [PresentationRequest] to feed into a presentation pipeline. + * @param onStateChanged A callback function that receives [PaywallState] updates. + * @return A [PaywallView] to present. + */ +fun Superwall.getPaywallSync( + request: PresentationRequest, + onStateChanged: (PaywallState) -> Unit = {}, +): Either { + val scope = Superwall.instance.ioScope + val publisher = MutableSharedFlow() + scope.launch { + publisher.collectLatest { + onStateChanged(it) + } + } + return runBlocking { + getPaywall(request, publisher) + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/PublicGetPaywall.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/PublicGetPaywall.kt index df082f58..c3c94914 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/PublicGetPaywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/PublicGetPaywall.kt @@ -8,9 +8,9 @@ import com.superwall.sdk.misc.toResult import com.superwall.sdk.paywall.presentation.internal.PresentationRequestType import com.superwall.sdk.paywall.presentation.internal.request.PaywallOverrides import com.superwall.sdk.paywall.presentation.internal.request.PresentationInfo -import com.superwall.sdk.paywall.vc.PaywallView -import com.superwall.sdk.paywall.vc.delegate.PaywallViewCallback -import com.superwall.sdk.paywall.vc.delegate.PaywallViewDelegateAdapter +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.delegate.PaywallViewCallback +import com.superwall.sdk.paywall.view.delegate.PaywallViewDelegateAdapter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_presentation_result/InternalGetPresentationResult.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_presentation_result/InternalGetPresentationResult.kt index 95d20c5d..0e0e2b16 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_presentation_result/InternalGetPresentationResult.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_presentation_result/InternalGetPresentationResult.kt @@ -41,7 +41,6 @@ private fun handle( } return when (error) { - is PaywallPresentationRequestStatusReason.UserIsSubscribed -> PresentationResult.UserIsSubscribed() is PaywallPresentationRequestStatusReason.NoPaywallView -> PresentationResult.PaywallNotAvailable() is PaywallPresentationRequestStatusReason.NoRuleMatch -> PresentationResult.NoRuleMatch() is PaywallPresentationRequestStatusReason.Holdout -> PresentationResult.Holdout(error.experiment) @@ -50,7 +49,7 @@ private fun handle( is PaywallPresentationRequestStatusReason.NoPresenter, is PaywallPresentationRequestStatusReason.PaywallAlreadyPresented, is PaywallPresentationRequestStatusReason.NoConfig, - is PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout, + is PaywallPresentationRequestStatusReason.EntitlementStatusTimeout, -> PresentationResult.PaywallNotAvailable() } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_presentation_result/PublicGetPresentationResult.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_presentation_result/PublicGetPresentationResult.kt index 87115c57..cacf2a3e 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_presentation_result/PublicGetPresentationResult.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_presentation_result/PublicGetPresentationResult.kt @@ -10,9 +10,23 @@ import com.superwall.sdk.paywall.presentation.internal.PresentationRequestType import com.superwall.sdk.paywall.presentation.internal.request.PresentationInfo import com.superwall.sdk.paywall.presentation.result.PresentationResult import com.superwall.sdk.utilities.withErrorTracking +import kotlinx.coroutines.runBlocking import java.util.Date import java.util.HashMap +/** + * Preemptively gets the result of registering an event. + * + * This helps you determine whether a particular event will present a paywall + * in the future. + * + * Note that this method does not present a paywall. To do that, use + * `register(event:params:handler:feature:)`. + * + * @param event The name of the event you want to register. + * @param params Optional parameters you'd like to pass with your event. + * @return A [PresentationResult] that indicates the result of registering an event. + */ suspend fun Superwall.getPresentationResult( event: String, params: Map? = null, @@ -32,6 +46,32 @@ suspend fun Superwall.getPresentationResult( ) }.toResult() +/** + * Synchronously preemptively gets the result of registering an event. + * + * This helps you determine whether a particular event will present a paywall + * in the future. + * + * Note that this method does not present a paywall. To do that, use + * `register(event:params:handler:feature:)`. + * + * Warning: This blocks the calling thread. + * + * @param event The name of the event you want to register. + * @param params Optional parameters you'd like to pass with your event. + * @return A [PresentationResult] that indicates the result of registering an event. + */ +fun Superwall.getPresentationResultSync( + event: String, + params: Map? = null, +): Result = + runBlocking { + getPresentationResult( + event, + params, + ) + } + internal suspend fun Superwall.internallyGetPresentationResult( event: Trackable, isImplicit: Boolean, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/GetPaywallComponents.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/GetPaywallComponents.kt index 331937f4..7acbab70 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/GetPaywallComponents.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/GetPaywallComponents.kt @@ -6,16 +6,16 @@ import com.superwall.sdk.misc.toResult import com.superwall.sdk.models.assignment.ConfirmedAssignment import com.superwall.sdk.paywall.presentation.get_paywall.PaywallComponents import com.superwall.sdk.paywall.presentation.internal.operators.checkDebuggerPresentation -import com.superwall.sdk.paywall.presentation.internal.operators.checkUserSubscription import com.superwall.sdk.paywall.presentation.internal.operators.confirmHoldoutAssignment import com.superwall.sdk.paywall.presentation.internal.operators.confirmPaywallAssignment import com.superwall.sdk.paywall.presentation.internal.operators.evaluateRules import com.superwall.sdk.paywall.presentation.internal.operators.getPaywallView import com.superwall.sdk.paywall.presentation.internal.operators.getPresenterIfNecessary -import com.superwall.sdk.paywall.presentation.internal.operators.waitForSubsStatusAndConfig +import com.superwall.sdk.paywall.presentation.internal.operators.waitForEntitlementsAndConfig import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.runBlocking /** * Runs a pipeline of operations to get a paywall to present and associated components. @@ -23,7 +23,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow * @param request The presentation request. * @param publisher A `MutableStateFlow` that gets sent `PaywallState` objects. * @return A `PaywallComponents` object that contains objects associated with the - * paywall view controller. + * paywall view. * @throws PresentationPipelineError object associated with stages of the pipeline. */ @Throws(Throwable::class) @@ -32,7 +32,7 @@ suspend fun Superwall.getPaywallComponents( publisher: MutableSharedFlow? = null, ): Result = withErrorTracking { - waitForSubsStatusAndConfig(request, publisher) + waitForEntitlementsAndConfig(request, publisher) // TODO: // val debugInfo = logPresentation(request) val debugInfo = emptyMap() @@ -42,12 +42,6 @@ suspend fun Superwall.getPaywallComponents( val rulesOutcome = evaluateRules(request) val outcome = rulesOutcome.getOrThrow() - checkUserSubscription( - request = request, - triggerResult = outcome.triggerResult, - paywallStatePublisher = publisher, - ) - confirmHoldoutAssignment(request = request, rulesOutcome = outcome) val paywallView = @@ -71,7 +65,7 @@ suspend fun Superwall.getPaywallComponents( internal suspend fun Superwall.confirmAssignment(request: PresentationRequest): Either { return withErrorTracking { - waitForSubsStatusAndConfig(request) + waitForEntitlementsAndConfig(request) val rules = evaluateRules(request) if (rules.isFailure) { throw rules.exceptionOrNull()!! @@ -88,3 +82,21 @@ internal suspend fun Superwall.confirmAssignment(request: PresentationRequest): } } } + +/** + * Synchronously runs a pipeline of operations to get a paywall to present and associated components. + * + * @param request The presentation request. + * @param publisher A `MutableStateFlow` that gets sent `PaywallState` objects. + * @return A `PaywallComponents` object that contains objects associated with the + * paywall view controller. + * @throws PresentationPipelineError object associated with stages of the pipeline. + */ + +fun Superwall.getPaywallComponentsSync( + request: PresentationRequest, + publisher: MutableSharedFlow? = null, +): Result = + runBlocking { + getPaywallComponents(request, publisher) + } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/InternalPresentation.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/InternalPresentation.kt index 31bcd89b..8b2749e6 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/InternalPresentation.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/InternalPresentation.kt @@ -5,7 +5,7 @@ import com.superwall.sdk.paywall.presentation.PaywallCloseReason import com.superwall.sdk.paywall.presentation.internal.operators.* import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult import com.superwall.sdk.paywall.presentation.internal.state.PaywallState -import com.superwall.sdk.paywall.vc.PaywallView +import com.superwall.sdk.paywall.view.PaywallView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -13,8 +13,15 @@ import kotlinx.coroutines.withContext typealias PaywallStatePublisher = Flow +/** + * Runs a background task to present a paywall, publishing [PaywallState] objects that provide updates on the lifecycle of the paywall. + * + * @param request A presentation request of type [PresentationRequest] to feed into a presentation pipeline. + * @param publisher A publisher fed into the pipeline that sends state updates. + */ + @Throws(Throwable::class) -suspend fun Superwall.internallyPresent( +internal suspend fun Superwall.internallyPresent( request: PresentationRequest, publisher: MutableSharedFlow, ) { @@ -48,7 +55,7 @@ suspend fun Superwall.internallyPresent( } } -suspend fun Superwall.dismiss( +internal suspend fun Superwall.dismiss( paywallView: PaywallView, result: PaywallResult, closeReason: PaywallCloseReason = PaywallCloseReason.SystemLogic, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/InternalPresentationLogic.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/InternalPresentationLogic.kt index 26145825..e61d5593 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/InternalPresentationLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/InternalPresentationLogic.kt @@ -1,49 +1,10 @@ package com.superwall.sdk.paywall.presentation.internal -import com.superwall.sdk.models.paywall.PresentationCondition - object InternalPresentationLogic { - data class UserSubscriptionOverrides( - val isDebuggerLaunched: Boolean, - val shouldIgnoreSubscriptionStatus: Boolean?, - var presentationCondition: PresentationCondition?, - ) - - fun userSubscribedAndNotOverridden( - isUserSubscribed: Boolean, - overrides: UserSubscriptionOverrides, - ): Boolean { - if (overrides.isDebuggerLaunched) { - return false - } - - fun checkSubscriptionStatus(): Boolean { - if (!isUserSubscribed) { - return false - } - if (overrides.shouldIgnoreSubscriptionStatus == true) { - return false - } - return true - } - - val presentationCondition = overrides.presentationCondition ?: return checkSubscriptionStatus() - - if (presentationCondition == PresentationCondition.ALWAYS) { - return false - } - - return checkSubscriptionStatus() - } - fun presentationError( domain: String, code: Int, title: String, value: String, - ): Throwable { - // In Kotlin, we usually throw exceptions rather than errors - // Kotlin does not have a built-in equivalent to NSError - return RuntimeException("$domain: $code, $title - $value") - } + ): Throwable = RuntimeException("$domain: $code, $title - $value") } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PaywallPresentationRequestStatus.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PaywallPresentationRequestStatus.kt index 7be81925..ea243222 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PaywallPresentationRequestStatus.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PaywallPresentationRequestStatus.kt @@ -24,9 +24,6 @@ sealed class PaywallPresentationRequestStatusReason( /** There's already a paywall presented. */ class PaywallAlreadyPresented : PaywallPresentationRequestStatusReason("paywall_already_presented") - /** The user is subscribed. */ - class UserIsSubscribed : PaywallPresentationRequestStatusReason("user_is_subscribed") - /** The user is in a holdout group. */ data class Holdout( val experiment: Experiment, @@ -48,12 +45,10 @@ sealed class PaywallPresentationRequestStatusReason( class NoConfig : PaywallPresentationRequestStatusReason("no_config") /** - * The subscription status timed out. - * This happens when the subscriptionStatus stays unknown for more than 5 seconds. + * The entitlement status timed out. + * This happens when the entitlementStatus stays unknown for more than 5 seconds. */ - class SubscriptionStatusTimeout : PaywallPresentationRequestStatusReason("subscription_status_timeout") + class EntitlementStatusTimeout : PaywallPresentationRequestStatusReason("subscription_status_timeout") } -@Deprecated("Will be removed in the upcoming versions, use NoPaywallView instead") -typealias NoPaywallController = PaywallPresentationRequestStatusReason.NoPaywallView typealias PresentationPipelineError = PaywallPresentationRequestStatusReason diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PresentationErrors.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PresentationErrors.kt deleted file mode 100644 index 85f90311..00000000 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PresentationErrors.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.superwall.sdk.paywall.presentation.internal - -import com.superwall.sdk.Superwall -import com.superwall.sdk.paywall.presentation.internal.state.PaywallSkippedReason -import com.superwall.sdk.paywall.presentation.internal.state.PaywallState -import kotlinx.coroutines.flow.MutableSharedFlow - -suspend fun Superwall.userIsSubscribed(paywallStatePublisher: MutableSharedFlow?): PresentationPipelineError { - val state = PaywallState.Skipped(PaywallSkippedReason.UserIsSubscribed()) - paywallStatePublisher?.emit(state) - return PaywallPresentationRequestStatusReason.UserIsSubscribed() -} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/CheckDebuggerPresentation.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/CheckDebuggerPresentation.kt index 398c9a8c..ec63b4a1 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/CheckDebuggerPresentation.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/CheckDebuggerPresentation.kt @@ -10,6 +10,12 @@ import com.superwall.sdk.paywall.presentation.internal.PresentationRequest import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import kotlinx.coroutines.flow.MutableSharedFlow +/** + * Cancels the state publisher if the debugger is already launched. + * + * @param request The presentation request. + * @param paywallStatePublisher A [MutableSharedFlow] that gets sent [PaywallState] objects. + */ suspend fun Superwall.checkDebuggerPresentation( request: PresentationRequest, paywallStatePublisher: MutableSharedFlow?, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/CheckNoPaywallAlreadyPresented.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/CheckNoPaywallAlreadyPresented.kt index b9240db5..1ce2f4e5 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/CheckNoPaywallAlreadyPresented.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/CheckNoPaywallAlreadyPresented.kt @@ -10,7 +10,7 @@ import com.superwall.sdk.paywall.presentation.internal.PresentationRequest import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import kotlinx.coroutines.flow.MutableSharedFlow -suspend fun Superwall.checkNoPaywallAlreadyPresented( +internal suspend fun Superwall.checkNoPaywallAlreadyPresented( request: PresentationRequest, paywallStatePublisher: MutableSharedFlow, ) { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/CheckUserSubscription.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/CheckUserSubscription.kt deleted file mode 100644 index c363126c..00000000 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/CheckUserSubscription.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.superwall.sdk.paywall.presentation.internal.operators - -import com.superwall.sdk.Superwall -import com.superwall.sdk.delegate.SubscriptionStatus -import com.superwall.sdk.models.triggers.InternalTriggerResult -import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatusReason -import com.superwall.sdk.paywall.presentation.internal.PresentationRequest -import com.superwall.sdk.paywall.presentation.internal.state.PaywallSkippedReason -import com.superwall.sdk.paywall.presentation.internal.state.PaywallState -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.first - -suspend fun Superwall.checkUserSubscription( - request: PresentationRequest, - triggerResult: InternalTriggerResult, - paywallStatePublisher: MutableSharedFlow? = null, -) { - when (triggerResult) { - is InternalTriggerResult.Paywall -> return - else -> { - val subscriptionStatus = request.flags.subscriptionStatus.first() - if (subscriptionStatus == SubscriptionStatus.ACTIVE) { - paywallStatePublisher?.emit(PaywallState.Skipped(PaywallSkippedReason.UserIsSubscribed())) - throw PaywallPresentationRequestStatusReason.UserIsSubscribed() - } - } - } -} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRules.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRules.kt index b40e4660..92c37b16 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRules.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRules.kt @@ -18,6 +18,13 @@ data class AssignmentPipelineOutput( val debugInfo: Map, ) +/** + * Evaluates the rules from the campaign that the event belongs to. + * + * @param request The presentation request + * @return A [RuleEvaluationOutcome] object containing the trigger result, + * confirmable assignment, and unsaved occurrence. + */ suspend fun Superwall.evaluateRules(request: PresentationRequest): Result { val eventData = request.presentationInfo.eventData diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetExperiment.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetExperiment.kt index f2985536..cb2643a3 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetExperiment.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetExperiment.kt @@ -30,7 +30,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow * @return A data class that contains info for the next operation. */ @Throws(Throwable::class) -suspend fun Superwall.getExperiment( +internal suspend fun Superwall.getExperiment( request: PresentationRequest, rulesOutcome: RuleEvaluationOutcome, debugInfo: Map, @@ -67,7 +67,7 @@ suspend fun Superwall.getExperiment( Logger.debug( logLevel = LogLevel.error, scope = LogScope.paywallPresentation, - message = "Error Getting Paywall View Controller", + message = "Error Getting Paywall view", info = debugInfo, error = rulesOutcome.triggerResult.error, ) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPaywallVC.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPaywallVC.kt index bedbd863..9e443226 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPaywallVC.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPaywallVC.kt @@ -1,26 +1,21 @@ package com.superwall.sdk.paywall.presentation.internal.operators import com.superwall.sdk.Superwall -import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.dependencies.DependencyContainer import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.toResult -import com.superwall.sdk.paywall.presentation.internal.InternalPresentationLogic import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatusReason import com.superwall.sdk.paywall.presentation.internal.PresentationRequest import com.superwall.sdk.paywall.presentation.internal.PresentationRequestType -import com.superwall.sdk.paywall.presentation.internal.state.PaywallSkippedReason import com.superwall.sdk.paywall.presentation.internal.state.PaywallState -import com.superwall.sdk.paywall.presentation.internal.userIsSubscribed import com.superwall.sdk.paywall.presentation.rule_logic.RuleEvaluationOutcome import com.superwall.sdk.paywall.request.PaywallRequest import com.superwall.sdk.paywall.request.ResponseIdentifiers -import com.superwall.sdk.paywall.vc.PaywallView -import com.superwall.sdk.paywall.vc.web_view.webViewExists +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.webview.webViewExists import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.first internal suspend fun Superwall.getPaywallView( request: PresentationRequest, @@ -44,13 +39,6 @@ internal suspend fun Superwall.getPaywallView( experiment = experiment, ) - var requestRetryCount = 6 - - val subscriptionStatus = request.flags.subscriptionStatus.first() - if (subscriptionStatus == SubscriptionStatus.ACTIVE) { - requestRetryCount = 0 - } - val paywallRequest = dependencyContainer.makePaywallRequest( eventData = request.presentationInfo.eventData, @@ -62,7 +50,6 @@ internal suspend fun Superwall.getPaywallView( ), isDebuggerLaunched = request.flags.isDebuggerLaunched, presentationSourceType = request.presentationSourceType, - retryCount = requestRetryCount, ) return try { val isForPresentation = @@ -96,35 +83,15 @@ internal suspend fun Superwall.getPaywallView( Result.failure(PaywallPresentationRequestStatusReason.NoPaywallView()) } } catch (e: Throwable) { - if (subscriptionStatus == SubscriptionStatus.ACTIVE) { - Result.failure(userIsSubscribed(paywallStatePublisher)) - } else { - Result.failure(presentationFailure(e, request, debugInfo, paywallStatePublisher)) - } + Result.failure(presentationFailure(e, debugInfo, paywallStatePublisher)) } } private suspend fun presentationFailure( error: Throwable, - request: PresentationRequest, debugInfo: Map, paywallStatePublisher: MutableSharedFlow?, ): Throwable { - val subscriptionStatus = request.flags.subscriptionStatus.first() - if (InternalPresentationLogic.userSubscribedAndNotOverridden( - isUserSubscribed = subscriptionStatus == SubscriptionStatus.ACTIVE, - overrides = - InternalPresentationLogic.UserSubscriptionOverrides( - isDebuggerLaunched = request.flags.isDebuggerLaunched, - shouldIgnoreSubscriptionStatus = request.paywallOverrides?.ignoreSubscriptionStatus, - presentationCondition = null, - ), - ) - ) { - paywallStatePublisher?.emit(PaywallState.Skipped(PaywallSkippedReason.UserIsSubscribed())) - return PaywallPresentationRequestStatusReason.UserIsSubscribed() - } - Logger.debug( logLevel = LogLevel.error, scope = LogScope.paywallPresentation, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPresenter.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPresenter.kt index a93f83b9..2aaaaf3a 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPresenter.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPresenter.kt @@ -4,52 +4,37 @@ import android.app.Activity import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent -import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger -import com.superwall.sdk.models.assignment.ConfirmableAssignment import com.superwall.sdk.models.triggers.InternalTriggerResult import com.superwall.sdk.paywall.presentation.internal.InternalPresentationLogic import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatusReason import com.superwall.sdk.paywall.presentation.internal.PresentationRequest 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.PaywallSkippedReason import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import com.superwall.sdk.paywall.presentation.rule_logic.RuleEvaluationOutcome -import com.superwall.sdk.paywall.vc.PaywallView +import com.superwall.sdk.paywall.view.PaywallView import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.first -data class PresentablePipelineOutput( - val debugInfo: Map, - val paywallView: PaywallView, - val presenter: Activity, - val confirmableAssignment: ConfirmableAssignment?, -) - -suspend fun Superwall.getPresenterIfNecessary( +/** + * Checks conditions for whether the paywall can present before accessing a window on + * which the paywall can present. + * + * @param paywallView The [PaywallView] to present. + * @param rulesOutcome The output from evaluating rules. + * @param request The presentation request. + * @param paywallStatePublisher A [MutableSharedFlow] that gets sent [PaywallState] objects. + * + * @return An [Activity] to present on, or null if presentation is not necessary. + */ +internal suspend fun Superwall.getPresenterIfNecessary( paywallView: PaywallView, rulesOutcome: RuleEvaluationOutcome, request: PresentationRequest, paywallStatePublisher: MutableSharedFlow? = null, ): Activity? { - val subscriptionStatus = request.flags.subscriptionStatus.first() - if (InternalPresentationLogic.userSubscribedAndNotOverridden( - isUserSubscribed = subscriptionStatus == SubscriptionStatus.ACTIVE, - overrides = - InternalPresentationLogic.UserSubscriptionOverrides( - isDebuggerLaunched = request.flags.isDebuggerLaunched, - shouldIgnoreSubscriptionStatus = request.paywallOverrides?.ignoreSubscriptionStatus, - presentationCondition = paywallView.paywall.presentation.condition, - ), - ) - ) { - paywallStatePublisher?.emit(PaywallState.Skipped(PaywallSkippedReason.UserIsSubscribed())) - throw PaywallPresentationRequestStatusReason.UserIsSubscribed() - } - when (request.flags.type) { is PresentationRequestType.GetPaywall -> { val sessionId = @@ -64,6 +49,7 @@ suspend fun Superwall.getPresenterIfNecessary( is PresentationRequestType.GetPresentationResult, is PresentationRequestType.ConfirmAllAssignments, -> return null + is PresentationRequestType.Presentation -> Unit else -> Unit } @@ -96,7 +82,7 @@ suspend fun Superwall.getPresenterIfNecessary( return currentActivity } -suspend fun Superwall.attemptTriggerFire( +internal suspend fun Superwall.attemptTriggerFire( request: PresentationRequest, triggerResult: InternalTriggerResult, ) { @@ -108,9 +94,11 @@ suspend fun Superwall.attemptTriggerFire( when (triggerResult) { is InternalTriggerResult.Error, is InternalTriggerResult.EventNotFound -> return + else -> {} // No-op } } + is PresentationInfo.FromIdentifier -> {} // No-op } val trackedEvent = diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/LogErrors.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/LogErrors.kt index ddbf13fa..61503c55 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/LogErrors.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/LogErrors.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -suspend fun Superwall.logErrors( +internal suspend fun Superwall.logErrors( request: PresentationRequest, error: Throwable, ) { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt index bfb8947e..5cec969d 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt @@ -13,18 +13,19 @@ import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationReques import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatusReason import com.superwall.sdk.paywall.presentation.internal.PresentationRequest import com.superwall.sdk.paywall.presentation.internal.state.PaywallState -import com.superwall.sdk.paywall.vc.PaywallView +import com.superwall.sdk.paywall.view.PaywallView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * Presents the paywall view controller, stores the presentation request for future use, + * Presents the paywall view, stores the presentation request for future use, * and sends back a `presented` state to the paywall state publisher. * - * @param paywallView The paywall view controller to present. - * @param presenter The view controller to present the paywall on. + * @param paywallView The paywall view to present. + * @param presenter The view to present the paywall on. * @param unsavedOccurrence The trigger rule occurrence to save, if available. * @param debugInfo Information to help with debugging. * @param request The request to present the paywall. @@ -89,19 +90,33 @@ suspend fun Superwall.presentPaywallView( } } -@Deprecated("Will be removed in the upcoming versions, use `presentPaywallView` instead.") -suspend fun Superwall.presentPaywallViewController( +/** + * A synchronous version of `presentPaywallView` which will invoke a callback with the paywall state. + * Warning: This blocks the calling thread. + **/ + +fun Superwall.presentPaywallViewSync( paywallView: PaywallView, presenter: Activity, unsavedOccurrence: TriggerRuleOccurrence?, debugInfo: Map, request: PresentationRequest, - paywallStatePublisher: MutableSharedFlow, -) = presentPaywallView( - paywallView = paywallView, - presenter = presenter, - unsavedOccurrence = unsavedOccurrence, - debugInfo = debugInfo, - request = request, - paywallStatePublisher = paywallStatePublisher, -) + onStateChanged: (PaywallState) -> Unit, +) { + mainScope.launch { + val publisher = MutableSharedFlow() + ioScope.launch { + publisher.collectLatest { + onStateChanged(it) + } + } + presentPaywallView( + paywallView = paywallView, + presenter = presenter, + unsavedOccurrence = unsavedOccurrence, + debugInfo = debugInfo, + request = request, + paywallStatePublisher = publisher, + ) + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt index 3e5e8fa6..4c07e33d 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt @@ -4,18 +4,16 @@ import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.config.models.ConfigState -import com.superwall.sdk.config.models.getConfig -import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.dependencies.DependencyContainer import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.paywall.presentation.internal.InternalPresentationLogic import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatus import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatusReason import com.superwall.sdk.paywall.presentation.internal.PresentationRequest import com.superwall.sdk.paywall.presentation.internal.state.PaywallState -import com.superwall.sdk.paywall.presentation.internal.userIsSubscribed import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow @@ -25,7 +23,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlin.time.Duration.Companion.seconds -internal suspend fun Superwall.waitForSubsStatusAndConfig( +internal suspend fun Superwall.waitForEntitlementsAndConfig( request: PresentationRequest, paywallStatePublisher: MutableSharedFlow? = null, dependencyContainer: DependencyContainer? = null, @@ -35,8 +33,8 @@ internal suspend fun Superwall.waitForSubsStatusAndConfig( try { withTimeout(5.seconds) { - request.flags.subscriptionStatus - .filter { it != SubscriptionStatus.UNKNOWN } + request.flags.entitlements + .filter { it !is EntitlementStatus.Unknown } .first() } } catch (e: TimeoutCancellationException) { @@ -47,7 +45,7 @@ internal suspend fun Superwall.waitForSubsStatusAndConfig( eventData = request.presentationInfo.eventData, type = request.flags.type, status = PaywallPresentationRequestStatus.Timeout, - statusReason = PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout(), + statusReason = PaywallPresentationRequestStatusReason.EntitlementStatusTimeout(), factory = dependencyContainer, ) track(trackedEvent) @@ -56,7 +54,7 @@ internal suspend fun Superwall.waitForSubsStatusAndConfig( logLevel = LogLevel.info, scope = LogScope.paywallPresentation, message = - "Timeout: Superwall.instance.subscriptionStatus has been \"unknown\" for " + + "Timeout: Superwall.instance.entitlement.status has been \"unknown\" for " + "over 5 seconds resulting in a failure.", ) val error = @@ -64,10 +62,10 @@ internal suspend fun Superwall.waitForSubsStatusAndConfig( domain = "SWKPresentationError", code = 105, title = "Timeout", - value = "The subscription status failed to change from \"unknown\".", + value = "The entitlement status failed to change from \"unknown\".", ) paywallStatePublisher?.emit(PaywallState.PresentationError(error)) - throw PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout() + throw PaywallPresentationRequestStatusReason.EntitlementStatusTimeout() } val configState = dependencyContainer.configManager.configState @@ -79,53 +77,53 @@ internal suspend fun Superwall.waitForSubsStatusAndConfig( } } - val subscriptionIsActive = subscriptionStatus.value == SubscriptionStatus.ACTIVE when { // Config is still retrieving, wait for <=1 second. // At 1s we cancel the task and check config again. - subscriptionIsActive && - configState.value is ConfigState.Retrieving -> { + configState.value is ConfigState.Retrieving -> { try { withTimeout(1.seconds) { configState .configOrThrow() } } catch (e: TimeoutCancellationException) { - ioScope.launch { - val trackedEvent = - InternalSuperwallEvent.PresentationRequest( - eventData = request.presentationInfo.eventData, - type = request.flags.type, - status = PaywallPresentationRequestStatus.Timeout, - statusReason = PaywallPresentationRequestStatusReason.NoConfig(), - factory = dependencyContainer, + try { + // Check config again just in case + configState.configOrThrow() + } catch (e: Exception) { + ioScope.launch { + val trackedEvent = + InternalSuperwallEvent.PresentationRequest( + eventData = request.presentationInfo.eventData, + type = request.flags.type, + status = PaywallPresentationRequestStatus.Timeout, + statusReason = PaywallPresentationRequestStatusReason.NoConfig(), + factory = dependencyContainer, + ) + track(trackedEvent) + } + Logger.debug( + logLevel = LogLevel.info, + scope = LogScope.paywallPresentation, + message = "Timeout: The config could not be retrieved in a reasonable time for a subscribed user.", + ) + val state = + PaywallState.PresentationError( + InternalPresentationLogic.presentationError( + domain = "SWKPresentationError", + code = 104, + title = "No Config", + value = "Trying to present paywall without the superwall config.", + ), ) - track(trackedEvent) + paywallStatePublisher?.emit(state) + throw PaywallPresentationRequestStatusReason.NoConfig() } - Logger.debug( - logLevel = LogLevel.info, - scope = LogScope.paywallPresentation, - message = "Timeout: The config could not be retrieved in a reasonable time for a subscribed user.", - ) - throw userIsSubscribed(paywallStatePublisher) } } - // If the user is subscribed and there's no config (for whatever reason), - // just call the feature block. - subscriptionIsActive && - configState.value.getConfig() == null && - configState.value !is ConfigState.Retrieving -> { - throw userIsSubscribed(paywallStatePublisher) - } - - subscriptionIsActive && - configState.value.getConfig() != null -> { - // If the user is subscribed and there is config, continue. - } - // User is not subscribed, so we either wait for config and show the paywall - // Or we show the paywall without config. else -> { + // Try to get the config and continue or throw an error try { configState.configOrThrow() } catch (e: Throwable) { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/request/PresentationRequest.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/request/PresentationRequest.kt index 60a2605c..c6cd24d2 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/request/PresentationRequest.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/request/PresentationRequest.kt @@ -1,10 +1,10 @@ package com.superwall.sdk.paywall.presentation.internal import android.app.Activity -import com.superwall.sdk.delegate.SubscriptionStatus +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.paywall.presentation.internal.request.PaywallOverrides import com.superwall.sdk.paywall.presentation.internal.request.PresentationInfo -import com.superwall.sdk.paywall.vc.delegate.PaywallViewDelegateAdapter +import com.superwall.sdk.paywall.view.delegate.PaywallViewDelegateAdapter import kotlinx.coroutines.flow.StateFlow import java.lang.ref.WeakReference @@ -32,9 +32,6 @@ sealed class PresentationRequestType { else -> "Unknown" } - @Deprecated("Will be removed in the upcoming versions, use paywallViewDelegateAdapter instead") - val paywallVcDelegateAdapter: PaywallViewDelegateAdapter? = paywallViewDelegateAdapter - val paywallViewDelegateAdapter: PaywallViewDelegateAdapter? get() = if (this is GetPaywall) this.adapter else null @@ -58,7 +55,7 @@ data class PresentationRequest( ) { data class Flags( var isDebuggerLaunched: Boolean, - var subscriptionStatus: StateFlow, + var entitlements: StateFlow, var isPaywallPresented: Boolean, var type: PresentationRequestType, ) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/result/PresentationResult.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/result/PresentationResult.kt index 109b50c2..629d7674 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/result/PresentationResult.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/result/PresentationResult.kt @@ -31,16 +31,6 @@ sealed class PresentationResult { val experiment: Experiment, ) : PresentationResult() - // The user is subscribed. - // - // This means ``Superwall/subscriptionStatus`` is set to `.active`. If you're - // letting Superwall handle subscription-related logic, it will be based on the on-device - // receipts. Otherwise it'll be based on the value you've set. - // - // By default, paywalls do not show to users who are already subscribed. You can override this - // behavior in the paywall editor. - class UserIsSubscribed : PresentationResult() - // No view controller could be found to present on. class PaywallNotAvailable : PresentationResult() } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt index 02b7a22e..68d8c60a 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt @@ -47,7 +47,7 @@ internal class SuperscriptEvaluator( rule: TriggerRule, eventData: EventData?, ): TriggerRuleOutcome { - if (rule.expressionJs == null && rule.expression == null) { + if (rule.expressionCEL == null) { return rule.tryToMatchOccurrence(storage, true) } @@ -56,12 +56,7 @@ internal class SuperscriptEvaluator( val factory = factory.makeRuleAttributes(eventData, rule.computedPropertyRequests) val userAttributes = factory.toPassableValue() val expression = - ( - rule.expressionCEL - ?: run { - rule.expression ?: "".replace("and", "&&").replace("or", "||") - } - ).replace("device.", "computed.") + rule.expressionCEL val executionContext = ExecutionContext( diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/CombinedExpressionEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/CombinedExpressionEvaluator.kt index 81096640..f7158158 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/CombinedExpressionEvaluator.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/CombinedExpressionEvaluator.kt @@ -1,16 +1,12 @@ package com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator -import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent -import com.superwall.sdk.dependencies.RuleAttributesFactory import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.triggers.TriggerRule import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.models.triggers.UnmatchedRule import com.superwall.sdk.paywall.presentation.rule_logic.cel.SuperscriptEvaluator -import com.superwall.sdk.paywall.presentation.rule_logic.javascript.JavascriptEvaluator import com.superwall.sdk.paywall.presentation.rule_logic.tryToMatchOccurrence import com.superwall.sdk.storage.LocalStorage -import org.json.JSONObject interface ExpressionEvaluating { suspend fun evaluateExpression( @@ -21,73 +17,25 @@ interface ExpressionEvaluating { internal class CombinedExpressionEvaluator( private val storage: LocalStorage, - private val factory: RuleAttributesFactory, - private val evaluator: JavascriptEvaluator, - private val shouldTraceResults: Boolean, private val superscriptEvaluator: SuperscriptEvaluator, - private val track: suspend (InternalSuperwallEvent.ExpressionResult) -> Unit, ) : ExpressionEvaluating { override suspend fun evaluateExpression( rule: TriggerRule, eventData: EventData?, ): TriggerRuleOutcome { // Expression matches all - if (rule.expressionJs == null && rule.expression == null) { + if (rule.expressionJs == null && rule.expression == null && rule.expressionCEL == null) { return rule.tryToMatchOccurrence(storage.coreDataManager, true) } - val base64Params = - getBase64Params(rule, eventData) ?: return TriggerRuleOutcome.noMatch( - UnmatchedRule.Source.EXPRESSION, - rule.experiment.id, - ) - - val result = evaluator.evaluate(base64Params, rule) + // If we are evaluating JS/Liquid, we encode rules, otherwise we return null + // and evaluate superscript only val celEvaluation = try { superscriptEvaluator.evaluateExpression(rule, eventData) } catch (e: Exception) { TriggerRuleOutcome.noMatch(UnmatchedRule.Source.EXPRESSION, rule.experiment.id) } - if (shouldTraceResults) { - track( - InternalSuperwallEvent.ExpressionResult( - liquidExpression = rule.expression, - celExpression = rule.expressionCEL, - celExpressionResult = if (celEvaluation is TriggerRuleOutcome.Match) true else false, - jsExpression = rule.expressionJs, - jsExpressionResult = if (result is TriggerRuleOutcome.Match) true else false, - ), - ) - } - return result - } - - private suspend fun getBase64Params( - rule: TriggerRule, - eventData: EventData?, - ): String? { - val jsonAttributes = - factory.makeRuleAttributes(eventData, rule.computedPropertyRequests) - - rule.expressionJs?.let { expressionJs -> - JavascriptExpressionEvaluatorParams( - expressionJs, - JSONObject(jsonAttributes), - ).toBase64Input()?.let { base64Params -> - return "\n SuperwallSDKJS.evaluateJS64('$base64Params');" - } - } - - rule.expression?.let { expression -> - LiquidExpressionEvaluatorParams( - expression, - JSONObject(jsonAttributes), - ).toBase64Input()?.let { base64Params -> - return "\n SuperwallSDKJS.evaluate64('$base64Params');" - } - } - - return null + return celEvaluation } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorParams.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorParams.kt deleted file mode 100644 index 07867e18..00000000 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorParams.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator - -import com.superwall.sdk.logger.LogLevel -import com.superwall.sdk.logger.LogScope -import com.superwall.sdk.logger.Logger -import kotlinx.serialization.SerializationException -import org.json.JSONObject -import java.util.* - -data class LiquidExpressionEvaluatorParams( - val expression: String, - val values: JSONObject, -) { - fun toJson(): String { - var obj = JSONObject() - obj.put("expression", expression) - obj.put("values", values) - return obj.toString() - } - - fun toBase64Input(): String? = - try { - val jsonString = toJson() - Logger.debug( - LogLevel.debug, - LogScope.all, - "!! jsonString: $jsonString", - ) - jsonString.encodeToByteArray().toBase64() - } catch (e: SerializationException) { - null - } -} - -data class JavascriptExpressionEvaluatorParams( - val expressionJs: String, - val values: JSONObject, -) { - fun toJson(): String { - var obj = JSONObject() - obj.put("expressionJS", expressionJs) - obj.put("values", values) - return obj.toString() - } - - fun toBase64Input(): String? = - try { - toJson().encodeToByteArray().toBase64() - } catch (e: SerializationException) { - null - } -} - -fun ByteArray.toBase64(): String = Base64.getEncoder().encodeToString(this) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvalutor.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvalutor.kt deleted file mode 100644 index c55faf9b..00000000 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvalutor.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.superwall.sdk.paywall.presentation.rule_logic.javascript - -import android.content.Context -import android.webkit.WebView -import androidx.javascriptengine.JavaScriptSandbox -import androidx.javascriptengine.SandboxDeadException -import com.superwall.sdk.logger.LogLevel -import com.superwall.sdk.logger.LogScope -import com.superwall.sdk.logger.Logger -import com.superwall.sdk.misc.IOScope -import com.superwall.sdk.misc.MainScope -import com.superwall.sdk.misc.asEither -import com.superwall.sdk.misc.toResult -import com.superwall.sdk.models.triggers.TriggerRule -import com.superwall.sdk.models.triggers.TriggerRuleOutcome -import com.superwall.sdk.models.triggers.UnmatchedRule -import com.superwall.sdk.paywall.vc.web_view.webViewExists -import com.superwall.sdk.storage.LocalStorage -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.async -import kotlinx.coroutines.guava.await -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex - -class DefaultJavascriptEvalutor( - private val ioScope: IOScope, - private val uiScope: MainScope, - private val context: Context, - private val storage: LocalStorage, - private val createSandbox: suspend (ctx: Context) -> Result = { - asEither { - JavaScriptSandbox.createConnectedInstanceAsync(it).await() - }.toResult() - }, -) : JavascriptEvaluator { - private val mutex = Mutex() - private var eval: Deferred? = null - - /* - * Tries to evaluate JS using existing evaluator. If it is broken, tears it down and creates - * tries to execute it again, falling back to a WebView if that fails. - * */ - override suspend fun evaluate( - base64params: String, - rule: TriggerRule, - ): TriggerRuleOutcome = - try { - // Try to evaluate with the existing evaluator - createEvaluatorIfDoesntExist().evaluate(base64params, rule) - } catch (throwable: SandboxDeadException) { - // If evaluation failed, try teardown and recreate evaluator - teardown() - tryEvaluateWithFallback(base64params, rule) - } catch (e: Exception) { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.superwallCore, - message = "Failed to evaluate rule with fallback: ${e.message}", - ) - TriggerRuleOutcome.noMatch(UnmatchedRule.Source.EXPRESSION, rule.experiment.id) - } - - override fun teardown() { - runBlocking { - try { - eval?.await()?.teardown() - } catch (e: Exception) { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.superwallCore, - message = "Failed to teardown evaluator: ${e.message}", - ) - } - // Clear the existing evaluator and try with fallback to webview - eval = null - } - } - - private suspend fun createNewEvaluator(context: Context): JavascriptEvaluator = - when { - JavaScriptSandbox.isSupported() -> createSandboxEvaluator(context) - webViewExists() -> createWebViewEvaluator(context) - else -> NoSupportedEvaluator - } - - private suspend fun createSandboxEvaluator(context: Context): JavascriptEvaluator = - createSandbox(context) - .fold(onSuccess = { - SandboxJavascriptEvaluator(it, ioScope, storage.coreDataManager) - }, onFailure = { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.superwallCore, - message = "Failed to create javascript sandbox evaluator: ${it.message}", - ) - createWebViewEvaluator(context) // Fallback to WebView - }) - - private suspend fun createWebViewEvaluator(context: Context): JavascriptEvaluator = - uiScope - .async { - WebviewJavascriptEvaluator(WebView(context), uiScope, storage.coreDataManager) - }.await() - - // Tries to create a JSSandbox and if it fails, it falls back to a WebView - private suspend fun tryEvaluateWithFallback( - base64params: String, - rule: TriggerRule, - ): TriggerRuleOutcome = - try { - createEvaluatorIfDoesntExist().evaluate(base64params, rule) - } catch (e: Exception) { - teardown() - createEvaluatorIfDoesntExist { - createWebViewEvaluator(context) - }.evaluate(base64params, rule) - } - - private suspend fun createEvaluatorIfDoesntExist( - invoke: suspend () -> JavascriptEvaluator = { - createNewEvaluator(context) - }, - ): JavascriptEvaluator { - mutex.lock() - val current = eval - val evaluator = - if (current == null) { - val call = - ioScope.async { - invoke() - } - eval = call - call.await() - } else { - current.await() - } - mutex.unlock() - return evaluator - } -} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/NoSupportedEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/NoSupportedEvaluator.kt deleted file mode 100644 index c24e92e5..00000000 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/NoSupportedEvaluator.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.superwall.sdk.paywall.presentation.rule_logic.javascript - -import com.superwall.sdk.logger.LogLevel -import com.superwall.sdk.logger.LogScope -import com.superwall.sdk.logger.Logger -import com.superwall.sdk.models.triggers.TriggerRule -import com.superwall.sdk.models.triggers.TriggerRuleOutcome -import com.superwall.sdk.models.triggers.UnmatchedRule - -object NoSupportedEvaluator : JavascriptEvaluator { - override suspend fun evaluate( - base64params: String, - rule: TriggerRule, - ): TriggerRuleOutcome { - Logger.debug( - LogLevel.warn, - LogScope.jsEvaluator, - "Javascript sandbox and Webview are unsupported, nothing was evaluated.", - ) - return TriggerRuleOutcome.noMatch( - UnmatchedRule.Source.EXPRESSION, - rule.experiment.id, - ) - } - - override fun teardown() {} -} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/JavascriptEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/RuleEvaluator.kt similarity index 53% rename from superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/JavascriptEvaluator.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/RuleEvaluator.kt index 79c3561f..61dc724e 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/JavascriptEvaluator.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/RuleEvaluator.kt @@ -1,18 +1,9 @@ package com.superwall.sdk.paywall.presentation.rule_logic.javascript import android.content.Context -import com.superwall.sdk.models.triggers.TriggerRule -import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluating -interface JavascriptEvaluator { - suspend fun evaluate( - base64params: String, - rule: TriggerRule, - ): TriggerRuleOutcome - - fun teardown() - +interface RuleEvaluator { fun interface Factory { suspend fun provideRuleEvaluator(context: Context): ExpressionEvaluating } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/SandboxJavascriptEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/SandboxJavascriptEvaluator.kt deleted file mode 100644 index b0cce428..00000000 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/SandboxJavascriptEvaluator.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.superwall.sdk.paywall.presentation.rule_logic.javascript - -import androidx.javascriptengine.JavaScriptSandbox -import com.superwall.sdk.logger.LogLevel -import com.superwall.sdk.logger.LogScope -import com.superwall.sdk.logger.Logger -import com.superwall.sdk.models.triggers.TriggerRule -import com.superwall.sdk.models.triggers.TriggerRuleOutcome -import com.superwall.sdk.models.triggers.UnmatchedRule -import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.SDKJS -import com.superwall.sdk.paywall.presentation.rule_logic.tryToMatchOccurrence -import com.superwall.sdk.storage.core_data.CoreDataManager -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.guava.await -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext - -internal class SandboxJavascriptEvaluator( - private val jsSandbox: JavaScriptSandbox, - private val ioScope: CoroutineScope, - private val storage: CoreDataManager, -) : JavascriptEvaluator { - override suspend fun evaluate( - base64params: String, - rule: TriggerRule, - ): TriggerRuleOutcome = - withContext(ioScope.coroutineContext) { - val jsIsolate = jsSandbox.createIsolate() - jsIsolate.addOnTerminatedCallback { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.superwallCore, - message = "$it", - ) - } - - val resultFuture = jsIsolate.evaluateJavaScriptAsync("$SDKJS\n $base64params") - - val result = resultFuture.await() - jsIsolate.close() - - if (result.isNullOrEmpty()) { - TriggerRuleOutcome.noMatch(UnmatchedRule.Source.EXPRESSION, rule.experiment.id) - } else { - val expressionMatched = result == "true" - rule.tryToMatchOccurrence(storage, expressionMatched) - } - } - - override fun teardown() { - runBlocking { - jsSandbox.close() - } - } -} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/WebviewJavascriptEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/WebviewJavascriptEvaluator.kt deleted file mode 100644 index 63364180..00000000 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/WebviewJavascriptEvaluator.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.superwall.sdk.paywall.presentation.rule_logic.javascript - -import android.webkit.ConsoleMessage -import android.webkit.WebChromeClient -import android.webkit.WebView -import com.superwall.sdk.logger.LogLevel -import com.superwall.sdk.logger.LogScope -import com.superwall.sdk.logger.Logger -import com.superwall.sdk.models.triggers.TriggerRule -import com.superwall.sdk.models.triggers.TriggerRuleOutcome -import com.superwall.sdk.models.triggers.UnmatchedRule -import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.SDKJS -import com.superwall.sdk.paywall.presentation.rule_logic.tryToMatchOccurrence -import com.superwall.sdk.storage.core_data.CoreDataManager -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.launch - -internal class WebviewJavascriptEvaluator( - private val webView: WebView, - private val mainScope: CoroutineScope, - private val storage: CoreDataManager, -) : JavascriptEvaluator { - init { - webView.settings.javaScriptEnabled = true - webView.webChromeClient = - object : WebChromeClient() { - override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean = true - } - } - - override suspend fun evaluate( - base64params: String, - rule: TriggerRule, - ): TriggerRuleOutcome { - val deferred: CompletableDeferred = CompletableDeferred() - mainScope.async { - webView!!.evaluateJavascript( - "$SDKJS\n $base64params", - ) { result -> - Logger.debug(LogLevel.debug, LogScope.jsEvaluator, "!! evaluateJavascript result: $result") - - if (result == null) { - deferred.complete( - TriggerRuleOutcome.noMatch( - UnmatchedRule.Source.EXPRESSION, - rule.experiment.id, - ), - ) - } else { - val expressionMatched = result.replace("\"", "") == "true" - - CoroutineScope(Dispatchers.IO).launch { - val ruleMatched = rule.tryToMatchOccurrence(storage, expressionMatched) - deferred.complete(ruleMatched) - } - } - } - } - - return deferred.await() - } - - override fun teardown() { - webView.destroy() - } -} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt index 31423ded..3aabcd71 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt @@ -18,7 +18,7 @@ import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.network.Network import com.superwall.sdk.paywall.presentation.PaywallInfo -import com.superwall.sdk.store.StoreKitManager +import com.superwall.sdk.store.StoreManager import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Deferred @@ -30,7 +30,7 @@ interface PaywallRequestManagerDepFactory : ConfigManagerFactory class PaywallRequestManager( - private val storeKitManager: StoreKitManager, + private val storeManager: StoreManager, private val network: Network, private val factory: PaywallRequestManagerDepFactory, private val ioScope: IOScope, @@ -228,12 +228,14 @@ class PaywallRequestManager( var paywall = paywall val result = - storeKitManager.getProducts( + storeManager.getProducts( substituteProducts = request.overrides.products, paywall = paywall, request = request, ) - paywall = result.paywall + if (result.paywall != null) { + paywall = result.paywall + } paywall.productItems = result.productItems val outcome = diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/delegate/PaywallViewCallback.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/delegate/PaywallViewCallback.kt deleted file mode 100644 index 886ec67e..00000000 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/delegate/PaywallViewCallback.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.superwall.sdk.paywall.vc.delegate - -import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult -import com.superwall.sdk.paywall.vc.PaywallView -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent - -@Deprecated("Will be removed in the upcoming versions, use PaywallViewDelegate instead") -typealias PaywallViewControllerDelegate = PaywallViewCallback - -@Deprecated("Will be removed in the upcoming versions, use PaywallViewEventDelegate instead") -typealias PaywallViewControllerEventDelegate = PaywallViewEventCallback - -interface PaywallViewCallback { - // TODO: missing `shouldDismiss` - - @Deprecated("Will be removed in the upcoming versions, use onFinish instead") - fun didFinish( - paywall: PaywallView, - result: PaywallResult, - shouldDismiss: Boolean, - ) = onFinished(paywall, result, shouldDismiss) - - fun onFinished( - paywall: PaywallView, - result: PaywallResult, - shouldDismiss: Boolean, - ) -} - -fun interface PaywallViewEventCallback { - suspend fun eventDidOccur( - paywallEvent: PaywallWebEvent, - paywallView: PaywallView, - ) -} - -sealed class PaywallLoadingState { - class Unknown : PaywallLoadingState() - - class LoadingPurchase : PaywallLoadingState() - - class LoadingURL : PaywallLoadingState() - - class ManualLoading : PaywallLoadingState() - - class Ready : PaywallLoadingState() -} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/LoadingView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/LoadingView.kt similarity index 85% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/LoadingView.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/LoadingView.kt index 3f51aa73..e58e5392 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/LoadingView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/LoadingView.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc +package com.superwall.sdk.paywall.view import android.content.Context import android.graphics.Color @@ -6,13 +6,13 @@ import android.view.Gravity import android.view.ViewGroup import android.widget.FrameLayout import android.widget.ProgressBar -import com.superwall.sdk.paywall.vc.delegate.PaywallLoadingState +import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState class LoadingView( context: Context, ) : FrameLayout(context) { companion object { - internal const val TAG = "LoadingViewController" + internal const val TAG = "LoadingView" } init { @@ -41,11 +41,11 @@ class LoadingView( } fun setupFor( - paywallViewController: PaywallView, + paywallView: PaywallView, loadingState: PaywallLoadingState, ) { (this.parent as? ViewGroup)?.removeView(this) - paywallViewController.addView(this) + paywallView.addView(this) visibility = when (loadingState) { is PaywallLoadingState.LoadingPurchase, is PaywallLoadingState.ManualLoading -> diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt similarity index 91% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallView.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt index 93463a17..46d1417a 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc +package com.superwall.sdk.paywall.view import android.app.Activity import android.content.Context @@ -45,16 +45,16 @@ import com.superwall.sdk.paywall.presentation.internal.operators.storePresentati import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import com.superwall.sdk.paywall.presentation.result.PresentationResult -import com.superwall.sdk.paywall.vc.Survey.SurveyManager -import com.superwall.sdk.paywall.vc.Survey.SurveyPresentationResult -import com.superwall.sdk.paywall.vc.delegate.PaywallLoadingState -import com.superwall.sdk.paywall.vc.delegate.PaywallViewDelegateAdapter -import com.superwall.sdk.paywall.vc.delegate.PaywallViewEventCallback -import com.superwall.sdk.paywall.vc.web_view.PaywallMessage -import com.superwall.sdk.paywall.vc.web_view.SWWebView -import com.superwall.sdk.paywall.vc.web_view.SWWebViewDelegate -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallMessageHandlerDelegate -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent +import com.superwall.sdk.paywall.view.Survey.SurveyManager +import com.superwall.sdk.paywall.view.Survey.SurveyPresentationResult +import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState +import com.superwall.sdk.paywall.view.delegate.PaywallViewDelegateAdapter +import com.superwall.sdk.paywall.view.delegate.PaywallViewEventCallback +import com.superwall.sdk.paywall.view.webview.PaywallMessage +import com.superwall.sdk.paywall.view.webview.SWWebView +import com.superwall.sdk.paywall.view.webview.SWWebViewDelegate +import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandlerDelegate +import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.delay @@ -118,11 +118,11 @@ class PaywallView( private var shimmerView: ShimmerView? = null - private var loadingViewController: LoadingView? = null + private var loadingView: LoadingView? = null var paywallStatePublisher: MutableSharedFlow? = null - // The full screen activity instance if this view controller has been presented in one. + // The full screen activity instance if this view has been presented in one. override var encapsulatingActivity: WeakReference? = null // / Stores the ``PaywallResult`` on dismiss of paywall. @@ -165,7 +165,7 @@ class PaywallView( override val isActive: Boolean get() = isPresented - // / Defines whether the view controller is being presented or not. + // / Defines whether the view is being presented or not. private var isPresented = false private var presentationWillPrepare = true private var presentationDidFinishPrepare = false @@ -233,7 +233,7 @@ class PaywallView( } internal fun setupLoading(loadingView: LoadingView) { - this.loadingViewController = loadingView + this.loadingView = loadingView loadingView.setupFor(this, loadingState) } @@ -247,7 +247,7 @@ class PaywallView( LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) } this.shimmerView = shimmerView - this.loadingViewController = loadingView + this.loadingView = loadingView } fun present( @@ -282,9 +282,6 @@ class PaywallView( viewCreatedCompletion = completion } - @Deprecated("Will be removed in the upcoming versions, use beforeViewCreated instead") - fun viewWillAppear() = beforeViewCreated() - fun beforeViewCreated() { if (isBrowserViewPresented) { return @@ -312,7 +309,9 @@ class PaywallView( Superwall.instance.dependencyContainer.delegateAdapter .willPresentPaywall(info) - webView.setRendererPriorityPolicy(RENDERER_PRIORITY_IMPORTANT, true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + webView.setRendererPriorityPolicy(RENDERER_PRIORITY_IMPORTANT, true) + } webView.scrollTo(0, 0) if (loadingState is PaywallLoadingState.Ready) { webView.messageHandler.handle(PaywallMessage.TemplateParamsAndUserAttributes) @@ -471,19 +470,15 @@ class PaywallView( super.onAttachedToWindow() // Assert if no `request` - // fatalAssert(request != null, "Must be presenting a PaywallViewController with a `request` instance.") + // fatalAssert(request != null, "Must be presenting a Paywallview with a `request` instance.") if (loadingState is PaywallLoadingState.Unknown) { loadWebView() } } - // / Lets the view controller know that presentation has finished. + // Lets the view know that presentation has finished. // Only called once per presentation. - - @Deprecated("Will be removed in the upcoming versions, use onViewCreated instead") - fun viewDidAppear() = onViewCreated() - fun onViewCreated() { viewCreatedCompletion?.invoke(true) viewCreatedCompletion = null @@ -557,7 +552,7 @@ class PaywallView( if (transactionBackgroundView != PaywallOptions.TransactionBackgroundView.SPINNER) { return } - loadingViewController?.let { + loadingView?.let { mainScope.launch { it.visibility = View.VISIBLE } @@ -565,7 +560,7 @@ class PaywallView( } private fun hideLoadingView() { - loadingViewController?.let { + loadingView?.let { mainScope.launch { it.visibility = View.GONE } @@ -627,19 +622,6 @@ class PaywallView( // TODO: Implement this } - @Deprecated( - "Will be removed in the upcoming versions, use presentAlert instead", - ReplaceWith("showAlert(title, message, actionTitle, closeActionTitle, action, onClose)"), - ) - fun presentAlert( - title: String? = null, - message: String? = null, - actionTitle: String? = null, - closeActionTitle: String = "Done", - action: (() -> Unit)? = null, - onClose: (() -> Unit)? = null, - ) = showAlert(title, message, actionTitle, closeActionTitle, action, onClose) - fun showAlert( title: String? = null, message: String? = null, @@ -741,12 +723,15 @@ class PaywallView( } webView.onRenderProcessCrashed = { + val isOverO = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O Logger.debug( logLevel = LogLevel.error, scope = LogScope.paywallView, message = "Webview Process has crashed for paywall with identifier: ${paywall.identifier}.\n" + - "Crashed by the system: ${it.didCrash()} - priority ${it.rendererPriorityAtExit()}", + "Crashed by the system: ${ + if (isOverO)it.didCrash() else "Unknown"} - priority ${ + if (isOverO)it.rendererPriorityAtExit() else "Unknown"}", ) recreateWebview() } @@ -833,14 +818,8 @@ class PaywallView( context?.startActivity(deepLinkIntent) } - @Deprecated("Will be removed in the upcoming versions, use presentBrowserInApp instead") - override fun presentSafariInApp(url: String) = presentBrowserInApp(url) - - @Deprecated("Will be removed in the upcoming versions, use presentBrowserExternal instead") - override fun presentSafariExternal(url: String) = presentBrowserExternal(url) - //region GameController - override fun gameControllerEventDidOccur(event: GameControllerEvent) { + override fun gameControllerEventOccured(event: GameControllerEvent) { val payload = try { gameControllerJson.encodeToString(event) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/ShimmerView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/ShimmerView.kt similarity index 93% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/ShimmerView.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/ShimmerView.kt index 5ce08f87..ce8e4971 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/ShimmerView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/ShimmerView.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc +package com.superwall.sdk.paywall.view import android.animation.ValueAnimator import android.content.Context @@ -18,7 +18,7 @@ import androidx.core.graphics.BlendModeCompat import com.superwall.sdk.R import com.superwall.sdk.misc.isDarkColor import com.superwall.sdk.misc.readableOverlayColor -import com.superwall.sdk.paywall.vc.delegate.PaywallLoadingState +import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState class ShimmerView( context: Context, @@ -52,12 +52,12 @@ class ShimmerView( private var tintColor: Int = 0 fun setupFor( - paywallViewController: PaywallView, + paywallView: PaywallView, loadingState: PaywallLoadingState, ) { (this.parent as? ViewGroup)?.removeView(this) - if (background != paywallViewController.backgroundColor) { - background = paywallViewController.backgroundColor + if (background != paywallView.backgroundColor) { + background = paywallView.backgroundColor setBackgroundColor(background) isLightBackground = !background.isDarkColor() tintColor = background.readableOverlayColor() @@ -75,7 +75,7 @@ class ShimmerView( else -> GONE } - paywallViewController.addView(this) + paywallView.addView(this) layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) checkForOrientationChanges() diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt similarity index 95% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallPaywallActivity.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt index 39095d35..4d97d486 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallPaywallActivity.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc +package com.superwall.sdk.paywall.view import android.Manifest import android.animation.ArgbEvaluator @@ -49,7 +49,7 @@ import com.superwall.sdk.models.paywall.LocalNotification import com.superwall.sdk.models.paywall.PaywallPresentationStyle import com.superwall.sdk.paywall.presentation.PaywallCloseReason import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult -import com.superwall.sdk.paywall.vc.web_view.SWWebView +import com.superwall.sdk.paywall.view.webview.SWWebView import com.superwall.sdk.store.transactions.notifications.NotificationScheduler import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.CoroutineScope @@ -490,7 +490,6 @@ class SuperwallPaywallActivity : AppCompatActivity() { suspend fun attemptToScheduleNotifications( notifications: List, factory: DeviceHelperFactory, - context: Context, ) = suspendCoroutine { continuation -> if (notifications.isEmpty()) { continuation.resume(Unit) // Resume immediately as there's nothing to schedule @@ -506,7 +505,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { NotificationScheduler.scheduleNotifications( notifications = notifications, factory = factory, - context = context, + context = this@SuperwallPaywallActivity, ) } continuation.resume(Unit) // Resume coroutine after processing @@ -517,20 +516,22 @@ class SuperwallPaywallActivity : AppCompatActivity() { } private fun createNotificationChannel() { - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = - NotificationChannel( - NOTIFICATION_CHANNEL_ID, - NOTIFICATION_CHANNEL_NAME, - importance, - ).apply { - description = NOTIFICATION_CHANNEL_DESCRIPTION - } - channel.setShowBadge(false) - // Register the channel with the system - val notificationManager: NotificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = + NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_CHANNEL_NAME, + importance, + ).apply { + description = NOTIFICATION_CHANNEL_DESCRIPTION + } + channel.setShowBadge(false) + // Register the channel with the system + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } } private fun checkAndRequestNotificationPermissions( @@ -569,9 +570,11 @@ class SuperwallPaywallActivity : AppCompatActivity() { private fun areNotificationsEnabled(context: Context): Boolean { val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val channel = notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) - if (channel?.importance == NotificationManager.IMPORTANCE_NONE) { - return false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) + if (channel?.importance == NotificationManager.IMPORTANCE_NONE) { + return false + } } return NotificationManagerCompat.from(context).areNotificationsEnabled() } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallStoreOwner.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallStoreOwner.kt similarity index 94% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallStoreOwner.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallStoreOwner.kt index 4315d50c..f95b3f44 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallStoreOwner.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallStoreOwner.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc +package com.superwall.sdk.paywall.view import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/Survey/SurveyManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/Survey/SurveyManager.kt similarity index 98% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/Survey/SurveyManager.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/Survey/SurveyManager.kt index efb2b597..0c76f84b 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/Survey/SurveyManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/Survey/SurveyManager.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc.Survey +package com.superwall.sdk.paywall.view.Survey import android.app.Activity import android.text.Editable @@ -25,8 +25,8 @@ import com.superwall.sdk.logger.Logger import com.superwall.sdk.paywall.presentation.PaywallCloseReason import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult -import com.superwall.sdk.paywall.vc.PaywallView -import com.superwall.sdk.paywall.vc.delegate.PaywallLoadingState +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.storage.SurveyAssignmentKey import kotlinx.coroutines.CoroutineScope diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/Survey/SurveyPresentationResult.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/Survey/SurveyPresentationResult.kt similarity index 74% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/Survey/SurveyPresentationResult.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/Survey/SurveyPresentationResult.kt index e8debf1c..754e140b 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/Survey/SurveyPresentationResult.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/Survey/SurveyPresentationResult.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc.Survey +package com.superwall.sdk.paywall.view.Survey enum class SurveyPresentationResult( val rawValue: String, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorage.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/ViewStorage.kt similarity index 91% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorage.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/ViewStorage.kt index ae6370a2..22ccb4ab 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorage.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/ViewStorage.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc +package com.superwall.sdk.paywall.view import android.view.View import java.util.concurrent.ConcurrentHashMap diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorageViewModel.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/ViewStorageViewModel.kt similarity index 88% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorageViewModel.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/ViewStorageViewModel.kt index 1d2c727f..3ef52962 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorageViewModel.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/ViewStorageViewModel.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc +package com.superwall.sdk.paywall.view import android.view.View import androidx.lifecycle.ViewModel diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/delegate/PaywallViewCallback.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/delegate/PaywallViewCallback.kt new file mode 100644 index 00000000..8377edad --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/delegate/PaywallViewCallback.kt @@ -0,0 +1,32 @@ +package com.superwall.sdk.paywall.view.delegate + +import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent + +interface PaywallViewCallback { + fun onFinished( + paywall: PaywallView, + result: PaywallResult, + shouldDismiss: Boolean, + ) +} + +fun interface PaywallViewEventCallback { + suspend fun eventDidOccur( + paywallEvent: PaywallWebEvent, + paywallView: PaywallView, + ) +} + +sealed class PaywallLoadingState { + class Unknown : PaywallLoadingState() + + class LoadingPurchase : PaywallLoadingState() + + class LoadingURL : PaywallLoadingState() + + class ManualLoading : PaywallLoadingState() + + class Ready : PaywallLoadingState() +} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/delegate/PaywallViewDelegateAdapter.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/delegate/PaywallViewDelegateAdapter.kt similarity index 50% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/delegate/PaywallViewDelegateAdapter.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/delegate/PaywallViewDelegateAdapter.kt index 06d67d4d..2770dfe7 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/delegate/PaywallViewDelegateAdapter.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/delegate/PaywallViewDelegateAdapter.kt @@ -1,7 +1,7 @@ -package com.superwall.sdk.paywall.vc.delegate +package com.superwall.sdk.paywall.view.delegate import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult -import com.superwall.sdk.paywall.vc.PaywallView +import com.superwall.sdk.paywall.view.PaywallView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -11,13 +11,6 @@ class PaywallViewDelegateAdapter( val hasJavaDelegate: Boolean get() = false - @Deprecated("Will be removed in the upcoming versions, use onFinished instead") - suspend fun didFinish( - paywall: PaywallView, - result: PaywallResult, - shouldDismiss: Boolean, - ) = onFinished(paywall, result, shouldDismiss) - suspend fun onFinished( paywall: PaywallView, result: PaywallResult, @@ -26,6 +19,3 @@ class PaywallViewDelegateAdapter( kotlinDelegate?.onFinished(paywall, result, shouldDismiss) } } - -@Deprecated("Will be removed in the upcoming versions, use PaywallViewDelegateAdapter instead") -typealias PaywallViewControllerDelegateAdapter = PaywallViewDelegateAdapter diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/DefaultWebviewClient.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DefaultWebviewClient.kt similarity index 79% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/DefaultWebviewClient.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DefaultWebviewClient.kt index 22565c6b..9b262097 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/DefaultWebviewClient.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/DefaultWebviewClient.kt @@ -1,6 +1,7 @@ -package com.superwall.sdk.paywall.vc.web_view +package com.superwall.sdk.paywall.view.webview import android.graphics.Bitmap +import android.os.Build import android.webkit.RenderProcessGoneDetail import android.webkit.WebResourceError import android.webkit.WebResourceRequest @@ -82,11 +83,19 @@ internal open class DefaultWebviewClient( ioScope.launch { webviewClientEvents.emit( WebviewClientEvent.OnError( - WebviewError.NetworkError( - error.errorCode, - error.description.toString(), - forUrl, - ), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + WebviewError.NetworkError( + error.errorCode, + error.description.toString(), + forUrl, + ) + } else { + WebviewError.NetworkError( + -1, + "Error description unavailable, Android API version < 23", + forUrl, + ) + }, ), ) } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/SWWebView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt similarity index 88% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/SWWebView.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt index f39be417..9e3f8301 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/SWWebView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc.web_view +package com.superwall.sdk.paywall.view.webview import android.content.Context import android.graphics.Color @@ -11,9 +11,11 @@ import android.view.inputmethod.BaseInputConnection import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import android.webkit.ConsoleMessage +import android.webkit.CookieManager import android.webkit.RenderProcessGoneDetail import android.webkit.WebChromeClient import android.webkit.WebView +import android.webkit.WebViewClient import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent @@ -26,8 +28,8 @@ import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.MainScope import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.paywall.presentation.PaywallInfo -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallMessageHandler -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallMessageHandlerDelegate +import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandler +import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandlerDelegate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -76,6 +78,8 @@ class SWWebView( } } + private var lastWebViewClient: WebViewClient? = null + internal fun prepareWebview() { addJavascriptInterface(messageHandler, "SWAndroid") @@ -114,7 +118,7 @@ class SWWebView( mainScope = mainScope, ioScope = ioScope, loadUrl = { - loadUrl(it.url) + super.loadUrl(transformUri(it.url)) }, stopLoading = { stopLoading() @@ -122,12 +126,17 @@ class SWWebView( onCrashed = onRenderProcessCrashed, ) this.webViewClient = client - listenToWebviewClientEvents(this.webViewClient as DefaultWebviewClient) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + lastWebViewClient = client + } + listenToWebviewClientEvents(client) client.loadWithFallback() } fun enableOffscreenRender() { - settings.offscreenPreRaster = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + settings.offscreenPreRaster = true + } } // ??? @@ -152,13 +161,24 @@ class SWWebView( override fun loadUrl(url: String) { prepareWebview() - this.webViewClient = + val client = DefaultWebviewClient( forUrl = url, ioScope = CoroutineScope(Dispatchers.IO), onWebViewCrash = onRenderProcessCrashed, ) - listenToWebviewClientEvents(this.webViewClient as DefaultWebviewClient) + this.webViewClient = client + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + lastWebViewClient = client + } + + listenToWebviewClientEvents(client) + + super.loadUrl(transformUri(url)) + } + + private fun transformUri(url: String): String { // Parse the url and add the query parameter val uri = Uri.parse(url) @@ -177,7 +197,7 @@ class SWWebView( LogScope.paywallView, "SWWebView.loadUrl: $urlString", ) - super.loadUrl(urlString) + return urlString } private fun listenToWebviewClientEvents(client: DefaultWebviewClient) { @@ -186,7 +206,11 @@ class SWWebView( .takeWhile { mainScope .async { - webViewClient == client + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + webViewClient == client + } else { + lastWebViewClient == client + } }.await() }.collect { mainScope.launch { @@ -336,7 +360,11 @@ class SWWebView( internal fun webViewExists(): Boolean = try { - WebView.getCurrentWebViewPackage() != null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + WebView.getCurrentWebViewPackage() != null + } else { + runCatching { CookieManager.getInstance() }.isSuccess + } } catch (e: Throwable) { false } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/ScrollDisabled.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/ScrollDisabled.kt similarity index 90% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/ScrollDisabled.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/ScrollDisabled.kt index 6193a1d1..860ac4ee 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/ScrollDisabled.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/ScrollDisabled.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc.web_view +package com.superwall.sdk.paywall.view.webview import android.view.GestureDetector import android.view.MotionEvent diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewClientEvent.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewClientEvent.kt similarity index 96% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewClientEvent.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewClientEvent.kt index ff66266b..3cabad4e 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewClientEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewClientEvent.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc.web_view +package com.superwall.sdk.paywall.view.webview sealed class WebviewClientEvent { data class OnPageFinished( diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewFallbackClient.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewFallbackClient.kt similarity index 99% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewFallbackClient.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewFallbackClient.kt index cc0c2fc2..b3e7fda5 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewFallbackClient.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/WebviewFallbackClient.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc.web_view +package com.superwall.sdk.paywall.view.webview import android.graphics.Bitmap import android.webkit.RenderProcessGoneDetail diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessage.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt similarity index 98% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessage.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt index 937cff1a..5235c293 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessage.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc.web_view +package com.superwall.sdk.paywall.view.webview import android.net.Uri import com.superwall.sdk.logger.LogLevel diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt similarity index 93% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessageHandler.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt index fd77c2d8..a78293a2 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessageHandler.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt @@ -1,7 +1,8 @@ -package com.superwall.sdk.paywall.vc.web_view.messaging +package com.superwall.sdk.paywall.view.webview.messaging import TemplateLogic import android.net.Uri +import android.util.Base64 import android.webkit.JavascriptInterface import android.webkit.WebView import com.superwall.sdk.Superwall @@ -17,10 +18,10 @@ import com.superwall.sdk.misc.MainScope import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.paywall.presentation.internal.PresentationRequest -import com.superwall.sdk.paywall.vc.delegate.PaywallLoadingState -import com.superwall.sdk.paywall.vc.web_view.PaywallMessage -import com.superwall.sdk.paywall.vc.web_view.WrappedPaywallMessages -import com.superwall.sdk.paywall.vc.web_view.parseWrappedPaywallMessages +import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState +import com.superwall.sdk.paywall.view.webview.PaywallMessage +import com.superwall.sdk.paywall.view.webview.WrappedPaywallMessages +import com.superwall.sdk.paywall.view.webview.parseWrappedPaywallMessages import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -30,7 +31,6 @@ import kotlinx.serialization.json.Json import org.json.JSONObject import java.net.URI import java.nio.charset.StandardCharsets -import java.util.Base64 import java.util.Date import java.util.LinkedList import java.util.Queue @@ -47,12 +47,6 @@ interface PaywallMessageHandlerDelegate { fun openDeepLink(url: String) - @Deprecated("Will be removed in the upcoming versions, use presentBrowserInApp instead") - fun presentSafariInApp(url: String) = presentBrowserInApp(url) - - @Deprecated("Will be removed in the upcoming versions, use presentBrowserExternal instead") - fun presentSafariExternal(url: String) = presentBrowserExternal(url) - fun presentBrowserInApp(url: String) fun presentBrowserExternal(url: String) @@ -63,7 +57,6 @@ class PaywallMessageHandler( private val factory: VariablesFactory, private val mainScope: MainScope, private val ioScope: CoroutineScope, - private val encoder: Base64.Encoder = Base64.getEncoder(), private val json: Json = Json { encodeDefaults = true }, ) { private companion object { @@ -203,7 +196,7 @@ class PaywallMessageHandler( // Encode the JSON string to Base64 val base64Event = - encoder.encodeToString(jsonString.toByteArray(StandardCharsets.UTF_8)) + Base64.encodeToString(jsonString.toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP) passMessageToWebView(base64String = base64Event) } @@ -218,7 +211,6 @@ class PaywallMessageHandler( event = eventData, factory = factory, json = json, - base64 = encoder, ) passMessageToWebView(base64String = templates) } @@ -284,7 +276,6 @@ class PaywallMessageHandler( event = eventData, factory = factory, json = json, - base64 = encoder, ) val scriptSrc = """ window.paywall.accept64('$templates'); @@ -342,9 +333,6 @@ class PaywallMessageHandler( delegate?.presentBrowserInApp(url.toString()) } - @Deprecated("Will be removed in the upcoming versions, use openUrlInChrome instead") - private fun openUrlInSafari(url: URI) = openUrlInBrowser(url) - private fun openUrlInBrowser(url: URI) { detectHiddenPaywallEvent( "openUrlInSafari", diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallWebEvent.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt similarity index 94% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallWebEvent.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt index 60f6611c..bb13f2d2 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallWebEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc.web_view.messaging +package com.superwall.sdk.paywall.view.webview.messaging import android.net.Uri import kotlinx.serialization.SerialName diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/RawWebMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/RawWebMessageHandler.kt similarity index 90% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/RawWebMessageHandler.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/RawWebMessageHandler.kt index b63f67d0..31bda972 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/RawWebMessageHandler.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/RawWebMessageHandler.kt @@ -1,12 +1,12 @@ -package com.superwall.sdk.paywall.vc.web_view.messaging +package com.superwall.sdk.paywall.view.webview.messaging import android.webkit.JavascriptInterface import android.webkit.WebViewClient import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger -import com.superwall.sdk.paywall.vc.web_view.PaywallMessage -import com.superwall.sdk.paywall.vc.web_view.parseWrappedPaywallMessages +import com.superwall.sdk.paywall.view.webview.PaywallMessage +import com.superwall.sdk.paywall.view.webview.parseWrappedPaywallMessages import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/TemplateLogic.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/TemplateLogic.kt similarity index 89% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/TemplateLogic.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/TemplateLogic.kt index e1957a04..d8642fd2 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/TemplateLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/TemplateLogic.kt @@ -4,17 +4,14 @@ import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall -import com.superwall.sdk.paywall.vc.web_view.templating.models.FreeTrialTemplate -import com.superwall.sdk.paywall.vc.web_view.templating.models.JsonVariables +import com.superwall.sdk.paywall.view.webview.templating.models.FreeTrialTemplate +import com.superwall.sdk.paywall.view.webview.templating.models.JsonVariables import com.superwall.sdk.paywall.view_controller.web_view.templating.models.ProductTemplate -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import java.util.* object TemplateLogic { suspend fun getBase64EncodedTemplates( json: Json, - base64: Base64.Encoder, paywall: Paywall, event: EventData?, factory: VariablesFactory, @@ -59,7 +56,7 @@ object TemplateLogic { "!!! Template Logic: $templatesString", ) - return base64.encodeToString(templatesData) + return android.util.Base64.encodeToString(templatesData, android.util.Base64.NO_WRAP) } // private fun swProductTemplate( diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/models/DeviceTemplate.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt similarity index 91% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/models/DeviceTemplate.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt index beb5083f..d0e4c885 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/models/DeviceTemplate.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc.web_view.templating.models +package com.superwall.sdk.paywall.view.webview.templating.models import com.superwall.sdk.storage.core_data.toNullableTypedMap import kotlinx.serialization.SerialName @@ -44,7 +44,9 @@ data class DeviceTemplate( val utcDateTime: String, val localDateTime: String, val isSandbox: String, - val subscriptionStatus: String, + val activeEntitlements: List, + val activeEntitlementsObject: List>, + val activeProducts: List, val isFirstAppOpen: Boolean, val sdkVersion: String, val sdkVersionPadded: String, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/models/FreeTrialTemplate.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/FreeTrialTemplate.kt similarity index 77% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/models/FreeTrialTemplate.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/FreeTrialTemplate.kt index 37175ce7..a34c5158 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/models/FreeTrialTemplate.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/FreeTrialTemplate.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc.web_view.templating.models +package com.superwall.sdk.paywall.view.webview.templating.models import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/models/ProductTemplate.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/ProductTemplate.kt similarity index 100% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/models/ProductTemplate.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/ProductTemplate.kt diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/models/Variables.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/Variables.kt similarity index 96% rename from superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/models/Variables.kt rename to superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/Variables.kt index 907eada0..ecf86471 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/templating/models/Variables.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/Variables.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc.web_view.templating.models +package com.superwall.sdk.paywall.view.webview.templating.models import com.superwall.sdk.models.product.ProductVariable import com.superwall.sdk.models.serialization.AnySerializer diff --git a/superwall/src/main/java/com/superwall/sdk/storage/Cache.kt b/superwall/src/main/java/com/superwall/sdk/storage/Cache.kt index 0d405c4a..1ff8f225 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/Cache.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/Cache.kt @@ -34,7 +34,6 @@ class Cache( fun read(storable: Storable): T? { var data = memCache[storable.key] as? T - if (data == null) { runBlocking(ioQueue) { val file = storable.file(context = context) diff --git a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt index a5fd1d88..8c444496 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt @@ -1,10 +1,12 @@ package com.superwall.sdk.storage import android.content.Context -import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.models.geo.GeoInfo import com.superwall.sdk.models.serialization.AnySerializer +import com.superwall.sdk.models.transactions.SavedTransaction import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.ExperimentID import com.superwall.sdk.store.abstractions.transactions.StoreTransaction @@ -15,6 +17,7 @@ import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException import kotlinx.serialization.Serializer import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.SetSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor @@ -41,8 +44,17 @@ enum class SearchPathDirectory { fun fileDirectory(context: Context): File = when (this) { CACHE -> context.cacheDir - USER_SPECIFIC_DOCUMENTS -> context.getDir("user_specific_document_dir", Context.MODE_PRIVATE) - APP_SPECIFIC_DOCUMENTS -> context.getDir("app_specific_document_dir", Context.MODE_PRIVATE) + USER_SPECIFIC_DOCUMENTS -> + context.getDir( + "user_specific_document_dir", + Context.MODE_PRIVATE, + ) + + APP_SPECIFIC_DOCUMENTS -> + context.getDir( + "app_specific_document_dir", + Context.MODE_PRIVATE, + ) } } @@ -209,15 +221,26 @@ object SdkVersion : Storable { get() = String.serializer() } -object ActiveSubscriptionStatus : Storable { +object StoredEntitlementStatus : Storable { override val key: String - get() = "store.subscriptionStatus" + get() = "store.entitlementStatus" override val directory: SearchPathDirectory get() = SearchPathDirectory.APP_SPECIFIC_DOCUMENTS - override val serializer: KSerializer - get() = SubscriptionStatus.serializer() + override val serializer: KSerializer + get() = EntitlementStatus.serializer() +} + +object StoredEntitlementsByProductId : Storable>> { + override val key: String + get() = "store.entitlementByProductId" + + override val directory: SearchPathDirectory + get() = SearchPathDirectory.APP_SPECIFIC_DOCUMENTS + + override val serializer: KSerializer>> + get() = MapSerializer(String.serializer(), SetSerializer(Entitlement.serializer())) } object SurveyAssignmentKey : Storable { @@ -269,6 +292,23 @@ internal object LatestGeoInfo : Storable { get() = GeoInfo.serializer() } +internal object SavedTransactions : Storable> { + override val key: String + get() = "store.savedTransactions" + override val directory: SearchPathDirectory + get() = SearchPathDirectory.APP_SPECIFIC_DOCUMENTS + override val serializer: KSerializer> + get() = SetSerializer(SavedTransaction.serializer()) +} + +internal object PurchasingProductdIds : Storable> { + override val key: String + get() = "store.purchasingProductIds" + override val directory: SearchPathDirectory + get() = SearchPathDirectory.APP_SPECIFIC_DOCUMENTS + override val serializer: KSerializer> + get() = SetSerializer(String.serializer()) +} //endregion // region Serializers @@ -280,7 +320,8 @@ object DateSerializer : KSerializer { timeZone = TimeZone.getTimeZone("UTC") } - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING) + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING) override fun serialize( encoder: Encoder, @@ -292,7 +333,8 @@ object DateSerializer : KSerializer { override fun deserialize(decoder: Decoder): Date { val dateString = decoder.decodeString() - return format.parse(dateString) ?: throw SerializationException("Invalid date format: $dateString") + return format.parse(dateString) + ?: throw SerializationException("Invalid date format: $dateString") } } diff --git a/superwall/src/main/java/com/superwall/sdk/storage/core_data/CoreDataManager.kt b/superwall/src/main/java/com/superwall/sdk/storage/core_data/CoreDataManager.kt index fecc7473..d4a62b82 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/core_data/CoreDataManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/core_data/CoreDataManager.kt @@ -1,7 +1,6 @@ package com.superwall.sdk.storage.core_data import android.content.Context -import android.icu.util.Calendar import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger @@ -13,6 +12,7 @@ import com.superwall.sdk.storage.core_data.entities.ManagedTriggerRuleOccurrence import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.util.Calendar import java.util.Date import kotlin.coroutines.CoroutineContext diff --git a/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt similarity index 85% rename from superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt rename to superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt index 71ced155..ba718ef6 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt @@ -2,17 +2,27 @@ package com.superwall.sdk.store import android.app.Activity import android.content.Context -import com.android.billingclient.api.* +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryPurchasesParams import com.superwall.sdk.Superwall import com.superwall.sdk.billing.RECONNECT_TIMER_MAX_TIME_MILLISECONDS import com.superwall.sdk.billing.RECONNECT_TIMER_START_MILLISECONDS +import com.superwall.sdk.config.models.ConfigurationStatus import com.superwall.sdk.delegate.PurchaseResult import com.superwall.sdk.delegate.RestorationResult -import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.delegate.subscription_controller.PurchaseController import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger +import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.store.abstractions.product.OfferType import com.superwall.sdk.store.abstractions.product.RawStoreProduct import kotlinx.coroutines.CompletableDeferred @@ -24,8 +34,10 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlin.math.min -class ExternalNativePurchaseController( +class AutomaticPurchaseController( var context: Context, + val scope: IOScope, + val entitlementsInfo: Entitlements, ) : PurchaseController, PurchasesUpdatedListener { private var billingClient: BillingClient = @@ -34,6 +46,7 @@ class ExternalNativePurchaseController( .setListener(this) .enablePendingPurchases() .build() + private val isConnected = MutableStateFlow(false) private val purchaseResults = MutableStateFlow(null) @@ -43,7 +56,7 @@ class ExternalNativePurchaseController( //region Initialization init { - CoroutineScope(Dispatchers.IO).launch { + scope.launch { startConnection() } } @@ -95,8 +108,8 @@ class ExternalNativePurchaseController( //region Public - fun syncSubscriptionStatus() { - CoroutineScope(Dispatchers.IO).launch { + private fun syncSubscriptionStatus() { + scope.launch { Superwall.hasInitialized.first { it } syncSubscriptionStatusAndWait() } @@ -248,7 +261,7 @@ class ExternalNativePurchaseController( } } - CoroutineScope(Dispatchers.IO).launch { + scope.launch { // Emit the purchase result to any observers purchaseResults.emit(result) @@ -262,14 +275,34 @@ class ExternalNativePurchaseController( //region Private private suspend fun syncSubscriptionStatusAndWait() { + // We await for configuration to be set so our entitlements are available + Superwall.instance.configurationStateListener.first { it is ConfigurationStatus.Configured } val subscriptionPurchases = queryPurchasesOfType(BillingClient.ProductType.SUBS) val inAppPurchases = queryPurchasesOfType(BillingClient.ProductType.INAPP) val allPurchases = subscriptionPurchases + inAppPurchases val hasActivePurchaseOrSubscription = allPurchases.any { it.purchaseState == Purchase.PurchaseState.PURCHASED } - val status: SubscriptionStatus = - if (hasActivePurchaseOrSubscription) SubscriptionStatus.ACTIVE else SubscriptionStatus.INACTIVE + val status: EntitlementStatus = + if (hasActivePurchaseOrSubscription) { + subscriptionPurchases + .flatMap { + it.products + }.toSet() + .flatMap { + val res = entitlementsInfo.byProductId(it) + res + }.toSet() + .let { entitlements -> + if (entitlements.isNotEmpty()) { + EntitlementStatus.Active(entitlements) + } else { + EntitlementStatus.Inactive + } + } + } else { + EntitlementStatus.Inactive + } if (!Superwall.initialized) { Logger.debug( @@ -280,7 +313,7 @@ class ExternalNativePurchaseController( return } - Superwall.instance.setSubscriptionStatus(status) + Superwall.instance.setEntitlementStatus(status) } private suspend fun queryPurchasesOfType(productType: String): List { diff --git a/superwall/src/main/java/com/superwall/sdk/store/Entitlements.kt b/superwall/src/main/java/com/superwall/sdk/store/Entitlements.kt new file mode 100644 index 00000000..3d84e6f8 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/store/Entitlements.kt @@ -0,0 +1,148 @@ +package com.superwall.sdk.store + +import com.superwall.sdk.billing.DecomposedProductIds +import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.entitlements.EntitlementStatus +import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.StoredEntitlementStatus +import com.superwall.sdk.storage.StoredEntitlementsByProductId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * A class that handles the Set of Entitlement objects retrieved from + * the Superwall dashboard. + */ +class Entitlements( + private val storage: Storage, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default), +) { + // MARK: - Private Properties + private val _entitlementsByProduct = ConcurrentHashMap>() + + private val _status: MutableStateFlow = + MutableStateFlow(EntitlementStatus.Unknown) + + /** + * A StateFlow of the entitlement status of the user. Set this using + * [Superwall.instance.setEntitlementStatus]. + * + * You can collect this flow to get notified whenever it changes. + */ + val status: StateFlow + get() = _status.asStateFlow() + + // MARK: - Backing Fields + private val _all = mutableSetOf() + private val _active = mutableSetOf() + private val _inactive = mutableSetOf() + + // MARK: - Public Properties + + /** + * All entitlements, regardless of whether they're active or not. + */ + val all: Set + get() = _all.toSet() + + /** + * The active entitlements. + */ + val active: Set + get() = _active.toSet() + + /** + * The inactive entitlements. + */ + val inactive: Set + get() = _inactive.toSet() + + init { + storage.read(StoredEntitlementStatus)?.let { + setEntitlementStatus(it) + } + storage.read(StoredEntitlementsByProductId)?.let { + _entitlementsByProduct.putAll(it) + } + + scope.launch { + status.collect { + storage.write(StoredEntitlementStatus, it) + } + } + } + + /** + * Sets the entitlement status and updates the corresponding entitlement collections. + */ + fun setEntitlementStatus(value: EntitlementStatus) { + when (value) { + is EntitlementStatus.Active -> { + if (value.entitlements.isEmpty()) { + setEntitlementStatus(EntitlementStatus.Inactive) + } else { + _active.clear() + _all.addAll(value.entitlements) + _active.addAll(value.entitlements) + _inactive.removeAll(value.entitlements) + _status.value = value + } + } + + is EntitlementStatus.Inactive -> { + _active.clear() + _inactive.clear() + _status.value = value + } + + is EntitlementStatus.Unknown -> { + _active.clear() + _inactive.clear() + _status.value = value + } + } + } + + /** + * Returns a Set of Entitlements belonging to a given productId. + * + * @param id A String representing a productId + * @return A Set of Entitlements + */ + internal fun byProductId(id: String): Set { + val decomposedProductIds = DecomposedProductIds.from(id) + listOf( + decomposedProductIds.fullId, + "${decomposedProductIds.subscriptionId}:${decomposedProductIds.basePlanId}", + decomposedProductIds.subscriptionId, + ).forEach { id -> + _entitlementsByProduct.entries + .firstOrNull { it.key.contains(id) && it.value.isNotEmpty() } + .let { + if (it != null) { + return it.value + } + } + } + return emptySet() + } + + /** + * Updates the entitlements associated with product IDs and persists them to storage. + */ + internal fun addEntitlementsByProductId(idToEntitlements: Map>) { + _entitlementsByProduct.putAll( + idToEntitlements.mapValues { (_, entitlements) -> + entitlements.toSet() + }, + ) + _all.clear() + _all.addAll(_entitlementsByProduct.values.flatten()) + storage.write(StoredEntitlementsByProductId, _entitlementsByProduct) + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt index bee9ee70..d2ed9055 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt @@ -18,6 +18,9 @@ class InternalPurchaseController( val hasExternalPurchaseController: Boolean get() = kotlinPurchaseController != null || javaPurchaseController != null + val hasInternalPurchaseController: Boolean + get() = hasExternalPurchaseController && kotlinPurchaseController is AutomaticPurchaseController + override suspend fun purchase( activity: Activity, productDetails: ProductDetails, diff --git a/superwall/src/main/java/com/superwall/sdk/store/PurchasingObserverState.kt b/superwall/src/main/java/com/superwall/sdk/store/PurchasingObserverState.kt new file mode 100644 index 00000000..3e69b76e --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/store/PurchasingObserverState.kt @@ -0,0 +1,35 @@ +package com.superwall.sdk.store + +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase + +sealed class PurchasingObserverState { + /** + * Tracks a beginning of a purchase flow for a product, equalt to Transaction Start event. + * @param product The product that is being purchased. + * */ + class PurchaseWillBegin( + val product: ProductDetails, + ) : PurchasingObserverState() + + /** + * Tracks a successful purchase flow for a product, equal to Transaction Success event. + * @param result The result of the purchase flow. + * @param purchases The list of purchases that were made. + */ + class PurchaseResult( + val result: BillingResult, + val purchases: List?, + ) : PurchasingObserverState() + + /** + * Tracks a failed purchase flow for a product, equal to Transaction Fail event. + * @param product The product that was being purchased. + * @param error The error that caused the purchase to fail. + */ + class PurchaseError( + val product: ProductDetails, + val error: Throwable, + ) : PurchasingObserverState() +} diff --git a/superwall/src/main/java/com/superwall/sdk/store/StoreKit.kt b/superwall/src/main/java/com/superwall/sdk/store/StoreKit.kt new file mode 100644 index 00000000..77811a33 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/store/StoreKit.kt @@ -0,0 +1,28 @@ +package com.superwall.sdk.store + +import com.superwall.sdk.models.paywall.Paywall +import com.superwall.sdk.models.product.ProductVariable +import com.superwall.sdk.paywall.request.PaywallRequest +import com.superwall.sdk.store.abstractions.product.StoreProduct + +interface StoreKit { + suspend fun getProductVariables( + paywall: Paywall, + request: PaywallRequest, + ): List + + suspend fun getProducts( + substituteProducts: Map? = null, + paywall: Paywall, + request: PaywallRequest? = null, + ): GetProductsResponse + + suspend fun getProductsWithoutPaywall( + productIds: List, + substituteProducts: Map? = null, + ): Map + + suspend fun refreshReceipt() + + suspend fun loadPurchasedProducts() +} diff --git a/superwall/src/main/java/com/superwall/sdk/store/StoreKitManagerInterface.kt b/superwall/src/main/java/com/superwall/sdk/store/StoreKitManagerInterface.kt index d7b193d3..fd686c83 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/StoreKitManagerInterface.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/StoreKitManagerInterface.kt @@ -3,16 +3,15 @@ package com.superwall.sdk.store import com.superwall.sdk.delegate.RestorationResult import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.paywall.PaywallProducts -import com.superwall.sdk.models.product.Product import com.superwall.sdk.models.product.ProductItem import com.superwall.sdk.models.product.ProductVariable -import com.superwall.sdk.paywall.vc.PaywallView +import com.superwall.sdk.paywall.view.PaywallView import com.superwall.sdk.store.abstractions.product.StoreProduct data class GetProductsResponse( val productsByFullId: Map, val productItems: List, - val paywall: Paywall, + val paywall: Paywall? = null, ) interface StoreKitManagerInterface { @@ -23,7 +22,7 @@ interface StoreKitManagerInterface { suspend fun getProducts( responseProductIds: List, paywallName: String? = null, - responseProducts: List = listOf(), + responseProducts: List = listOf(), substituteProducts: PaywallProducts? = null, ): GetProductsResponse diff --git a/superwall/src/main/java/com/superwall/sdk/store/StoreKitManager.kt b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt similarity index 73% rename from superwall/src/main/java/com/superwall/sdk/store/StoreKitManager.kt rename to superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt index d6c3bd84..f9a50ace 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/StoreKitManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt @@ -1,12 +1,11 @@ package com.superwall.sdk.store -import android.content.Context import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.billing.Billing import com.superwall.sdk.billing.BillingError import com.superwall.sdk.billing.DecomposedProductIds -import com.superwall.sdk.billing.GoogleBillingWrapper import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger @@ -22,88 +21,15 @@ import com.superwall.sdk.store.abstractions.product.receipt.ReceiptManager import com.superwall.sdk.store.coordinator.ProductsFetcher import java.util.Date -/* -class StoreKitManager(private val context: Context) : StoreKitManagerInterface { - private val fetcher: GooglePlayProductsFetcher = GooglePlayProductsFetcher(context) - - override val productsById: Map - get() = TODO("Not yet implemented") - - override suspend fun getProductVariables(paywall: Paywall): List { - TODO("Not yet implemented") - } - -// data class GetProductsResponse( -// val productsById: Map, -// val products: List -// ) - - override suspend fun getProducts( - responseProductIds: List, - paywallName: String?, - responseProducts: List, - substituteProducts: PaywallProducts? - ): GetProductsResponse { - var productsById = mutableMapOf() - println("!! responseProductIds: $responseProductIds") - val products = fetcher.products(responseProductIds) - println("!! products: $products") - - for (product in products) { - when(product.value) { - is GooglePlayProductsFetcher.Result.Success -> { - val rawStoreProduct = (product.value as GooglePlayProductsFetcher.Result.Success).value - println("!! rawStoreProduct: $rawStoreProduct") - productsById[product.key] = StoreProduct(rawStoreProduct) - } else -> { - // TODO: ?? - } - } - } - - return GetProductsResponse(productsById, responseProducts) - } - - override suspend fun tryToRestore(paywallViewController: PaywallViewController) { -// TODO("Not yet implemented") - } - - override suspend fun processRestoration( - restorationResult: RestorationResult, - paywallViewController: PaywallViewController - ) { - TODO("Not yet implemented") - } - - override suspend fun refreshReceipt() { - TODO("Not yet implemented") - } - - override suspend fun loadPurchasedProducts() { - TODO("Not yet implemented") - } - - override suspend fun isFreeTrialAvailable(product: StoreProduct): Boolean { - // TODO: Implement this - return false - } - - override suspend fun products( - identifiers: Set, - paywallName: String? - ): Set { - TODO("Not yet implemented") - } -} -*/ - -class StoreKitManager( - private val context: Context, +class StoreManager( val purchaseController: InternalPurchaseController, - val billingWrapper: GoogleBillingWrapper, - // val productFetcher: GooglePlayProductsFetcher -) : ProductsFetcher { - private val receiptManager by lazy { ReceiptManager(delegate = this) } + val billing: Billing, + private val track: suspend (InternalSuperwallEvent) -> Unit = { + Superwall.instance.track(it) + }, +) : ProductsFetcher, + StoreKit { + val receiptManager by lazy { ReceiptManager(delegate = this, billing) } var productsByFullId: MutableMap = mutableMapOf() @@ -113,7 +39,7 @@ class StoreKitManager( val productItems: List, ) - suspend fun getProductVariables( + override suspend fun getProductVariables( paywall: Paywall, request: PaywallRequest, ): List { @@ -136,10 +62,39 @@ class StoreKitManager( return productAttributes } - suspend fun getProducts( - substituteProducts: Map? = null, + override suspend fun getProductsWithoutPaywall( + productIds: List, + substituteProducts: Map?, + ): Map { + val processingResult = + removeAndStore( + substituteProductsByName = substituteProducts, + fullProductIds = productIds, + productItems = emptyList(), + ) + + val products: Set + try { + products = billing.awaitGetProducts(processingResult.fullProductIdsToLoad) + } catch (error: Throwable) { + throw error + } + + val productsById = processingResult.substituteProductsById.toMutableMap() + + for (product in products) { + val fullProductIdentifier = product.fullIdentifier + productsById[fullProductIdentifier] = product + this.productsByFullId[fullProductIdentifier] = product + } + + return products.map { it.fullIdentifier to it }.toMap() + } + + override suspend fun getProducts( + substituteProducts: Map?, paywall: Paywall, - request: PaywallRequest? = null, + request: PaywallRequest?, ): GetProductsResponse { val processingResult = removeAndStore( @@ -150,7 +105,7 @@ class StoreKitManager( var products: Set = setOf() try { - products = billingWrapper.awaitGetProducts(processingResult.fullProductIdsToLoad) + products = billing.awaitGetProducts(processingResult.fullProductIdsToLoad) } catch (error: Throwable) { paywall.productsLoadingInfo.failAt = Date() val paywallInfo = paywall.getInfo(request?.eventData) @@ -160,7 +115,7 @@ class StoreKitManager( paywallInfo = paywallInfo, eventData = request?.eventData, ) - Superwall.instance.track(productLoadEvent) + track(productLoadEvent) // If billing isn't available, make it call the onError handler when requesting // a paywall. @@ -211,6 +166,7 @@ class StoreKitManager( productItems[index] = ProductItem( name = productItems[index].name, + entitlements = productItems[index].entitlements, type = ProductItem.StoreProductType.PlayStore( PlayStoreProduct( @@ -231,6 +187,7 @@ class StoreKitManager( productItems.add( ProductItem( name = name, + entitlements = emptySet(), type = ProductItem.StoreProductType.PlayStore( PlayStoreProduct( @@ -261,7 +218,7 @@ class StoreKitManager( ) } - suspend fun refreshReceipt() { + override suspend fun refreshReceipt() { Logger.debug( logLevel = LogLevel.debug, scope = LogScope.storeKitManager, // Rename this scope to reflect Billing Manager @@ -270,7 +227,7 @@ class StoreKitManager( receiptManager.refreshReceipt() } - suspend fun loadPurchasedProducts() { + override suspend fun loadPurchasedProducts() { Logger.debug( logLevel = LogLevel.debug, scope = LogScope.storeKitManager, // Rename this scope to reflect Billing Manager @@ -280,5 +237,5 @@ class StoreKitManager( } @Throws(Throwable::class) - override suspend fun products(identifiers: Set): Set = billingWrapper.awaitGetProducts(identifiers) + override suspend fun products(identifiers: Set): Set = billing.awaitGetProducts(identifiers) } diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt index 8e714a81..cdb29799 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt @@ -2,13 +2,14 @@ package com.superwall.sdk.store.abstractions.product import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.ProductDetails.PricingPhase import com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails +import com.superwall.sdk.billing.DecomposedProductIds import com.superwall.sdk.contrib.threeteen.AmountFormats import com.superwall.sdk.utilities.DateUtils import com.superwall.sdk.utilities.dateFormat import kotlinx.serialization.Transient +import org.threeten.bp.Period import java.math.BigDecimal import java.math.RoundingMode -import java.time.Period import java.util.Calendar import java.util.Currency import java.util.Locale @@ -19,6 +20,18 @@ class RawStoreProduct( val basePlanId: String?, private val offerType: OfferType?, ) : StoreProductType { + companion object { + fun from(details: ProductDetails): RawStoreProduct { + val ids = DecomposedProductIds.from(details.productId) + return RawStoreProduct( + underlyingProductDetails = details, + fullIdentifier = details.productId, + basePlanId = ids.basePlanId, + offerType = ids.offerType, + ) + } + } + @Transient private val priceFormatterProvider = PriceFormatterProvider() @@ -277,7 +290,10 @@ class RawStoreProduct( val offersForBasePlan = subscriptionOfferDetails.filter { it.basePlanId == basePlanId } // In offers that match base plan, if there's only 1 pricing phase then this offer represents the base plan. - val basePlan = offersForBasePlan.firstOrNull { it.pricingPhases.pricingPhaseList.size == 1 } ?: return null + val basePlan = + offersForBasePlan.firstOrNull { + it.pricingPhases.pricingPhaseList.size == 1 + } ?: return null return when (offerType) { is OfferType.Auto -> { @@ -485,7 +501,8 @@ class RawStoreProduct( .billingPeriod try { - SubscriptionPeriod.from(baseBillingPeriod) + SubscriptionPeriod.from(baseBillingPeriod).also { + } } catch (e: Throwable) { null } diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/receipt/ReceiptManager.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/receipt/ReceiptManager.kt index 0d8645bf..98464231 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/receipt/ReceiptManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/receipt/ReceiptManager.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.store.abstractions.product.receipt +import com.superwall.sdk.billing.Billing import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger @@ -14,63 +15,25 @@ data class InAppPurchase( class ReceiptManager( private var delegate: ProductsFetcher?, + private val billing: Billing, // private val receiptData: () -> ByteArray? = ReceiptLogic::getReceiptData ) { var purchasedSubscriptionGroupIds: Set? = null - private var purchases: MutableSet = mutableSetOf() + private var _purchases: MutableSet = mutableSetOf() private var receiptRefreshCompletion: ((Boolean) -> Unit)? = null + val purchases: Set + get() = _purchases.map { it.productIdentifier }.toSet() + @Suppress("RedundantSuspendModifier") suspend fun loadPurchasedProducts(): Set? = - coroutineScope { -// val hasPurchaseController = Superwall.instance.dependencyContainer.delegateAdapter.hasPurchaseController -// -// val payload = ReceiptLogic.getPayload(receiptData()) ?: run { -// // if (!hasPurchaseController) { -// // Superwall.instance.subscriptionStatus = SubscriptionStatus.INACTIVE -// // } -// return@coroutineScope null -// } -// -// delegate?.let { delegate -> -// val localPurchases = payload.purchases -// this@ReceiptManager.purchases = localPurchases.toMutableSet() -// -// if (!hasPurchaseController) { -// val activePurchases = localPurchases.filter { it.isActive } -// if (activePurchases.isEmpty()) { -// Superwall.instance.subscriptionStatus = SubscriptionStatus.INACTIVE -// } else { -// Superwall.instance.subscriptionStatus = SubscriptionStatus.ACTIVE -// } -// } -// -// val purchasedProductIds = localPurchases.map { it.productIdentifier }.toSet() -// -// try { -// val products = delegate.products(purchasedProductIds, null) -// val purchasedSubscriptionGroupIds = mutableSetOf() -// for (product in products) { -// product.subscriptionGroupIdentifier?.let { -// purchasedSubscriptionGroupIds.add(it) -// } -// } -// this@ReceiptManager.purchasedSubscriptionGroupIds = purchasedSubscriptionGroupIds -// products -// } catch (e: Throwable) { -// null -// } -// } ?: run { -// if (!hasPurchaseController) { -// Superwall.instance.subscriptionStatus = SubscriptionStatus.INACTIVE -// } -// return@coroutineScope null -// } - - // SW-2218 - // https://linear.app/superwall/issue/SW-2218/%5Bandroid%5D-%5Bv0%5D-replace-receipt-validation-with-google-play-billing - return@coroutineScope emptySet() - } + billing + .queryAllPurchases() + .flatMap { it.products } + .let { products -> + _purchases.addAll(products.map { InAppPurchase(it) }.toSet()) + delegate?.products(products.toSet()) + }?.toSet() suspend fun refreshReceipt() { Logger.debug( @@ -85,5 +48,5 @@ class ReceiptManager( // https://linear.app/superwall/issue/SW-2218/%5Bandroid%5D-%5Bv0%5D-replace-receipt-validation-with-google-play-billing } - fun hasPurchasedProduct(productId: String): Boolean = purchases.firstOrNull { it.productIdentifier == productId } != null + fun hasPurchasedProduct(productId: String): Boolean = _purchases.firstOrNull { it.productIdentifier == productId } != null } diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/GoogleBillingPurchaseTransaction.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/GoogleBillingPurchaseTransaction.kt index b03b7266..52b7c3f5 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/GoogleBillingPurchaseTransaction.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/GoogleBillingPurchaseTransaction.kt @@ -44,6 +44,8 @@ data class GoogleBillingPurchaseTransaction( @Serializable(with = UUIDSerializer::class) @SerialName("app_account_token") override val appAccountToken: UUID?, + @SerialName("purchase_token") + override val purchaseToken: String, override var payment: StorePayment, ) : StoreTransactionType { constructor(transaction: Purchase) : this( @@ -62,5 +64,6 @@ data class GoogleBillingPurchaseTransaction( revocationDate = null, // Replace with correct mapping appAccountToken = null, // Replace with correct mapping payment = StorePayment(transaction), // Replace with correct mapping + purchaseToken = transaction.purchaseToken, ) } diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt index 0976b419..a04bbad3 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt @@ -42,6 +42,8 @@ class StoreTransaction( @Serializable(with = UUIDSerializer::class) override val appAccountToken: UUID? get() = transaction.appAccountToken + override val purchaseToken: String + get() = transaction.purchaseToken // fun toDictionary(): Map { // val json = Json { encodeDefaults = true } diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransactionType.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransactionType.kt index 8572f4af..74b46bbf 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransactionType.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransactionType.kt @@ -26,6 +26,7 @@ interface StoreTransactionType { val offerId: String? val revocationDate: Date? val appAccountToken: UUID? + val purchaseToken: String } // Custom serializer diff --git a/superwall/src/main/java/com/superwall/sdk/store/coordinator/CoordinatorProtocols.kt b/superwall/src/main/java/com/superwall/sdk/store/coordinator/CoordinatorProtocols.kt index ec74072d..a062047a 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/coordinator/CoordinatorProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/coordinator/CoordinatorProtocols.kt @@ -21,3 +21,8 @@ interface TransactionRestorer { // obtaining the restored transactions suspend fun restorePurchases(): RestorationResult } + +interface Purchasing : + ProductPurchaser, + ProductsFetcher, + TransactionRestorer 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 8fb62a0a..858bbc40 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 @@ -1,102 +1,292 @@ package com.superwall.sdk.store.transactions -import android.content.Context +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase import com.superwall.sdk.Superwall -import com.superwall.sdk.analytics.SessionEventsManager import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent.Transaction.TransactionSource +import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent import com.superwall.sdk.analytics.superwall.SuperwallEvents +import com.superwall.sdk.delegate.InternalPurchaseResult import com.superwall.sdk.delegate.PurchaseResult import com.superwall.sdk.delegate.RestorationResult -import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.delegate.subscription_controller.PurchaseController +import com.superwall.sdk.dependencies.CacheFactory import com.superwall.sdk.dependencies.DeviceHelperFactory +import com.superwall.sdk.dependencies.HasExternalPurchaseControllerFactory +import com.superwall.sdk.dependencies.HasInternalPurchaseControllerFactory import com.superwall.sdk.dependencies.OptionsFactory import com.superwall.sdk.dependencies.StoreTransactionFactory -import com.superwall.sdk.dependencies.SuperwallScopeFactory import com.superwall.sdk.dependencies.TransactionVerifierFactory import com.superwall.sdk.dependencies.TriggerFactory import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.ActivityProvider +import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.launchWithTracking +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.models.paywall.LocalNotificationType -import com.superwall.sdk.paywall.presentation.internal.dismiss +import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult -import com.superwall.sdk.paywall.vc.PaywallView -import com.superwall.sdk.paywall.vc.SuperwallPaywallActivity -import com.superwall.sdk.paywall.vc.delegate.PaywallLoadingState +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.SuperwallPaywallActivity +import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState import com.superwall.sdk.storage.EventsQueue -import com.superwall.sdk.store.StoreKitManager +import com.superwall.sdk.storage.PurchasingProductdIds +import com.superwall.sdk.storage.Storage +import com.superwall.sdk.store.PurchasingObserverState +import com.superwall.sdk.store.StoreManager +import com.superwall.sdk.store.abstractions.product.RawStoreProduct import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.abstractions.transactions.StoreTransaction -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap class TransactionManager( - private val storeKitManager: StoreKitManager, + private val storeManager: StoreManager, private val purchaseController: PurchaseController, - private val sessionEventsManager: SessionEventsManager, private val eventsQueue: EventsQueue, + private val storage: Storage, private val activityProvider: ActivityProvider, private val factory: Factory, - private val context: Context, + private val ioScope: IOScope, + private val track: suspend (TrackableSuperwallEvent) -> Unit = { + Superwall.instance.track(it) + }, + private val dismiss: suspend (paywallView: PaywallView, result: PaywallResult) -> Unit, + private val entitlementStatus: () -> EntitlementStatus = { + Superwall.instance.entitlements.status.value + }, ) { + sealed class PurchaseSource { + data class Internal( + val productId: String, + val paywallView: PaywallView, + ) : PurchaseSource() + + data class ExternalPurchase( + val product: StoreProduct, + ) : PurchaseSource() + + data class ObserverMode( + val product: StoreProduct, + ) : PurchaseSource() + } + interface Factory : OptionsFactory, TriggerFactory, TransactionVerifierFactory, StoreTransactionFactory, DeviceHelperFactory, - SuperwallScopeFactory + CacheFactory, + HasExternalPurchaseControllerFactory, + HasInternalPurchaseControllerFactory private var lastPaywallView: PaywallView? = null - suspend fun purchase( - productId: String, - paywallView: PaywallView, + private var transactionsInProgress: ConcurrentHashMap = + ConcurrentHashMap() + + private val shouldObserveTransactionFinishingAutomatically: Boolean + get() = factory.makeSuperwallOptions().shouldObservePurchases + + init { + if (shouldObserveTransactionFinishingAutomatically + ) { + ioScope.launch { + storeManager.billing.purchaseResults + .asSharedFlow() + .dropWhile { + transactionsInProgress.isEmpty() + }.filterNotNull() + .collectLatest { it: InternalPurchaseResult -> + val state = + when (it) { + is InternalPurchaseResult.Purchased -> { + it.purchase.products.forEach { + transactionsInProgress.remove(it) + } + PurchasingObserverState.PurchaseResult( + BillingResult + .newBuilder() + .setResponseCode(BillingClient.BillingResponseCode.OK) + .build(), + listOf(it.purchase), + ) + } + + is InternalPurchaseResult.Cancelled -> { + val last = transactionsInProgress.entries.last() + transactionsInProgress.remove(last.key) + PurchasingObserverState.PurchaseResult( + BillingResult + .newBuilder() + .setResponseCode(BillingClient.BillingResponseCode.USER_CANCELED) + .build(), + emptyList(), + ) + } + + else -> { + val last = transactionsInProgress.entries.last() + transactionsInProgress.remove(last.key) + PurchasingObserverState.PurchaseError( + error = + (it as? InternalPurchaseResult.Failed)?.error + ?: Throwable("Unknown error"), + product = last.value, + ) + } + } + handle(it, state) + } + } + } + } + + internal suspend fun handle( + result: InternalPurchaseResult, + state: PurchasingObserverState, ) { - val product = - storeKitManager.productsByFullId[productId] ?: run { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.paywallTransactions, - message = - "Trying to purchase ($productId) but the product has failed to load. Visit https://superwall.com/l/missing-products to diagnose.", + if (transactionsInProgress.isEmpty()) { + return + } else { + transactionsInProgress.clear() + } + + when (result) { + is InternalPurchaseResult.Purchased -> { + val state = state as PurchasingObserverState.PurchaseResult + state.purchases?.forEach { purchase -> + purchase.products.map { + storeManager.productsByFullId[it]?.let { product -> + didPurchase( + product, + PurchaseSource.ObserverMode(product), + product.hasFreeTrial, + purchase, + ) + } + } + } + } + + InternalPurchaseResult.Cancelled -> { + val lastProduct = transactionsInProgress.entries.last() + val product = StoreProduct(RawStoreProduct.from(lastProduct.value)) + trackCancelled( + product = product, + purchaseSource = PurchaseSource.ObserverMode(product), ) - return } + is InternalPurchaseResult.Failed -> { + val state = state as PurchasingObserverState.PurchaseError + val product = StoreProduct(RawStoreProduct.from(state.product)) + trackFailure( + state.error.localizedMessage ?: "Unknown error", + product, + PurchaseSource.ObserverMode(product), + ) + } + + InternalPurchaseResult.Pending -> { + val result = state as PurchasingObserverState.PurchaseResult + result.purchases?.forEach { purchase -> + purchase.products.map { + storeManager.productsByFullId[it]?.let { product -> + handlePendingTransaction(PurchaseSource.ObserverMode(product)) + } + } + } + } + + InternalPurchaseResult.Restored -> { + val state = state as PurchasingObserverState.PurchaseResult + state.purchases?.forEach { purchase -> + purchase.products.map { + storeManager.productsByFullId[it]?.let { product -> + didRestore(product, PurchaseSource.ObserverMode(product)) + } + } + } + } + } + } + + fun updatePaymentQueue(removedTransactions: List) { + var stored = storage.read(PurchasingProductdIds) + val remainingTransactions = + stored?.filter { transaction -> + !removedTransactions.any { it == transaction } + } ?: emptyList() + storage.write(PurchasingProductdIds, remainingTransactions.toSet()) + } + + suspend fun purchase(purchaseSource: PurchaseSource): PurchaseResult { + val product = + when (purchaseSource) { + is PurchaseSource.Internal -> + storeManager.productsByFullId[purchaseSource.productId] ?: run { + log( + LogLevel.error, + "Trying to purchase (${purchaseSource.productId}) but the product has failed to load. Visit https://superwall.com/l/missing-products to diagnose.", + ) + return PurchaseResult.Failed("Product not found") + } + + is PurchaseSource.ExternalPurchase -> { + purchaseSource.product + } + + is PurchaseSource.ObserverMode -> purchaseSource.product + } val rawStoreProduct = product.rawStoreProduct - Logger.debug( - LogLevel.debug, - LogScope.paywallTransactions, - "!!! Purchasing product ${rawStoreProduct.hasFreeTrial}", + log( + message = + "!!! Purchasing product ${rawStoreProduct.hasFreeTrial}", ) val productDetails = rawStoreProduct.underlyingProductDetails - val activity = activityProvider.getCurrentActivity() ?: return - - prepareToStartTransaction(product, paywallView) + val activity = + activityProvider.getCurrentActivity() + ?: return PurchaseResult.Failed("Activity not found - required for starting the billing flow") + prepareToPurchase(product, purchaseSource) val result = - storeKitManager.purchaseController.purchase( + storeManager.purchaseController.purchase( activity = activity, productDetails = productDetails, offerId = rawStoreProduct.offerId, basePlanId = rawStoreProduct.basePlanId, ) + if (purchaseSource is PurchaseSource.ExternalPurchase && + factory.makeHasExternalPurchaseController() && + !factory.makeHasInternalPurchaseController() + ) { + return result + } + + val isEligibleForTrial = rawStoreProduct.selectedOffer != null + when (result) { is PurchaseResult.Purchased -> { - didPurchase(product, paywallView) + didPurchase(product, purchaseSource, isEligibleForTrial && product.hasFreeTrial) } is PurchaseResult.Restored -> { didRestore( product = product, - paywallView = paywallView, + purchaseSource = purchaseSource, ) } @@ -107,41 +297,45 @@ class TransactionManager( val triggers = factory.makeTriggers() val transactionFailExists = triggers.contains(SuperwallEvents.TransactionFail.rawName) - if (shouldShowPurchaseFailureAlert && !transactionFailExists) { trackFailure( result.errorMessage, product, - paywallView, - ) - presentAlert( - Error(result.errorMessage), - product, - paywallView, + purchaseSource, ) + if (purchaseSource is PurchaseSource.Internal) { + presentAlert( + Error(result.errorMessage), + product, + purchaseSource.paywallView, + ) + } } else { trackFailure( result.errorMessage, product, - paywallView, + purchaseSource, ) - return paywallView.togglePaywallSpinner(isHidden = true) + if (purchaseSource is PurchaseSource.Internal) { + purchaseSource.paywallView.togglePaywallSpinner(isHidden = true) + } } } is PurchaseResult.Pending -> { - handlePendingTransaction(paywallView) + handlePendingTransaction(purchaseSource) } is PurchaseResult.Cancelled -> { - trackCancelled(product, paywallView) + trackCancelled(product, purchaseSource) } } + return result } private suspend fun didRestore( product: StoreProduct? = null, - paywallView: PaywallView, + purchaseSource: PurchaseSource, ) { val purchasingCoordinator = factory.makeTransactionVerifier() var transaction: StoreTransaction? @@ -159,230 +353,388 @@ class TransactionManager( restoreType = RestoreType.ViaRestore } - val paywallInfo = paywallView.info - val trackedEvent = InternalSuperwallEvent.Transaction( state = InternalSuperwallEvent.Transaction.State.Restore(restoreType), - paywallInfo = paywallInfo, + paywallInfo = if (purchaseSource is PurchaseSource.Internal) purchaseSource.paywallView.info else PaywallInfo.empty(), product = product, model = null, + isObserved = factory.makeSuperwallOptions().shouldObservePurchases, + source = + when (purchaseSource) { + is PurchaseSource.ExternalPurchase -> TransactionSource.EXTERNAL + is PurchaseSource.Internal -> TransactionSource.INTERNAL + is PurchaseSource.ObserverMode -> TransactionSource.OBSERVER + }, ) - Superwall.instance.track(trackedEvent) + track(trackedEvent) val superwallOptions = factory.makeSuperwallOptions() - if (superwallOptions.paywalls.automaticallyDismiss) { - Superwall.instance.dismiss(paywallView, result = PaywallResult.Restored()) + if (superwallOptions.paywalls.automaticallyDismiss && purchaseSource is PurchaseSource.Internal) { + dismiss( + purchaseSource.paywallView, + PaywallResult.Restored(), + ) } } private fun trackFailure( errorMessage: String, product: StoreProduct, - paywallView: PaywallView, + purchaseSource: PurchaseSource, ) { - // Log the error - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.paywallTransactions, - message = "Transaction Error: $errorMessage", - info = - mapOf( - "product_id" to product.fullIdentifier, - "paywall_vc" to paywallView, - ), - ) + when (purchaseSource) { + is PurchaseSource.Internal -> { + log( + message = - // Launch a coroutine to handle async tasks - factory.ioScope().launchWithTracking { - val paywallInfo = paywallView.info - val trackedEvent = - InternalSuperwallEvent.Transaction( - state = - InternalSuperwallEvent.Transaction.State.Fail( - TransactionError.Failure( - errorMessage, - product, - ), + "Transaction Error: $errorMessage", + info = + mapOf( + "product_id" to product.fullIdentifier, + "paywall_vc" to purchaseSource.paywallView, ), - paywallInfo = paywallInfo, - product = product, - model = null, ) - // Assuming Superwall.instance.track and sessionEventsManager.triggerSession.trackTransactionError are suspend functions - Superwall.instance.track(trackedEvent) + ioScope.launchWithTracking { + val paywallInfo = purchaseSource.paywallView.info + val trackedEvent = + InternalSuperwallEvent.Transaction( + state = + InternalSuperwallEvent.Transaction.State.Fail( + TransactionError.Failure( + errorMessage, + product, + ), + ), + paywallInfo = paywallInfo, + product = product, + model = null, + isObserved = factory.makeSuperwallOptions().shouldObservePurchases, + source = TransactionSource.INTERNAL, + ) + + track(trackedEvent) + } + } + + is PurchaseSource.ExternalPurchase, is PurchaseSource.ObserverMode -> { + log( + message = "Transaction Error: $errorMessage", + info = mapOf("product_id" to product.fullIdentifier), + error = Error(errorMessage), + ) + ioScope.launch { + val trackedEvent = + InternalSuperwallEvent.Transaction( + state = + InternalSuperwallEvent.Transaction.State.Fail( + TransactionError.Failure( + errorMessage, + product, + ), + ), + paywallInfo = PaywallInfo.empty(), + product = product, + model = null, + isObserved = purchaseSource is PurchaseSource.ObserverMode, + source = TransactionSource.EXTERNAL, + ) + track(trackedEvent) + } + } } } - private suspend fun prepareToStartTransaction( + internal suspend fun prepareToPurchase( product: StoreProduct, - paywallView: PaywallView, + source: PurchaseSource, ) { - factory.ioScope().launch { - Logger.debug( - LogLevel.debug, - LogScope.paywallTransactions, - "Transaction Purchasing", - mapOf("paywall_vc" to paywallView), - null, - ) - } + val isObserved = + source is PurchaseSource.ObserverMode - val paywallInfo = paywallView.info - val trackedEvent = - InternalSuperwallEvent.Transaction( - InternalSuperwallEvent.Transaction.State.Start(product), - paywallInfo, - product, - null, - ) - Superwall.instance.track(trackedEvent) + when (source) { + is PurchaseSource.Internal -> { + ioScope.launch { + log( + message = - withContext(Dispatchers.Main) { - paywallView.loadingState = PaywallLoadingState.LoadingPurchase() - } + "Transaction Purchasing", + info = mapOf("paywall_vc" to source), + ) + } + + val paywallInfo = source.paywallView.info + val trackedEvent = + InternalSuperwallEvent.Transaction( + InternalSuperwallEvent.Transaction.State.Start(product), + paywallInfo, + product, + null, + isObserved = isObserved, + source = TransactionSource.INTERNAL, + ) + track(trackedEvent) + + source.paywallView.loadingState = PaywallLoadingState.LoadingPurchase() + + lastPaywallView = source.paywallView + } - lastPaywallView = paywallView + is PurchaseSource.ExternalPurchase, is PurchaseSource.ObserverMode -> { + if (isObserved) { + transactionsInProgress.put( + product.fullIdentifier, + product.rawStoreProduct.underlyingProductDetails, + ) + } + if (!storeManager.productsByFullId.contains(product.fullIdentifier)) { + storeManager.productsByFullId[product.fullIdentifier] = product + } + + if (factory.makeHasExternalPurchaseController() && !factory.makeHasInternalPurchaseController()) { + return + } + // If an external purchase controller is being used, skip because this will + // get called by the purchase function of the purchase controller. + val options = factory.makeSuperwallOptions() + if (!options.shouldObservePurchases && factory.makeHasExternalPurchaseController()) { + return + } + + ioScope.launch { + log( + message = + + "External Transaction Purchasing", + ) + } + + val trackedEvent = + InternalSuperwallEvent.Transaction( + InternalSuperwallEvent.Transaction.State.Start(product), + PaywallInfo.empty(), + product, + null, + isObserved = isObserved, + source = TransactionSource.EXTERNAL, + ) + track(trackedEvent) + } + } } - // ... Remaining functions translated in a similar fashion ... private suspend fun didPurchase( product: StoreProduct, - paywallView: PaywallView, + purchaseSource: PurchaseSource, + didStartFreeTrial: Boolean, + purchase: Purchase? = null, ) { - factory.ioScope().launch { - Logger.debug( - LogLevel.debug, - LogScope.paywallTransactions, - "Transaction Succeeded", - mapOf( - "product_id" to product.fullIdentifier, - "paywall_vc" to paywallView, - ), - null, - ) - } + when (purchaseSource) { + is PurchaseSource.Internal -> { + ioScope.launch { + log( + message = "Transaction Succeeded", + info = + mapOf( + "product_id" to product.fullIdentifier, + "paywall_vc" to purchaseSource.paywallView, + ), + ) + } - val transactionVerifier = factory.makeTransactionVerifier() - val transaction = - transactionVerifier.getLatestTransaction( - factory = factory, - ) + val transactionVerifier = factory.makeTransactionVerifier() + val transaction = + transactionVerifier.getLatestTransaction( + factory = factory, + ) - transaction?.let { - sessionEventsManager.enqueue(it) - } + storeManager.loadPurchasedProducts() - storeKitManager.loadPurchasedProducts() + trackTransactionDidSucceed(transaction, product, purchaseSource, didStartFreeTrial) - trackTransactionDidSucceed(transaction, product) + if (factory.makeSuperwallOptions().paywalls.automaticallyDismiss) { + dismiss( + purchaseSource.paywallView, + PaywallResult.Purchased(product.fullIdentifier), + ) + } + } - if (Superwall.instance.options.paywalls.automaticallyDismiss) { - Superwall.instance.dismiss( - paywallView, - PaywallResult.Purchased(product.fullIdentifier), - ) + is PurchaseSource.ExternalPurchase, is PurchaseSource.ObserverMode -> { + log( + message = "Transaction Succeeded", + info = mapOf("product_id" to product.fullIdentifier), + ) + val transactionVerifier = factory.makeTransactionVerifier() + val transaction = + transactionVerifier.getLatestTransaction( + factory = factory, + ) + + if (purchase != null) { + factory.makeStoreTransaction(purchase) + } else { + transactionVerifier.getLatestTransaction( + factory = factory, + ) + } + storeManager.loadPurchasedProducts() + + trackTransactionDidSucceed(transaction, product, purchaseSource, didStartFreeTrial) + } } } private suspend fun trackCancelled( product: StoreProduct, - paywallView: PaywallView, + purchaseSource: PurchaseSource, ) { - factory.ioScope().launch { - Logger.debug( - LogLevel.debug, - LogScope.paywallTransactions, - "Transaction Abandoned", - mapOf( - "product_id" to product.fullIdentifier, - "paywall_vc" to paywallView, - ), - null, - ) - } + val isObserved = + purchaseSource is PurchaseSource.ObserverMode + + when (purchaseSource) { + is PurchaseSource.Internal -> { + ioScope.launch { + log( + message = "Transaction Abandoned", + info = + mapOf( + "product_id" to product.fullIdentifier, + "paywall_vc" to purchaseSource.paywallView, + ), + ) + } - val paywallInfo = paywallView.info - val trackedEvent = - InternalSuperwallEvent.Transaction( - InternalSuperwallEvent.Transaction.State.Abandon(product), - paywallInfo, - product, - null, - ) - Superwall.instance.track(trackedEvent) + val paywallInfo = purchaseSource.paywallView.info + val trackedEvent = + InternalSuperwallEvent.Transaction( + InternalSuperwallEvent.Transaction.State.Abandon(product), + paywallInfo, + product, + null, + isObserved = isObserved, + source = TransactionSource.INTERNAL, + ) + track(trackedEvent) + + purchaseSource.paywallView.loadingState = PaywallLoadingState.Ready() + } - withContext(Dispatchers.Main) { - paywallView.loadingState = PaywallLoadingState.Ready() + is PurchaseSource.ExternalPurchase, is PurchaseSource.ObserverMode -> { + ioScope.launch { + log( + message = "Transaction Abandoned", + info = mapOf("product_id" to product.fullIdentifier), + ) + } + + val trackedEvent = + InternalSuperwallEvent.Transaction( + InternalSuperwallEvent.Transaction.State.Abandon(product), + PaywallInfo.empty(), + product, + null, + isObserved = isObserved, + source = TransactionSource.EXTERNAL, + ) + track(trackedEvent) + } } } - private suspend fun handlePendingTransaction(paywallView: PaywallView) { - factory.ioScope().launch { - Logger.debug( - LogLevel.debug, - LogScope.paywallTransactions, - "Transaction Pending", - mapOf("paywall_vc" to paywallView), - null, - ) - } + private suspend fun handlePendingTransaction(purchaseSource: PurchaseSource) { + val isObserved = + purchaseSource is PurchaseSource.ObserverMode - val paywallInfo = paywallView.info + when (purchaseSource) { + is PurchaseSource.Internal -> { + ioScope.launch { + log( + message = "Transaction Pending", + info = mapOf("paywall_vc" to purchaseSource.paywallView), + ) + } - val trackedEvent = - InternalSuperwallEvent.Transaction( - InternalSuperwallEvent.Transaction.State.Fail(TransactionError.Pending("Needs parental approval")), - paywallInfo, - null, - null, - ) - Superwall.instance.track(trackedEvent) + val paywallInfo = purchaseSource.paywallView.info - paywallView.showAlert( - "Waiting for Approval", - "Thank you! This purchase is pending approval from your parent. Please try again once it is approved.", - ) + val trackedEvent = + InternalSuperwallEvent.Transaction( + InternalSuperwallEvent.Transaction.State.Fail(TransactionError.Pending("Needs parental approval")), + paywallInfo, + null, + null, + isObserved = factory.makeSuperwallOptions().shouldObservePurchases, + source = TransactionSource.INTERNAL, + ) + track(trackedEvent) + + purchaseSource.paywallView.showAlert( + "Waiting for Approval", + "Thank you! This purchase is pending approval from your parent. Please try again once it is approved.", + ) + } + + is PurchaseSource.ExternalPurchase, + is PurchaseSource.ObserverMode, + -> { + ioScope.launch { + log(message = "Transaction Pending") + } + + val trackedEvent = + InternalSuperwallEvent.Transaction( + InternalSuperwallEvent.Transaction.State.Fail(TransactionError.Pending("Needs parental approval")), + PaywallInfo.empty(), + null, + null, + isObserved = isObserved, + source = TransactionSource.EXTERNAL, + ) + track(trackedEvent) + } + } } - suspend fun tryToRestore(paywallView: PaywallView) { - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.paywallTransactions, - message = "Attempting Restore", - ) + /** + * Attempt to restore purchases. + * + * @param paywallView The paywall view that initiated the restore or null if initiated externally. + * @return A [RestorationResult] indicating the result of the restoration. + */ + suspend fun tryToRestorePurchases(paywallView: PaywallView?): RestorationResult { + log(message = "Attempting Restore") - paywallView.loadingState = PaywallLoadingState.LoadingPurchase() + val paywallInfo = paywallView?.info ?: PaywallInfo.empty() - Superwall.instance.track( + paywallView?.loadingState = PaywallLoadingState.LoadingPurchase() + + track( InternalSuperwallEvent.Restore( state = InternalSuperwallEvent.Restore.State.Start, - paywallInfo = paywallView.info, + paywallInfo = paywallInfo, ), ) val restorationResult = purchaseController.restorePurchases() val hasRestored = restorationResult is RestorationResult.Restored - val isUserSubscribed = - Superwall.instance.subscriptionStatus.value == SubscriptionStatus.ACTIVE - - if (hasRestored && isUserSubscribed) { - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.paywallTransactions, - message = "Transactions Restored", - ) - Superwall.instance.track( + val hasEntitlements = + entitlementStatus() is EntitlementStatus.Active + storeManager.loadPurchasedProducts() + if (hasRestored && hasEntitlements) { + log(message = "Transactions Restored") + track( InternalSuperwallEvent.Restore( state = InternalSuperwallEvent.Restore.State.Complete, - paywallInfo = paywallView.info, + paywallInfo = paywallView?.info ?: PaywallInfo.empty(), ), ) - didRestore(paywallView = paywallView) + if (paywallView != null) { + didRestore(null, PurchaseSource.Internal("", paywallView)) + } } else { val msg = "Transactions Failed to Restore.${ - if (hasRestored && !isUserSubscribed) { + if (hasRestored && !hasEntitlements) { " The user's subscription status is \"inactive\", but the restoration result is \"restored\"." + " Ensure the subscription status is active before confirming successful restoration." } else { @@ -395,24 +747,30 @@ class TransactionManager( } }" - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.paywallTransactions, - message = msg, - ) - Superwall.instance.track( + log(message = msg) + track( InternalSuperwallEvent.Restore( state = InternalSuperwallEvent.Restore.State.Failure(msg), - paywallInfo = paywallView.info, + paywallInfo = paywallView?.info ?: PaywallInfo.empty(), ), ) - paywallView.showAlert( - title = Superwall.instance.options.paywalls.restoreFailed.title, - message = Superwall.instance.options.paywalls.restoreFailed.message, - closeActionTitle = Superwall.instance.options.paywalls.restoreFailed.closeButtonTitle, + paywallView?.showAlert( + title = + factory + .makeSuperwallOptions() + .paywalls.restoreFailed.title, + message = + factory + .makeSuperwallOptions() + .paywalls.restoreFailed.message, + closeActionTitle = + factory + .makeSuperwallOptions() + .paywalls.restoreFailed.closeButtonTitle, ) } + return restorationResult } private suspend fun presentAlert( @@ -420,16 +778,15 @@ class TransactionManager( product: StoreProduct, paywallView: PaywallView, ) { - factory.ioScope().launch { - Logger.debug( - LogLevel.debug, - LogScope.paywallTransactions, - "Transaction Error", - mapOf( - "product_id" to product.fullIdentifier, - "paywall_vc" to paywallView, - ), - error, + ioScope.launch { + log( + message = "Transaction Error", + info = + mapOf( + "product_id" to product.fullIdentifier, + "paywall_vc" to paywallView, + ), + error = error, ) } @@ -446,8 +803,10 @@ class TransactionManager( paywallInfo, product, null, + source = TransactionSource.INTERNAL, + isObserved = factory.makeSuperwallOptions().shouldObservePurchases, ) - Superwall.instance.track(trackedEvent) + track(trackedEvent) paywallView.showAlert( "An error occurred", @@ -455,59 +814,116 @@ class TransactionManager( ) } - // ... and so on for the other methods ... private suspend fun trackTransactionDidSucceed( transaction: StoreTransaction?, product: StoreProduct, + purchaseSource: PurchaseSource, + didStartFreeTrial: Boolean, ) { - val paywallView = lastPaywallView ?: return + val isObserved = + purchaseSource is PurchaseSource.ObserverMode - val paywallShowingFreeTrial = paywallView.paywall.isFreeTrialAvailable == true - val didStartFreeTrial = product.hasFreeTrial && paywallShowingFreeTrial + when (purchaseSource) { + is PurchaseSource.Internal -> { + val paywallView = lastPaywallView ?: return - val paywallInfo = paywallView.info + val paywallShowingFreeTrial = paywallView.paywall.isFreeTrialAvailable == true + val didStartFreeTrial = product.hasFreeTrial && paywallShowingFreeTrial - val trackedEvent = - InternalSuperwallEvent.Transaction( - InternalSuperwallEvent.Transaction.State.Complete(product, transaction), - paywallInfo, - product, - transaction, - ) - Superwall.instance.track(trackedEvent) + val paywallInfo = paywallView.info - // Immediately flush the events queue on transaction complete. - eventsQueue.flushInternal() + val trackedEvent = + InternalSuperwallEvent.Transaction( + InternalSuperwallEvent.Transaction.State.Complete(product, transaction), + paywallInfo, + product, + transaction, + source = TransactionSource.INTERNAL, + isObserved = false, + ) + track(trackedEvent) + eventsQueue.flushInternal() + + if (product.subscriptionPeriod == null) { + val nonRecurringEvent = + InternalSuperwallEvent.NonRecurringProductPurchase( + paywallInfo, + product, + ) + track(nonRecurringEvent) + } else { + if (didStartFreeTrial) { + val freeTrialEvent = + InternalSuperwallEvent.FreeTrialStart(paywallInfo, product) + track(freeTrialEvent) + + val notifications = + paywallInfo.localNotifications.filter { it.type == LocalNotificationType.TrialStarted } + val paywallActivity = + paywallView.encapsulatingActivity?.get() as? SuperwallPaywallActivity + ?: return + paywallActivity.attemptToScheduleNotifications( + notifications = notifications, + factory = factory, + ) + } else { + val subscriptionEvent = + InternalSuperwallEvent.SubscriptionStart(paywallInfo, product) + track(subscriptionEvent) + } + } - if (product.subscriptionPeriod == null) { - val nonRecurringEvent = - InternalSuperwallEvent.NonRecurringProductPurchase( - paywallInfo, - product, - ) - Superwall.instance.track(nonRecurringEvent) - } else { - if (didStartFreeTrial) { - val freeTrialEvent = InternalSuperwallEvent.FreeTrialStart(paywallInfo, product) - Superwall.instance.track(freeTrialEvent) - - val notifications = - paywallInfo.localNotifications.filter { it.type == LocalNotificationType.TrialStarted } - val paywallActivity = - paywallView.encapsulatingActivity?.get() as? SuperwallPaywallActivity - ?: return - paywallActivity.attemptToScheduleNotifications( - notifications = notifications, - factory = factory, - context = context, - ) - } else { - val subscriptionEvent = - InternalSuperwallEvent.SubscriptionStart(paywallInfo, product) - Superwall.instance.track(subscriptionEvent) + lastPaywallView = null } - } - lastPaywallView = null + is PurchaseSource.ExternalPurchase, is PurchaseSource.ObserverMode -> { + val trackedEvent = + InternalSuperwallEvent.Transaction( + InternalSuperwallEvent.Transaction.State.Complete(product, transaction), + PaywallInfo.empty(), + product, + transaction, + source = TransactionSource.EXTERNAL, + isObserved = isObserved, + ) + track(trackedEvent) + eventsQueue.flushInternal() + + if (product.subscriptionPeriod == null) { + val nonRecurringEvent = + InternalSuperwallEvent.NonRecurringProductPurchase( + PaywallInfo.empty(), + product, + ) + track(nonRecurringEvent) + } else { + if (didStartFreeTrial) { + val freeTrialEvent = + InternalSuperwallEvent.FreeTrialStart(PaywallInfo.empty(), product) + track(freeTrialEvent) + } else { + val subscriptionEvent = + InternalSuperwallEvent.SubscriptionStart( + PaywallInfo.empty(), + product, + ) + track(subscriptionEvent) + } + } + } + } } + + private fun log( + logLevel: LogLevel = LogLevel.debug, + message: String, + info: Map? = null, + error: Throwable? = null, + ) = Logger.debug( + logLevel = logLevel, + scope = LogScope.paywallTransactions, + message = message, + info = info, + error = error, + ) } diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationScheduler.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationScheduler.kt index ceb48757..fa92025d 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationScheduler.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationScheduler.kt @@ -9,7 +9,7 @@ import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger import com.superwall.sdk.models.paywall.LocalNotification -import com.superwall.sdk.paywall.vc.SuperwallPaywallActivity +import com.superwall.sdk.paywall.view.SuperwallPaywallActivity import java.util.concurrent.TimeUnit internal class NotificationScheduler { @@ -27,6 +27,7 @@ internal class NotificationScheduler { "id" to notification.id, "title" to notification.title, "body" to notification.body, + "subtitle" to notification.subtitle, ) var delay = notification.delay // delay in milliseconds diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationWorker.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationWorker.kt index b12fcf9f..699baa3c 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationWorker.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationWorker.kt @@ -8,7 +8,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.work.Worker import androidx.work.WorkerParameters -import com.superwall.sdk.paywall.vc.SuperwallPaywallActivity +import com.superwall.sdk.paywall.view.SuperwallPaywallActivity internal class NotificationWorker( val context: Context, @@ -24,7 +24,11 @@ internal class NotificationWorker( .Builder(applicationContext, SuperwallPaywallActivity.NOTIFICATION_CHANNEL_ID) .setSmallIcon(context.applicationInfo.icon) .setContentTitle(title) - .setContentText(text) + .let { + inputData.getString("subtitle")?.let { subtitle -> + it.setSubText(subtitle) + } ?: it + }.setContentText(text) .setPriority(NotificationCompat.PRIORITY_HIGH) with(NotificationManagerCompat.from(applicationContext)) { diff --git a/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt b/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt index 690f158a..d6a01109 100644 --- a/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt +++ b/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt @@ -71,11 +71,37 @@ internal class ErrorTracker( } } + fun String.replaceNonSuperwallPackages() = + lines() + .map { + if (it.containsAny( + "com.superwall.sdk", + "com.superwall.supercel", + "java.lang", + "net.java.dev.jna", + "kotlin.", + "kotlinx.", + "android.os", + "androidx.os", + "com.android.", + "com.google.", + "org.threeten.", + "com.revenuecat.purchases", + ) + ) { + it.map { if (it.isLetter()) "*" else it } + } else { + it + } + }.joinToString("\n") + + fun String.containsAny(vararg strings: String) = strings.any { this.contains(it) } + override fun trackError(throwable: Throwable) { val errorOccurence = ErrorTracking.ErrorOccurence( message = throwable.message ?: "", - stacktrace = throwable.stackTraceToString(), + stacktrace = throwable.stackTraceToString().replaceNonSuperwallPackages(), timestamp = System.currentTimeMillis(), isFatal = throwable.isFatal(), type = throwable.javaClass.simpleName, diff --git a/superwall/src/test/java/com/superwall/sdk/contrib/AmountFormatsTest.kt b/superwall/src/test/java/com/superwall/sdk/contrib/AmountFormatsTest.kt new file mode 100644 index 00000000..4f382d96 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/contrib/AmountFormatsTest.kt @@ -0,0 +1,219 @@ +package com.superwall.sdk.contrib + +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.contrib.threeteen.AmountFormats +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.threeten.bp.Duration +import org.threeten.bp.Period +import org.threeten.bp.format.DateTimeParseException +import java.util.* + +class AmountFormatsTest { + @Test + fun testIso8601Format() { + Given("a period and duration") { + val period = Period.of(1, 2, 3) + val duration = Duration.ofHours(4).plusMinutes(5).plusSeconds(6) + + When("formatting to ISO-8601") { + val result = AmountFormats.iso8601(period, duration) + + Then("it should return the correct ISO-8601 string") { + assertEquals("P1Y2M3DT4H5M6S", result) + } + } + } + + Given("a zero period and non-zero duration") { + val period = Period.ZERO + val duration = Duration.ofMinutes(30) + + When("formatting to ISO-8601") { + val result = AmountFormats.iso8601(period, duration) + + Then("it should return only the duration string") { + assertEquals("PT30M", result) + } + } + } + + Given("a non-zero period and zero duration") { + val period = Period.ofMonths(3) + val duration = Duration.ZERO + + When("formatting to ISO-8601") { + val result = AmountFormats.iso8601(period, duration) + + Then("it should return only the period string") { + assertEquals("P3M", result) + } + } + } + } + + @Test + fun testWordBasedPeriod() { + Given("a period with multiple units") { + val period = Period.of(2, 3, 15) + + When("formatting to word-based with English locale") { + val result = AmountFormats.wordBased(period, Locale.ENGLISH) + + Then("it should return the correct word-based string") { + assertEquals("2 years, 3 months and 15 days", result) + } + } + } + + Given("a period with opposite signs") { + val period = Period.of(1, -2, 0) + + When("formatting to word-based") { + val result = AmountFormats.wordBased(period, Locale.ENGLISH) + + Then("it should normalize the period") { + assertEquals("10 months", result) + } + } + } + } + + @Test + fun testWordBasedDuration() { + Given("a duration with multiple units") { + val duration = + Duration + .ofHours(25) + .plusMinutes(30) + .plusSeconds(45) + .plusMillis(500) + + When("formatting to word-based with English locale") { + val result = AmountFormats.wordBased(duration, Locale.ENGLISH) + + Then("it should return the correct word-based string") { + assertEquals("25 hours, 30 minutes, 45 seconds and 500 milliseconds", result) + } + } + } + } + + @Test + fun testWordBasedPeriodAndDuration() { + Given("a period and duration with multiple units") { + val period = Period.of(1, 2, 3) + val duration = Duration.ofHours(4).plusMinutes(5).plusSeconds(6) + + When("formatting to word-based with English locale") { + val result = AmountFormats.wordBased(period, duration, Locale.ENGLISH) + + Then("it should return the correct word-based string") { + assertEquals("1 year, 2 months, 3 days, 4 hours, 5 minutes and 6 seconds", result) + } + } + } + } + + @Test + fun testParseUnitBasedDuration() { + Given("a valid duration string") { + val durationString = "2h45m30s" + + When("parsing the unit-based duration") { + val result = AmountFormats.parseUnitBasedDuration(durationString) + + Then("it should return the correct Duration") { + assertEquals(Duration.ofHours(2).plusMinutes(45).plusSeconds(30), result) + } + } + } + + Given("a duration string with a negative value") { + val durationString = "-1.5h" + + When("parsing the unit-based duration") { + val result = AmountFormats.parseUnitBasedDuration(durationString) + + Then("it should return the correct negative Duration") { + assertEquals(Duration.ofMinutes(-90), result) + } + } + } + + Given("a duration string with mixed units") { + val durationString = "2h30m500ms" + + When("parsing the unit-based duration") { + val result = AmountFormats.parseUnitBasedDuration(durationString) + + Then("it should return the correct Duration") { + assertEquals(Duration.ofHours(2).plusMinutes(30).plusMillis(500), result) + } + } + } + + Given("an invalid duration string") { + val durationString = "2h30x" + + When("parsing the unit-based duration") { + Then("it should throw a DateTimeParseException") { + assertThrows(DateTimeParseException::class.java) { + AmountFormats.parseUnitBasedDuration(durationString) + } + } + } + } + + Given("a duration string with an empty value") { + val durationString = "" + + When("parsing the unit-based duration") { + Then("it should throw a DateTimeParseException") { + assertThrows(DateTimeParseException::class.java) { + AmountFormats.parseUnitBasedDuration(durationString) + } + } + } + } + + Given("a duration string with only a zero") { + val durationString = "0" + + When("parsing the unit-based duration") { + val result = AmountFormats.parseUnitBasedDuration(durationString) + + Then("it should return Duration.ZERO") { + assertEquals(Duration.ZERO, result) + } + } + } + + Given("a duration string with a very large value") { + val durationString = "9223372036854775807ns" + + When("parsing the unit-based duration") { + val result = AmountFormats.parseUnitBasedDuration(durationString) + + Then("it should return the correct Duration") { + assertEquals(Duration.ofNanos(9223372036854775807), result) + } + } + } + + Given("a duration string that exceeds the valid range") { + val durationString = "9223372036854775808ns" + + When("parsing the unit-based duration") { + Then("it should throw a DateTimeParseException") { + assertThrows(DateTimeParseException::class.java) { + AmountFormats.parseUnitBasedDuration(durationString) + } + } + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/models/paywall/PaywallProductTest.kt b/superwall/src/test/java/com/superwall/sdk/models/paywall/PaywallProductTest.kt index f6bfbe5e..ee701ba6 100644 --- a/superwall/src/test/java/com/superwall/sdk/models/paywall/PaywallProductTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/models/paywall/PaywallProductTest.kt @@ -1,7 +1,6 @@ package com.superwall.sdk.models.paywall -import com.superwall.sdk.models.product.Product -import com.superwall.sdk.models.product.ProductType +import com.superwall.sdk.models.product.ProductItem import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNamingStrategy import org.junit.Test @@ -11,7 +10,18 @@ class PaywallProductTest { fun `test parsing of config`() { val productString = """ - {"product": "primary", "product_id": "abc-def:ghi:jkl", "product_id_android": "abc-def:ghi:jkl"} + { + "reference_name": "primary", + "store_product": { + "store": "PLAY_STORE", + "product_identifier": "abc-def", + "base_plan_identifier": "ghi", + "offer": { + "type": "SPECIFIED", + "offer_identifier": "jkl" + } + } + } """.trimIndent() val json = @@ -19,9 +29,10 @@ class PaywallProductTest { ignoreUnknownKeys = true namingStrategy = JsonNamingStrategy.SnakeCase } - val product = json.decodeFromString(productString) + val product = json.decodeFromString(productString) assert(product != null) - assert(product.id == "abc-def:ghi:jkl") - assert(product.type == ProductType.PRIMARY) + assert(product.fullProductId == "abc-def:ghi:jkl") + assert(product.name == "primary") + assert(product.entitlements.isEmpty()) } } diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorParamsTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorParamsTest.kt deleted file mode 100644 index d09853c4..00000000 --- a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorParamsTest.kt +++ /dev/null @@ -1,153 +0,0 @@ -import java.util.* - -class ExpressionEvaluatorParamsTest { -/* - @Test - fun expression_evaluator_params_test() { - val expected = """ - { - "expression": "user.id == '123'", - "values": { - "user": { - "id": "123", - "email": "test@gmail.com" - }, - "device": {}, - "params": { - "id": "567" - } - } - } - """ - - val jsonValues = JSONObject() - jsonValues.put("user", JSONObject(mapOf("id" to "123", "email" to "test@gmail.com"))) - jsonValues.put("device", JSONObject(emptyMap())) - jsonValues.put("params", JSONObject(mapOf("id" to "567"))) - - val liquidExpressionParams = LiquidExpressionEvaluatorParams( - expression = "user.id == '123'", - values = jsonValues - ) - - val jsonString = liquidExpressionParams.toJson() - println("!! jsonString: $jsonString") - - // Parse jsonString into a JSONObject - val parsedJson = JSONObject(jsonString) - - // Test top-level properties - assert(parsedJson.getString("expression") == "user.id == '123'") - - // Test nested properties - val values = parsedJson.getJSONObject("values") - - val user = values.getJSONObject("user") - assert(user.getString("id") == "123") - assert(user.getString("email") == "test@gmail.com") - - val device = values.getJSONObject("device") - assert(device.names() == null) // Check that device is empty - - val params = values.getJSONObject("params") - assert(params.getString("id") == "567") - - - val base64String = liquidExpressionParams.toBase64Input() - // Try to base64 decode the string - val decodedString = Base64.getDecoder().decode(base64String) - // Parse the json - val parsedJson2 = JSONObject(String(decodedString, Charsets.UTF_8)) - - // Test top-level properties - assert(parsedJson2.getString("expression") == "user.id == '123'") - - // Test nested properties - val values2 = parsedJson2.getJSONObject("values") - - val user2 = values2.getJSONObject("user") - assert(user2.getString("id") == "123") - assert(user2.getString("email") == "test@gmail.com") - - val device2 = values2.getJSONObject("device") - assert(device2.names() == null) // Check that device2 is empty - - val params2 = values2.getJSONObject("params") - assert(params2.getString("id") == "567") - - } - - @Test - fun javascript_expression_evaluator_params_test() { - val expected = """ - { - "expressionJS": "user.id == '123'", - "values": { - "user": { - "id": "123", - "email": "test@gmail.com" - }, - "device": {}, - "params": { - "id": "567" - } - } - } - """ - - val jsonValues = JSONObject() - jsonValues.put("user", mapOf("id" to "123", "email" to "test@gmail.com")) - jsonValues.put("device", emptyMap()) - jsonValues.put("params", mapOf("id" to "567")) - - val jsExpressionParams = JavascriptExpressionEvaluatorParams( - expressionJs = "user.id == '123'", - values = jsonValues - ) - - val jsonString = jsExpressionParams.toJson() - - // Parse jsonString into a JSONObject - val parsedJson = JSONObject(jsonString) - - // Test top-level properties - assert(parsedJson.getString("expressionJS") == "user.id == '123'") - - // Test nested properties - val values = parsedJson.getJSONObject("values") - - val user = values.getJSONObject("user") - assert(user.getString("id") == "123") - assert(user.getString("email") == "test@gmail.com") - - val device = values.getJSONObject("device") - assert(device.names() == null) // Check that device is empty - - val params = values.getJSONObject("params") - assert(params.getString("id") == "567") - - val base64String = jsExpressionParams.toBase64Input() - // Try to base64 decode the string - val decodedByteArray = Base64.getDecoder().decode(base64String) - val decodedString = String(decodedByteArray, Charsets.UTF_8) - // Parse the json - val parsedJson2 = JSONObject(decodedString) - - // Test top-level properties - assert(parsedJson2.getString("expressionJS") == "user.id == '123'") - - // Test nested properties - val values2 = parsedJson2.getJSONObject("values") - - val user2 = values2.getJSONObject("user") - assert(user2.getString("id") == "123") - assert(user2.getString("email") == "test@gmail.com") - - val device2 = values2.getJSONObject("device") - assert(device2.names() == null) // Check that device2 is empty - - val params2 = values2.getJSONObject("params") - assert(params2.getString("id") == "567") - } -*/ -} diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/vc/web_view/PaywallMessageTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageTest.kt similarity index 96% rename from superwall/src/test/java/com/superwall/sdk/paywall/vc/web_view/PaywallMessageTest.kt rename to superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageTest.kt index d0a8ae28..d1a251a1 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/vc/web_view/PaywallMessageTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageTest.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc.web_view +package com.superwall.sdk.paywall.view.webview class PaywallMessageTest { // private val jsonFormat = Json { ignoreUnknownKeys = true } diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/vc/web_view/templating/models/DeviceTemplateTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplateTest.kt similarity index 94% rename from superwall/src/test/java/com/superwall/sdk/paywall/vc/web_view/templating/models/DeviceTemplateTest.kt rename to superwall/src/test/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplateTest.kt index d5b71c21..7dfac885 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/vc/web_view/templating/models/DeviceTemplateTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplateTest.kt @@ -1,4 +1,4 @@ -package com.superwall.sdk.paywall.vc.web_view.templating.models +package com.superwall.sdk.paywall.view.webview.templating.models import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject @@ -51,7 +51,8 @@ class DeviceTemplateTest { utcDateTime = "2024-03-20T10:00:00", localDateTime = "2024-03-20T02:00:00", isSandbox = "true", - subscriptionStatus = "active", + activeEntitlements = listOf("active"), + activeEntitlementsObject = listOf(mapOf("identifier" to "active", "type" to "SERVICE_LEVEL")), isFirstAppOpen = false, sdkVersion = "1.0.0", sdkVersionPadded = "001.000.000", @@ -128,7 +129,8 @@ class DeviceTemplateTest { utcDateTime = "2024-03-20T10:00:00", localDateTime = "2024-03-20T02:00:00", isSandbox = "true", - subscriptionStatus = "none", + activeEntitlements = listOf(), + activeEntitlementsObject = listOf(), isFirstAppOpen = true, sdkVersion = "1.0.0", sdkVersionPadded = "001.000.000", diff --git a/superwall/src/test/java/com/superwall/sdk/store/EntitlementsTest.kt b/superwall/src/test/java/com/superwall/sdk/store/EntitlementsTest.kt new file mode 100644 index 00000000..79c5860d --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/store/EntitlementsTest.kt @@ -0,0 +1,185 @@ +package com.superwall.sdk.store + +import com.superwall.sdk.And +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.entitlements.EntitlementStatus +import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.StoredEntitlementStatus +import com.superwall.sdk.storage.StoredEntitlementsByProductId +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class EntitlementsTest { + private val storage: Storage = mockk(relaxUnitFun = true) + + private lateinit var entitlements: Entitlements + + @Test + fun `test initialization with stored entitlement status`() = + runTest { + Given("storage contains entitlement status") { + val storedEntitlements = setOf(Entitlement("test_entitlement")) + val storedStatus = EntitlementStatus.Active(storedEntitlements) + every { storage.read(StoredEntitlementStatus) } returns storedStatus + every { storage.read(StoredEntitlementsByProductId) } returns + mapOf( + "test" to + setOf( + Entitlement("test_entitlement"), + ), + ) + entitlements = Entitlements(storage) + + When("Entitlements is initialized") { + val entitlements = Entitlements(storage) + + Then("it should load the stored status") { + assertEquals(storedStatus, entitlements.status.value) + assertEquals(storedEntitlements, entitlements.active) + assertEquals(storedEntitlements, entitlements.all) + assertTrue(entitlements.inactive.isEmpty()) + } + } + } + } + + @Test + fun `test setEntitlementStatus with active entitlements`() = + runTest { + Given("an Entitlements instance") { + val activeEntitlements = + setOf( + Entitlement("entitlement1"), + Entitlement("entitlement2"), + ) + every { storage.read(StoredEntitlementStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + entitlements = Entitlements(storage) + + When("setting active entitlement status") { + entitlements.setEntitlementStatus(EntitlementStatus.Active(activeEntitlements)) + + Then("it should update all collections correctly") { + assertEquals(activeEntitlements, entitlements.active) + assertEquals(activeEntitlements, entitlements.all) + assertTrue(entitlements.inactive.isEmpty()) + assertTrue(entitlements.status.value is EntitlementStatus.Active) + } + + And("it should store the status") { + verify { + storage.write( + StoredEntitlementStatus, + EntitlementStatus.Active(activeEntitlements), + ) + } + } + } + } + } + + @Test + fun `test setEntitlementStatus with empty active entitlements`() = + runTest { + Given("an Entitlements instance") { + every { storage.read(StoredEntitlementStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + entitlements = Entitlements(storage) + + When("setting active entitlement status with empty set") { + entitlements.setEntitlementStatus(EntitlementStatus.Active(emptySet())) + + Then("it should convert to NoActiveEntitlements status") { + assertTrue(entitlements.status.value is EntitlementStatus.Inactive) + assertTrue(entitlements.active.isEmpty()) + assertTrue(entitlements.inactive.isEmpty()) + } + } + } + } + + @Test + fun `test setEntitlementStatus with no active entitlements`() = + runTest { + Given("an Entitlements instance") { + every { storage.read(StoredEntitlementStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + entitlements = Entitlements(storage) + When("setting NoActiveEntitlements status") { + entitlements.setEntitlementStatus(EntitlementStatus.Inactive) + + Then("it should clear all collections") { + assertTrue(entitlements.active.isEmpty()) + assertTrue(entitlements.inactive.isEmpty()) + assertTrue(entitlements.status.value is EntitlementStatus.Inactive) + } + } + } + } + + @Test + fun `test setEntitlementStatus with unknown status`() = + runTest { + Given("an Entitlements instance") { + every { storage.read(StoredEntitlementStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + entitlements = Entitlements(storage) + When("setting Unknown status") { + entitlements.setEntitlementStatus(EntitlementStatus.Unknown) + + Then("it should clear all collections") { + assertTrue(entitlements.active.isEmpty()) + assertTrue(entitlements.inactive.isEmpty()) + assertTrue(entitlements.all.isEmpty()) + assertTrue(entitlements.status.value is EntitlementStatus.Unknown) + } + } + } + } + + @Test + fun `test byProductId functionality`() = + runTest { + Given("storage contains entitlements by product ID") { + val productEntitlements = + mapOf( + "product1" to setOf(Entitlement("entitlement1")), + "product2" to setOf(Entitlement("entitlement2")), + ) + every { storage.read(StoredEntitlementStatus) } returns + EntitlementStatus.Active( + setOf( + Entitlement("entitlement1"), + Entitlement("entitlement2"), + ), + ) + every { storage.read(StoredEntitlementsByProductId) } returns productEntitlements + entitlements = Entitlements(storage) + + When("creating a new Entitlements instance") { + Then("it should return correct entitlements for each product") { + assertEquals( + productEntitlements["product1"], + entitlements.byProductId("product1"), + ) + assertEquals( + productEntitlements["product2"], + entitlements.byProductId("product2"), + ) + } + + And("it should return empty set for unknown product") { + assertTrue(entitlements.byProductId("unknown_product").isEmpty()) + } + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt new file mode 100644 index 00000000..f9d13435 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt @@ -0,0 +1,256 @@ +package com.superwall.sdk.store + +import com.superwall.sdk.And +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.assertTrue +import com.superwall.sdk.billing.Billing +import com.superwall.sdk.billing.BillingError +import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.paywall.Paywall +import com.superwall.sdk.models.product.Offer +import com.superwall.sdk.models.product.PlayStoreProduct +import com.superwall.sdk.models.product.ProductItem +import com.superwall.sdk.models.product.Store +import com.superwall.sdk.paywall.request.PaywallRequest +import com.superwall.sdk.store.abstractions.product.StoreProduct +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test + +class StoreManagerTest { + private lateinit var purchaseController: InternalPurchaseController + private lateinit var billing: Billing + private lateinit var storeManager: StoreManager + private val entitlementsBasic = setOf(Entitlement("entitlement1")) + + @Before + fun setup() { + purchaseController = mockk() + billing = mockk() + storeManager = + StoreManager( + purchaseController, + billing, + track = {}, + ) + } + + @Test + fun test_getProductVariables_with_successful_product_fetch() = + runTest { + Given("a paywall with product items") { + val paywall = + Paywall.stub().copy( + productIds = listOf("product1", "product2"), + _productItems = + listOf( + ProductItem( + "Item1", + ProductItem.StoreProductType.PlayStore( + PlayStoreProduct( + store = Store.PLAY_STORE, + productIdentifier = "product1", + basePlanIdentifier = "basePlan1", + offer = Offer.Automatic(), + ), + ), + entitlements = entitlementsBasic.toSet(), + ), + ProductItem( + "Item2", + type = + ProductItem.StoreProductType.PlayStore( + PlayStoreProduct( + store = Store.PLAY_STORE, + productIdentifier = "product2", + basePlanIdentifier = "basePlan1", + offer = Offer.Automatic(), + ), + ), + entitlements = entitlementsBasic.toSet(), + ), + ), + ) + val request = mockk() + val storeProducts = + setOf( + mockk { + every { fullIdentifier } returns "product1:basePlan1:sw-auto" + every { attributes } returns mapOf("attr1" to "value1") + }, + mockk { + every { fullIdentifier } returns "product2:basePlan1:sw-auto" + every { attributes } returns mapOf("attr2" to "value2") + }, + ) + + coEvery { billing.awaitGetProducts(any()) } returns storeProducts + + When("getProductVariables is called") { + val result = storeManager.getProductVariables(paywall, request) + + Then("it should return the correct product variables") { + assertEquals(2, result.size) + assertEquals("Item1", result[0].name) + assertEquals(mapOf("attr1" to "value1"), result[0].attributes) + assertEquals("Item2", result[1].name) + assertEquals(mapOf("attr2" to "value2"), result[1].attributes) + } + } + } + } + + @Test + fun test_getProducts_with_substitute_products() = + runTest { + Given("a paywall and substitute products") { + val paywall = + Paywall.stub().copy( + productIds = listOf("product1", "product2"), + _productItems = + listOf( + ProductItem( + "Item1", + ProductItem.StoreProductType.PlayStore( + PlayStoreProduct( + store = Store.PLAY_STORE, + productIdentifier = "product1", + basePlanIdentifier = "basePlan1", + offer = Offer.Automatic(), + ), + ), + entitlements = entitlementsBasic.toSet(), + ), + ProductItem( + "Item2", + type = + ProductItem.StoreProductType.PlayStore( + PlayStoreProduct( + store = Store.PLAY_STORE, + productIdentifier = "product2", + basePlanIdentifier = "basePlan1", + offer = Offer.Automatic(), + ), + ), + entitlements = entitlementsBasic.toSet(), + ), + ), + ) + val substituteProducts = + mapOf( + "Item1" to + mockk { + every { fullIdentifier } returns "substitute1" + every { attributes } returns mapOf("attr1" to "value1") + }, + ) + + coEvery { billing.awaitGetProducts(any()) } returns + setOf( + mockk { + every { fullIdentifier } returns "product2" + every { attributes } returns mapOf("attr2" to "value2") + }, + ) + + When("getProducts is called with substitute products") { + val result = storeManager.getProducts(substituteProducts, paywall, null) + + Then("it should use the substitute product and fetch the remaining product") { + assertEquals(2, result.productsByFullId.size) + assertTrue(result.productsByFullId.containsKey("substitute1")) + assertTrue(result.productsByFullId.containsKey("product2")) + } + + And("it should update the product items accordingly") { + assertEquals(2, result.productItems.size) + assertEquals("Item1", result.productItems[0].name) + assertEquals("Item2", result.productItems[1].name) + } + } + } + } + + @Test + fun `test getProducts with billing error`() = + runTest { + Given("a paywall and a billing error") { + val paywall = + Paywall.stub().copy( + productIds = listOf("product1"), + _productItems = + listOf( + ProductItem( + "Item1", + ProductItem.StoreProductType.PlayStore( + PlayStoreProduct( + store = Store.PLAY_STORE, + productIdentifier = "product1", + basePlanIdentifier = "basePlan1", + offer = Offer.Automatic(), + ), + ), + entitlements = entitlementsBasic.toSet(), + ), + ProductItem( + "Item2", + type = + ProductItem.StoreProductType.PlayStore( + PlayStoreProduct( + store = Store.PLAY_STORE, + productIdentifier = "product2", + basePlanIdentifier = "basePlan1", + offer = Offer.Automatic(), + ), + ), + entitlements = entitlementsBasic.toSet(), + ), + ), + ) + + coEvery { billing.awaitGetProducts(any()) } throws + BillingError.BillingNotAvailable( + "Billing not available", + ) + + When("getProducts is called") { + Then("it should throw a BillingNotAvailable error") { + assertThrows(BillingError.BillingNotAvailable::class.java) { + runBlocking { storeManager.getProducts(null, paywall, null) } + } + } + } + } + } + + @Test + fun `test products method`() = + runTest { + Given("a set of product identifiers") { + val identifiers = setOf("product1", "product2") + val expectedProducts = + setOf( + mockk { every { fullIdentifier } returns "product1" }, + mockk { every { fullIdentifier } returns "product2" }, + ) + + coEvery { billing.awaitGetProducts(identifiers) } returns expectedProducts + + When("products method is called") { + val result = storeManager.products(identifiers) + + Then("it should return the correct set of StoreProducts") { + assertEquals(expectedProducts, result) + } + } + } + } +} 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 new file mode 100644 index 00000000..b3549aed --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt @@ -0,0 +1,1060 @@ +package com.superwall.sdk.store.transactions + +import com.android.billingclient.api.ProductDetails +import com.superwall.sdk.And +import com.superwall.sdk.Given +import com.superwall.sdk.Then +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.analytics.superwall.SuperwallEvent +import com.superwall.sdk.billing.Billing +import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.delegate.PurchaseResult +import com.superwall.sdk.delegate.RestorationResult +import com.superwall.sdk.misc.ActivityProvider +import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.entitlements.EntitlementStatus +import com.superwall.sdk.models.paywall.Paywall +import com.superwall.sdk.models.product.Offer +import com.superwall.sdk.models.product.PlayStoreProduct +import com.superwall.sdk.models.product.ProductItem +import com.superwall.sdk.paywall.presentation.PaywallInfo +import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState +import com.superwall.sdk.products.mockPricingPhase +import com.superwall.sdk.products.mockSubscriptionOfferDetails +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.OfferType +import com.superwall.sdk.store.abstractions.product.RawStoreProduct +import com.superwall.sdk.store.abstractions.product.StoreProduct +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.lang.ref.WeakReference + +class TransactionManagerTest { + private val playProduct = + mockk { + every { productId } returns "product1" + every { oneTimePurchaseOfferDetails } returns + mockk { + every { priceAmountMicros } returns 1000000 + every { priceCurrencyCode } returns "USD" + } + } + + val entitlements = setOf("test-entitlement").map { Entitlement(it) }.toSet() + + private val mockProduct = + RawStoreProduct( + playProduct, + "product1", + "basePlan", + OfferType.Auto, + ) + + private val mockItems = + listOf( + ProductItem( + "Item1", + ProductItem.StoreProductType.PlayStore( + PlayStoreProduct( + productIdentifier = "product1", + basePlanIdentifier = "basePlan", + offer = Offer.Automatic(), + ), + ), + entitlements = entitlements.toSet(), + ), + ) + + private val pwInfo = + PaywallInfo.empty().copy( + products = + mockItems, + ) + + private val mockedPaywall: Paywall = + mockk { + every { getInfo(any()) } returns pwInfo + every { productIds } returns listOf("product1") + every { productItems } returns mockItems + every { isFreeTrialAvailable } returns true + } + private val paywallView = + mockk(relaxUnitFun = true) { + every { info } returns pwInfo + every { paywall } returns mockedPaywall + } + + private var purchaseController = mockk() + private var billing: Billing = + mockk { + coEvery { awaitGetProducts(any()) } returns + setOf( + StoreProduct( + mockProduct, + ), + ) + } + private var storeManager = spyk(StoreManager(purchaseController, billing)) + private var activityProvider = + mockk { + every { getCurrentActivity() } returns mockk() + } + + private var eventsQueue = mockk(relaxUnitFun = true) + private var transactionManagerFactory = + mockk { + every { makeHasExternalPurchaseController() } returns false + every { makeTransactionVerifier() } returns + mockk { + coEvery { getLatestTransaction(any()) } returns mockk() + } + every { makeHasExternalPurchaseController() } returns false + every { makeSuperwallOptions() } returns SuperwallOptions() + } + + private var storage = mockk(relaxUnitFun = true) + + fun TestScope.manager( + track: (TrackableSuperwallEvent) -> Unit = {}, + dismiss: (paywallView: PaywallView, result: PaywallResult) -> Unit = { _, _ -> }, + entitlementStatus: () -> EntitlementStatus = { + EntitlementStatus.Active(entitlements) + }, + options: SuperwallOptions.() -> Unit = {}, + ): TransactionManager { + coEvery { transactionManagerFactory.makeSuperwallOptions() } returns + SuperwallOptions().apply( + options, + ) + return TransactionManager( + purchaseController = purchaseController, + storeManager = storeManager, + activityProvider = activityProvider, + entitlementStatus = entitlementStatus, + track = { track(it) }, + dismiss = { i, e -> dismiss(i, e) }, + eventsQueue = eventsQueue, + factory = transactionManagerFactory, + ioScope = IOScope(this.coroutineContext), + storage = storage, + ) + } + + @Test + fun test_purchase_internal_product_not_found() = + runTest { + Given("We try to purchase a product that does not exist") { + val transactionManager: TransactionManager = manager() + When("We try to purchase a product from the paywall") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.Internal( + "product1", + paywallView, + ), + ) + Then("The purchase fails") { + assert(result is PurchaseResult.Failed && result.errorMessage == "Product not found") + } + } + } + } + + @Test + fun test_purchase_activity_not_found() = + runTest { + Given("We have loaded products but no activity") { + storeManager.getProducts(paywall = mockedPaywall) + every { activityProvider.getCurrentActivity() } returns null + val transactionManager: TransactionManager = manager() + When("We try to purchase a product from the paywall") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.Internal( + "product1", + paywallView, + ), + ) + Then("The purchase fails") { + assert( + result is PurchaseResult.Failed && + result.errorMessage == "Activity not found - required for starting the billing flow", + ) + } + } + } + } + + @Test + fun test_purchase_successful_internal() = + runTest { + val events = MutableStateFlow(emptyList()) + Given("We have loaded products and we can purchase successfully") { + // Pretend a paywall loaded a product + storeManager.getProducts(paywall = mockedPaywall) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { + it + e + } + }) + coEvery { + purchaseController.purchase( + any(), + any(), + "basePlan", + any(), + ) + } returns PurchaseResult.Purchased() + + When("We try to purchase a product from the paywall") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.Internal( + "product1", + paywallView, + ), + ) + Then("The purchase is successful") { + assert(result is PurchaseResult.Purchased) + coVerify { storeManager.loadPurchasedProducts() } + And("Verify event order") { + val transactionEvents = + events.value.filterIsInstance() + + assert(transactionEvents.first().superwallEvent is SuperwallEvent.TransactionStart) + assert(transactionEvents.first().product?.fullIdentifier == "product1") + assert(transactionEvents.last().superwallEvent is SuperwallEvent.TransactionComplete) + + val purchase = + events.value.filterIsInstance() + assert(purchase.first().product?.fullIdentifier == "product1") + } + } + } + } + } + + @Test + fun test_purchase_successful_external() = + runTest { + Given("We have loaded products and we can purchase successfully externally") { + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { it + e } + }, options = { + paywalls.automaticallyDismiss = true + }) + coEvery { + purchaseController.purchase( + any(), + any(), + "basePlan", + any(), + ) + } returns PurchaseResult.Purchased() + + When("We try to purchase a product externally") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.ExternalPurchase( + StoreProduct(mockProduct), + ), + ) + Then("The purchase is successful") { + assert(result is PurchaseResult.Purchased) + coVerify { storeManager.loadPurchasedProducts() } + And("Verify event order") { + val transactionEvents = + events.value.filterIsInstance() + assert(transactionEvents.first().superwallEvent is SuperwallEvent.TransactionStart) + assert(transactionEvents.last().superwallEvent is SuperwallEvent.TransactionComplete) + + val purchase = + events.value.filterIsInstance() + assert(purchase.first().product?.fullIdentifier == "product1") + } + } + } + } + } + + @Test + fun test_purchase_restored_internal() = + runTest { + Given("We have loaded products and a purchase results in restoration") { + storeManager.getProducts(paywall = mockedPaywall) + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager( + track = { e -> + events.update { it + e } + }, + options = { + paywalls.automaticallyDismiss = true + }, + dismiss = { view, res -> + assert(view == paywallView) + assert(res is PaywallResult.Restored) + }, + ) + coEvery { + purchaseController.restorePurchases() + } returns RestorationResult.Restored() + + When("We try to restore a product from the paywall") { + val result = + transactionManager.tryToRestorePurchases( + paywallView, + ) + Then("The restore results in restoration") { + assert(result is RestorationResult.Restored) + And("Verify restoration event") { + val restorationEvent = + events.value + .filterIsInstance() + .find { it.state is InternalSuperwallEvent.Transaction.State.Restore } + assert(restorationEvent != null) + } + } + } + } + } + + @Test + fun test_purchase_restored_external() = + runTest { + Given("We want to restore a product externally") { + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { it + e } + }) + coEvery { + purchaseController.restorePurchases() + } returns RestorationResult.Restored() + + When("We try to restore a product from the paywall") { + val result = transactionManager.tryToRestorePurchases(null) + Then("The purchase results in restoration") { + assert(result is RestorationResult.Restored) + And("Verify restoration event") { + advanceUntilIdle() + val restorationEvent = + events.value + .filterIsInstance() + .find { it.state is InternalSuperwallEvent.Restore.State.Complete } + assert(restorationEvent != null) + } + } + } + } + } + + @Test + fun test_purchase_failed_with_alert() = + runTest { + Given("We have loaded products and a purchase fails") { + storeManager.getProducts(paywall = mockedPaywall) + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { it + e } + }, options = { + paywalls.shouldShowPurchaseFailureAlert = true + }) + coEvery { transactionManagerFactory.makeTriggers() } returns + events.value + .map { it.rawName } + .toSet() + coEvery { + purchaseController.purchase( + any(), + any(), + "basePlan", + any(), + ) + } returns PurchaseResult.Failed("Test failure") + + When("We try to purchase a product from the paywall") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.Internal( + "product1", + paywallView, + ), + ) + Then("The purchase fails and an alert is shown") { + assert(result is PurchaseResult.Failed) + coVerify { + paywallView.showAlert( + any(), + any(), + any(), + any(), + isNull(), + isNull(), + ) + } + And("Verify failure event") { + val failureEvent = + events.value + .filterIsInstance() + .find { it.state is InternalSuperwallEvent.Transaction.State.Fail } + assert(failureEvent != null) + } + } + } + } + } + + @Test + fun test_purchase_failed_without_alert() = + runTest { + Given("We have loaded products and a purchase fails") { + storeManager.getProducts(paywall = mockedPaywall) + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { it + e } + }, options = { + paywalls.shouldShowPurchaseFailureAlert = false + }) + coEvery { transactionManagerFactory.makeTriggers() } returns + events.value + .map { it.rawName } + .toSet() + coEvery { + purchaseController.purchase( + any(), + any(), + "basePlan", + any(), + ) + } returns PurchaseResult.Failed("Test failure") + + When("We try to purchase a product from the paywall") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.Internal( + "product1", + paywallView, + ), + ) + Then("The purchase fails and no alert is shown") { + assert(result is PurchaseResult.Failed) + coVerify(exactly = 0) { + paywallView.showAlert( + any(), + any(), + any(), + any(), + isNull(), + isNull(), + ) + } + And("Verify failure event") { + advanceUntilIdle() + val failureEvent = + events.value + .filterIsInstance() + .find { it.state is InternalSuperwallEvent.Transaction.State.Fail } + assert(failureEvent != null) + } + } + } + } + } + + @Test + fun test_purchase_pending() = + runTest { + Given("We have loaded products and a purchase is pending") { + storeManager.getProducts(paywall = mockedPaywall) + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { it + e } + }) + coEvery { + purchaseController.purchase( + any(), + any(), + "basePlan", + any(), + ) + } returns PurchaseResult.Pending() + + When("We try to purchase a product from the paywall") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.Internal( + "product1", + paywallView, + ), + ) + Then("The purchase is pending") { + assert(result is PurchaseResult.Pending) + coVerify { paywallView.showAlert(any(), any()) } + And("Verify pending event") { + val pendingEvent = + events.value + .filterIsInstance() + .find { + it.state is InternalSuperwallEvent.Transaction.State.Fail && + (it.state as InternalSuperwallEvent.Transaction.State.Fail).error is TransactionError.Pending + } + assert(pendingEvent != null) + } + } + } + } + } + + @Test + fun test_purchase_cancelled_internal() = + runTest { + Given("We have loaded products and a purchase is pending") { + storeManager.getProducts(paywall = mockedPaywall) + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { it + e } + }) + coEvery { + purchaseController.purchase( + any(), + any(), + "basePlan", + any(), + ) + } returns PurchaseResult.Cancelled() + + When("We try to purchase a product from the paywall") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.Internal( + "product1", + paywallView, + ), + ) + Then("The purchase is pending") { + assert(result is PurchaseResult.Cancelled) + verify { + paywallView setProperty "loadingState" value + any( + PaywallLoadingState.Ready::class, + ) + } + And("Verify pending event") { + val pendingEvent = + events.value + .filterIsInstance() + .find { it.state is InternalSuperwallEvent.Transaction.State.Abandon } + assert(pendingEvent != null) + } + } + } + } + } + + @Test + fun test_purchase_cancelled_external() = + runTest { + Given("An external purchase was cancelled") { + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { it + e } + }) + coEvery { + purchaseController.purchase( + any(), + any(), + any(), + any(), + ) + } returns PurchaseResult.Cancelled() + + When("We try to purchase a product from the paywall") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.ExternalPurchase( + StoreProduct(mockProduct), + ), + ) + Then("The purchase is cancelled") { + assert(result is PurchaseResult.Cancelled) + verify(exactly = 0) { + paywallView setProperty "loadingState" value + any( + PaywallLoadingState.Ready::class, + ) + } + And("Verify pending event") { + val pendingEvent = + events.value + .filterIsInstance() + .find { it.state is InternalSuperwallEvent.Transaction.State.Abandon } + assert(pendingEvent != null) + } + } + } + } + } + + @Test + fun test_try_to_restore_purchases_success() = + runTest { + Given("We can successfully restore purchases from a paywall") { + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager( + track = { e -> + events.update { it + e } + }, + entitlementStatus = { EntitlementStatus.Active(entitlements) }, + ) + coEvery { purchaseController.restorePurchases() } returns RestorationResult.Restored() + + When("We try to restore purchases") { + val result = transactionManager.tryToRestorePurchases(paywallView) + Then("The restoration is successful") { + assert(result is RestorationResult.Restored) + And("Verify restoration events") { + val restoreEvents = + events.value.filterIsInstance() + assert(restoreEvents.size == 2) + assert(restoreEvents[0].state is InternalSuperwallEvent.Restore.State.Start) + assert(restoreEvents[1].state is InternalSuperwallEvent.Restore.State.Complete) + } + } + } + } + } + + @Test + fun test_try_to_restore_purchases_success_externally() = + runTest { + Given("We can successfully restore purchases without a paywall") { + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager( + track = { e -> + events.update { it + e } + }, + entitlementStatus = { EntitlementStatus.Active(entitlements) }, + ) + coEvery { purchaseController.restorePurchases() } returns RestorationResult.Restored() + + When("We try to restore purchases") { + val result = transactionManager.tryToRestorePurchases(null) + Then("The restoration is successful") { + assert(result is RestorationResult.Restored) + And("Verify restoration events") { + val restoreEvents = + events.value.filterIsInstance() + assert(restoreEvents.size == 2) + assert(restoreEvents[0].state is InternalSuperwallEvent.Restore.State.Start) + assert(restoreEvents[1].state is InternalSuperwallEvent.Restore.State.Complete) + } + } + } + } + } + + @Test + fun test_try_to_restore_purchases_failure() = + runTest { + Given("Restoration of purchases fails") { + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager( + track = { e -> + events.update { it + e } + }, + entitlementStatus = { EntitlementStatus.Inactive }, + ) + + coEvery { purchaseController.restorePurchases() } returns + RestorationResult.Failed( + Exception("Test failure"), + ) + + When("We try to restore purchases") { + val result = transactionManager.tryToRestorePurchases(paywallView) + Then("The restoration fails") { + assert(result is RestorationResult.Failed) + coVerify { + paywallView.showAlert( + any(), + any(), + any(), + any(), + isNull(), + isNull(), + ) + } + And("Verify restoration events") { + val restoreEvents = + events.value.filterIsInstance() + assert(restoreEvents.size == 2) + assert(restoreEvents[0].state is InternalSuperwallEvent.Restore.State.Start) + assert(restoreEvents[1].state is InternalSuperwallEvent.Restore.State.Failure) + } + } + } + } + } + + @Test + fun test_try_to_restore_purchases_restored_but_inactive() = + runTest { + Given("Restoration of purchases fails") { + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager( + track = { e -> + events.update { it + e } + }, + entitlementStatus = { EntitlementStatus.Inactive }, + ) + + coEvery { purchaseController.restorePurchases() } returns RestorationResult.Restored() + + When("We try to restore purchases") { + val result = transactionManager.tryToRestorePurchases(paywallView) + Then("The restoration fails because subscription is inactive") { + assert(result is RestorationResult.Restored) + coVerify { + paywallView.showAlert( + any(), + any(), + any(), + any(), + isNull(), + isNull(), + ) + } + And("Verify restoration events") { + val restoreEvents = + events.value.filterIsInstance() + assert(restoreEvents.size == 2) + assert(restoreEvents[0].state is InternalSuperwallEvent.Restore.State.Start) + val failure: InternalSuperwallEvent.Restore.State.Failure = + restoreEvents[1].state as InternalSuperwallEvent.Restore.State.Failure + assert(failure.reason.contains("\"inactive\"")) + } + } + } + } + } + + @Test + fun test_purchase_with_free_trial_internal() = + runTest { + Given("We have loaded products with a free trial and we can purchase successfully") { + storeManager.getProducts(paywall = mockedPaywall) + every { paywallView.encapsulatingActivity } returns WeakReference(mockk()) + every { playProduct.oneTimePurchaseOfferDetails } returns null + every { playProduct.subscriptionOfferDetails } returns + listOf( + mockSubscriptionOfferDetails( + basePlanId = "basePlan", + pricingPhases = listOf(mockPricingPhase()), + ), + mockSubscriptionOfferDetails( + offerId = "offer1", + basePlanId = "basePlan", + pricingPhases = listOf(mockPricingPhase(0.0), mockPricingPhase()), + ), + ) + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { it + e } + }) + coEvery { + purchaseController.purchase( + any(), + any(), + "basePlan", + any(), + ) + } returns PurchaseResult.Purchased() + When("We try to purchase a product with a free trial from the paywall") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.Internal( + "product1", + paywallView, + ), + ) + Then("The purchase is successful") { + assert(result is PurchaseResult.Purchased) + coVerify { storeManager.loadPurchasedProducts() } + And("Verify free trial start event") { + val freeTrialStartEvent = + events.value.filterIsInstance() + assert(freeTrialStartEvent.isNotEmpty()) + assert(freeTrialStartEvent.first().product?.fullIdentifier == "product1") + } + } + } + } + } + + @Test + fun test_purchase_with_free_trial_external() = + runTest { + Given("We can purchase a product with a free trial externally") { + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { it + e } + }) + every { playProduct.oneTimePurchaseOfferDetails } returns null + every { playProduct.subscriptionOfferDetails } returns + listOf( + mockSubscriptionOfferDetails( + basePlanId = "basePlan", + pricingPhases = listOf(mockPricingPhase()), + ), + mockSubscriptionOfferDetails( + offerId = "offer1", + basePlanId = "basePlan", + pricingPhases = listOf(mockPricingPhase(0.0), mockPricingPhase()), + ), + ) + coEvery { + purchaseController.purchase( + any(), + any(), + "basePlan", + any(), + ) + } returns PurchaseResult.Purchased() + + When("We try to purchase the product") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.ExternalPurchase( + StoreProduct(mockProduct), + ), + ) + Then("The purchase is successful") { + assert(result is PurchaseResult.Purchased) + coVerify { storeManager.loadPurchasedProducts() } + And("Verify free trial start event") { + val freeTrialStartEvent = + events.value.filterIsInstance() + assert(freeTrialStartEvent.isNotEmpty()) + assert(freeTrialStartEvent.first().product?.fullIdentifier == "product1") + } + } + } + } + } + + @Test + fun test_purchase_non_recurring_product_internal() = + runTest { + Given("We have loaded a non-recurring product and we can purchase successfully") { + storeManager.getProducts(paywall = mockedPaywall) + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { it + e } + }) + every { playProduct.oneTimePurchaseOfferDetails } returns null + every { playProduct.subscriptionOfferDetails } returns null + coEvery { + purchaseController.purchase( + any(), + any(), + any(), + any(), + ) + } returns PurchaseResult.Purchased() + + When("We try to purchase a non-recurring product from the paywall") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.Internal( + "product1", + paywallView, + ), + ) + Then("The purchase is successful") { + assert(result is PurchaseResult.Purchased) + coVerify { storeManager.loadPurchasedProducts() } + And("Verify non-recurring product purchase event") { + val nonRecurringPurchaseEvent = + events.value.filterIsInstance() + assert(nonRecurringPurchaseEvent.isNotEmpty()) + assert(nonRecurringPurchaseEvent.first().product?.fullIdentifier == "product1") + } + } + } + } + } + + @Test + fun test_purchase_non_recurring_product_external() = + runTest { + Given("We can purchase a non-recurring product externally") { + every { playProduct.oneTimePurchaseOfferDetails } returns null + every { playProduct.subscriptionOfferDetails } returns null + + val nonRecurringStoreProduct = StoreProduct(mockProduct) + + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { it + e } + }) + coEvery { + purchaseController.purchase( + any(), + any(), + any(), + any(), + ) + } returns PurchaseResult.Purchased() + + When("We try to purchase the product") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.ExternalPurchase( + nonRecurringStoreProduct, + ), + ) + Then("The purchase is successful") { + assert(result is PurchaseResult.Purchased) + coVerify { storeManager.loadPurchasedProducts() } + And("Verify non-recurring product purchase event") { + val nonRecurringPurchaseEvent = + events.value.filterIsInstance() + assert(nonRecurringPurchaseEvent.isNotEmpty()) + assert(nonRecurringPurchaseEvent.first().product?.fullIdentifier == "product1") + } + } + } + } + } + + @Test + fun test_purchase_subscription_without_trial_internal() = + runTest { + Given("We have loaded a subscription product without trial and we can purchase successfully") { + storeManager.getProducts(paywall = mockedPaywall) + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { it + e } + }) + every { playProduct.oneTimePurchaseOfferDetails } returns null + every { playProduct.subscriptionOfferDetails } returns + listOf( + mockSubscriptionOfferDetails( + basePlanId = "basePlan", + pricingPhases = listOf(mockPricingPhase()), + ), + ) + coEvery { + purchaseController.purchase( + any(), + any(), + "basePlan", + any(), + ) + } returns PurchaseResult.Purchased() + + When("We try to purchase a subscription without trial from the paywall") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.Internal( + "product1", + paywallView, + ), + ) + Then("The purchase is successful") { + assert(result is PurchaseResult.Purchased) + coVerify { storeManager.loadPurchasedProducts() } + And("Verify subscription start event") { + val subscriptionStartEvent = + events.value.filterIsInstance() + assert(subscriptionStartEvent.isNotEmpty()) + assert(subscriptionStartEvent.first().product?.fullIdentifier == "product1") + } + } + } + } + } + + @Test + fun test_purchase_subscription_without_trial_external() = + runTest { + Given("We can purchase a subscription product without trial externally") { + val events = MutableStateFlow(emptyList()) + val transactionManager: TransactionManager = + manager(track = { e -> + events.update { it + e } + }) + every { playProduct.oneTimePurchaseOfferDetails } returns null + every { playProduct.subscriptionOfferDetails } returns + listOf( + mockSubscriptionOfferDetails( + basePlanId = "basePlan", + pricingPhases = listOf(mockPricingPhase()), + ), + ) + coEvery { + purchaseController.purchase( + any(), + any(), + "basePlan", + any(), + ) + } returns PurchaseResult.Purchased() + + When("We try to purchase a subscription without trial externally") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.ExternalPurchase( + StoreProduct(mockProduct), + ), + ) + Then("The purchase is successful") { + assert(result is PurchaseResult.Purchased) + coVerify { storeManager.loadPurchasedProducts() } + And("Verify subscription start event") { + val subscriptionStartEvent = + events.value.filterIsInstance() + assert(subscriptionStartEvent.isNotEmpty()) + assert(subscriptionStartEvent.first().product?.fullIdentifier == "product1") + } + } + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/utils.kt b/superwall/src/test/java/com/superwall/sdk/utils.kt index 740afb21..adba8e65 100644 --- a/superwall/src/test/java/com/superwall/sdk/utils.kt +++ b/superwall/src/test/java/com/superwall/sdk/utils.kt @@ -1,3 +1,5 @@ +@file:Suppress("ktlint:standard:function-naming") + package com.superwall.sdk fun assertTrue(value: Boolean) { @@ -11,3 +13,55 @@ fun assertFalse(value: Boolean) { throw AssertionError("Expected false, got true") } } + +@DslMarker annotation class TestingDSL + +class GivenWhenThenScope( + val text: MutableList, +) + +@TestingDSL +inline fun Given( + what: String, + block: GivenWhenThenScope.() -> Unit, +) { + val scope = GivenWhenThenScope(mutableListOf("Given $what")) + try { + block(scope) + } catch (e: Throwable) { + e.printStackTrace() + println(scope.text.joinToString("\n")) + throw e + } +} + +@TestingDSL +inline fun GivenWhenThenScope.When( + what: String, + block: GivenWhenThenScope.() -> T, +): T { + text.add("\tWhen $what") + try { + return block(this) + } catch (e: Throwable) { + throw e + } +} + +@TestingDSL +inline fun GivenWhenThenScope.Then( + what: String, + block: GivenWhenThenScope.() -> Unit, +) { + text.add("\t\tThen $what") + block() +} + +@TestingDSL +inline fun GivenWhenThenScope.And( + what: String, + block: GivenWhenThenScope.() -> Unit, +) { + text.add("\t\t\tAnd $what") + block() +}