diff --git a/CHANGELOG.md b/CHANGELOG.md index 1863ae72..8a6a7089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 1.2.2 + +### Enhancements + +- Adds support for multiple paywall URLs, in case one CDN provider fails. +- `ActivityEncapsulatable` now uses a WeakReference instead of a reference +- SW-2900: Adds Superwall.instance.localeIdentifier as a convenience variable that you can use to dynamically update the locale used for evaluating rules and getting localized paywalls. +- SW-2919: Adds a `custom_placement` event that you can attach to any element in the paywall with a dictionary of parameters. When the element is tapped, the event will be tracked. The name of the placement can be used to trigger a paywall and its params used in audience filters. +- Adds support for bottom sheet presentation style (DRAWER), no animation style and default animation. +- Adds `build_id` and `cache_key` to `PaywallInfo`. +- SW-2917: Tracks a `config_attributes` event after calling `Superwall.configure`, which contains info about the configuration of the SDK. This gets tracked whenever you set the delegate. +- Adds in device attributes tracking after setting the interface style override. +- To comply with new Google Play Billing requirements we now avoid setting empty `offerToken` for one-time purchases ## 1.2.1 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 6d14f718..a25356aa 100644 --- a/app/src/main/java/com/superwall/superapp/test/UITestMocks.kt +++ b/app/src/main/java/com/superwall/superapp/test/UITestMocks.kt @@ -25,7 +25,7 @@ class MockPaywallViewDelegate : PaywallViewCallback { ) { paywallViewFinished?.invoke(paywall, result, shouldDismiss) if (shouldDismiss) { - paywall.encapsulatingActivity?.finish() + paywall.encapsulatingActivity?.get()?.finish() } } diff --git a/superwall/build.gradle.kts b/superwall/build.gradle.kts index 26938f15..3cd29138 100644 --- a/superwall/build.gradle.kts +++ b/superwall/build.gradle.kts @@ -20,7 +20,7 @@ plugins { id("signing") } -version = "1.2.1" +version = "1.2.2" android { compileSdk = 34 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 new file mode 100644 index 00000000..08755c0e --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt @@ -0,0 +1,57 @@ +package com.superwall.sdk.analytics.internal + +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import com.superwall.sdk.Superwall +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.identity.IdentityInfo +import com.superwall.sdk.network.Network +import com.superwall.sdk.network.device.DeviceHelper +import com.superwall.sdk.storage.LastPaywallView +import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.TotalPaywallViews +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class TrackingLogicTest { + val store = + mockk { + every { apiKey } returns "pk_test_1234" + every { didTrackFirstSession } returns true + every { didTrackFirstSeen } returns true + every { get(LastPaywallView) } returns null + every { get(TotalPaywallViews) } returns 0 + } + val network = mockk() + + @Test + fun should_clean_up_attributes() = + runTest { + val ctx = InstrumentationRegistry.getInstrumentation().context + Superwall.configure(ctx, "pk_test_1234", null, null, null, null) + val deviceHelper = + spyk( + DeviceHelper( + ctx, + storage = store, + network = network, + factory = + object : DeviceHelper.Factory { + override suspend fun makeIdentityInfo(): IdentityInfo = IdentityInfo("aliasId", "appUserId") + + override fun makeLocaleIdentifier(): String? = "en-US" + }, + ), + ) { + every { appVersion } returns "123" + } + val attributes = deviceHelper.getTemplateDevice() + val event = InternalSuperwallEvent.DeviceAttributes(HashMap(attributes)) + val res = TrackingLogic.processParameters(event, "appSessionId") + Log.e("res", res.toString()) + assert(res.eventParams.isEmpty()) + } +} 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 new file mode 100644 index 00000000..43320084 --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/WebviewFallbackClientTest.kt @@ -0,0 +1,341 @@ +package com.superwall.sdk.paywall.presentation.rule_logic.vc.webview + +import And +import Given +import Then +import When +import android.util.Log +import android.webkit.WebView +import androidx.test.platform.app.InstrumentationRegistry +import com.superwall.sdk.analytics.SessionEventsManager +import com.superwall.sdk.dependencies.VariablesFactory +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 io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class WebviewFallbackClientTest { + private suspend fun WebView.clientEvents(mainScope: CoroutineScope) = + mainScope + .async { + (webViewClient as DefaultWebviewClient) + }.await() + .webviewClientEvents + + private suspend fun WebView.waitForEvent( + mainScope: CoroutineScope, + check: (WebviewClientEvent) -> Boolean, + ) = clientEvents(mainScope).first(check) + + private val mainScope = CoroutineScope(Dispatchers.Main) + + private fun createPaywallConfig( + vararg configs: PaywallWebviewUrl, + maxAttemps: Int = configs.size, + ) = Paywall.stub().let { + it.copy( + urlConfig = + it.urlConfig!!.copy( + maxAttempts = maxAttemps, + endpoints = configs.toList(), + ), + ) + } + + @Test + fun test_successful_loading() = + runTest(timeout = 60.seconds) { + val handler = + PaywallMessageHandler( + mockk(), + mockk(), + this, + ) + val paywall = + createPaywallConfig( + PaywallWebviewUrl( + url = "https://www.google.com", + score = 10, + timeout = 1000, + ), + ) + val context = InstrumentationRegistry.getInstrumentation().context + val webview = + mainScope + .async { + SWWebView(context, handler) + }.await() + Given("We have list of paywall URLS") { + When("we try to load one") { + mainScope.launch { + webview.loadPaywallWithFallbackUrl(paywall) + } + Then("the loading is successful") { + webview + .waitForEvent(mainScope) { + it is OnPageFinished + }.let { + assert(it is OnPageFinished) + } + } + } + } + } + + @Test + fun test_fail_loading_when_timeout_0() = + runTest(timeout = 60.seconds) { + val handler = + PaywallMessageHandler( + mockk(), + mockk(), + this, + ) + val paywall = + createPaywallConfig( + PaywallWebviewUrl( + url = "https://www.google.com", + score = 10, + timeout = 0, + ), + ) + val context = InstrumentationRegistry.getInstrumentation().context + val webview = + mainScope + .async { + SWWebView(context, handler) + }.await() + Given("We have list of paywall URLS") { + When("we try to load one with timeout 0") { + mainScope.launch { + webview.loadPaywallWithFallbackUrl(paywall) + } + Then("the loading fails with an `AllUrlsFailed` error") { + val event = + webview.waitForEvent(mainScope) { + it is WebviewClientEvent.OnError && it.webviewError is WebviewError.AllUrlsFailed + } as WebviewClientEvent.OnError + val error = event.webviewError as WebviewError.AllUrlsFailed + assert(error.urls.containsAll(paywall.urlConfig!!.endpoints.map { it.url })) + } + } + } + } + + @Test + fun test_failed_loading_falls_back_to_next() = + runTest(timeout = 60.seconds) { + val handler = + PaywallMessageHandler( + mockk(), + mockk(), + this, + ) + val paywall = + createPaywallConfig( + PaywallWebviewUrl( + url = "https://www.this-url-doesnt-exist-so-test-fails.com", + timeout = 100, + score = 100, + ), + PaywallWebviewUrl( + url = "https://www.wikipedia.org", + timeout = 500, + score = 10, + ), + ) + val context = InstrumentationRegistry.getInstrumentation().context + val webview = + mainScope + .async { + SWWebView(context, handler) + }.await() + + Given("We have list of paywall URLS where the first one fails") { + When("we try to load one") { + mainScope.launch { + webview.loadPaywallWithFallbackUrl(paywall) + } + Then("the loading fails and the next one is loaded") { + val events = + webview + .clientEvents(mainScope) + .take(4) + .toList() + assert(events[0] is WebviewClientEvent.OnError) + assert(events.count { it is OnPageFinished && it.url.contains("wiki") } == 1) + } + } + } + } + + @Test + fun test_failed_loading_until_last_with_score_0() = + runTest { + val handler = + PaywallMessageHandler( + mockk(), + mockk(), + this, + ) + val paywall = + createPaywallConfig( + PaywallWebviewUrl( + url = "https://www.this-url-doesnt-exist-so-test-fails.com", + timeout = 1, + score = 100, + ), + PaywallWebviewUrl( + url = "https://www.this-url-doesnt-exist-so-test-fails-too.com", + timeout = 500, + score = 10, + ), + PaywallWebviewUrl( + url = "https://www.wikipedia.org", + timeout = 500, + score = 0, + ), + ) + val context = InstrumentationRegistry.getInstrumentation().context + val webview = + mainScope + .async { + SWWebView(context, handler) + }.await() + Given("We have list of paywall URLS where the first two fail") { + When("we try to load the paywall") { + mainScope.launch { + webview.loadPaywallWithFallbackUrl(paywall) + } + Then("the loading fails until the last one") { + val events = + webview + .clientEvents(mainScope) + .take(7) + .toList() + And("the last one is the one with score 0") { + val last = events.filterIsInstance().last() + assert(last.url.contains("wiki")) + } + } + } + } + } + + @Test + fun test_failed_loading_all() = + runTest { + val handler = + PaywallMessageHandler( + mockk(), + mockk(), + this, + ) + val paywall = + createPaywallConfig( + PaywallWebviewUrl( + url = "https://www.this-url-doesnt-exist-so-test-fails.com", + timeout = 1, + score = 100, + ), + PaywallWebviewUrl( + url = "https://www.this-url-doesnt-exist-so-test-fails-too.com", + timeout = 500, + score = 10, + ), + PaywallWebviewUrl( + url = "https://www.this-url-doesnt-exist-so-test-fails-third.com", + timeout = 500, + score = 0, + ), + ) + val context = InstrumentationRegistry.getInstrumentation().context + val webview = + mainScope + .async { + SWWebView(context, handler) + }.await() + Given("We have list of paywall URLS where the first two fail") { + When("we try to load the paywall") { + mainScope.launch { + webview.loadPaywallWithFallbackUrl(paywall) + } + Then("the loading fails with AllUrlsFailed") { + val event = + webview + .clientEvents(mainScope) + .first { + it is WebviewClientEvent.OnError && it.webviewError is WebviewError.AllUrlsFailed + } as WebviewClientEvent.OnError + val error = event.webviewError as WebviewError.AllUrlsFailed + assert(error.urls.containsAll(paywall.urlConfig!!.endpoints.map { it.url })) + } + } + } + } + + private fun failingUrl( + index: Int = 0, + score: Int = 10, + ) = PaywallWebviewUrl( + url = "https://www.this-url-doesnt-exist-$index.com/", + score = score, + timeout = 0, + ) + + @Test + fun test_fail_loading_when_max_attempts_breached() = + runTest(timeout = 60.seconds) { + val handler = + PaywallMessageHandler( + mockk(), + mockk(), + this, + ) + val paywall = + createPaywallConfig( + *( + (0 until 3).map { failingUrl(it) } + + (0 until 3).map { failingUrl(it + 3, 0) } + ).toTypedArray(), + maxAttemps = 3, + ) + val context = InstrumentationRegistry.getInstrumentation().context + val webview = + mainScope + .async { + SWWebView(context, handler) + }.await() + Given("We have list of paywall URLS") { + When("we try to load them") { + mainScope.launch { + webview.loadPaywallWithFallbackUrl(paywall) + } + Then("we reach max attempts") { + val event = + webview.waitForEvent(mainScope) { + it is WebviewClientEvent.OnError && it.webviewError is WebviewError.MaxAttemptsReached + } as WebviewClientEvent.OnError + val error = event.webviewError as WebviewError.MaxAttemptsReached + Log.e("WebviewFallbackClientTest", "errorUrls: ${error.urls}") + assert(error.urls.size == paywall.urlConfig?.maxAttempts) + } + } + } + } +} 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 new file mode 100644 index 00000000..f438bd0b --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/messaging/TestPaywallMessageHandlerDelegate.kt @@ -0,0 +1,44 @@ +package com.superwall.sdk.paywall.presentation.rule_logic.vc.webview.messaging + +import android.webkit.WebView +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 + +class TestPaywallMessageHandlerDelegate( + override val request: PresentationRequest? = null, + override var paywall: Paywall = Paywall.stub(), + override val info: PaywallInfo = Paywall.stub().getInfo(EventData.stub()), + override val webView: WebView, + override var loadingState: PaywallLoadingState = PaywallLoadingState.Unknown(), + override val isActive: Boolean = true, + val onEvent: (PaywallWebEvent) -> Unit = {}, +) : PaywallMessageHandlerDelegate { + override fun eventDidOccur(paywallWebEvent: PaywallWebEvent) { + onEvent(paywallWebEvent) + } + + override fun openDeepLink(url: String) { + 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") + } + + override fun presentBrowserExternal(url: String) { + TODO("Not yet implemented") + } +} diff --git a/superwall/src/androidTest/java/com/superwall/sdk/utilities/ErrorTrackingTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/utilities/ErrorTrackingTest.kt new file mode 100644 index 00000000..61e24d30 --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/utilities/ErrorTrackingTest.kt @@ -0,0 +1,59 @@ +package com.superwall.sdk.utilities + +import com.superwall.sdk.storage.ErrorLog +import com.superwall.sdk.storage.Storage +import io.mockk.Runs +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ErrorTrackingTest { + @Test + fun should_save_error_when_occured() = + runTest { + val storage = + mockk { + every { save(any(), ErrorLog) } just Runs + every { get(ErrorLog) } returns null + } + val errorTracker: ErrorTracking = + ErrorTracker(this, storage, { + assert(false) + }) + + errorTracker.trackError(Exception("Test Error")) + coVerify { storage.save(any(), ErrorLog) } + } + + @Test + fun should_track_error_when_invoked() = + runTest { + val error = ErrorTracking.ErrorOccurence("Test Error", "Test Stacktrace", System.currentTimeMillis()) + val storage = + mockk { + every { save(any(), ErrorLog) } just Runs + every { get(ErrorLog) } returns error + every { remove(ErrorLog) } just Runs + } + + var tracked = MutableStateFlow(false) + launch { + // Will timeout if not tracked + tracked.filter { it }.first() + } + val errorTracker: ErrorTracking = + ErrorTracker(this, storage, { + tracked.update { true } + }) + + coVerify { storage.get(ErrorLog) } + } +} diff --git a/superwall/src/androidTest/java/utils.kt b/superwall/src/androidTest/java/utils.kt new file mode 100644 index 00000000..c72c4b12 --- /dev/null +++ b/superwall/src/androidTest/java/utils.kt @@ -0,0 +1,53 @@ +@file:Suppress("ktlint:standard:function-naming") + +@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() +} diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 8ea34da2..78c08a55 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -40,6 +40,8 @@ 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.utilities.withErrorTracking +import com.superwall.sdk.utilities.withErrorTrackingAsync import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -78,6 +80,15 @@ class Superwall( internal val presentationItems: PresentationItems = PresentationItems() + var localeIdentifier: String? + get() = dependencyContainer.configManager.options.localeIdentifier + set(value) { + dependencyContainer.configManager.options.localeIdentifier = value + ioScope.launch { + track(dependencyContainer.makeConfigAttributes()) + } + } + /** * The presented paywall view. */ @@ -98,6 +109,9 @@ class Superwall( get() = options.logging.level set(newValue) { options.logging.level = newValue + ioScope.launch { + track(dependencyContainer.makeConfigAttributes()) + } } /** @@ -113,6 +127,9 @@ class Superwall( get() = dependencyContainer.delegateAdapter.kotlinDelegate set(newValue) { dependencyContainer.delegateAdapter.kotlinDelegate = newValue + ioScope.launch { + track(dependencyContainer.makeConfigAttributes()) + } } /** @@ -120,7 +137,12 @@ class Superwall( */ @JvmName("setDelegate") fun setJavaDelegate(newValue: SuperwallDelegateJava?) { - dependencyContainer.delegateAdapter.javaDelegate = newValue + withErrorTracking { + dependencyContainer.delegateAdapter.javaDelegate = newValue + ioScope.launch { + track(dependencyContainer.makeConfigAttributes()) + } + } } /** @@ -258,7 +280,8 @@ class Superwall( return } val purchaseController = - purchaseController ?: ExternalNativePurchaseController(context = applicationContext) + purchaseController + ?: ExternalNativePurchaseController(context = applicationContext) instance = Superwall( context = applicationContext, @@ -317,33 +340,38 @@ class Superwall( internal val serialTaskManager = SerialTaskManager() internal fun setup() { - synchronized(this) { - this._dependencyContainer = - DependencyContainer( - context = context, - purchaseController = purchaseController, - options = _options, - activityProvider = activityProvider, - ) - } + withErrorTracking { + synchronized(this) { + _dependencyContainer = + DependencyContainer( + context = context, + purchaseController = purchaseController, + options = _options, + activityProvider = activityProvider, + ) + } - val cachedSubsStatus = - dependencyContainer.storage.get(ActiveSubscriptionStatus) ?: SubscriptionStatus.UNKNOWN - setSubscriptionStatus(cachedSubsStatus) + val cachedSubsStatus = + dependencyContainer.storage.get(ActiveSubscriptionStatus) + ?: SubscriptionStatus.UNKNOWN + setSubscriptionStatus(cachedSubsStatus) - addListeners() + addListeners() + } ioScope.launch { - dependencyContainer.storage.configure(apiKey = apiKey) - dependencyContainer.storage.recordAppInstall { - track(event = it) - } - // Implicitly wait - dependencyContainer.configManager.fetchConfiguration() - dependencyContainer.identityManager.configure() + withErrorTrackingAsync { + dependencyContainer.storage.configure(apiKey = apiKey) + dependencyContainer.storage.recordAppInstall { + track(event = it) + } + // Implicitly wait + dependencyContainer.configManager.fetchConfiguration() + dependencyContainer.identityManager.configure() - CoroutineScope(Dispatchers.Main).launch { - completion?.invoke() + CoroutineScope(Dispatchers.Main).launch { + completion?.invoke() + } } } } @@ -351,15 +379,17 @@ class Superwall( // / Listens to config and the subscription status private fun addListeners() { ioScope.launch { - subscriptionStatus // Removes duplicates by default - .drop(1) // Drops the first item - .collect { newValue -> - // Save and handle the new value - dependencyContainer.storage.save(newValue, ActiveSubscriptionStatus) - dependencyContainer.delegateAdapter.subscriptionStatusDidChange(newValue) - val event = InternalSuperwallEvent.SubscriptionStatusDidChange(newValue) - track(event) - } + withErrorTrackingAsync { + subscriptionStatus // Removes duplicates by default + .drop(1) // Drops the first item + .collect { newValue -> + // Save and handle the new value + dependencyContainer.storage.save(newValue, ActiveSubscriptionStatus) + dependencyContainer.delegateAdapter.subscriptionStatusDidChange(newValue) + val event = InternalSuperwallEvent.SubscriptionStatusDidChange(newValue) + track(event) + } + } } } @@ -373,17 +403,28 @@ class Superwall( */ fun togglePaywallSpinner(isHidden: Boolean) { ioScope.launch { - val paywallView = - dependencyContainer.paywallManager.currentView ?: return@launch - paywallView.togglePaywallSpinner(isHidden) + withErrorTracking { + val paywallView = + dependencyContainer.paywallManager.currentView ?: return@withErrorTracking + paywallView.togglePaywallSpinner(isHidden) + } } } /** * Do not use this function, this is for internal use only. */ - fun setPlatformWrapper(wrapper: String) { - dependencyContainer.deviceHelper.platformWrapper = wrapper + fun setPlatformWrapper( + wrapper: String, + version: String, + ) { + withErrorTracking { + dependencyContainer.deviceHelper.platformWrapper = wrapper + dependencyContainer.deviceHelper.platformWrapperVersion = version + ioScope.launch { + track(InternalSuperwallEvent.DeviceAttributes(dependencyContainer.makeSessionDeviceAttributes())) + } + } } /** @@ -391,16 +432,23 @@ class Superwall( * back to using the system setting. */ fun setInterfaceStyle(interfaceStyle: InterfaceStyle?) { - dependencyContainer.deviceHelper.interfaceStyleOverride = interfaceStyle + withErrorTracking { + dependencyContainer.deviceHelper.interfaceStyleOverride = interfaceStyle + ioScope.launch { + track(InternalSuperwallEvent.DeviceAttributes(dependencyContainer.makeSessionDeviceAttributes())) + } + } } /** * Removes all of Superwall's pending local notifications. */ fun cancelAllScheduledNotifications() { - WorkManager - .getInstance(context) - .cancelAllWorkByTag(SuperwallPaywallActivity.NOTIFICATION_CHANNEL_ID) + withErrorTracking { + WorkManager + .getInstance(context) + .cancelAllWorkByTag(SuperwallPaywallActivity.NOTIFICATION_CHANNEL_ID) + } } // MARK: - Reset @@ -409,20 +457,24 @@ class Superwall( * Resets the [userId], on-device paywall assignments, and data stored by Superwall. */ fun reset() { - reset(duringIdentify = false) + withErrorTracking { + reset(duringIdentify = false) + } } /** * Asynchronously resets. Presentation of paywalls is suspended until reset completes. */ internal fun reset(duringIdentify: Boolean) { - dependencyContainer.identityManager.reset(duringIdentify) - dependencyContainer.storage.reset() - dependencyContainer.paywallManager.resetCache() - presentationItems.reset() - dependencyContainer.configManager.reset() - ioScope.launch { - track(InternalSuperwallEvent.Reset) + withErrorTracking { + dependencyContainer.identityManager.reset(duringIdentify) + dependencyContainer.storage.reset() + dependencyContainer.paywallManager.resetCache() + presentationItems.reset() + dependencyContainer.configManager.reset() + ioScope.launch { + track(InternalSuperwallEvent.Reset) + } } } @@ -439,12 +491,13 @@ class Superwall( * @param uri The URL of the deep link. * @return A `Boolean` that is `true` if the deep link was handled. */ - fun handleDeepLink(uri: Uri): Boolean { - ioScope.launch { - track(InternalSuperwallEvent.DeepLink(uri = uri)) + fun handleDeepLink(uri: Uri): Boolean = + withErrorTracking { + ioScope.launch { + track(InternalSuperwallEvent.DeepLink(uri = uri)) + } + dependencyContainer.debugManager.handle(deepLinkUrl = uri) } - return dependencyContainer.debugManager.handle(deepLinkUrl = uri) - } //endregion @@ -461,7 +514,9 @@ class Superwall( */ fun preloadAllPaywalls() { ioScope.launch { - dependencyContainer.configManager.preloadAllPaywalls() + withErrorTrackingAsync { + dependencyContainer.configManager.preloadAllPaywalls() + } } } @@ -477,9 +532,11 @@ class Superwall( */ fun preloadPaywalls(eventNames: Set) { ioScope.launch { - dependencyContainer.configManager.preloadPaywallsByNames( - eventNames = eventNames, - ) + withErrorTrackingAsync { + dependencyContainer.configManager.preloadPaywallsByNames( + eventNames = eventNames, + ) + } } } //endregion @@ -489,59 +546,78 @@ class Superwall( paywallView: PaywallView, ) { withContext(Dispatchers.Main) { - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.paywallView, - message = "Event Did Occur", - info = mapOf("event" to paywallEvent), - ) - - when (paywallEvent) { - is Closed -> { - dismiss( - paywallView, - result = PaywallResult.Declined(), - closeReason = PaywallCloseReason.ManualClose, - ) - } + withErrorTrackingAsync { + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.paywallView, + message = "Event Did Occur", + info = mapOf("event" to paywallEvent), + ) - is InitiatePurchase -> { - if (purchaseTask != null) { - // If a purchase is already in progress, do not start another - return@withContext + when (paywallEvent) { + is Closed -> { + dismiss( + paywallView, + result = PaywallResult.Declined(), + closeReason = PaywallCloseReason.ManualClose, + ) } - purchaseTask = - launch { - try { - dependencyContainer.transactionManager.purchase( - paywallEvent.productId, - paywallView, - ) - } finally { - // Ensure the task is cleared once the purchase is complete or if an error occurs - purchaseTask = null - } + + is InitiatePurchase -> { + if (purchaseTask != null) { + // If a purchase is already in progress, do not start another + return@withErrorTrackingAsync } - } + purchaseTask = + launch { + try { + dependencyContainer.transactionManager.purchase( + paywallEvent.productId, + paywallView, + ) + } finally { + // Ensure the task is cleared once the purchase is complete or if an error occurs + purchaseTask = null + } + } + } - is InitiateRestore -> { - dependencyContainer.transactionManager.tryToRestore(paywallView) - } + is InitiateRestore -> { + dependencyContainer.transactionManager.tryToRestore(paywallView) + } - is OpenedURL -> { - dependencyContainer.delegateAdapter.paywallWillOpenURL(url = paywallEvent.url) - } + is OpenedURL -> { + dependencyContainer.delegateAdapter.paywallWillOpenURL(url = paywallEvent.url) + } - is OpenedUrlInChrome -> { - dependencyContainer.delegateAdapter.paywallWillOpenURL(url = paywallEvent.url) - } + is OpenedUrlInChrome -> { + dependencyContainer.delegateAdapter.paywallWillOpenURL(url = paywallEvent.url) + } - is OpenedDeepLink -> { - dependencyContainer.delegateAdapter.paywallWillOpenDeepLink(url = paywallEvent.url) - } + is OpenedDeepLink -> { + dependencyContainer.delegateAdapter.paywallWillOpenDeepLink(url = paywallEvent.url) + } - is Custom -> { - dependencyContainer.delegateAdapter.handleCustomPaywallAction(name = paywallEvent.string) + is Custom -> { + dependencyContainer.delegateAdapter.handleCustomPaywallAction(name = paywallEvent.string) + } + + is PaywallWebEvent.CustomPlacement -> { + track( + InternalSuperwallEvent.CustomPlacement( + placementName = paywallEvent.name, + params = + paywallEvent.params.let { + val map = mutableMapOf() + for (key in it.keys()) { + map[key] = it.get(key) + } + map + }, + paywallInfo = paywallView.info, + ), + ) + } } } } 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 8038e279..cf1d3345 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 @@ -17,88 +17,91 @@ import com.superwall.sdk.paywall.presentation.internal.operators.waitForSubsStat import com.superwall.sdk.paywall.presentation.internal.request.PresentationInfo import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import com.superwall.sdk.storage.DisableVerboseEvents +import com.superwall.sdk.utilities.withErrorTrackingAsync import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.* +import java.util.Date suspend fun Superwall.track(event: Trackable): TrackingResult { - // Wait for the SDK to be fully initialized - Superwall.hasInitialized.first() - - // Get parameters to be sent to the delegate and stored in an event. - // now with Date - val eventCreatedAt = Date() - val parameters = - TrackingLogic.processParameters( - trackableEvent = event, - appSessionId = dependencyContainer.appSessionManager.appSession.id, - ) - - // For a trackable superwall event, send params to delegate - if (event is TrackableSuperwallEvent) { - val info = - SuperwallEventInfo( - event = event.superwallEvent, - params = parameters.delegateParams, + return withErrorTrackingAsync { + // Wait for the SDK to be fully initialized + Superwall.hasInitialized.first() + + // Get parameters to be sent to the delegate and stored in an event. + // now with Date + val eventCreatedAt = Date() + val parameters = + TrackingLogic.processParameters( + trackableEvent = event, + appSessionId = dependencyContainer.appSessionManager.appSession.id, ) - dependencyContainer.delegateAdapter.handleSuperwallEvent(eventInfo = info) - - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.events, - message = "Logged Event", - info = parameters.eventParams, - ) - } - - val eventData = - EventData( - name = event.rawName, - parameters = parameters.eventParams, - createdAt = eventCreatedAt, - ) + // For a trackable superwall event, send params to delegate + if (event is TrackableSuperwallEvent) { + val info = + SuperwallEventInfo( + event = event.superwallEvent, + params = parameters.delegateParams, + ) + + dependencyContainer.delegateAdapter.handleSuperwallEvent(eventInfo = info) + + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.events, + message = "Logged Event", + info = parameters.audienceFilterParams, + ) + } - // If config doesn't exist yet, we rely on previously saved feature flag - // to determine whether to disable verbose events. - val existingDisableVerboseEvents = - dependencyContainer.configManager.config - ?.featureFlags - ?.disableVerboseEvents - val previousDisableVerboseEvents = dependencyContainer.storage.get(DisableVerboseEvents) + val eventData = + EventData( + name = event.rawName, + parameters = parameters.audienceFilterParams, + createdAt = eventCreatedAt, + ) - val verboseEvents = existingDisableVerboseEvents ?: previousDisableVerboseEvents + // If config doesn't exist yet, we rely on previously saved feature flag + // to determine whether to disable verbose events. + val existingDisableVerboseEvents = + dependencyContainer.configManager.config + ?.featureFlags + ?.disableVerboseEvents + val previousDisableVerboseEvents = dependencyContainer.storage.get(DisableVerboseEvents) - if (TrackingLogic.isNotDisabledVerboseEvent( - event = event, - disableVerboseEvents = verboseEvents, - isSandbox = dependencyContainer.makeIsSandbox(), - ) - ) { - dependencyContainer.eventsQueue.enqueue( - data = eventData, - event = event, - ) - } - dependencyContainer.storage.coreDataManager.saveEventData(eventData) + val verboseEvents = existingDisableVerboseEvents ?: previousDisableVerboseEvents - if (event.canImplicitlyTriggerPaywall) { - CoroutineScope(Dispatchers.IO).launch { - Superwall.instance.handleImplicitTrigger( + if (TrackingLogic.isNotDisabledVerboseEvent( + event = event, + disableVerboseEvents = verboseEvents, + isSandbox = dependencyContainer.makeIsSandbox(), + ) + ) { + dependencyContainer.eventsQueue.enqueue( + data = eventData, event = event, - eventData = eventData, ) } - } + dependencyContainer.storage.coreDataManager.saveEventData(eventData) + + if (event.canImplicitlyTriggerPaywall) { + CoroutineScope(Dispatchers.IO).launch { + Superwall.instance.handleImplicitTrigger( + event = event, + eventData = eventData, + ) + } + } - return TrackingResult( - data = eventData, - parameters = parameters, - ) + return@withErrorTrackingAsync TrackingResult( + data = eventData, + parameters = parameters, + ) + } } suspend fun Superwall.handleImplicitTrigger( @@ -114,50 +117,54 @@ private suspend fun Superwall.internallyHandleImplicitTrigger( event: Trackable, eventData: EventData, ) = withContext(Dispatchers.Main) { - val presentationInfo = PresentationInfo.ImplicitTrigger(eventData) + return@withContext withErrorTrackingAsync { + val presentationInfo = PresentationInfo.ImplicitTrigger(eventData) + + var request = + dependencyContainer.makePresentationRequest( + presentationInfo = presentationInfo, + isPaywallPresented = isPaywallPresented, + type = PresentationRequestType.Presentation, + ) - var request = - dependencyContainer.makePresentationRequest( - presentationInfo = presentationInfo, - isPaywallPresented = isPaywallPresented, - type = PresentationRequestType.Presentation, - ) + try { + waitForSubsStatusAndConfig(request, null) + } catch (e: Throwable) { + logErrors(request, e) + return@withErrorTrackingAsync + } - try { - waitForSubsStatusAndConfig(request, null) - } catch (e: Throwable) { - logErrors(request, e) - return@withContext - } + val outcome = + TrackingLogic.canTriggerPaywall( + event, + dependencyContainer.configManager.triggersByEventName.keys + .toSet(), + paywallView, + ) - val outcome = - TrackingLogic.canTriggerPaywall( - event, - dependencyContainer.configManager.triggersByEventName.keys - .toSet(), - paywallView, - ) + var statePublisher = MutableSharedFlow() - var statePublisher = MutableSharedFlow() + when (outcome) { + TrackingLogic.ImplicitTriggerOutcome.DeepLinkTrigger -> { + dismiss() + } - when (outcome) { - TrackingLogic.ImplicitTriggerOutcome.DeepLinkTrigger -> { - dismiss() - } - TrackingLogic.ImplicitTriggerOutcome.ClosePaywallThenTriggerPaywall -> { - val lastPresentationItems = presentationItems.last ?: return@withContext - dismissForNextPaywall() - statePublisher = lastPresentationItems.statePublisher - } - TrackingLogic.ImplicitTriggerOutcome.TriggerPaywall -> {} - TrackingLogic.ImplicitTriggerOutcome.DontTriggerPaywall -> { - return@withContext - } + TrackingLogic.ImplicitTriggerOutcome.ClosePaywallThenTriggerPaywall -> { + val lastPresentationItems = presentationItems.last ?: return@withErrorTrackingAsync + dismissForNextPaywall() + statePublisher = lastPresentationItems.statePublisher + } - else -> {} - } + TrackingLogic.ImplicitTriggerOutcome.TriggerPaywall -> {} + TrackingLogic.ImplicitTriggerOutcome.DontTriggerPaywall -> { + return@withErrorTrackingAsync + } + + else -> {} + } - request.flags.isPaywallPresented = isPaywallPresented + request.flags.isPaywallPresented = isPaywallPresented - internallyPresent(request, statePublisher) + internallyPresent(request, statePublisher) + } } 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 07cf72a5..2cfcf0a7 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,6 +8,12 @@ import com.superwall.sdk.paywall.vc.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 java.net.URL +import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.* sealed class TrackingLogic { @@ -32,7 +38,7 @@ sealed class TrackingLogic { val superwallParameters = trackableEvent.getSuperwallParameters().toMutableMap() superwallParameters["app_session_id"] = appSessionId - val customParameters = trackableEvent.customParameters + val dirtyAudienceFilterParams = trackableEvent.audienceFilterParams val eventName = trackableEvent.rawName val delegateParams: MutableMap = mutableMapOf("is_superwall" to true) @@ -40,7 +46,7 @@ sealed class TrackingLogic { // Add a special property if it's a superwall event val isStandardEvent = trackableEvent is TrackableSuperwallEvent - val eventParams: MutableMap = + val audienceFilterParams: MutableMap = mutableMapOf( "\$is_standard_event" to isStandardEvent, "\$event_name" to eventName, @@ -51,7 +57,7 @@ sealed class TrackingLogic { superwallParameters.forEach { (key, value) -> clean(value)?.let { val keyWithDollar = "$$key" - eventParams[keyWithDollar] = it + audienceFilterParams[keyWithDollar] = it // no $ for delegate methods delegateParams[key] = it @@ -59,18 +65,18 @@ sealed class TrackingLogic { } // Filter then assign custom parameters - customParameters.forEach { (key, value) -> + dirtyAudienceFilterParams.forEach { (key, value) -> clean(value)?.let { if (key.startsWith("$")) { // Log dropping key due to $ signs not allowed } else { delegateParams[key] = it - eventParams[key] = it + audienceFilterParams[key] = it } } } - return@withContext TrackingParameters(delegateParams, eventParams) + return@withContext TrackingParameters(delegateParams, audienceFilterParams) } fun isNotDisabledVerboseEvent( @@ -92,6 +98,7 @@ sealed class TrackingLogic { is InternalSuperwallEvent.PaywallLoad.State.Start, is InternalSuperwallEvent.PaywallLoad.State.Complete, -> !disableVerboseEvents + else -> true } } @@ -101,6 +108,7 @@ sealed class TrackingLogic { is InternalSuperwallEvent.PaywallProductsLoad.State.Start, is InternalSuperwallEvent.PaywallProductsLoad.State.Complete, -> !disableVerboseEvents + else -> true } } @@ -110,6 +118,7 @@ sealed class TrackingLogic { is InternalSuperwallEvent.PaywallWebviewLoad.State.Start, is InternalSuperwallEvent.PaywallWebviewLoad.State.Complete, -> !disableVerboseEvents + else -> true } } @@ -118,30 +127,27 @@ sealed class TrackingLogic { } @OptIn(ExperimentalSerializationApi::class) - private fun clean(input: Any?): Any? { - return input - - // TODO: (Analytics) Fix this -// input?.let { value -> -// when (value) { -// is List<*> -> null -// is Map<*, *> -> null -// else -> { -// try { -// Json.encodeToString(JsonElement.serializer(), value) -// value -// } catch (e: SerializationException) { -// when (value) { -// is LocalDateTime -> value.atZone(ZoneOffset.UTC).toInstant().toEpochMilli() -// is URL -> value.toString() -// else -> null -// } -// } -// } -// } -// } ?: kotlin.run { return null } -// return null - } + private fun clean(input: Any?): Any? = + input?.let { value -> + when (value) { + is List<*> -> null + is Map<*, *> -> value.mapValues { clean(it.value) }.filterValues { it != null } + is String -> value + is Int, is Float, is Double, is Long, is Boolean -> value.toString() + else -> { + try { + Json.encodeToString(value) + value + } catch (e: SerializationException) { + when (value) { + is LocalDateTime -> value.atZone(ZoneOffset.UTC).toInstant().toEpochMilli() + is URL -> value.toString() + else -> null + } + } + } + } + } ?: null @Throws(Exception::class) fun checkNotSuperwallEvent(event: String) { @@ -175,6 +181,7 @@ sealed class TrackingLogic { SuperwallEvents.TransactionAbandon.rawName, SuperwallEvents.TransactionFail.rawName, SuperwallEvents.PaywallDecline.rawName, + SuperwallEvents.CustomPlacement.rawName, ) val referringEventName = paywallView?.info?.presentedByEventWithName @@ -191,7 +198,9 @@ sealed class TrackingLogic { SuperwallEvents.TransactionFail, SuperwallEvents.SurveyResponse, SuperwallEvents.PaywallDecline, + SuperwallEvents.CustomPlacement, -> ImplicitTriggerOutcome.ClosePaywallThenTriggerPaywall + else -> ImplicitTriggerOutcome.TriggerPaywall } } diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingParameters.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingParameters.kt index 166ed9bc..392af23b 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingParameters.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingParameters.kt @@ -2,13 +2,13 @@ package com.superwall.sdk.analytics.internal data class TrackingParameters( val delegateParams: Map, - val eventParams: Map, + val audienceFilterParams: Map, ) { companion object { fun stub(): TrackingParameters = TrackingParameters( delegateParams = emptyMap(), - eventParams = emptyMap(), + audienceFilterParams = emptyMap(), ) } } diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/Trackable.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/Trackable.kt index 0beb6058..155ef350 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/Trackable.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/Trackable.kt @@ -2,7 +2,7 @@ package com.superwall.sdk.analytics.internal.trackable interface Trackable { val rawName: String - val customParameters: Map + val audienceFilterParams: Map val canImplicitlyTriggerPaywall: Boolean suspend fun getSuperwallParameters(): Map 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 7b7ca32f..f7e60d95 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 @@ -5,6 +5,8 @@ import com.superwall.sdk.analytics.superwall.SuperwallEvent import com.superwall.sdk.analytics.superwall.TransactionProduct 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 @@ -16,6 +18,7 @@ import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationReques 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.store.abstractions.product.StoreProduct import com.superwall.sdk.store.abstractions.transactions.StoreTransaction import com.superwall.sdk.store.abstractions.transactions.StoreTransactionType @@ -36,7 +39,7 @@ sealed class InternalSuperwallEvent( get() = this.superwallEvent.canImplicitlyTriggerPaywall class AppOpen( - override var customParameters: HashMap = HashMap(), + override var audienceFilterParams: HashMap = HashMap(), ) : InternalSuperwallEvent(SuperwallEvent.AppOpen()) { override suspend fun getSuperwallParameters(): HashMap = HashMap() } @@ -44,7 +47,7 @@ sealed class InternalSuperwallEvent( class AppInstall( val appInstalledAtString: String, val hasExternalPurchaseController: Boolean, - override var customParameters: HashMap = HashMap(), + override var audienceFilterParams: HashMap = HashMap(), ) : InternalSuperwallEvent(SuperwallEvent.AppInstall()) { override suspend fun getSuperwallParameters(): HashMap = hashMapOf( @@ -56,7 +59,7 @@ sealed class InternalSuperwallEvent( // TODO: Implement the rest class SurveyClose( - override val customParameters: MutableMap = mutableMapOf(), + override val audienceFilterParams: MutableMap = mutableMapOf(), ) : InternalSuperwallEvent(SuperwallEvent.SurveyClose()) { override suspend fun getSuperwallParameters(): Map = emptyMap() } @@ -83,9 +86,9 @@ sealed class InternalSuperwallEvent( paywallInfo, ) - override val customParameters: Map + override val audienceFilterParams: Map get() { - val output = paywallInfo.customParams() + val output = paywallInfo.audienceFilterParams() return output + mapOf( "survey_selected_option_title" to selectedOption.title, @@ -106,15 +109,15 @@ sealed class InternalSuperwallEvent( } class AppLaunch( - override var customParameters: HashMap = HashMap(), + override var audienceFilterParams: HashMap = HashMap(), ) : InternalSuperwallEvent(SuperwallEvent.AppLaunch()) { override suspend fun getSuperwallParameters(): HashMap = HashMap() } class Attributes( val appInstalledAtString: String, - override var customParameters: HashMap = HashMap(), - ) : InternalSuperwallEvent(SuperwallEvent.UserAttributes(customParameters)) { + override var audienceFilterParams: HashMap = HashMap(), + ) : InternalSuperwallEvent(SuperwallEvent.UserAttributes(audienceFilterParams)) { override suspend fun getSuperwallParameters(): HashMap = hashMapOf( "application_installed_at" to appInstalledAtString, @@ -122,14 +125,14 @@ sealed class InternalSuperwallEvent( } class IdentityAlias( - override var customParameters: HashMap = HashMap(), + override var audienceFilterParams: HashMap = HashMap(), ) : InternalSuperwallEvent(SuperwallEvent.IdentityAlias()) { override suspend fun getSuperwallParameters(): HashMap = HashMap() } class DeepLink( val uri: Uri, - override var customParameters: HashMap = extractQueryParameters(uri), + override var audienceFilterParams: HashMap = extractQueryParameters(uri), ) : InternalSuperwallEvent(SuperwallEvent.DeepLink(uri)) { override suspend fun getSuperwallParameters(): HashMap = hashMapOf( @@ -173,13 +176,13 @@ sealed class InternalSuperwallEvent( } class FirstSeen( - override var customParameters: HashMap = HashMap(), + override var audienceFilterParams: HashMap = HashMap(), ) : InternalSuperwallEvent(SuperwallEvent.FirstSeen()) { override suspend fun getSuperwallParameters(): HashMap = HashMap() } class AppClose( - override var customParameters: HashMap = HashMap(), + override var audienceFilterParams: HashMap = HashMap(), ) : InternalSuperwallEvent(SuperwallEvent.AppClose()) { override suspend fun getSuperwallParameters(): HashMap = HashMap() } @@ -225,10 +228,10 @@ sealed class InternalSuperwallEvent( ) } - override val customParameters: Map + override val audienceFilterParams: Map get() = when (state) { - is State.Complete -> state.paywallInfo.customParams() + is State.Complete -> state.paywallInfo.audienceFilterParams() else -> emptyMap() } @@ -254,7 +257,7 @@ sealed class InternalSuperwallEvent( class SubscriptionStatusDidChange( val subscriptionStatus: SubscriptionStatus, - override var customParameters: HashMap = HashMap(), + override var audienceFilterParams: HashMap = HashMap(), ) : InternalSuperwallEvent(SuperwallEvent.SubscriptionStatusDidChange()) { override suspend fun getSuperwallParameters(): HashMap = hashMapOf( @@ -263,14 +266,35 @@ sealed class InternalSuperwallEvent( } class SessionStart( - override var customParameters: HashMap = HashMap(), + override var audienceFilterParams: HashMap = HashMap(), ) : InternalSuperwallEvent(SuperwallEvent.SessionStart()) { override suspend fun getSuperwallParameters(): HashMap = HashMap() } + class ConfigAttributes( + val options: SuperwallOptions, + val hasExternalPurchaseController: Boolean, + val hasDelegate: Boolean, + ) : InternalSuperwallEvent(SuperwallEvent.ConfigAttributes) { + override val audienceFilterParams: Map = emptyMap() + + override suspend fun getSuperwallParameters(): HashMap = + hashMapOf( + *options + .toMap() + .plus( + mapOf( + "using_purchase_controller" to hasExternalPurchaseController, + "has_delegate" to hasDelegate, + ), + ).toList() + .toTypedArray(), + ) + } + class DeviceAttributes( val deviceAttributes: HashMap, - override var customParameters: HashMap = HashMap(), + override var audienceFilterParams: HashMap = HashMap(), ) : InternalSuperwallEvent(SuperwallEvent.DeviceAttributes(attributes = deviceAttributes)) { override suspend fun getSuperwallParameters(): HashMap = deviceAttributes } @@ -278,7 +302,7 @@ sealed class InternalSuperwallEvent( class TriggerFire( val triggerResult: InternalTriggerResult, val triggerName: String, - override var customParameters: HashMap = HashMap(), + override var audienceFilterParams: HashMap = HashMap(), ) : InternalSuperwallEvent( SuperwallEvent.TriggerFire( eventName = triggerName, @@ -337,7 +361,7 @@ sealed class InternalSuperwallEvent( val status: PaywallPresentationRequestStatus, val statusReason: PaywallPresentationRequestStatusReason?, val factory: PresentationRequest.Factory, - override var customParameters: HashMap = HashMap(), + override var audienceFilterParams: HashMap = HashMap(), ) : InternalSuperwallEvent( SuperwallEvent.PaywallPresentationRequest( status = status, @@ -361,9 +385,9 @@ sealed class InternalSuperwallEvent( class PaywallOpen( val paywallInfo: PaywallInfo, ) : InternalSuperwallEvent(SuperwallEvent.PaywallOpen(paywallInfo = paywallInfo)) { - override val customParameters: Map + override val audienceFilterParams: Map get() { - return paywallInfo.customParams() + return paywallInfo.audienceFilterParams() } override suspend fun getSuperwallParameters(): HashMap = HashMap(paywallInfo.eventParams()) @@ -373,9 +397,9 @@ sealed class InternalSuperwallEvent( val paywallInfo: PaywallInfo, val surveyPresentationResult: SurveyPresentationResult, ) : InternalSuperwallEvent(SuperwallEvent.PaywallClose(paywallInfo)) { - override val customParameters: Map + override val audienceFilterParams: Map get() { - return paywallInfo.customParams() + return paywallInfo.audienceFilterParams() } override suspend fun getSuperwallParameters(): HashMap { @@ -399,9 +423,9 @@ sealed class InternalSuperwallEvent( ) : InternalSuperwallEvent(SuperwallEvent.PaywallDecline(paywallInfo = paywallInfo)) { override suspend fun getSuperwallParameters(): HashMap = HashMap(paywallInfo.eventParams()) - override val customParameters: Map + override val audienceFilterParams: Map get() { - return paywallInfo.customParams() + return paywallInfo.audienceFilterParams() } } @@ -436,9 +460,9 @@ sealed class InternalSuperwallEvent( class Timeout : State() } - override val customParameters: Map + override val audienceFilterParams: Map get() { - return paywallInfo.customParams().let { + return paywallInfo.audienceFilterParams().let { if (superwallEvent is SuperwallEvent.TransactionAbandon) { it.plus("abandoned_product_id" to (product?.productIdentifier ?: "")) } else { @@ -542,9 +566,9 @@ sealed class InternalSuperwallEvent( paywallInfo = paywallInfo, ), ) { - override val customParameters: Map + override val audienceFilterParams: Map get() { - return paywallInfo.customParams() + return paywallInfo.audienceFilterParams() } override suspend fun getSuperwallParameters(): HashMap = HashMap(paywallInfo.eventParams(product)) @@ -559,9 +583,9 @@ sealed class InternalSuperwallEvent( paywallInfo = paywallInfo, ), ) { - override val customParameters: Map + override val audienceFilterParams: Map get() { - return paywallInfo.customParams() + return paywallInfo.audienceFilterParams() } override suspend fun getSuperwallParameters(): HashMap = HashMap(paywallInfo.eventParams(product)) @@ -579,9 +603,9 @@ sealed class InternalSuperwallEvent( paywallInfo = paywallInfo, ), ) { - override val customParameters: Map + override val audienceFilterParams: Map get() { - return paywallInfo.customParams() + return paywallInfo.audienceFilterParams() } override suspend fun getSuperwallParameters(): HashMap = HashMap(paywallInfo.eventParams(product)) @@ -595,9 +619,12 @@ sealed class InternalSuperwallEvent( class Start : State() data class Fail( - val errorMessage: String, + val error: WebviewError, + val urls: List, ) : State() + object Fallback : State() + class Timeout : State() class Complete : State() @@ -619,21 +646,41 @@ sealed class InternalSuperwallEvent( is PaywallWebviewLoad.State.Fail -> SuperwallEvent.PaywallWebviewLoadFail( paywallInfo, - state.errorMessage, + state.error, ) is PaywallWebviewLoad.State.Complete -> SuperwallEvent.PaywallWebviewLoadComplete( paywallInfo, ) + + is State.Fallback -> { + SuperwallEvent.PaywallWebviewLoadFallback(paywallInfo) + } } - override val customParameters: Map + override val audienceFilterParams: Map get() { - return paywallInfo.customParams() + return paywallInfo.audienceFilterParams() } - override suspend fun getSuperwallParameters(): HashMap = HashMap(paywallInfo.eventParams()) + override suspend fun getSuperwallParameters(): HashMap { + val extras = + when (state) { + is State.Fail -> + mapOf( + "error_message" to state.error, + *state.urls + .mapIndexed { i, it -> + "url_$i" to it + }.toTypedArray(), + ) + + else -> mapOf() + } + val params = paywallInfo.eventParams() + extras + return HashMap(params) + } } class PaywallProductsLoad( @@ -679,9 +726,9 @@ sealed class InternalSuperwallEvent( ) } - override val customParameters: Map + override val audienceFilterParams: Map get() { - return paywallInfo.customParams() + return paywallInfo.audienceFilterParams() } override suspend fun getSuperwallParameters(): HashMap { @@ -707,13 +754,13 @@ sealed class InternalSuperwallEvent( } object ConfigRefresh : InternalSuperwallEvent(SuperwallEvent.ConfigRefresh) { - override val customParameters: Map = emptyMap() + override val audienceFilterParams: Map = emptyMap() override suspend fun getSuperwallParameters(): Map = emptyMap() } object Reset : InternalSuperwallEvent(SuperwallEvent.Reset) { - override val customParameters: Map = emptyMap() + override val audienceFilterParams: Map = emptyMap() override suspend fun getSuperwallParameters(): Map = emptyMap() } @@ -746,7 +793,45 @@ sealed class InternalSuperwallEvent( } } - override val customParameters: Map - get() = paywallInfo.customParams() + override val audienceFilterParams: Map + get() = paywallInfo.audienceFilterParams() + } + + data class CustomPlacement( + val placementName: String, + val paywallInfo: PaywallInfo, + val params: Map, + ) : InternalSuperwallEvent(SuperwallEvent.CustomPlacement) { + override val audienceFilterParams: Map + get() = paywallInfo.audienceFilterParams() + params + + override val rawName: String + get() = placementName + + override suspend fun getSuperwallParameters(): Map = + paywallInfo.eventParams() + params + mapOf("name" to placementName) + + override val canImplicitlyTriggerPaywall: Boolean = true + } + + internal data class ErrorThrown( + val message: String, + val stacktrace: String, + val occuredAt: Long, + ) : InternalSuperwallEvent(SuperwallEvent.ErrorThrown) { + constructor(error: Throwable) : this( + error.message ?: "", + error.stackTraceToString(), + System.currentTimeMillis(), + ) + + override val audienceFilterParams: Map = emptyMap() + + override suspend fun getSuperwallParameters() = + mapOf( + "error_message" to message, + "error_stack_trace" to stacktrace, + "occured_at" to occuredAt, + ) } } diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/UserIntiatedEvents.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/UserIntiatedEvents.kt index c2bb9e33..21578462 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/UserIntiatedEvents.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/UserIntiatedEvents.kt @@ -5,7 +5,7 @@ interface TrackableUserInitiatedEvent : Trackable sealed class UserInitiatedEvent( override val rawName: String, override val canImplicitlyTriggerPaywall: Boolean, - override var customParameters: Map = emptyMap(), + override var audienceFilterParams: Map = emptyMap(), val isFeatureGatable: Boolean, ) : TrackableUserInitiatedEvent { class Track( 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 e97732e3..af084762 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,6 +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.store.abstractions.product.StoreProduct import com.superwall.sdk.store.abstractions.transactions.StoreTransactionType import com.superwall.sdk.store.transactions.RestoreType @@ -58,6 +59,11 @@ sealed class SuperwallEvent { get() = "session_start" } + object ConfigAttributes : SuperwallEvent() { + override val rawName: String + get() = "config_attributes" + } + // / When device attributes are sent to the backend. data class DeviceAttributes( val attributes: Map, @@ -282,7 +288,7 @@ sealed class SuperwallEvent { // / When a paywall's website fails to load. data class PaywallWebviewLoadFail( val paywallInfo: PaywallInfo, - val errorMessage: String?, + val errorMessage: WebviewError?, ) : SuperwallEvent() { override val rawName: String get() = "paywallWebviewLoad_fail" @@ -304,6 +310,14 @@ sealed class SuperwallEvent { get() = "paywallWebviewLoad_timeout" } + // When the paywall uses a fallback URL + data class PaywallWebviewLoadFallback( + val paywallInfo: PaywallInfo, + ) : SuperwallEvent() { + override val rawName: String + get() = SuperwallEvents.PaywallWebviewLoadFallback.rawName + } + // / When the request to load the paywall's products started. data class PaywallProductsLoadStart( val triggeredEventName: String?, @@ -370,6 +384,15 @@ sealed class SuperwallEvent { get() = "reset" } + object CustomPlacement : SuperwallEvent() { + override val rawName: String = SuperwallEvents.CustomPlacement.rawName + } + + object ErrorThrown : SuperwallEvent() { + override val rawName: String + get() = "error_thrown" + } + open val rawName: String get() = this.toString() 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 35e1a129..28358b85 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 @@ -38,6 +38,7 @@ enum class SuperwallEvents( PaywallWebviewLoadFail("paywallWebviewLoad_fail"), PaywallWebviewLoadComplete("paywallWebviewLoad_complete"), PaywallWebviewLoadTimeout("paywallWebviewLoad_timeout"), + PaywallWebviewLoadFallback("paywallWebviewLoad_fallback"), PaywallProductsLoadStart("paywallProductsLoad_start"), PaywallProductsLoadFail("paywallProductsLoad_fail"), PaywallProductsLoadComplete("paywallProductsLoad_complete"), @@ -45,4 +46,6 @@ enum class SuperwallEvents( RestoreStart("restore_start"), RestoreFail("restore_fail"), RestoreComplete("restore_complete"), + CustomPlacement("custom_placement"), + ConfigAttributes("config_attributes"), } diff --git a/superwall/src/main/java/com/superwall/sdk/composable/PaywallComposable.kt b/superwall/src/main/java/com/superwall/sdk/composable/PaywallComposable.kt index f1284354..00918a04 100644 --- a/superwall/src/main/java/com/superwall/sdk/composable/PaywallComposable.kt +++ b/superwall/src/main/java/com/superwall/sdk/composable/PaywallComposable.kt @@ -24,6 +24,7 @@ import com.superwall.sdk.paywall.vc.delegate.PaywallViewCallback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.lang.ref.WeakReference @Composable fun PaywallComposable( @@ -55,7 +56,7 @@ fun PaywallComposable( LaunchedEffect(Unit) { try { val newView = Superwall.instance.getPaywall(event, params, paywallOverrides, delegate) - newView.encapsulatingActivity = context as? Activity + newView.encapsulatingActivity = WeakReference(context as? Activity) newView.beforeViewCreated() viewState.value = newView } catch (e: Throwable) { 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 cd28d9bc..43afabac 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -20,12 +20,13 @@ import com.superwall.sdk.misc.awaitFirstValidConfig import com.superwall.sdk.models.assignment.AssignmentPostback import com.superwall.sdk.models.assignment.ConfirmableAssignment import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.paywall.CacheKey +import com.superwall.sdk.models.paywall.PaywallIdentifier import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.ExperimentID import com.superwall.sdk.models.triggers.Trigger import com.superwall.sdk.network.Network import com.superwall.sdk.network.device.DeviceHelper -import com.superwall.sdk.paywall.manager.PaywallCacheLogic import com.superwall.sdk.paywall.manager.PaywallManager import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluator import com.superwall.sdk.paywall.presentation.rule_logic.javascript.JavascriptEvaluator @@ -382,25 +383,30 @@ open class ConfigManager( oldConfig: Config, newConfig: Config, ) { - val oldPaywallIds = oldConfig.paywalls.map { it.identifier }.toSet() - val newPaywallIds = newConfig.paywalls.map { it.identifier }.toSet() + val oldPaywalls = oldConfig.paywalls + val newPaywalls = newConfig.paywalls val presentedPaywallId = paywallManager.currentView?.paywall?.identifier - val missingPaywallIds = - if (presentedPaywallId != null) { - oldPaywallIds.minus(presentedPaywallId).subtract(newPaywallIds) - } else { - oldPaywallIds.subtract(newPaywallIds) - } - - missingPaywallIds.forEach { - val paywall = oldConfig.paywalls.first { wall -> wall.identifier == it } - val key = - PaywallCacheLogic.key( - identifier = paywall.identifier, - locale = factory.makeDeviceInfo().locale, - ) - paywallManager.removePaywallView(key) + val oldPaywallCacheIds: Map = + oldPaywalls + .map { it.identifier to it.cacheKey } + .toMap() + val newPaywallCacheIds: Map = newPaywalls.map { it.identifier to it.cacheKey }.toMap() + + val removedIds: Set = + (oldPaywallCacheIds.keys - newPaywallCacheIds.keys).toSet() + + val changedIds = + removedIds + + newPaywalls + .filter { + val oldCacheKey = oldPaywallCacheIds[it.identifier] + val keyChanged = oldCacheKey != newPaywallCacheIds[it.identifier] + oldCacheKey != null && keyChanged + }.map { it.identifier } - presentedPaywallId + + changedIds.toSet().filterNotNull().forEach { + paywallManager.removePaywallView(it) } } diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt index baa53a43..ce5297cf 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt @@ -67,3 +67,19 @@ class PaywallOptions { // **Note:** This feature is still in development and could change. var transactionBackgroundView: TransactionBackgroundView? = TransactionBackgroundView.SPINNER } + +internal fun PaywallOptions.toMap() = + mapOf( + "is_haptic_feedback_enabled" to isHapticFeedbackEnabled, + "restore_failed" to + mapOf( + "title" to restoreFailed.title, + "message" to restoreFailed.message, + "close_button_title" to restoreFailed.closeButtonTitle, + ), + "should_show_purchase_failure_alert" to shouldShowPurchaseFailureAlert, + "should_preload" to shouldPreload, + "use_cached_templates" to useCachedTemplates, + "automatically_dismiss" to automaticallyDismiss, + "transaction_background_view" to transactionBackgroundView?.name, + ) 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 3fe57687..ab90c711 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 @@ -85,3 +85,28 @@ class SuperwallOptions { // The log scope and level to print to the console. var logging: Logging = Logging() } + +internal fun SuperwallOptions.NetworkEnvironment.toMap(): Map = + listOfNotNull( + "host_domain" to hostDomain, + "base_host" to baseHost, + "collector_host" to collectorHost, + "scheme" to scheme, + port?.let { "port" to it }, + ).toMap() + +internal fun SuperwallOptions.Logging.toMap(): Map = + mapOf( + "level" to level.toString(), + "scopes" to scopes.map { it.toString() }, + ) + +internal fun SuperwallOptions.toMap(): Map = + listOfNotNull( + "paywalls" to paywalls.toMap(), + "network_environment" to networkEnvironment.toMap(), + "is_external_data_collection_enabled" to isExternalDataCollectionEnabled, + localeIdentifier?.let { "locale_identifier" to it }, + "is_game_controller_enabled" to isGameControllerEnabled, + "logging" to logging.toMap(), + ).toMap() 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 83c1b0ba..730ea34e 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -61,6 +61,7 @@ import com.superwall.sdk.store.abstractions.transactions.GoogleBillingPurchaseTr import com.superwall.sdk.store.abstractions.transactions.StoreTransaction import com.superwall.sdk.store.transactions.TransactionManager import com.superwall.sdk.utilities.DateUtils +import com.superwall.sdk.utilities.ErrorTracker import com.superwall.sdk.utilities.dateFormat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -97,7 +98,8 @@ class DependencyContainer( AppSessionManager.Factory, DebugView.Factory, JavascriptEvaluator.Factory, - JsonFactory { + JsonFactory, + ConfigAttributesFactory { var network: Network override lateinit var api: Api override lateinit var deviceHelper: DeviceHelper @@ -126,6 +128,8 @@ class DependencyContainer( ) } + internal val errorTracker: ErrorTracker + init { // TODO: Add delegate adapter @@ -163,7 +167,7 @@ class DependencyContainer( delegateAdapter = SuperwallDelegateAdapter() storage = Storage(context = context, factory = this) network = Network(factory = this) - + errorTracker = ErrorTracker(scope = ioScope, cache = storage) paywallRequestManager = PaywallRequestManager( storeKitManager = storeKitManager, @@ -258,7 +262,7 @@ class DependencyContainer( val headers = mapOf( "Authorization" to auth, - "X-Platform" to "iOS", + "X-Platform" to "Android", "X-Platform-Environment" to "SDK", "X-Platform-Wrapper" to deviceHelper.platformWrapper, "X-App-User-ID" to (identityManager.appUserId ?: ""), @@ -312,7 +316,6 @@ class DependencyContainer( SWWebView( context = context, messageHandler = messageHandler, - sessionEventsManager = sessionEventsManager, ) val paywallView = @@ -327,6 +330,9 @@ class DependencyContainer( storage = storage, webView = webView, eventCallback = Superwall.instance, + useMultipleUrls = + configManager.config?.featureFlags?.enableMultiplePaywallUrls + ?: false, ) webView.delegate = paywallView messageHandler.delegate = paywallView @@ -352,7 +358,7 @@ class DependencyContainer( return view } - override fun makeCache(): PaywallViewCache = PaywallViewCache(context, Superwall.instance.viewStore(), activityProvider!!) + override fun makeCache(): PaywallViewCache = PaywallViewCache(context, Superwall.instance.viewStore(), activityProvider!!, deviceHelper) override fun makeDeviceInfo(): DeviceInfo = DeviceInfo( @@ -378,7 +384,7 @@ class DependencyContainer( override fun makeUserAttributesEvent(): InternalSuperwallEvent.Attributes = InternalSuperwallEvent.Attributes( appInstalledAtString = deviceHelper.appInstalledAtString, - customParameters = HashMap(identityManager.userAttributes), + audienceFilterParams = HashMap(identityManager.userAttributes), ) override fun makeHasExternalPurchaseController(): Boolean = storeKitManager.purchaseController.hasExternalPurchaseController @@ -511,6 +517,13 @@ class DependencyContainer( override suspend fun makeSuperwallOptions(): SuperwallOptions = configManager.options override suspend fun makeTriggers(): Set = configManager.triggersByEventName.keys - + override suspend fun provideJavascriptEvaluator(context: Context) = evaluator + + override fun makeConfigAttributes(): InternalSuperwallEvent.ConfigAttributes = + InternalSuperwallEvent.ConfigAttributes( + options = configManager.options, + hasExternalPurchaseController = makeHasExternalPurchaseController(), + hasDelegate = delegateAdapter.kotlinDelegate != null || delegateAdapter.javaDelegate != null, + ) } 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 93723d83..e4261575 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt @@ -112,6 +112,10 @@ interface DeviceHelperFactory { suspend fun makeSessionDeviceAttributes(): HashMap } +interface ConfigAttributesFactory { + fun makeConfigAttributes(): InternalSuperwallEvent.ConfigAttributes +} + interface UserAttributesEventFactory { fun makeUserAttributesEvent(): InternalSuperwallEvent.Attributes } diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt index ff7f8705..183b65bd 100644 --- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt @@ -16,6 +16,7 @@ import com.superwall.sdk.storage.DidTrackFirstSeen import com.superwall.sdk.storage.Seed import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.UserAttributes +import com.superwall.sdk.utilities.withErrorTrackingAsync import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -130,59 +131,61 @@ class IdentityManager( options: IdentityOptions? = null, ) { scope.launch { - IdentityLogic.sanitize(userId)?.let { sanitizedUserId -> - if (_appUserId == sanitizedUserId || sanitizedUserId == "") { - if (sanitizedUserId == "") { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.identityManager, - message = "The provided userId was empty.", - ) + withErrorTrackingAsync { + IdentityLogic.sanitize(userId)?.let { sanitizedUserId -> + if (_appUserId == sanitizedUserId || sanitizedUserId == "") { + if (sanitizedUserId == "") { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.identityManager, + message = "The provided userId was empty.", + ) + } + return@withErrorTrackingAsync } - return@launch - } - identityFlow.emit(false) + identityFlow.emit(false) - val oldUserId = _appUserId - if (oldUserId != null && sanitizedUserId != oldUserId) { - Superwall.instance.reset(duringIdentify = true) - } + val oldUserId = _appUserId + if (oldUserId != null && sanitizedUserId != oldUserId) { + Superwall.instance.reset(duringIdentify = true) + } - _appUserId = sanitizedUserId + _appUserId = sanitizedUserId - // If we haven't gotten config yet, we need - // to leave this open to grab the appUserId for headers - identityJobs += - CoroutineScope(Dispatchers.IO).launch { - val config = configManager.configState.awaitFirstValidConfig() + // If we haven't gotten config yet, we need + // to leave this open to grab the appUserId for headers + identityJobs += + CoroutineScope(Dispatchers.IO).launch { + val config = configManager.configState.awaitFirstValidConfig() - if (config?.featureFlags?.enableUserIdSeed == true) { - sanitizedUserId.sha256MappedToRange()?.let { seed -> - _seed = seed - saveIds() + if (config?.featureFlags?.enableUserIdSeed == true) { + sanitizedUserId.sha256MappedToRange()?.let { seed -> + _seed = seed + saveIds() + } } } - } - saveIds() + saveIds() - CoroutineScope(Dispatchers.IO).launch { - val trackableEvent = InternalSuperwallEvent.IdentityAlias() - Superwall.instance.track(trackableEvent) - } + CoroutineScope(Dispatchers.IO).launch { + val trackableEvent = InternalSuperwallEvent.IdentityAlias() + Superwall.instance.track(trackableEvent) + } - if (options?.restorePaywallAssignments == true) { - identityJobs += + if (options?.restorePaywallAssignments == true) { + identityJobs += + CoroutineScope(Dispatchers.IO).launch { + configManager.getAssignments() + didSetIdentity() + } + } else { CoroutineScope(Dispatchers.IO).launch { configManager.getAssignments() - didSetIdentity() } - } else { - CoroutineScope(Dispatchers.IO).launch { - configManager.getAssignments() + didSetIdentity() } - didSetIdentity() } } } 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 d396e6f5..cc154751 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 @@ -38,6 +38,7 @@ data class Config( val featureFlags: FeatureFlags get() = FeatureFlags( + enableMultiplePaywallUrls = rawFeatureFlags.find { it.key == "enable_multiple_paywall_urls" }?.enabled ?: false, enableConfigRefresh = rawFeatureFlags.find { it.key == "enable_config_refresh" }?.enabled ?: false, enableSessionEvents = rawFeatureFlags.find { it.key == "enable_session_events" }?.enabled diff --git a/superwall/src/main/java/com/superwall/sdk/models/config/FeatureFlags.kt b/superwall/src/main/java/com/superwall/sdk/models/config/FeatureFlags.kt index 87fe7b34..5ab60b83 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/config/FeatureFlags.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/config/FeatureFlags.kt @@ -16,6 +16,7 @@ data class FeatureFlags( @SerialName("enable_postback") var enablePostback: Boolean, @SerialName("enable_userid_seed") var enableUserIdSeed: Boolean, @SerialName("disable_verbose_events") var disableVerboseEvents: Boolean, + @SerialName("enable_multiple_paywall_urls") var enableMultiplePaywallUrls: Boolean, ) fun List.value( 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 13faed6d..254a4e37 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 @@ -34,7 +34,7 @@ data class Paywalls( data class Paywall( @SerialName("id") val databaseId: String, - var identifier: String, + var identifier: PaywallIdentifier, val name: String, val url: @Serializable(with = URLSerializer::class) @@ -49,7 +49,15 @@ data class Paywall( @kotlinx.serialization.Transient() var presentation: PaywallPresentationInfo = PaywallPresentationInfo( - style = PaywallPresentationStyle.valueOf(presentationStyle.uppercase()), + style = + PaywallPresentationStyle.entries.find { it.rawValue == presentationStyle.uppercase() } + ?: PaywallPresentationStyle.NONE.also { + Logger.debug( + LogLevel.warn, + LogScope.paywallPresentation, + "Unknown or unsupported presentation style: $presentationStyle", + ) + }, condition = PresentationCondition.valueOf(presentationCondition.uppercase()), delay = presentationDelay, ), @@ -87,6 +95,14 @@ data class Paywall( var experiment: Experiment? = null, @kotlinx.serialization.Transient() var closeReason: PaywallCloseReason = PaywallCloseReason.None, + @SerialName("url_config") + val urlConfig: PaywallWebviewUrl.Config? = null, + @Serializable + @SerialName("cache_key") + val cacheKey: CacheKey, + @Serializable + @SerialName("build_id") + val buildId: String, /** Surveys to potentially show when an action happens in the paywall. */ @@ -189,6 +205,8 @@ data class Paywall( computedPropertyRequests = computedPropertyRequests, surveys = surveys, presentation = presentation, + cacheKey = cacheKey, + buildId = buildId, ) companion object { @@ -245,6 +263,15 @@ data class Paywall( featureGating = FeatureGatingBehavior.NonGated, localNotifications = arrayListOf(), presentationDelay = 300, + urlConfig = + PaywallWebviewUrl.Config( + 3, + listOf( + PaywallWebviewUrl("https://google.com", 1000L, 1), + ), + ), + cacheKey = "123", + buildId = "test", ) } } diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallPresentationStyle.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallPresentationStyle.kt index ef26d063..ce6b7eac 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallPresentationStyle.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallPresentationStyle.kt @@ -4,22 +4,24 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -enum class PaywallPresentationStyle { +enum class PaywallPresentationStyle( + val rawValue: String, +) { @SerialName("MODAL") - MODAL, + MODAL("MODAL"), @SerialName("FULLSCREEN") - FULLSCREEN, + FULLSCREEN("FULLSCREEN"), @SerialName("NO_ANIMATION") - FULLSCREEN_NO_ANIMATION, + FULLSCREEN_NO_ANIMATION("NO_ANIMATION"), @SerialName("PUSH") - PUSH, + PUSH("PUSH"), @SerialName("DRAWER") - DRAWER, + DRAWER("DRAWER"), @SerialName("NONE") - NONE, + NONE("NONE"), } diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallWebviewUrl.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallWebviewUrl.kt new file mode 100644 index 00000000..3412e790 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallWebviewUrl.kt @@ -0,0 +1,22 @@ +package com.superwall.sdk.models.paywall + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PaywallWebviewUrl( + @SerialName("url") + val url: String, + @SerialName("timeout_ms") + val timeout: Long, + @SerialName("percentage") + val score: Int, +) { + @Serializable + data class Config( + @SerialName("max_attempts") + val maxAttempts: Int, + @SerialName("endpoints") + val endpoints: List, + ) +} diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/typealiases.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/typealiases.kt new file mode 100644 index 00000000..76ff6612 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/typealiases.kt @@ -0,0 +1,4 @@ +package com.superwall.sdk.models.paywall + +typealias CacheKey = String +typealias PaywallIdentifier = String diff --git a/superwall/src/main/java/com/superwall/sdk/network/device/Capability.kt b/superwall/src/main/java/com/superwall/sdk/network/device/Capability.kt index 6e072169..6e032973 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/device/Capability.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/device/Capability.kt @@ -31,6 +31,10 @@ internal sealed class Capability( SuperwallEvents.PaywallClose, ).map { it.rawName } } + + @Serializable + @SerialName("multiple_paywall_urls") + object MultiplePaywallUrls : Capability("multiple_paywall_urls") } internal fun List.toJson(json: Json): JsonElement = json.encodeToJsonElement(this) 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 b2b7d898..47f2d64b 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 @@ -140,6 +140,7 @@ class DeviceHelper( get() = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) var platformWrapper: String = "" + var platformWrapperVersion: String = "" private val _locale: Locale = Locale.getDefault() @@ -417,7 +418,8 @@ class DeviceHelper( ) null } - val capabilities: List = listOf(Capability.PaywallEventReceiver()) + val capabilities: List = listOf(Capability.PaywallEventReceiver(), Capability.MultiplePaywallUrls) + val deviceTemplate = DeviceTemplate( publicApiKey = storage.apiKey, @@ -472,6 +474,8 @@ class DeviceHelper( ipTimezone = geo?.timezone, capabilities = capabilities.map { it.name }, capabilitiesConfig = capabilities.toJson(factory.json()), + platformWrapper = platformWrapper, + platformWrapperVersion = platformWrapperVersion, ) return deviceTemplate.toDictionary() 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 6315a6d4..73c03a95 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 @@ -3,6 +3,7 @@ package com.superwall.sdk.paywall.manager import com.superwall.sdk.dependencies.CacheFactory import com.superwall.sdk.dependencies.DeviceHelperFactory import com.superwall.sdk.dependencies.ViewFactory +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 @@ -52,8 +53,8 @@ class PaywallManager( removePaywallView(forKey) } - fun removePaywallView(forKey: String) { - cache.removePaywallView(forKey) + fun removePaywallView(identifier: PaywallIdentifier) { + cache.removePaywallView(identifier) } fun resetCache() { @@ -116,7 +117,7 @@ class PaywallManager( cache = cache, delegate = delegate, ) - cache.save(paywallView, cacheKey) + cache.save(paywallView, paywall.identifier) if (isForPresentation) { // Only preload if it's actually gonna present the view. // Not if we're just checking its result 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 4f20ed94..970026f2 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 @@ -2,6 +2,8 @@ package com.superwall.sdk.paywall.manager 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 @@ -15,6 +17,7 @@ class PaywallViewCache( private val appCtx: Context, private val store: ViewStorage, private val activityProvider: ActivityProvider, + private val deviceHelper: DeviceHelper, ) { private val ctx: Context = activityProvider.getCurrentActivity() ?: appCtx private var _activePaywallVcKey: String? = null @@ -52,9 +55,17 @@ class PaywallViewCache( fun save( paywallView: PaywallView, - key: String, + identifier: PaywallIdentifier, ) { - CoroutineScope(singleThreadContext).launch { store.storeView(key, paywallView) } + CoroutineScope(singleThreadContext).launch { + store.storeView( + PaywallCacheLogic.key( + identifier, + locale = deviceHelper.locale, + ), + paywallView, + ) + } } fun acquireLoadingView(): LoadingView { @@ -79,8 +90,15 @@ class PaywallViewCache( fun getPaywallView(key: String): PaywallView? = runBlocking(singleThreadContext) { store.retrieveView(key) as PaywallView? } - fun removePaywallView(key: String) { - CoroutineScope(singleThreadContext).launch { store.removeView(key) } + fun removePaywallView(identifier: PaywallIdentifier) { + CoroutineScope(singleThreadContext).launch { + store.removeView( + PaywallCacheLogic.key( + identifier, + locale = deviceHelper.locale, + ), + ) + } } fun removeAll() { 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 ba1bdbc5..62e3dad2 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 @@ -62,6 +62,8 @@ data class PaywallInfo( val computedPropertyRequests: List, val surveys: List, val presentation: PaywallPresentationInfo, + val buildId: String, + val cacheKey: String, ) { constructor( databaseId: String, @@ -91,6 +93,8 @@ data class PaywallInfo( closeReason: PaywallCloseReason, surveys: List, presentation: PaywallPresentationInfo, + buildId: String, + cacheKey: String, ) : this( databaseId = databaseId, identifier = identifier, @@ -140,13 +144,15 @@ data class PaywallInfo( closeReason = closeReason, surveys = surveys, presentation = presentation, + cacheKey = cacheKey, + buildId = buildId, ) fun eventParams( product: StoreProduct? = null, otherParams: Map? = null, ): Map { - var output = customParams() + var output = audienceFilterParams() val params = mutableMapOf( @@ -211,8 +217,8 @@ data class PaywallInfo( return output } - // / Parameters that can be used in rules. - fun customParams(): MutableMap { + // Parameters that can be used in audience filters. + fun audienceFilterParams(): MutableMap { val featureGatingSerialized = Json {}.encodeToString(FeatureGatingBehavior.serializer(), featureGatingBehavior) 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 cfd426f2..d4a32ce1 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 @@ -16,6 +16,8 @@ import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult.Decli import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult.Purchased import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult.Restored import com.superwall.sdk.paywall.presentation.internal.state.PaywallState +import com.superwall.sdk.utilities.withErrorTracking +import com.superwall.sdk.utilities.withErrorTrackingAsync import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -27,11 +29,13 @@ suspend fun Superwall.dismiss() = withContext(Dispatchers.Main) { val completionSignal = CompletableDeferred() - paywallView?.let { - dismiss(paywallView = it, result = PaywallResult.Declined()) { - completionSignal.complete(Unit) - } - } ?: completionSignal.complete(Unit) + withErrorTrackingAsync { + paywallView?.let { + dismiss(paywallView = it, result = PaywallResult.Declined()) { + completionSignal.complete(Unit) + } + } ?: completionSignal.complete(Unit) + } completionSignal.await() } @@ -40,12 +44,17 @@ suspend fun Superwall.dismissForNextPaywall() = withContext(Dispatchers.Main) { val completionSignal = CompletableDeferred() - paywallView?.let { - dismiss(paywallView = it, result = PaywallResult.Declined(), closeReason = PaywallCloseReason.ForNextPaywall) { - completionSignal.complete(Unit) - } - } ?: completionSignal.complete(Unit) - + withErrorTrackingAsync { + paywallView?.let { + dismiss( + paywallView = it, + result = PaywallResult.Declined(), + closeReason = PaywallCloseReason.ForNextPaywall, + ) { + completionSignal.complete(Unit) + } + } ?: completionSignal.complete(Unit) + } completionSignal.await() } @@ -71,47 +80,49 @@ private fun Superwall.internallyRegister( collectionWillStart.complete(Unit) publisher.collect { state -> - when (state) { - is PaywallState.Presented -> { - handler?.onPresentHandler?.invoke(state.paywallInfo) - } - - is PaywallState.Dismissed -> { - val (paywallInfo, paywallResult) = state - handler?.onDismissHandler?.invoke(paywallInfo) - when (paywallResult) { - is Purchased, is Restored -> { - completion?.invoke() - } + withErrorTracking { + when (state) { + is PaywallState.Presented -> { + handler?.onPresentHandler?.invoke(state.paywallInfo) + } - is Declined -> { - val closeReason = paywallInfo.closeReason - val featureGating = paywallInfo.featureGatingBehavior - if (closeReason != PaywallCloseReason.ForNextPaywall && featureGating == FeatureGatingBehavior.NonGated) { + is PaywallState.Dismissed -> { + val (paywallInfo, paywallResult) = state + handler?.onDismissHandler?.invoke(paywallInfo) + when (paywallResult) { + is Purchased, is Restored -> { completion?.invoke() } - if (closeReason == PaywallCloseReason.WebViewFailedToLoad && featureGating == FeatureGatingBehavior.Gated) { - val error = - InternalPresentationLogic.presentationError( - domain = "SWKPresentationError", - code = 106, - title = "Webview Failed", - value = "Trying to present gated paywall but the webview could not load.", - ) - handler?.onErrorHandler?.invoke(error) + + is Declined -> { + val closeReason = paywallInfo.closeReason + val featureGating = paywallInfo.featureGatingBehavior + if (closeReason != PaywallCloseReason.ForNextPaywall && featureGating == FeatureGatingBehavior.NonGated) { + completion?.invoke() + } + if (closeReason == PaywallCloseReason.WebViewFailedToLoad && featureGating == FeatureGatingBehavior.Gated) { + val error = + InternalPresentationLogic.presentationError( + domain = "SWKPresentationError", + code = 106, + title = "Webview Failed", + value = "Trying to present gated paywall but the webview could not load.", + ) + handler?.onErrorHandler?.invoke(error) + } } } } - } - is PaywallState.Skipped -> { - val (reason) = state - handler?.onSkipHandler?.invoke(reason) - completion?.invoke() - } + is PaywallState.Skipped -> { + val (reason) = state + handler?.onSkipHandler?.invoke(reason) + completion?.invoke() + } - is PaywallState.PresentationError -> { - handler?.onErrorHandler?.invoke(state.error) + is PaywallState.PresentationError -> { + handler?.onErrorHandler?.invoke(state.error) + } } } } @@ -150,15 +161,17 @@ private suspend fun Superwall.trackAndPresentPaywall( isFeatureGatable = isFeatureGatable, ) - val trackResult = track(trackableEvent) + withErrorTrackingAsync { + val trackResult = track(trackableEvent) - val presentationRequest = - dependencyContainer.makePresentationRequest( - PresentationInfo.ExplicitTrigger(trackResult.data), - paywallOverrides, - isPaywallPresented = isPaywallPresented, - type = PresentationRequestType.Presentation, - ) + val presentationRequest = + dependencyContainer.makePresentationRequest( + PresentationInfo.ExplicitTrigger(trackResult.data), + paywallOverrides, + isPaywallPresented = isPaywallPresented, + type = PresentationRequestType.Presentation, + ) - internallyPresent(presentationRequest, publisher) + internallyPresent(presentationRequest, publisher) + } } 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 33ccbfe5..c25efecb 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 @@ -44,7 +44,7 @@ internal suspend fun Superwall.internallyGetPresentationResult( val eventData = EventData( name = event.rawName, - parameters = parameters.eventParams, + parameters = parameters.audienceFilterParams, createdAt = eventCreatedAt, ) 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..1833fe76 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 @@ -31,6 +31,8 @@ suspend fun Superwall.logErrors( track(trackedEvent) } } + } else { + track(InternalSuperwallEvent.ErrorThrown(Exception(error))) } Logger.debug( 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 cd6b661a..343d0bb6 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 @@ -51,37 +51,42 @@ suspend fun Superwall.presentPaywallView( ) track(trackedEvent) - paywallView.present( - presenter = presenter, - request = request, - unsavedOccurrence = unsavedOccurrence, - presentationStyleOverride = request.paywallOverrides?.presentationStyle, - paywallStatePublisher = paywallStatePublisher, - ) { isPresented -> - if (isPresented) { - val state = PaywallState.Presented(paywallView.info) - CoroutineScope(Dispatchers.IO).launch { - paywallStatePublisher.emit(state) - } - } else { - Logger.debug( - logLevel = LogLevel.info, - scope = LogScope.paywallPresentation, - message = "Paywall Already Presented", - info = debugInfo, - ) - val error = - InternalPresentationLogic.presentationError( - domain = "SWKPresentationError", - code = 102, - title = "Paywall Already Presented", - value = "Trying to present paywall while another paywall is presented.", + try { + paywallView.present( + presenter = presenter, + request = request, + unsavedOccurrence = unsavedOccurrence, + presentationStyleOverride = request.paywallOverrides?.presentationStyle, + paywallStatePublisher = paywallStatePublisher, + ) { isPresented -> + if (isPresented) { + val state = PaywallState.Presented(paywallView.info) + CoroutineScope(Dispatchers.IO).launch { + paywallStatePublisher.emit(state) + } + } else { + Logger.debug( + logLevel = LogLevel.info, + scope = LogScope.paywallPresentation, + message = "Paywall Already Presented", + info = debugInfo, ) - CoroutineScope(Dispatchers.IO).launch { - paywallStatePublisher.emit(PaywallState.PresentationError(error)) + val error = + InternalPresentationLogic.presentationError( + domain = "SWKPresentationError", + code = 102, + title = "Paywall Already Presented", + value = "Trying to present paywall while another paywall is presented.", + ) + CoroutineScope(Dispatchers.IO).launch { + paywallStatePublisher.emit(PaywallState.PresentationError(error)) + } + throw PaywallPresentationRequestStatusReason.PaywallAlreadyPresented() } - throw PaywallPresentationRequestStatusReason.PaywallAlreadyPresented() } + } catch (error: Throwable) { + logErrors(request, error = error) + throw error } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogic.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogic.kt index 3da91d6f..ba772e5d 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogic.kt @@ -14,6 +14,7 @@ import com.superwall.sdk.models.triggers.UnmatchedRule import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluator import com.superwall.sdk.paywall.presentation.rule_logic.javascript.JavascriptEvaluator import com.superwall.sdk.storage.Storage +import com.superwall.sdk.utilities.withErrorTrackingAsync data class RuleEvaluationOutcome( val confirmableAssignment: ConfirmableAssignment? = null, @@ -41,68 +42,70 @@ class RuleLogic( event: EventData, triggers: Map, ): RuleEvaluationOutcome { - val trigger = - triggers[event.name] - ?: return RuleEvaluationOutcome(triggerResult = InternalTriggerResult.EventNotFound) - - val ruleMatchOutcome = findMatchingRule(event, trigger) - - val matchedRuleItem: MatchedItem = - when (ruleMatchOutcome) { - is RuleMatchOutcome.Matched -> ruleMatchOutcome.item - is RuleMatchOutcome.NoMatchingRules -> return RuleEvaluationOutcome( - triggerResult = InternalTriggerResult.NoRuleMatch(ruleMatchOutcome.unmatchedRules), - ) - } + return withErrorTrackingAsync { + val trigger = + triggers[event.name] + ?: return@withErrorTrackingAsync RuleEvaluationOutcome(triggerResult = InternalTriggerResult.EventNotFound) + + val ruleMatchOutcome = findMatchingRule(event, trigger) + + val matchedRuleItem: MatchedItem = + when (ruleMatchOutcome) { + is RuleMatchOutcome.Matched -> ruleMatchOutcome.item + is RuleMatchOutcome.NoMatchingRules -> return@withErrorTrackingAsync RuleEvaluationOutcome( + triggerResult = InternalTriggerResult.NoRuleMatch(ruleMatchOutcome.unmatchedRules), + ) + } + + val rule = matchedRuleItem.rule + val confirmedAssignments = storage.getConfirmedAssignments() + val variant: Experiment.Variant + var confirmableAssignment: ConfirmableAssignment? = null + + variant = confirmedAssignments[rule.experiment.id] + ?: configManager.unconfirmedAssignments[rule.experiment.id] + ?: run { + return@withErrorTrackingAsync RuleEvaluationOutcome( + triggerResult = + InternalTriggerResult.Error( + PaywallNotFoundException(), + ), + ) + } - val rule = matchedRuleItem.rule - val confirmedAssignments = storage.getConfirmedAssignments() - val variant: Experiment.Variant - var confirmableAssignment: ConfirmableAssignment? = null - - variant = confirmedAssignments[rule.experiment.id] - ?: configManager.unconfirmedAssignments[rule.experiment.id] - ?: run { - return RuleEvaluationOutcome( - triggerResult = - InternalTriggerResult.Error( - PaywallNotFoundException(), - ), - ) + if (variant !in confirmedAssignments.values) { + confirmableAssignment = ConfirmableAssignment(rule.experiment.id, variant) } - if (variant !in confirmedAssignments.values) { - confirmableAssignment = ConfirmableAssignment(rule.experiment.id, variant) - } - - return when (variant.type) { - Experiment.Variant.VariantType.HOLDOUT -> - RuleEvaluationOutcome( - confirmableAssignment = confirmableAssignment, - unsavedOccurrence = matchedRuleItem.unsavedOccurrence, - triggerResult = - InternalTriggerResult.Holdout( - Experiment( - rule.experiment.id, - rule.experiment.groupId, - variant, + return@withErrorTrackingAsync when (variant.type) { + Experiment.Variant.VariantType.HOLDOUT -> + RuleEvaluationOutcome( + confirmableAssignment = confirmableAssignment, + unsavedOccurrence = matchedRuleItem.unsavedOccurrence, + triggerResult = + InternalTriggerResult.Holdout( + Experiment( + rule.experiment.id, + rule.experiment.groupId, + variant, + ), ), - ), - ) - - Experiment.Variant.VariantType.TREATMENT -> - RuleEvaluationOutcome( - confirmableAssignment = confirmableAssignment, - unsavedOccurrence = matchedRuleItem.unsavedOccurrence, - triggerResult = - InternalTriggerResult.Paywall( - Experiment( - rule.experiment.id, - rule.experiment.groupId, - variant, + ) + + Experiment.Variant.VariantType.TREATMENT -> + RuleEvaluationOutcome( + confirmableAssignment = confirmableAssignment, + unsavedOccurrence = matchedRuleItem.unsavedOccurrence, + triggerResult = + InternalTriggerResult.Paywall( + Experiment( + rule.experiment.id, + rule.experiment.groupId, + variant, + ), ), - ), - ) + ) + } } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallView.kt index 3477420e..6be275a3 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallView.kt @@ -56,6 +56,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch +import java.lang.ref.WeakReference import java.net.MalformedURLException import java.net.URL import java.util.Date @@ -72,6 +73,7 @@ class PaywallView( override val webView: SWWebView, private val loadingView: LoadingView = LoadingView(context), private val cache: PaywallViewCache?, + private val useMultipleUrls: Boolean, ) : FrameLayout(context), PaywallMessageHandlerDelegate, SWWebViewDelegate, @@ -97,7 +99,7 @@ class PaywallView( var paywallStatePublisher: MutableSharedFlow? = null // The full screen activity instance if this view controller has been presented in one. - override var encapsulatingActivity: Activity? = null + override var encapsulatingActivity: WeakReference? = null // / Stores the ``PaywallResult`` on dismiss of paywall. private var paywallResult: PaywallResult? = null @@ -181,7 +183,7 @@ class PaywallView( //endregion //region Initialization - val mainScope = CoroutineScope(Dispatchers.Main) + private val mainScope = CoroutineScope(Dispatchers.Main) init { // Add the webView @@ -402,22 +404,30 @@ class PaywallView( } } + val dismiss = { + CoroutineScope(Dispatchers.IO).launch { + dismissView() + } + } + SurveyManager.presentSurveyIfAvailable( paywall.surveys, paywallResult = result, paywallCloseReason = closeReason, - activity = encapsulatingActivity, + activity = + encapsulatingActivity?.get() ?: run { + dismiss() + return + }, paywallView = this, loadingState = loadingState, isDebuggerLaunched = request?.flags?.isDebuggerLaunched == true, paywallInfo = info, storage = storage, factory = factory, - ) { result -> - this.surveyPresentationResult = result - CoroutineScope(Dispatchers.IO).launch { - dismissView() - } + ) { res -> + this.surveyPresentationResult = res + dismiss() } } @@ -499,9 +509,9 @@ class PaywallView( Superwall.instance.track(trackedEvent) } - override fun eventDidOccur(paywallEvent: PaywallWebEvent) { + override fun eventDidOccur(paywallWebEvent: PaywallWebEvent) { CoroutineScope(Dispatchers.IO).launch { - eventCallback?.eventDidOccur(paywallEvent, this@PaywallView) + eventCallback?.eventDidOccur(paywallWebEvent, this@PaywallView) } } @@ -525,7 +535,7 @@ class PaywallView( // TODO: SW-2162 Implement animation support // https://linear.app/superwall/issue/SW-2162/%5Bandroid%5D-%5Bv1%5D-get-animated-presentation-working - encapsulatingActivity?.finish() + encapsulatingActivity?.get()?.finish() } private fun showLoadingView() { @@ -536,7 +546,7 @@ class PaywallView( } loadingViewController?.let { mainScope.launch { - loadingView?.visibility = View.VISIBLE + it.visibility = View.VISIBLE } } } @@ -544,7 +554,7 @@ class PaywallView( private fun hideLoadingView() { loadingViewController?.let { mainScope.launch { - loadingView?.visibility = View.GONE + it.visibility = View.GONE } } } @@ -552,26 +562,27 @@ class PaywallView( private fun showShimmerView() { shimmerView?.let { mainScope.launch { - shimmerView?.visibility = View.VISIBLE + it.visibility = View.VISIBLE } } - // TODO: Start shimmer animation if needed } private fun hideShimmerView() { shimmerView?.let { mainScope.launch { - shimmerView?.visibility = View.GONE + it.visibility = View.GONE } } - // TODO: Stop shimmer animation if needed } fun showRefreshButtonAfterTimeout(isVisible: Boolean) { // TODO: Implement this } - @Deprecated("Will be removed in the upcoming versions, use presentAlert instead") + @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, @@ -589,7 +600,7 @@ class PaywallView( action: (() -> Unit)? = null, onClose: (() -> Unit)? = null, ) { - val activity = encapsulatingActivity ?: return + val activity = encapsulatingActivity?.get() ?: return val alertController = AlertControllerFactory.make( @@ -697,7 +708,7 @@ class PaywallView( paywall.webviewLoadingInfo.startAt = Date() } - CoroutineScope(Dispatchers.IO).launch { + launch { val trackedEvent = InternalSuperwallEvent.PaywallWebviewLoad( state = InternalSuperwallEvent.PaywallWebviewLoad.State.Start(), @@ -706,13 +717,17 @@ class PaywallView( Superwall.instance.track(trackedEvent) } - launch(Dispatchers.Main) { + mainScope.launch { if (paywall.onDeviceCache is OnDeviceCaching.Enabled) { webView.settings.cacheMode = WebSettings.LOAD_CACHE_ELSE_NETWORK } else { webView.settings.cacheMode = WebSettings.LOAD_DEFAULT } - webView.loadUrl(url.toString()) + if (useMultipleUrls) { + webView.loadPaywallWithFallbackUrl(paywall) + } else { + webView.loadUrl(url.toString()) + } } loadingState = PaywallLoadingState.LoadingURL() @@ -813,5 +828,5 @@ class PaywallView( } interface ActivityEncapsulatable { - var encapsulatingActivity: Activity? + var encapsulatingActivity: WeakReference? } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallPaywallActivity.kt index 0cbaa806..c0f48f5e 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallPaywallActivity.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallPaywallActivity.kt @@ -2,6 +2,8 @@ package com.superwall.sdk.paywall.vc import android.Manifest import android.R +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator import android.app.Activity import android.app.NotificationChannel import android.app.NotificationManager @@ -9,6 +11,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.Color +import android.graphics.drawable.ColorDrawable import android.os.Build import android.os.Bundle import android.view.View @@ -16,13 +19,17 @@ import android.view.ViewGroup import android.view.Window import android.view.WindowInsetsController import android.view.WindowManager +import android.widget.FrameLayout import androidx.activity.OnBackPressedCallback +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ActivityCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.view.children +import com.google.android.material.bottomsheet.BottomSheetBehavior import com.superwall.sdk.Superwall import com.superwall.sdk.dependencies.DeviceHelperFactory import com.superwall.sdk.misc.isLightColor @@ -34,6 +41,7 @@ import com.superwall.sdk.store.transactions.notifications.NotificationScheduler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.lang.ref.WeakReference import java.util.UUID import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -48,6 +56,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { private const val VIEW_KEY = "viewKey" private const val PRESENTATION_STYLE_KEY = "presentationStyleKey" private const val IS_LIGHT_BACKGROUND_KEY = "isLightBackgroundKey" + private const val ACTIVE_PAYWALL_TAG = "active_paywall" fun startWithView( context: Context, @@ -88,16 +97,63 @@ class SuperwallPaywallActivity : AppCompatActivity() { private var contentView: View? = null private var notificationPermissionCallback: NotificationPermissionCallback? = null + private val isBottomSheetView + get() = contentView is CoordinatorLayout && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R override fun setContentView(view: View) { super.setContentView(view) contentView = view } + private val mainScope = CoroutineScope(Dispatchers.Main) + + private fun paywallView(): PaywallView? = contentView?.findViewWithTag(ACTIVE_PAYWALL_TAG) + + private fun setupBottomSheetLayout(paywallView: PaywallView) { + val activityView = + layoutInflater.inflate(com.superwall.sdk.R.layout.activity_bottom_sheet, null) + setContentView(activityView) + initBottomSheetBehavior() + val container = + activityView.findViewById(com.superwall.sdk.R.id.container) + activityView.setOnClickListener { finish() } + container.addView(paywallView) + container.requestLayout() + } + + private fun initBottomSheetBehavior() { + var bottomSheetBehavior = BottomSheetBehavior.from((contentView as ViewGroup).getChildAt(0)) + bottomSheetBehavior.halfExpandedRatio = 0.7f + // Expanded by default + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + bottomSheetBehavior.skipCollapsed = true + bottomSheetBehavior.addBottomSheetCallback( + object : + BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged( + bottomSheet: View, + newState: Int, + ) { + if (newState == BottomSheetBehavior.STATE_HIDDEN) { + finish() + } + } + + override fun onSlide( + bottomSheet: View, + slideOffset: Float, + ) { + } + }, + ) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) + val presentationStyle = + intent.getSerializableExtra(PRESENTATION_STYLE_KEY) as? PaywallPresentationStyle // Show content behind the status bar window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or @@ -134,10 +190,17 @@ class SuperwallPaywallActivity : AppCompatActivity() { return } - (view.parent as? ViewGroup)?.removeView(view) - view.encapsulatingActivity = this + val isBottomSheetStyle = presentationStyle == PaywallPresentationStyle.DRAWER - setContentView(view) + (view.parent as? ViewGroup)?.removeView(view) + view.tag = ACTIVE_PAYWALL_TAG + view.encapsulatingActivity = WeakReference(this) + // If it's a bottom sheet, we set activity as transparent and show the UI in a bottom sheet container + if (isBottomSheetStyle && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setupBottomSheetLayout(view) + } else { + setContentView(view) + } onBackPressedDispatcher.addCallback( this, @@ -159,26 +222,38 @@ class SuperwallPaywallActivity : AppCompatActivity() { // TODO: handle animation and style from `presentationStyleOverride` when (intent.getSerializableExtra(PRESENTATION_STYLE_KEY) as? PaywallPresentationStyle) { PaywallPresentationStyle.PUSH -> { - overridePendingTransition(R.anim.slide_in_left, R.anim.slide_in_left) - } - - PaywallPresentationStyle.DRAWER -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + overrideActivityTransition( + OVERRIDE_TRANSITION_OPEN, + R.anim.slide_in_left, + R.anim.slide_in_left, + ) + } else { + overridePendingTransition(R.anim.slide_in_left, R.anim.slide_in_left) + } } PaywallPresentationStyle.FULLSCREEN -> { + enableEdgeToEdge() } PaywallPresentationStyle.FULLSCREEN_NO_ANIMATION -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, 0, 0) + overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0) + } else { + overridePendingTransition(0, 0) + } + enableEdgeToEdge() } PaywallPresentationStyle.MODAL -> { + // TODO: Not yet supported in Android } - - PaywallPresentationStyle.NONE -> { - // Do nothing - } - - null -> { + PaywallPresentationStyle.NONE, + PaywallPresentationStyle.DRAWER, + null, + -> { // Do nothing } } @@ -186,7 +261,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { override fun onStart() { super.onStart() - val paywallVc = contentView as? PaywallView ?: return + val paywallVc = paywallView() ?: return if (paywallVc.isBrowserViewPresented) { paywallVc.isBrowserViewPresented = false @@ -195,19 +270,54 @@ class SuperwallPaywallActivity : AppCompatActivity() { paywallVc.beforeViewCreated() } + private fun setBottomSheetTransparency() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setTranslucent(true) + val colorFrom = Color.argb(0, 0, 0, 0) + val colorTo = Color.argb(200, 0, 0, 0) + with(ValueAnimator.ofObject(ArgbEvaluator(), colorFrom, colorTo)) { + setDuration(600) // milliseconds + addUpdateListener { animator -> window.setBackgroundDrawable(ColorDrawable(animator.animatedValue as Int)) } + start() + } + } else { + window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setTheme(android.R.style.Theme_Translucent_NoTitleBar) + } + } + + private fun hideBottomSheetAndFinish() { + val colorFrom = Color.argb(200, 0, 0, 0) + val colorTo = Color.argb(0, 0, 0, 0) + + // First animate the background dim, then call finish on the view + with(ValueAnimator.ofObject(ArgbEvaluator(), colorFrom, colorTo)) { + setDuration(300) // milliseconds + addUpdateListener { animator -> + val e = ((animator.animatedValue as Int) / colorFrom) + if (e < 0.1) { + super.finish() + } + window.setBackgroundDrawable(ColorDrawable(animator.animatedValue as Int)) + } + start() + } + } + override fun onResume() { super.onResume() - val paywallVc = contentView as? PaywallView ?: return - + val paywallVc = paywallView() ?: return + if (isBottomSheetView) { + setBottomSheetTransparency() + } paywallVc.onViewCreated() } override fun onPause() { super.onPause() - val paywallVc = contentView as? PaywallView ?: return - - CoroutineScope(Dispatchers.Main).launch { + val paywallVc = paywallView() ?: return + mainScope.launch { paywallVc.beforeOnDestroy() } } @@ -215,9 +325,9 @@ class SuperwallPaywallActivity : AppCompatActivity() { override fun onStop() { super.onStop() - val paywallVc = contentView as? PaywallView ?: return + val paywallVc = paywallView() ?: return - CoroutineScope(Dispatchers.Main).launch { + mainScope.launch { paywallVc.destroyed() } } @@ -226,7 +336,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { super.onDestroy() (contentView?.parent as? ViewGroup)?.removeView(contentView) // Clear reference to activity in the view - (contentView as? ActivityEncapsulatable)?.encapsulatingActivity = null + (paywallView() as? ActivityEncapsulatable)?.encapsulatingActivity = null // Clear the reference to the contentView contentView = null @@ -237,6 +347,16 @@ class SuperwallPaywallActivity : AppCompatActivity() { fun onPermissionResult(granted: Boolean) } + override fun finish() { + if (isBottomSheetView) { + mainScope.launch { + hideBottomSheetAndFinish() + } + } else { + super.finish() + } + } + suspend fun attemptToScheduleNotifications( notifications: List, factory: DeviceHelperFactory, 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/vc/web_view/DefaultWebviewClient.kt new file mode 100644 index 00000000..523bd226 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/DefaultWebviewClient.kt @@ -0,0 +1,79 @@ +package com.superwall.sdk.paywall.vc.web_view + +import android.graphics.Bitmap +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch + +internal open class DefaultWebviewClient( + private val ioScope: CoroutineScope, +) : WebViewClient() { + val webviewClientEvents: MutableSharedFlow = + MutableSharedFlow(extraBufferCapacity = 4) + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean = true + + override fun onPageStarted( + view: WebView?, + url: String?, + favicon: Bitmap?, + ) { + super.onPageStarted(view, url, favicon) + } + + override fun onPageFinished( + view: WebView, + url: String, + ) { + ioScope.launch { + webviewClientEvents.emit(WebviewClientEvent.OnPageFinished(url)) + } + } + + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse?, + ) { + ioScope.launch { + webviewClientEvents.emit( + WebviewClientEvent.OnError( + WebviewError.NetworkError( + errorResponse?.statusCode ?: -1, + errorResponse?.let { + val body = it.data?.bufferedReader()?.use { it.readText() } ?: "Unknown" + "Error: ${errorResponse.reasonPhrase} -\n $body" + } ?: "Unknown error", + request?.url?.toString() ?: "", + ), + ), + ) + } + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError, + ) { + ioScope.launch { + webviewClientEvents.emit( + WebviewClientEvent.OnError( + WebviewError.NetworkError( + error.errorCode, + error.description.toString(), + request?.url?.toString() ?: "", + ), + ), + ) + } + } +} 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/vc/web_view/SWWebView.kt index 541b380a..b5953f20 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/SWWebView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/SWWebView.kt @@ -11,21 +11,23 @@ import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import android.webkit.ConsoleMessage import android.webkit.WebChromeClient -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest import android.webkit.WebView -import android.webkit.WebViewClient 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.game.dispatchKeyEvent import com.superwall.sdk.game.dispatchMotionEvent +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger +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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch import java.util.Date @@ -40,10 +42,11 @@ interface SWWebViewDelegate : class SWWebView( context: Context, - private val sessionEventsManager: SessionEventsManager, val messageHandler: PaywallMessageHandler, + private val onFinishedLoading: ((url: String) -> Unit)? = null, ) : WebView(context) { var delegate: SWWebViewDelegate? = null + private val mainScope = CoroutineScope(Dispatchers.Main) init { @@ -74,31 +77,32 @@ class SWWebView( } // Set a WebViewClient this.webViewClient = - object : WebViewClient() { - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest?, - ): Boolean { - return true // This will prevent the loading of URLs inside your WebView - } - - override fun onPageFinished( - view: WebView?, - url: String?, - ) { - // Do something when page loading finished - } + DefaultWebviewClient(ioScope = CoroutineScope(Dispatchers.IO)) + listenToWebviewClientEvents(this.webViewClient as DefaultWebviewClient) + } - override fun onReceivedError( - view: WebView?, - request: WebResourceRequest?, - error: WebResourceError, - ) { - CoroutineScope(Dispatchers.Main).launch { - trackPaywallError(error) - } - } - } + internal fun loadPaywallWithFallbackUrl(paywall: Paywall) { + val client = + WebviewFallbackClient( + config = + paywall.urlConfig + ?: run { + Logger.debug( + LogLevel.error, + LogScope.paywallView, + "Tried to start a paywall with multiple URLS but without URL config", + ) + return + }, + mainScope = mainScope, + ioScope = CoroutineScope(Dispatchers.IO), + loadUrl = { + loadUrl(it.url) + }, + ) + this.webViewClient = client + listenToWebviewClientEvents(this.webViewClient as DefaultWebviewClient) + client.loadWithFallback() } // ??? @@ -139,19 +143,82 @@ class SWWebView( super.loadUrl(urlString) } - private suspend fun trackPaywallError(webResourceError: WebResourceError) { - delegate?.paywall?.webviewLoadingInfo?.failAt = Date() + private fun listenToWebviewClientEvents(client: DefaultWebviewClient) { + CoroutineScope(Dispatchers.IO).launch { + client.webviewClientEvents + .takeWhile { + mainScope + .async { + webViewClient == client + }.await() + }.collect { + mainScope.launch { + when (it) { + is WebviewClientEvent.OnError -> { + trackPaywallError( + it.webviewError, + when (val e = it.webviewError) { + is WebviewError.NetworkError -> + listOf(e.url) + + is WebviewError.NoUrls -> + emptyList() + + is WebviewError.MaxAttemptsReached -> + e.urls + is WebviewError.AllUrlsFailed -> e.urls + }, + ) + } + + is WebviewClientEvent.OnPageFinished -> { + onFinishedLoading?.invoke(it.url) + } + + is WebviewClientEvent.LoadingFallback -> { + trackLoadFallback() + } + } + } + } + } + } + + private fun trackLoadFallback() { + mainScope.launch { + delegate?.paywall?.webviewLoadingInfo?.failAt = Date() - val paywallInfo = delegate?.info ?: return + val paywallInfo = delegate?.info ?: return@launch - val trackedEvent = - InternalSuperwallEvent.PaywallWebviewLoad( - state = - InternalSuperwallEvent.PaywallWebviewLoad.State.Fail( - "Code: ${webResourceError.errorCode} - ${webResourceError.description}", - ), - paywallInfo = paywallInfo, - ) - Superwall.instance.track(trackedEvent) + val trackedEvent = + InternalSuperwallEvent.PaywallWebviewLoad( + state = + InternalSuperwallEvent.PaywallWebviewLoad.State.Fallback, + paywallInfo = paywallInfo, + ) + Superwall.instance.track(trackedEvent) + } + } + + private fun trackPaywallError( + error: WebviewError, + urls: List, + ) { + mainScope.launch { + delegate?.paywall?.webviewLoadingInfo?.failAt = Date() + + val paywallInfo = delegate?.info ?: return@launch + + val trackedEvent = + InternalSuperwallEvent.PaywallWebviewLoad( + state = + InternalSuperwallEvent.PaywallWebviewLoad.State.Fail( + error, + urls, + ), + paywallInfo = paywallInfo, + ) + Superwall.instance.track(trackedEvent) + } } } 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/vc/web_view/WebviewClientEvent.kt new file mode 100644 index 00000000..c7f3227a --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewClientEvent.kt @@ -0,0 +1,39 @@ +package com.superwall.sdk.paywall.vc.web_view + +sealed class WebviewClientEvent { + data class OnPageFinished( + val url: String, + ) : WebviewClientEvent() + + data class OnError( + val webviewError: WebviewError, + ) : WebviewClientEvent() + + data object LoadingFallback : WebviewClientEvent() +} + +sealed class WebviewError { + data class NetworkError( + val code: Int, + val description: String, + val url: String, + ) : WebviewError() { + override fun toString(): String = "The network failed with error code: $code - $description - $url ." + } + + data class MaxAttemptsReached( + val urls: List, + ) : WebviewError() { + override fun toString(): String = "The webview has attempted to load too many times." + } + + data object NoUrls : WebviewError() { + override fun toString() = "There were no paywall URLs provided." + } + + data class AllUrlsFailed( + val urls: List, + ) : WebviewError() { + override fun toString(): String = "All paywall URLs have failed to load." + } +} 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/vc/web_view/WebviewFallbackClient.kt new file mode 100644 index 00000000..3415f4ca --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewFallbackClient.kt @@ -0,0 +1,221 @@ +package com.superwall.sdk.paywall.vc.web_view + +import android.graphics.Bitmap +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import com.superwall.sdk.models.paywall.PaywallWebviewUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout + +/* +* An implementation of a FallbackHandler and a WebViewClient that will attempt to load a PaywallWebviewUrl from +* a list of URLs with a weighted probability. If the loading fails, it will try to load another URL from the list. +* */ +internal class WebviewFallbackClient( + private val config: PaywallWebviewUrl.Config, + private val ioScope: CoroutineScope, + private val mainScope: CoroutineScope, + private val loadUrl: (PaywallWebviewUrl) -> Unit, +) : DefaultWebviewClient(ioScope) { + private class MaxAttemptsReachedException : Exception("Max attempts reached") + + private var failureCount = 0 + + private val urls = config.endpoints + + private val untriedUrls = urls.toMutableSet() + + private sealed interface UrlState { + object None : UrlState + + object Loading : UrlState + + object PageStarted : UrlState + + object PageError : UrlState + + object Timeout : UrlState + } + + private val timeoutFlow = MutableStateFlow(UrlState.None) + + /* + * When page starts Loading, we wait for N miliseconds until we consider it timed out. + * If it is timedOut, we update the timeoutFlow so the client knows it can start loading + * next fallback URL + * */ + override fun onPageStarted( + view: WebView?, + url: String?, + favicon: Bitmap?, + ) { + super.onPageStarted(view, url, favicon) + val timeoutForUrl = + urls.find { url?.contains(it.url) ?: false }?.timeout + ?: error("No such URL($url in list of - ${urls.joinToString()} ") + ioScope.launch { + timeoutFlow.update { UrlState.Loading } + try { + withTimeout(timeoutForUrl) { + timeoutFlow.first { it is UrlState.PageStarted || it is UrlState.PageError } + } + } catch (e: TimeoutCancellationException) { + timeoutFlow.update { UrlState.Timeout } + } + } + } + + /* + * Indicates the page has started loading resources, meaning we won't need to + * fallback to another URL so we can stop the timeout handler. + * */ + + override fun onLoadResource( + view: WebView?, + url: String?, + ) { + super.onLoadResource(view, url) + ioScope.launch { + if (timeoutFlow.value == UrlState.Loading) { + timeoutFlow.emit(UrlState.PageStarted) + } + } + } + + internal fun nextUrl(): PaywallWebviewUrl { + if (failureCount < config.maxAttempts) { + failureCount += 1 + return if (untriedUrls.all { it.score == 0 }) { + nextWeightedUrl(untriedUrls) + } else { + nextWeightedUrl(untriedUrls.filter { it.score != 0 }) + } + } else { + throw MaxAttemptsReachedException() + } + } + + // HTML has loaded and started rendering + override fun onPageCommitVisible( + view: WebView?, + url: String?, + ) { + super.onPageCommitVisible(view, url) + failureCount = 0 + } + + private tailrec fun evaluateNext( + chosenNumber: Int, + untriedUrls: Collection, + accumulatedScore: Int = 0, + ): PaywallWebviewUrl { + val toTry = + untriedUrls.firstOrNull() ?: throw NoSuchElementException("No more URLs to evaluate") + val accScore = accumulatedScore + toTry.score + return if (chosenNumber < accScore) { + toTry + } else { + evaluateNext(chosenNumber, untriedUrls.drop(1), accScore) + } + } + + private fun nextWeightedUrl(fromUrls: Collection): PaywallWebviewUrl { + val totalScore = fromUrls.sumOf { it.score } + + if (totalScore == 0) { + val url = untriedUrls.random() + untriedUrls.remove(url) + return url + } + + val random = (0 until totalScore).random() + val next = evaluateNext(random, untriedUrls, 0) + untriedUrls.remove(next) + return next + } + + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse?, + ) { + super.onReceivedHttpError(view, request, errorResponse) + loadWithFallback() + } + + internal fun loadWithFallback() { + // If we have no URLs to try, we emit an error + if (urls.isEmpty()) { + ioScope.launch { + webviewClientEvents.emit(WebviewClientEvent.OnError(WebviewError.NoUrls)) + } + return + } + + // We try to get the next URL + val url = + try { + nextUrl() + } catch (e: NoSuchElementException) { + // If there is no more URLS, we let the client know + ioScope.launch { + webviewClientEvents.emit( + WebviewClientEvent.OnError( + WebviewError.AllUrlsFailed( + urls.map { it.url }, + ), + ), + ) + } + return + } catch (e: MaxAttemptsReachedException) { + // If we reached the max attempts, we let the client know + ioScope.launch { + webviewClientEvents.emit( + WebviewClientEvent.OnError( + WebviewError.MaxAttemptsReached( + urls.subtract(untriedUrls).map { it.url }, + ), + ), + ) + } + return + } + + if (failureCount > 0) { + ioScope.launch { + webviewClientEvents.emit(WebviewClientEvent.LoadingFallback) + } + } + + mainScope.launch { + loadUrl(url) + ioScope.launch { + val nextEvent = + timeoutFlow.first { it is UrlState.PageStarted || it is UrlState.Timeout || it is UrlState.PageError } + if (nextEvent is UrlState.Timeout) { + loadWithFallback() + } else { + timeoutFlow.update { UrlState.None } + } + } + } + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError, + ) { + timeoutFlow.update { UrlState.PageError } + super.onReceivedError(view, request, error) + loadWithFallback() + } +} 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/vc/web_view/messaging/PaywallMessage.kt index 9ebaf2ad..adf650e3 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/vc/web_view/messaging/PaywallMessage.kt @@ -46,6 +46,11 @@ sealed class PaywallMessage { val data: String, ) : PaywallMessage() + data class CustomPlacement( + val name: String, + val params: JSONObject, + ) : PaywallMessage() + object PaywallOpen : PaywallMessage() object PaywallClose : PaywallMessage() @@ -82,7 +87,14 @@ private fun parsePaywallMessage(json: JSONObject): PaywallMessage { json.getString("product"), json.getString("product_identifier"), ) + "custom" -> PaywallMessage.Custom(json.getString("data")) + "register_placement" -> + PaywallMessage.CustomPlacement( + json.getString("name"), + json.getJSONObject("params"), + ) + else -> throw IllegalArgumentException("Unknown event name: $eventName") } } 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/vc/web_view/messaging/PaywallMessageHandler.kt index 632402c9..1fbe0f3b 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/vc/web_view/messaging/PaywallMessageHandler.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.json.JSONObject import java.net.URL import java.nio.charset.StandardCharsets import java.util.Base64 @@ -146,6 +147,7 @@ class PaywallMessageHandler( } is PaywallMessage.Custom -> handleCustomEvent(message.data) + is PaywallMessage.CustomPlacement -> handleCustomPlacement(message.name, message.params) else -> { Logger.debug( LogLevel.error, @@ -368,6 +370,13 @@ class PaywallMessageHandler( delegate?.eventDidOccur(PaywallWebEvent.Custom(customEvent)) } + private fun handleCustomPlacement( + name: String, + params: JSONObject, + ) { + delegate?.eventDidOccur(PaywallWebEvent.CustomPlacement(name, params)) + } + private fun detectHiddenPaywallEvent( eventName: String, userInfo: Map? = null, 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/vc/web_view/messaging/PaywallWebEvent.kt index 29c5e8e9..c2bf9a05 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/vc/web_view/messaging/PaywallWebEvent.kt @@ -2,6 +2,7 @@ package com.superwall.sdk.paywall.vc.web_view.messaging import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import org.json.JSONObject import java.net.URL @Serializable @@ -34,4 +35,10 @@ sealed class PaywallWebEvent { data class OpenedDeepLink( val url: URL, ) : PaywallWebEvent() + + @SerialName("custom_placement") + data class CustomPlacement( + val name: String, + val params: JSONObject, + ) : PaywallWebEvent() } 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/vc/web_view/templating/models/DeviceTemplate.kt index c510702c..b10637aa 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/vc/web_view/templating/models/DeviceTemplate.kt @@ -61,6 +61,10 @@ data class DeviceTemplate( val capabilities: List, @SerialName("capabilities_config") val capabilitiesConfig: JsonElement, + @SerialName("platform_wrapper") + val platformWrapper: String, + @SerialName("platform_wrapper_version") + val platformWrapperVersion: String, ) { fun toDictionary(): Map { val json = Json { encodeDefaults = true } 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 a17faa17..5f4a30f3 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/Cache.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/Cache.kt @@ -75,7 +75,6 @@ class Cache( fun delete(storable: Storable) { memCache.remove(storable.key) - launch { val file = File(storable.path(context = context)) if (file.exists()) { 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 90dc5860..fd08a7a0 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt @@ -7,6 +7,7 @@ import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.ExperimentID import com.superwall.sdk.store.abstractions.transactions.StoreTransaction import com.superwall.sdk.utilities.DateUtils +import com.superwall.sdk.utilities.ErrorTracking import com.superwall.sdk.utilities.dateFormat import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException @@ -231,6 +232,14 @@ object DisableVerboseEvents : Storable { get() = Boolean.serializer() } +internal object ErrorLog : Storable { + override val key: String + get() = "store.errorLog" + override val directory: SearchPathDirectory + get() = SearchPathDirectory.CACHE + override val serializer: KSerializer + get() = ErrorTracking.ErrorOccurence.serializer() +} //endregion // region Serializers diff --git a/superwall/src/main/java/com/superwall/sdk/storage/Storage.kt b/superwall/src/main/java/com/superwall/sdk/storage/Storage.kt index c0459cd2..5fa56331 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/Storage.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/Storage.kt @@ -199,6 +199,8 @@ open class Storage( //region Cache Reading & Writing + fun remove(storable: Storable) = cache.delete(storable) + fun get(storable: Storable): T? = cache.read(storable) fun save( diff --git a/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt index facc6559..0dc6fe6a 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt @@ -13,6 +13,7 @@ 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.models.product.ProductType import com.superwall.sdk.store.abstractions.product.OfferType import com.superwall.sdk.store.abstractions.product.RawStoreProduct import kotlinx.coroutines.CompletableDeferred @@ -140,14 +141,21 @@ class ExternalNativePurchaseController( offerType = offerId?.let { OfferType.Offer(id = it) }, ) - val offerToken = rawStoreProduct.selectedOffer?.offerToken ?: "" + val offerToken = rawStoreProduct.selectedOffer?.offerToken + + val isOneTime = productDetails.productType == BillingClient.ProductType.INAPP && offerToken.isNullOrEmpty() val productDetailsParams = BillingFlowParams.ProductDetailsParams .newBuilder() .setProductDetails(productDetails) - .setOfferToken(offerToken) - .build() + .also { + // Do not set empty offer token for one time products + // as Google play is not supporting it since June 12th 2024 + if (!isOneTime) { + it.setOfferToken(offerToken ?: "") + } + }.build() val flowParams = BillingFlowParams diff --git a/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt b/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt new file mode 100644 index 00000000..5ec68173 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt @@ -0,0 +1,101 @@ +package com.superwall.sdk.utilities + +import com.superwall.sdk.Superwall +import com.superwall.sdk.analytics.internal.track +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.storage.ErrorLog +import com.superwall.sdk.storage.Storage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +internal interface ErrorTracking { + fun trackError(throwable: Throwable) + + @Serializable + data class ErrorOccurence( + @SerialName("message") + val message: String, + @SerialName("stacktrace") + val stacktrace: String, + @SerialName("timestamp") + val timestamp: Long, + ) +} + +/** + * Used to track errors that occur in the SDK. + * When an error occurs, it is stored in the cache and sent to the server when the SDK is initialized. + * This ensures the error is logged even with the crash occurring. We only save a single error + * in cache at a time, to ensure only the crashing error is logged. This also helps us avoid logging the + * same error multiple times without relying on hashcodes or unique identifier. + **/ +internal class ErrorTracker( + scope: CoroutineScope, + private val cache: Storage, + private val track: suspend (InternalSuperwallEvent.ErrorThrown) -> Unit = { + Superwall.instance.track( + it, + ) + }, +) : ErrorTracking { + init { + val exists = cache.get(ErrorLog) + if (exists != null) { + scope.launch { + track( + InternalSuperwallEvent.ErrorThrown( + exists.message, + exists.stacktrace, + exists.timestamp, + ), + ) + cache.remove(ErrorLog) + } + } + } + + override fun trackError(throwable: Throwable) { + val errorOccurence = + ErrorTracking.ErrorOccurence( + message = throwable.message ?: "", + stacktrace = throwable.stackTraceToString(), + timestamp = System.currentTimeMillis(), + ) + cache.save(errorOccurence, ErrorLog) + } +} + +// Utility methods and closures for error tracking + +internal fun Superwall.trackError(e: Throwable) { + dependencyContainer.errorTracker.trackError(e) +} + +internal fun withErrorTracking(block: () -> Unit) { + try { + block() + } catch (e: Throwable) { + Superwall.instance.trackError(e) + throw e + } +} + +internal suspend fun withErrorTrackingAsync(block: suspend () -> T): T { + try { + return block() + } catch (e: Throwable) { + Superwall.instance.trackError(e) + throw e + } +} + +internal fun withErrorTracking(block: () -> T): T { + try { + return block() + } catch (e: Throwable) { + Superwall.instance.trackError(e) + throw e + } +} diff --git a/superwall/src/main/res/layout/activity_bottom_sheet.xml b/superwall/src/main/res/layout/activity_bottom_sheet.xml new file mode 100644 index 00000000..1f7d4115 --- /dev/null +++ b/superwall/src/main/res/layout/activity_bottom_sheet.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/superwall/src/test/java/com/superwall/sdk/models/config/ConfigTest.kt b/superwall/src/test/java/com/superwall/sdk/models/config/ConfigTest.kt index cdc14db9..bc865e8d 100644 --- a/superwall/src/test/java/com/superwall/sdk/models/config/ConfigTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/models/config/ConfigTest.kt @@ -93,6 +93,8 @@ class ConfigTest { "id": "571", "url": "https://www.fitnessai.com/superwall-video?sw_cache_key=1659989801716", "name": "Example Paywall", + "cache_key" : "123example", + "build_id" : "123example", "identifier": "example-paywall-4de1-2022-03-15", "slug": "example-paywall-4de1-2022-03-15", "paywalljs_event": "W3siZXZlbnRfbmFtZSI6InRlbXBsYXRlX3N1YnN0aXR1dGlvbnMiLCJzdWJzdGl0dXRpb25zIjpbeyJrZXkiOiJUaXRsZSIsInZhbHVlIjoiVGVzdCJ9LHsia2V5IjoiVGltZWxpbmUgUm93IDEgVGl0bGUiLCJ2YWx1ZSI6IlRvZGF5IiwiZnJlZVRyaWFsVmFsdWUiOiJUb2RheSJ9LHsia2V5IjoiVGltZWxpbmUgUm93IDEgU3VidGl0bGUiLCJ2YWx1ZSI6IkdldCBmdWxsIGFjY2VzcyB0byBhbGwgb3VyIGZlYXR1cmVzIn0seyJrZXkiOiJUaW1lbGluZSBSb3cgMiBUaXRsZSIsInZhbHVlIjoiSW4gNSBEYXlzIn0seyJrZXkiOiJUaW1lbGluZSBSb3cgMiBTdWJ0aXRsZSIsInZhbHVlIjoiR2V0IGEgcmVtaW5kZXIgYWJvdXQgd2hlbiB5b3VyIGZyZWUgdHJpYWwgZW5kcyJ9LHsia2V5IjoiVGltZWxpbmUgUm93IDMgVGl0bGUiLCJ2YWx1ZSI6IkluIDcgRGF5cyJ9LHsia2V5IjoiVGltZWxpbmUgUm93IDMgU3VidGl0bGUiLCJ2YWx1ZSI6IkdldCBiaWxsZWQsIHVubGVzcyB5b3UgY2FuY2VsIGFueXRpbWUgYmVmb3JlIn0seyJrZXkiOiJSZXN0b3JlIExhYmVsIiwidmFsdWUiOiJBbHJlYWR5IHN1YnNjcmliZWQ/In0seyJrZXkiOiJQcmltYXJ5IFByb2R1Y3QgU3RyaWtlIFRocm91Z2giLCJ2YWx1ZSI6IiQwLjAwIn0seyJrZXkiOiJQcmltYXJ5IFByb2R1Y3QgTGluZSAxIiwidmFsdWUiOiIkMC4wMCBwZXIgcGVyaW9kIn0seyJrZXkiOiJQcmltYXJ5IFByb2R1Y3QgTGluZSAyIiwidmFsdWUiOiIwLWRheSBmcmVlIHRyaWFsIn0seyJrZXkiOiJQcmltYXJ5IFByb2R1Y3QgQmFkZ2UiLCJ2YWx1ZSI6IkJlc3QgVmFsdWUifSx7ImtleSI6IlNlY29uZGFyeSBQcm9kdWN0IExpbmUgMSIsInZhbHVlIjoiJDAuMDAgcGVyIHBlcmlvZCJ9LHsia2V5IjoiU2Vjb25kYXJ5IFByb2R1Y3QgTGluZSAyIiwidmFsdWUiOiIwLWRheSBmcmVlIHRyaWFsIn0seyJrZXkiOiJUZXJ0aWFyeSBQcm9kdWN0IExpbmUgMSIsInZhbHVlIjoiJDAuMDAgcGVyIHBlcmlvZCJ9LHsia2V5IjoiVGVydGlhcnkgUHJvZHVjdCBMaW5lIDIiLCJ2YWx1ZSI6IjAtZGF5IGZyZWUgdHJpYWwifSx7ImtleSI6IlByaW1hcnkgQ1RBIFN1YnRpdGxlIiwidmFsdWUiOiIkMC4wMC95ciBBZnRlciBZb3VyIEZyZWUgVHJpYWwifSx7ImtleSI6IlNlY29uZGFyeSBDVEEgU3VidGl0bGUiLCJ2YWx1ZSI6IiQwLjAwL3lyIEFmdGVyIFlvdXIgRnJlZSBUcmlhbCJ9LHsia2V5IjoiVGVydGlhcnkgQ1RBIFN1YnRpdGxlIiwidmFsdWUiOiIkMC4wMC95ciBBZnRlciBZb3VyIEZyZWUgVHJpYWwifSx7ImtleSI6Ik90aGVyIFBsYW5zIEJ1dHRvbiIsInZhbHVlIjoiT3RoZXIgUGxhbnMifSx7ImtleSI6IlB1cmNoYXNlIFByaW1hcnkiLCJ2YWx1ZSI6IkNvbnRpbnVlIn0seyJrZXkiOiJQdXJjaGFzZSBTZWNvbmRhcnkiLCJ2YWx1ZSI6IkNvbnRpbnVlIn0seyJrZXkiOiJQdXJjaGFzZSBUZXJ0aWFyeSIsInZhbHVlIjoiQ29udGludWUifSx7ImtleSI6InB1cmNoYXNlLXByaW1hcnkiLCJ2YWx1ZSI6IkNvbnRpbnVlIn0seyJrZXkiOiJwdXJjaGFzZS1zZWNvbmRhcnkiLCJ2YWx1ZSI6IkNvbnRpbnVlIn0seyJrZXkiOiJwdXJjaGFzZS10ZXJ0aWFyeSIsInZhbHVlIjoiQ29udGludWUifSx7ImtleSI6InRpdGxlIiwidmFsdWUiOiJ7e3ByaW1hcnkudHJpYWxQZXJpb2REYXlzfX0gZGF5cyBGUkVFIHRoZW4ge3twcmltYXJ5LmRhaWx5UHJpY2V9fS9kYXkgYmlsbGVkIGV2ZXJ5IHt7cHJpbWFyeS5wZXJpb2R9fSJ9LHsia2V5Ijoic3VidGl0bGUiLCJ2YWx1ZSI6IkhleSAge3sgdXNlci5maXJzdE5hbWUgfX0hXG5Pbmx5ICQxLjczIHBlciB3ZWVrIGJpbGxlZCBhbm51YWxseS4gVGhhdCdzIDUwLTEwMHggY2hlYXBlciB0aGFuIGEgdHJhaW5lci48YnI+In0seyJrZXkiOiJidWxsZXQtMSIsInZhbHVlIjoiT3B0aW1pemVkIHdvcmtvdXRzIGV2ZXJ5ZGF5PGJyPiJ9LHsia2V5IjoiYnVsbGV0LTIiLCJ2YWx1ZSI6IkFkYXB0ZWQgdG8geW91ciBzdHJlbmd0aDxicj4ifSx7ImtleSI6ImJ1bGxldC0zIiwidmFsdWUiOiIzNDcrIGRldGFpbGVkIGV4ZXJjaXNlIGd1aWRlczxicj4ifSx7ImtleSI6ImJ1bGxldC00IiwidmFsdWUiOiJXb3JsZCByZW5vd25lZCBjb2FjaGVzPGJyPiJ9LHsia2V5IjoiYnVsbGV0LTUiLCJ2YWx1ZSI6IkxpZmUgY2hhbmdpbmcgYWR2aWNlPGJyPiJ9LHsia2V5IjoiYnVsbGV0LTYiLCJ2YWx1ZSI6Ik92ZXIgMSwwMDAsMDAwIGhhcHB5IHVzZXJzPGJyPiJ9LHsia2V5IjoidGltZWxpbmUgdGl0bGUiLCJ2YWx1ZSI6IlNvIGhvdyBkb2VzIG15IGZyZWUgdHJpYWwgd29yaz8ifSx7ImtleSI6ImNhbGxvdXQtYmFkZ2UiLCJ2YWx1ZSI6IjQwJSBPRkYifSx7ImtleSI6ImNhbGxvdXQgdGl0bGUiLCJ2YWx1ZSI6Ikp1c3QgJDEuNzMgcGVyIHdlZWsifSx7ImtleSI6ImNhbGxvdXQgc3VidGl0bGUiLCJ2YWx1ZSI6IjcgZGF5cyBmcmVlLCBjYW5jZWwgYW55dGltZTxicj4ifSx7ImtleSI6InB1cmNoYXNlIGJ1dHRvbiBzdWJ0aXRsZSIsInZhbHVlIjoiNyBkYXlzIGZyZWUgdGhlbiBvbmx5IHt7IHByaW1hcnkucHJpY2UgfX0gcGVyIHlyPGJyPiJ9LHsia2V5IjoiUGFyYWdyYXBoIiwidmFsdWUiOiI8cCBjbGFzcz1cInBhcmFncmFwaC10ZXh0IGxlZnQtYWxpZ25cIj5UaGlzIGlzIHRoZSBwYXJhZ3JhcGggZWxlbWVudDxicj5saW5lIDI8L3A+In0seyJrZXkiOiJGb290bm90ZSIsInZhbHVlIjoiPHAgY2xhc3M9XCJmb290bm90ZS10ZXh0IGxlZnQtYWxpZ25cIj5UaGlzIGlzIHRoZSBmb290bm90ZSBlbGVtZW50PGJyPmxpbmUgMjwvcD4ifSx7ImtleSI6IkhlYWRpbmciLCJ2YWx1ZSI6IkhlYWRpbmc8YnI+bGluZSAyIn0seyJrZXkiOiJTdWJoZWFkaW5nIiwidmFsdWUiOiJUaGlzIGlzIHRoZSBzdWJoZWFkaW5nPGJyPmxpbmUgMiJ9LHsia2V5IjoiQmFkZ2UgVGl0bGUiLCJ2YWx1ZSI6IjctRGF5IEZyZWUgVHJpYWwifSx7ImtleSI6IkNhbGxvdXQgVGl0bGUiLCJ2YWx1ZSI6Ik9ubHkgJDUyL3lyIGZvciBhIGxpbWl0ZWQgdGltZSJ9LHsia2V5IjoiQ2FsbG91dCBTdWJ0aXRsZSIsInZhbHVlIjoiSW5jbHVkZXMgYSA3LWRheSBmcmVlIHRyaWFsIn0seyJrZXkiOiJDYWxsb3V0IEJhZGdlIFRleHQiLCJ2YWx1ZSI6IjQwJSBvZmYifSx7ImtleSI6IlJhdGluZyBWYWx1ZSIsInZhbHVlIjoiNC43In0seyJrZXkiOiJSYXRpbmcgTGFiZWwiLCJ2YWx1ZSI6IkF2ZXJhZ2UgUmF0aW5nIn0seyJrZXkiOiJSZXZpZXcgVGl0bGUiLCJ2YWx1ZSI6IlRoaXMgaXMgdGhlIGJlc3QgYXBwIG9mIGFsbCB0aW1lIn0seyJrZXkiOiJSZXZpZXcgQm9keSIsInZhbHVlIjoiVGhpcyBpcyB0aGUgcGFyYWdyYXBoIGVsZW1lbnQifSx7ImtleSI6IlJldmlldyBBdXRob3IiLCJ2YWx1ZSI6IuKAkyBKYWtlIE1vciJ9LHsia2V5IjoiTGlzdCBJdGVtIFRleHQiLCJ2YWx1ZSI6IlRoaXMgaXMgYSBsaXN0IGl0ZW0ifSx7ImtleSI6Ikxpc3QgSXRlbSBUaXRsZSIsInZhbHVlIjoiSGVhZGluZyJ9LHsia2V5IjoiTGlzdCBJdGVtIFN1YnRpdGxlIiwidmFsdWUiOiJUaGlzIGlzIHRoZSBzdWJoZWFkaW5nIn0seyJrZXkiOiJDaGVja2xpc3QgUm93IDEiLCJ2YWx1ZSI6IkNhbmNlbCBhbnl0aW1lIGluIHNlY29uZHMifSx7ImtleSI6IkNoZWNrbGlzdCBSb3cgMiIsInZhbHVlIjoiVG9ucyBvZiBpbmNyZWRpYmxlIGZlYXR1cmVzIn0seyJrZXkiOiJDaGVja2xpc3QgUm93IDMiLCJ2YWx1ZSI6IlBheW1lbnQgcHJvdGVjdGlvbiBwb2xpY3kifSx7ImtleSI6IkNoZWNrbGlzdCBSb3cgNCIsInZhbHVlIjoiRXhjZWxsZW50IGN1c3RvbWVyIHN1cHBvcnQifSx7ImtleSI6IkZBUSBRdWVzdGlvbiIsInZhbHVlIjoiRG8geW91IGhhdmUgZWxlbWVudHMgZm9yIEZBUXM/In0seyJrZXkiOiJGQVEgQW5zd2VyIiwidmFsdWUiOiJZZXMhIFdlIGFic29sdXRlbHkgZG8uIFdlIGhhdmUgbW9yZSBlbGVtZW50cyB0aGFuIHlvdSBtaWdodCB0aGluayA7KSJ9LHsia2V5IjoiVGFibGUgQ29sIDEgVGl0bGUiLCJ2YWx1ZSI6IkhlYWRlciJ9LHsia2V5IjoiVGFibGUgQ29sIDIgVGl0bGUiLCJ2YWx1ZSI6IkZyZWUifSx7ImtleSI6IlRhYmxlIENvbCAzIFRpdGxlIiwidmFsdWUiOiJQcmVtaXVtIn0seyJrZXkiOiJUYWJsZSBSb3cgMSIsInZhbHVlIjoiRmVhdHVyZSAxIn0seyJrZXkiOiJUYWJsZSBSb3cgMiIsInZhbHVlIjoiRmVhdHVyZSAyIn0seyJrZXkiOiJUYWJsZSBSb3cgMyIsInZhbHVlIjoiRmVhdHVyZSAzIn0seyJrZXkiOiJUYWJsZSBSb3cgNCIsInZhbHVlIjoiRmVhdHVyZSA0In0seyJrZXkiOiJUZWFtIE1lc3NhZ2UgVGl0bGUiLCJ2YWx1ZSI6Ik91ciBQcm9taXNlIn0seyJrZXkiOiJUZWFtIE1lc3NhZ2UgQm9keSIsInZhbHVlIjoiUGllZCBQaXBlciBoYXMgY2hhbmdlZCBtYW55IGxhbmRzY2FwZXMuIENvbXByZXNzaW9uLiBEYXRhLiBUaGUgSW50ZXJuZXQuPGJyPjxicj5PdXIgcHJvbWlzZSBpcyB0byBjb250aW51ZSB0byBjaGFuZ2UgdGhpbmdzIOKAlCBub3QgZm9yIHRoZSBzYWtlIG9mIGNoYW5nZSwgYnV0IHRvIG1ha2UgdGhlIHdvcmxkIGEgYmV0dGVyIHBsYWNlLCB1c2luZyBtaWRkbGUgb3V0IGNvbXByZXNzaW9uIGZvciBsb3NzbGVzcyBkYXRhIHByZXNlcnZhdGlvbi48YnI+PGJyPkFsc28sIGxvc2luZyBULkouIE1pbGxlciBpbiBzZWFzb24gNSB3YXMgYWJzb2x1dGVseSBoZWFyYnJlYWtpbmcuPGJyPiJ9LHsia2V5IjoiVGVhbSBNZXNzYWdlIEF1dGhvciIsInZhbHVlIjoiUmljaGFyZCBIZW5kcmlja3MifSx7ImtleSI6IlRlYW0gTWVzc2FnZSBBdXRob3IgVGl0bGUiLCJ2YWx1ZSI6IkZvdW5kZXIgJmFtcDsmbmJzcDtDRU8ifSx7ImtleSI6IkZlYXR1cmUgSGVhZGluZyIsInZhbHVlIjoiSGVhZGluZyJ9LHsia2V5IjoiRmVhdHVyZSBTdWJoZWFkaW5nIiwidmFsdWUiOiJUaGlzIGlzIHRoZSBzdWJoZWFkaW5nIn0seyJrZXkiOiJQcm9kdWN0IENUQSBTdWJ0aXRsZSIsInZhbHVlIjoiV2UnbGwgcmVtaW5kIHlvdSBiZWZvcmUgeW91J3JlIGNoYXJnZWQifSx7ImtleSI6IlByaW1hcnkgQnV0dG9uIExpbmUgMiIsInZhbHVlIjoiT25seSAkMi45OS93ZWVrIGFmdGVyIHlvdXIgdHJpYWwifSx7ImtleSI6IkJ1dHRvbiBTZWN0aW9uIFRpdGxlIiwidmFsdWUiOiJOb3QgY29udmluY2VkIHlldD8ifSx7ImtleSI6Im9wZW4tdXJsLTEiLCJ2YWx1ZSI6IlByaXZhY3kgUG9saWN5Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJhIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoiY2xpY2stYmVoYXZpb3IiLCJjbGlja0JlaGF2aW9yIjp7InR5cGUiOiJvcGVuLXVybCIsInVybCI6Imh0dHBzOi8vbW9vbnNldGxhYnMuY29tL2ZpdG5lc3MtYWkvcHJpdmFjeV9wb2xpY3kuaHRtbCJ9fX1dfSx7ImtleSI6Im9wZW4tdXJsLTIiLCJ2YWx1ZSI6IlRlcm1zIG9mIFVzZSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiYSIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6ImNsaWNrLWJlaGF2aW9yIiwiY2xpY2tCZWhhdmlvciI6eyJ0eXBlIjoib3Blbi11cmwiLCJ1cmwiOiJodHRwczovL21vb25zZXRsYWJzLmNvbS9maXRuZXNzLWFpL3Rlcm1zX2FuZF9jb25kaXRpb25zLmh0bWwifX19XX0seyJrZXkiOiJjbG9zZS0xIiwidmFsdWUiOiJFWElUIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJhIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoiY2xpY2stYmVoYXZpb3IiLCJjbGlja0JlaGF2aW9yIjp7InR5cGUiOiJjbG9zZSJ9fX1dfSx7ImtleSI6InJlc3RvcmUtMSIsInZhbHVlIjoiPGRpdiBjbGFzcz1cInRleHQtYmxvY2stMjFcIj5BbHJlYWR5IGEgbWVtYmVyPzwvZGl2PjxkaXYgY2xhc3M9XCJ0ZXh0LWJsb2NrLTIwXCI+UmVzdG9yZTwvZGl2PiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoiY2xpY2stYmVoYXZpb3IiLCJjbGlja0JlaGF2aW9yIjp7InR5cGUiOiJyZXN0b3JlIn19fV19LHsia2V5IjoiY3VzdG9tLTEiLCJ2YWx1ZSI6Ik1lc3NhZ2UgVXMiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6ImNsaWNrLWJlaGF2aW9yIiwiY2xpY2tCZWhhdmlvciI6eyJ0eXBlIjoiY3VzdG9tIiwiZGF0YSI6ImludGVyY29tIn19fV19XX1d", @@ -429,8 +431,8 @@ class ConfigTest { ], "presentation_condition": "CHECK_USER_SUBSCRIPTION", "presentation_delay": 0, - "presentation_style": "FULLSCREEN", - "presentation_style_v2": "FULLSCREEN", + "presentation_style": "NO_ANIMATION", + "presentation_style_v2": "NO_ANIMATION", "launch_option": "EXPLICIT", "dismissal_option": "NORMAL", "background_color_hex": "#000000" @@ -455,7 +457,8 @@ class ConfigTest { "disable_preload": { "all": false, "triggers": [] - } + }, + "build_id": "test" } """.trimIndent()