diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index 2ea735435..2f2376c36 100644
--- a/.github/badges/branches.svg
+++ b/.github/badges/branches.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8618d9c7b..e98d66d1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,25 +2,21 @@
The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub.
-## Unreleased
+## 2.8.2
## Enhancements
-- Significantly improves paywall loading
+- Significantly improves paywall loading times, reducing memory and CPU footprint when preloading
- Implicit placements that map to no campaign (e.g. `app_launch`, `session_start`) no longer occupy the presentation queue ahead of `register()` calls while waiting for entitlements.
- Config refreshes now diff paywalls by cache key and only evict changed ones from the request cache, so unchanged preloaded paywalls stay warm across refreshes.
-- The loading shimmer no longer forces a software layer.
-- Logs are now delivered on a dedicated background thread, so neither the console write nor a delegate's `handleLog` implementation costs frames on the thread that produced the log. Ordering is preserved, and one log's output can no longer interleave with another's.
-- Deciding whether a log is worth building no longer resolves the dependency container, making suppressed logs close to free.
+- The loading shimmer no longer forces a software layer
+- Logs are now delivered on a dedicated background thread
## Fixes
-- A failed subresource (image, font, analytics beacon) no longer restarts the whole paywall page load or counts toward fallback-URL attempts; only main-frame failures (and failures of the paywall runtime bundle) do. Transient main-frame failures are now retried (bounded) on Android 8+, where previously they were not retried at all.
- The popup presentation style's entrance animation no longer stretches to the paywall's configured loading delay, and the delay no longer postpones hiding the spinner after a purchase completes.
- `paywall_resourceLoad_fail` events now report the failing resource's URL.
-- Console logs carrying an `info` map no longer drop the log's message, and an empty `info` map no longer prints an empty `info: {}` line in its place.
- A `SuperwallDelegate.handleLog` implementation that throws no longer propagates the exception into whatever SDK code produced the log.
-## Breaking Changes
-- Removes `PaywallViewCache.entries`, a non-functional property that only ever contained a stale construction-time snapshot.
+## Potentially Breaking Changes
- `SuperwallDelegate.handleLog` is now always called on a background thread. It could previously be called on any thread, including main, so implementations that touch UI directly must now dispatch to the main thread themselves.
## 2.8.1
diff --git a/superwall/src/main/java/com/superwall/sdk/billing/BillingClientUseCase.kt b/superwall/src/main/java/com/superwall/sdk/billing/BillingClientUseCase.kt
index 4e04f3d65..d63c3f734 100644
--- a/superwall/src/main/java/com/superwall/sdk/billing/BillingClientUseCase.kt
+++ b/superwall/src/main/java/com/superwall/sdk/billing/BillingClientUseCase.kt
@@ -109,7 +109,18 @@ internal abstract class BillingClientUseCase(
val underlyingErrorMessage =
"Error loading products - DebugMessage: ${billingResult.debugMessage} " +
"ErrorCode: ${billingResult.responseCode}."
- val error = BillingError.BillingNotAvailable(underlyingErrorMessage)
+ val error =
+ when (billingResult.responseCode) {
+ BillingClient.BillingResponseCode.BILLING_UNAVAILABLE,
+ BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED,
+ -> BillingError.BillingNotAvailable(underlyingErrorMessage)
+
+ else ->
+ BillingError.WithCode(
+ code = billingResult.responseCode,
+ description = billingResult.debugMessage,
+ )
+ }
Logger.debug(
logLevel = LogLevel.error,
scope = LogScope.productsManager,
diff --git a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt
index d5128505d..fc547d566 100644
--- a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt
+++ b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt
@@ -27,8 +27,10 @@ import com.superwall.sdk.store.abstractions.transactions.StoreTransaction
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -459,11 +461,13 @@ class GoogleBillingWrapper(
* country of the user's Play Store account. Waits for the billing client to connect
* if it hasn't yet; resolves to `null` if billing is unavailable or the fetch fails.
*/
- override suspend fun getStorefrontCountryCode(): String? =
- suspendCoroutine { continuation ->
+ override suspend fun getStorefrontCountryCode(): String? {
+ val result = CompletableDeferred(parent = currentCoroutineContext()[Job])
+
+ try {
executeRequestOnUIThread { connectionError ->
if (connectionError != null) {
- continuation.resume(null)
+ result.complete(null)
return@executeRequestOnUIThread
}
val dispatched =
@@ -472,7 +476,7 @@ class GoogleBillingWrapper(
GetBillingConfigParams.newBuilder().build(),
) { billingResult, billingConfig ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && billingConfig != null) {
- continuation.resume(billingConfig.countryCode)
+ result.complete(billingConfig.countryCode)
} else {
Logger.debug(
LogLevel.debug,
@@ -480,15 +484,20 @@ class GoogleBillingWrapper(
"Failed to fetch billing config: ${billingResult.debugMessage} " +
"ErrorCode: ${billingResult.responseCode}",
)
- continuation.resume(null)
+ result.complete(null)
}
}
}
if (dispatched == null) {
- continuation.resume(null)
+ result.complete(null)
}
}
+
+ return result.await()
+ } finally {
+ result.cancel()
}
+ }
override fun onBillingSetupFinished(billingResult: BillingResult) {
threadHandler.post {
diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt
index 2752f9115..7e974edf7 100644
--- a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt
+++ b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt
@@ -36,63 +36,137 @@ class SuperwallDelegateAdapter {
hasAnyDelegate = value != null || kotlinDelegate != null
}
+ private inline fun dispatch(
+ callbackName: String,
+ kotlinCallback: (SuperwallDelegate) -> Unit,
+ javaCallback: (SuperwallDelegateJava) -> Unit,
+ ) {
+ kotlinDelegate?.let { delegate ->
+ invokeSafely(callbackName) { kotlinCallback(delegate) }
+ return
+ }
+
+ javaDelegate?.let { delegate ->
+ invokeSafely(callbackName) { javaCallback(delegate) }
+ }
+ }
+
+ private inline fun invokeSafely(
+ callbackName: String,
+ callback: () -> Unit,
+ ) {
+ try {
+ callback()
+ } catch (error: LinkageError) {
+ reportDelegateFailure(callbackName, error)
+ } catch (exception: Exception) {
+ reportDelegateFailure(callbackName, exception)
+ }
+ }
+
+ /**
+ * Delegate failures cannot be reported through [com.superwall.sdk.logger.Logger], because a
+ * failing `handleLog` implementation would recursively call the same delegate.
+ */
+ private fun reportDelegateFailure(
+ callbackName: String,
+ throwable: Throwable,
+ ) {
+ System.err.println(
+ "[!!Superwall] Delegate callback $callbackName failed: " +
+ "${throwable.javaClass.name}: ${throwable.localizedMessage}",
+ )
+ }
+
fun handleCustomPaywallAction(name: String) {
- kotlinDelegate?.handleCustomPaywallAction(name)
- ?: javaDelegate?.handleCustomPaywallAction(name)
+ dispatch(
+ callbackName = "handleCustomPaywallAction",
+ kotlinCallback = { it.handleCustomPaywallAction(name) },
+ javaCallback = { it.handleCustomPaywallAction(name) },
+ )
}
fun didRedeemLink(result: RedemptionResult) {
- kotlinDelegate?.didRedeemLink(result)
- ?: javaDelegate?.didRedeemLink(result)
+ dispatch(
+ callbackName = "didRedeemLink",
+ kotlinCallback = { it.didRedeemLink(result) },
+ javaCallback = { it.didRedeemLink(result) },
+ )
}
fun willRedeemLink() {
- kotlinDelegate?.willRedeemLink()
- ?: javaDelegate?.willRedeemLink()
+ dispatch(
+ callbackName = "willRedeemLink",
+ kotlinCallback = { it.willRedeemLink() },
+ javaCallback = { it.willRedeemLink() },
+ )
}
fun willDismissPaywall(paywallInfo: PaywallInfo) {
- kotlinDelegate?.willDismissPaywall(paywallInfo)
- ?: javaDelegate?.willDismissPaywall(paywallInfo)
+ dispatch(
+ callbackName = "willDismissPaywall",
+ kotlinCallback = { it.willDismissPaywall(paywallInfo) },
+ javaCallback = { it.willDismissPaywall(paywallInfo) },
+ )
}
fun didDismissPaywall(paywallInfo: PaywallInfo) {
- kotlinDelegate?.didDismissPaywall(paywallInfo)
- ?: javaDelegate?.didDismissPaywall(paywallInfo)
+ dispatch(
+ callbackName = "didDismissPaywall",
+ kotlinCallback = { it.didDismissPaywall(paywallInfo) },
+ javaCallback = { it.didDismissPaywall(paywallInfo) },
+ )
}
fun willPresentPaywall(paywallInfo: PaywallInfo) {
- kotlinDelegate?.willPresentPaywall(paywallInfo)
- ?: javaDelegate?.willPresentPaywall(paywallInfo)
+ dispatch(
+ callbackName = "willPresentPaywall",
+ kotlinCallback = { it.willPresentPaywall(paywallInfo) },
+ javaCallback = { it.willPresentPaywall(paywallInfo) },
+ )
}
fun didPresentPaywall(paywallInfo: PaywallInfo) {
- kotlinDelegate?.didPresentPaywall(paywallInfo)
- ?: javaDelegate?.didPresentPaywall(paywallInfo)
+ dispatch(
+ callbackName = "didPresentPaywall",
+ kotlinCallback = { it.didPresentPaywall(paywallInfo) },
+ javaCallback = { it.didPresentPaywall(paywallInfo) },
+ )
}
fun paywallWillOpenURL(url: URI) {
- kotlinDelegate?.paywallWillOpenURL(url)
- ?: javaDelegate?.paywallWillOpenURL(url)
+ dispatch(
+ callbackName = "paywallWillOpenURL",
+ kotlinCallback = { it.paywallWillOpenURL(url) },
+ javaCallback = { it.paywallWillOpenURL(url) },
+ )
}
fun paywallWillOpenDeepLink(url: Uri) {
- kotlinDelegate?.paywallWillOpenDeepLink(url)
- ?: javaDelegate?.paywallWillOpenDeepLink(url)
+ dispatch(
+ callbackName = "paywallWillOpenDeepLink",
+ kotlinCallback = { it.paywallWillOpenDeepLink(url) },
+ javaCallback = { it.paywallWillOpenDeepLink(url) },
+ )
}
fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
- // Calling this until we deprecate it
- kotlinDelegate?.handleSuperwallEvent(eventInfo)
- ?: javaDelegate?.handleSuperwallEvent(eventInfo)
+ dispatch(
+ callbackName = "handleSuperwallEvent",
+ kotlinCallback = { it.handleSuperwallEvent(eventInfo) },
+ javaCallback = { it.handleSuperwallEvent(eventInfo) },
+ )
}
fun subscriptionStatusDidChange(
from: com.superwall.sdk.models.entitlements.SubscriptionStatus,
to: com.superwall.sdk.models.entitlements.SubscriptionStatus,
) {
- kotlinDelegate?.subscriptionStatusDidChange(from, to)
- ?: javaDelegate?.subscriptionStatusDidChange(from, to)
+ dispatch(
+ callbackName = "subscriptionStatusDidChange",
+ kotlinCallback = { it.subscriptionStatusDidChange(from, to) },
+ javaCallback = { it.subscriptionStatusDidChange(from, to) },
+ )
}
fun handleLog(
@@ -102,33 +176,45 @@ class SuperwallDelegateAdapter {
info: Map?,
error: Throwable?,
) {
- kotlinDelegate?.handleLog(
- level = level,
- scope = scope,
- message = message,
- info = info,
- error = error,
- ) ?: javaDelegate?.handleLog(
- level = level,
- scope = scope,
- message = message,
- info = info,
- error = error,
+ dispatch(
+ callbackName = "handleLog",
+ kotlinCallback = {
+ it.handleLog(
+ level = level,
+ scope = scope,
+ message = message,
+ info = info,
+ error = error,
+ )
+ },
+ javaCallback = {
+ it.handleLog(
+ level = level,
+ scope = scope,
+ message = message,
+ info = info,
+ error = error,
+ )
+ },
)
}
fun userAttributesDidChange(newAttributes: Map) {
- kotlinDelegate?.userAttributesDidChange(newAttributes)
- ?: javaDelegate?.userAttributesDidChange(newAttributes)
+ dispatch(
+ callbackName = "userAttributesDidChange",
+ kotlinCallback = { it.userAttributesDidChange(newAttributes) },
+ javaCallback = { it.userAttributesDidChange(newAttributes) },
+ )
}
fun customerInfoDidChange(
from: CustomerInfo,
to: CustomerInfo,
) {
- kotlinDelegate?.customerInfoDidChange(from, to) ?: javaDelegate?.customerInfoDidChange(
- from,
- to,
+ dispatch(
+ callbackName = "customerInfoDidChange",
+ kotlinCallback = { it.customerInfoDidChange(from, to) },
+ javaCallback = { it.customerInfoDidChange(from, to) },
)
}
}
diff --git a/superwall/src/main/java/com/superwall/sdk/misc/CurrentActivityTracker.kt b/superwall/src/main/java/com/superwall/sdk/misc/CurrentActivityTracker.kt
index 410767e39..bf5da4329 100644
--- a/superwall/src/main/java/com/superwall/sdk/misc/CurrentActivityTracker.kt
+++ b/superwall/src/main/java/com/superwall/sdk/misc/CurrentActivityTracker.kt
@@ -53,6 +53,7 @@ class CurrentActivityTracker :
override fun onActivityStopped(activity: Activity) {
if (currentActivity?.get() === activity) {
+ currentActivity = null
activityState.value = null
}
}
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPresenter.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPresenter.kt
index 28b15fcb6..c1bab7dac 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPresenter.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPresenter.kt
@@ -16,6 +16,7 @@ import com.superwall.sdk.paywall.presentation.internal.request.PresentationInfo
import com.superwall.sdk.paywall.presentation.internal.state.PaywallState
import com.superwall.sdk.paywall.presentation.rule_logic.RuleEvaluationOutcome
import com.superwall.sdk.paywall.view.PaywallView
+import com.superwall.sdk.paywall.view.canPresentPaywall
import kotlinx.coroutines.flow.MutableSharedFlow
/**
@@ -64,11 +65,11 @@ internal suspend fun getPresenterIfNecessary(
val currentActivity = activity()
- if (currentActivity == null) {
+ if (currentActivity == null || !currentActivity.canPresentPaywall()) {
Logger.debug(
logLevel = LogLevel.error,
scope = LogScope.paywallPresentation,
- message = "Current Activity is null, can't present paywall",
+ message = "Current Activity is unavailable or no longer started, can't present paywall",
)
val error =
InternalPresentationLogic.presentationError(
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 d9dee297e..618d7e4ce 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt
@@ -13,6 +13,7 @@ import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationReques
import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatusReason
import com.superwall.sdk.paywall.presentation.internal.PresentationRequest
import com.superwall.sdk.paywall.presentation.internal.state.PaywallState
+import com.superwall.sdk.paywall.view.PaywallActivityLaunchException
import com.superwall.sdk.paywall.view.PaywallView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -89,6 +90,10 @@ suspend fun Superwall.presentPaywallView(
throw PaywallPresentationRequestStatusReason.PaywallAlreadyPresented()
}
}
+ } catch (error: PaywallActivityLaunchException) {
+ paywallStatePublisher.emit(PaywallState.PresentationError(error))
+ logErrors(request, error = error)
+ throw error
} catch (error: Throwable) {
logErrors(request, error = error)
throw error
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallActivityLauncher.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallActivityLauncher.kt
new file mode 100644
index 000000000..05e577cbd
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallActivityLauncher.kt
@@ -0,0 +1,71 @@
+package com.superwall.sdk.paywall.view
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+
+internal class PaywallActivityLaunchException(
+ message: String,
+ cause: Throwable? = null,
+) : RuntimeException(message, cause)
+
+/**
+ * Whether an Activity is still in a state from which it is safe to launch the paywall Activity.
+ *
+ * The LifecycleOwner check covers ComponentActivity/AppCompatActivity presenters. Plain framework
+ * Activities do not expose lifecycle state, so the platform finishing/destroyed checks remain the
+ * best validation available for those callers.
+ */
+internal fun Activity.canPresentPaywall(): Boolean {
+ if (isFinishing || isDestroyed) {
+ return false
+ }
+
+ return this !is LifecycleOwner || lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
+}
+
+/**
+ * Contains exceptions thrown by the Android activity-launch boundary only. Callers remain
+ * responsible for preparing the PaywallView before invoking this function, so preparation errors
+ * are never mistaken for platform launch failures.
+ */
+internal fun launchPaywallActivity(
+ context: Context,
+ intent: Intent,
+): Result {
+ if (context is Activity && !context.canPresentPaywall()) {
+ return Result.failure(
+ PaywallActivityLaunchException(
+ "The presenter Activity is no longer started or is finishing.",
+ ),
+ )
+ }
+
+ if (context !is Activity) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ return try {
+ context.startActivity(intent)
+ Result.success(Unit)
+ } catch (error: ActivityNotFoundException) {
+ Result.failure(
+ PaywallActivityLaunchException(
+ "SuperwallPaywallActivity is unavailable in the merged Android manifest.",
+ error,
+ ),
+ )
+ } catch (error: RuntimeException) {
+ // Android framework/OEM activity-task failures are delivered through this call as runtime
+ // exceptions (including RemoteException-backed NullPointerExceptions from system_server).
+ Result.failure(
+ PaywallActivityLaunchException(
+ "Android failed to launch SuperwallPaywallActivity.",
+ error,
+ ),
+ )
+ }
+}
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt
index 3f1cd37c7..c041e571d 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt
@@ -359,15 +359,40 @@ class PaywallView(
),
)
- SuperwallPaywallActivity.startWithView(
- presenter,
- this,
- state.cacheKey,
- state.presentationStyle,
- )
+ SuperwallPaywallActivity
+ .startWithViewForPresentation(
+ presenter,
+ this,
+ state.cacheKey,
+ state.presentationStyle,
+ ).getOrThrow()
startStateListener()
}
+ internal fun clearActivityLaunchState() {
+ controller.updateState(ClearViewCreatedCompletion)
+ cache?.activePaywallVcKey = null
+ }
+
+ internal fun handleActivityLaunchFailure(
+ error: Throwable,
+ emitPresentationError: Boolean,
+ ) {
+ Logger.debug(
+ logLevel = LogLevel.error,
+ scope = LogScope.paywallPresentation,
+ message = error.message ?: "Unable to launch SuperwallPaywallActivity",
+ error = error,
+ )
+ if (emitPresentationError) {
+ state.paywallStatePublisher?.let { publisher ->
+ ioScope.launch {
+ publisher.emit(PaywallState.PresentationError(error))
+ }
+ }
+ }
+ }
+
override fun updateState(update: PaywallViewState.Updates) {
controller.updateState(update)
}
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt
index 692078419..95c7762ad 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt
@@ -14,11 +14,12 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Resources
import android.graphics.Color
+import android.graphics.Outline
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Bundle
-import android.graphics.Outline
+import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
@@ -87,26 +88,77 @@ class SuperwallPaywallActivity : AppCompatActivity() {
key: String = UUID.randomUUID().toString(),
presentationStyleOverride: PaywallPresentationStyle? = null,
) {
- // We force this in main scope in case the user started it from a non-main thread
- CoroutineScope(Dispatchers.Main).launch {
- view.prepareViewForDisplay(key)
- val intent =
- Intent(context, SuperwallPaywallActivity::class.java).apply {
- putExtra(VIEW_KEY, key)
- putExtra(
- PRESENTATION_STYLE_KEY,
- presentationStyleOverride?.toIntentString(
- JsonFactory.JSON_POLYMORPHIC,
- ),
- )
- putExtra(
- IS_LIGHT_BACKGROUND_KEY,
- view.state.paywall.backgroundColor
- .isLightColor(),
- )
- flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
- }
- context.startActivity(intent)
+ val start = {
+ startWithViewOnMain(
+ context = context,
+ view = view,
+ key = key,
+ presentationStyleOverride = presentationStyleOverride,
+ ).onFailure { error ->
+ view.handleActivityLaunchFailure(error, emitPresentationError = true)
+ }
+ }
+
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ start()
+ } else {
+ // The public convenience API historically accepts calls from any thread. The SDK's
+ // presentation pipeline uses startWithViewForPresentation below and remains
+ // structured; only direct off-main callers require this compatibility hop.
+ CoroutineScope(Dispatchers.Main).launch {
+ start()
+ }
+ }
+ }
+
+ internal fun startWithViewForPresentation(
+ context: Activity,
+ view: PaywallView,
+ key: String,
+ presentationStyleOverride: PaywallPresentationStyle?,
+ ): Result =
+ startWithViewOnMain(
+ context = context,
+ view = view,
+ key = key,
+ presentationStyleOverride = presentationStyleOverride,
+ )
+
+ private fun startWithViewOnMain(
+ context: Context,
+ view: PaywallView,
+ key: String,
+ presentationStyleOverride: PaywallPresentationStyle?,
+ ): Result {
+ check(Looper.myLooper() == Looper.getMainLooper()) {
+ "SuperwallPaywallActivity must be prepared and launched on the main thread."
+ }
+
+ // Keep preparation outside the activity launch Result. Programming/state errors here
+ // must continue to surface instead of being mislabeled as Android launch failures.
+ view.prepareViewForDisplay(key)
+ val intent =
+ Intent(context, SuperwallPaywallActivity::class.java).apply {
+ putExtra(VIEW_KEY, key)
+ putExtra(
+ PRESENTATION_STYLE_KEY,
+ presentationStyleOverride?.toIntentString(
+ JsonFactory.JSON_POLYMORPHIC,
+ ),
+ )
+ putExtra(
+ IS_LIGHT_BACKGROUND_KEY,
+ view.state.paywall.backgroundColor
+ .isLightColor(),
+ )
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+
+ return launchPaywallActivity(context, intent).onFailure {
+ Superwall.instance.dependencyContainer
+ .makeViewStore()
+ .removeView(key)
+ view.clearActivityLaunchState()
}
}
diff --git a/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt b/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt
index 140a7dce4..dac2c8a59 100644
--- a/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt
+++ b/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt
@@ -153,12 +153,12 @@ internal inline fun Result>.flatten() =
else -> Result.failure(exceptionOrNull() ?: IllegalStateException("Unknown error"))
}
-private fun Throwable.shouldLog() =
+internal fun Throwable.shouldLog() =
this !is CancellationException &&
this !is InterruptedException &&
this !is PresentationPipelineError &&
this !is TransactionError &&
this !is PaywallSkippedReason &&
(this is NetworkError.Decoding || this !is NetworkError) &&
- this !is BillingError ||
+ this !is BillingError &&
this !is PaywallPresentationRequestStatusReason
diff --git a/superwall/src/test/java/com/superwall/sdk/billing/BillingClientUseCaseTest.kt b/superwall/src/test/java/com/superwall/sdk/billing/BillingClientUseCaseTest.kt
new file mode 100644
index 000000000..658339890
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/billing/BillingClientUseCaseTest.kt
@@ -0,0 +1,89 @@
+package com.superwall.sdk.billing
+
+import com.android.billingclient.api.BillingClient
+import com.android.billingclient.api.BillingResult
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BillingClientUseCaseTest {
+ private data class Params(
+ override val appInBackground: Boolean = false,
+ ) : UseCaseParams
+
+ private class TestUseCase(
+ params: UseCaseParams = Params(),
+ onError: (BillingError) -> Unit,
+ ) : BillingClientUseCase(
+ useCaseParams = params,
+ onError = onError,
+ executeRequestOnUIThread = { _, request -> request(null) },
+ ) {
+ var executeCount = 0
+
+ override fun executeAsync() {
+ executeCount++
+ }
+
+ override fun onOk(received: Unit) = Unit
+ }
+
+ private fun billingResult(code: Int): BillingResult =
+ BillingResult
+ .newBuilder()
+ .setResponseCode(code)
+ .setDebugMessage("test error")
+ .build()
+
+ @Test
+ fun exhaustedNetworkAndGenericErrorsRemainTransient() {
+ listOf(
+ BillingClient.BillingResponseCode.NETWORK_ERROR,
+ BillingClient.BillingResponseCode.ERROR,
+ ).forEach { responseCode ->
+ var receivedError: BillingError? = null
+ val useCase = TestUseCase(onError = { receivedError = it })
+
+ repeat(4) {
+ useCase.processResult(billingResult(responseCode), Unit)
+ }
+
+ assertEquals(3, useCase.executeCount)
+ assertTrue(receivedError is BillingError.WithCode)
+ assertEquals(responseCode, receivedError?.code)
+ }
+ }
+
+ @Test
+ fun exhaustedServiceUnavailableRemainsTransient() {
+ var receivedError: BillingError? = null
+ val useCase = TestUseCase(onError = { receivedError = it })
+
+ repeat(6) {
+ useCase.processResult(
+ billingResult(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE),
+ Unit,
+ )
+ }
+
+ assertEquals(5, useCase.executeCount)
+ assertTrue(receivedError is BillingError.WithCode)
+ assertEquals(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE, receivedError?.code)
+ }
+
+ @Test
+ fun unavailableBillingAndUnsupportedFeaturesRemainPermanent() {
+ listOf(
+ BillingClient.BillingResponseCode.BILLING_UNAVAILABLE,
+ BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED,
+ ).forEach { responseCode ->
+ var receivedError: BillingError? = null
+ val useCase = TestUseCase(onError = { receivedError = it })
+
+ useCase.processResult(billingResult(responseCode), Unit)
+
+ assertTrue(receivedError is BillingError.BillingNotAvailable)
+ assertEquals(0, useCase.executeCount)
+ }
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/billing/StorefrontTest.kt b/superwall/src/test/java/com/superwall/sdk/billing/StorefrontTest.kt
index 9fb4a61c4..d3260469d 100644
--- a/superwall/src/test/java/com/superwall/sdk/billing/StorefrontTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/billing/StorefrontTest.kt
@@ -5,6 +5,8 @@ import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingConfig
import com.android.billingclient.api.BillingConfigResponseListener
import com.android.billingclient.api.BillingResult
+import com.android.billingclient.api.ProductDetailsResponseListener
+import com.android.billingclient.api.QueryProductDetailsResult
import com.superwall.sdk.Given
import com.superwall.sdk.Then
import com.superwall.sdk.When
@@ -23,10 +25,14 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -106,6 +112,63 @@ class StorefrontTest {
}
}
+ @Test
+ fun getStorefrontCountryCode_ignoresDuplicateBillingCallbacks() =
+ runTest {
+ val config =
+ mockk {
+ every { countryCode } returns "US"
+ }
+ every { billingClient.getBillingConfigAsync(any(), any()) } answers {
+ secondArg().apply {
+ onBillingConfigResponse(okResult, config)
+ onBillingConfigResponse(errorResult, null)
+ }
+ }
+ val wrapper = makeWrapper()
+
+ assertEquals("US", wrapper.getStorefrontCountryCode())
+ }
+
+ @Test
+ fun getStorefrontCountryCode_ignoresCallbackAfterCancellation() =
+ runTest {
+ lateinit var responseListener: BillingConfigResponseListener
+ every { billingClient.getBillingConfigAsync(any(), any()) } answers {
+ responseListener = secondArg()
+ }
+ val wrapper = makeWrapper()
+ val request =
+ launch(start = CoroutineStart.UNDISPATCHED) {
+ wrapper.getStorefrontCountryCode()
+ }
+
+ request.cancel()
+ responseListener.onBillingConfigResponse(errorResult, null)
+ request.join()
+
+ assertTrue(request.isCancelled)
+ }
+
+ @Test
+ fun transientProductQueryErrorsAreNotCached() =
+ runTest {
+ val queryResult = mockk(relaxed = true)
+ every { billingClient.queryProductDetailsAsync(any(), any()) } answers {
+ secondArg().onProductDetailsResponse(errorResult, queryResult)
+ }
+ val wrapper = makeWrapper()
+ val productIds = setOf("product:base:sw-auto")
+
+ val firstResult = runCatching { wrapper.awaitGetProducts(productIds) }
+ assertTrue(firstResult.exceptionOrNull() is BillingError.WithCode)
+ verify(exactly = 8) { billingClient.queryProductDetailsAsync(any(), any()) }
+
+ val secondResult = runCatching { wrapper.awaitGetProducts(productIds) }
+ assertTrue(secondResult.exceptionOrNull() is BillingError.WithCode)
+ verify(exactly = 16) { billingClient.queryProductDetailsAsync(any(), any()) }
+ }
+
@Test
fun storeManager_loadsStorefrontCountryCodeOnce() =
runTest {
diff --git a/superwall/src/test/java/com/superwall/sdk/delegate/SuperwallDelegateAdapterTest.kt b/superwall/src/test/java/com/superwall/sdk/delegate/SuperwallDelegateAdapterTest.kt
new file mode 100644
index 000000000..383bb33c1
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/delegate/SuperwallDelegateAdapterTest.kt
@@ -0,0 +1,136 @@
+package com.superwall.sdk.delegate
+
+import com.superwall.sdk.paywall.presentation.PaywallInfo
+import io.mockk.mockk
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class SuperwallDelegateAdapterTest {
+ private val paywallInfo = mockk()
+
+ @Test
+ fun `kotlin delegate linkage failure does not escape or stop later callbacks`() {
+ val adapter = SuperwallDelegateAdapter()
+ var didPresentCount = 0
+ adapter.kotlinDelegate =
+ object : SuperwallDelegate {
+ override fun willPresentPaywall(withInfo: PaywallInfo) {
+ throw NoClassDefFoundError("missing.KotlinDelegateDependency")
+ }
+
+ override fun didPresentPaywall(withInfo: PaywallInfo) {
+ didPresentCount += 1
+ }
+ }
+
+ adapter.willPresentPaywall(paywallInfo)
+ adapter.didPresentPaywall(paywallInfo)
+
+ assertEquals(1, didPresentCount)
+ }
+
+ @Test
+ fun `kotlin delegate exception does not escape or stop later callbacks`() {
+ val adapter = SuperwallDelegateAdapter()
+ var didPresentCount = 0
+ adapter.kotlinDelegate =
+ object : SuperwallDelegate {
+ override fun willPresentPaywall(withInfo: PaywallInfo) {
+ throw IllegalArgumentException("bad lazy value")
+ }
+
+ override fun didPresentPaywall(withInfo: PaywallInfo) {
+ didPresentCount += 1
+ }
+ }
+
+ adapter.willPresentPaywall(paywallInfo)
+ adapter.didPresentPaywall(paywallInfo)
+
+ assertEquals(1, didPresentCount)
+ }
+
+ @Test
+ fun `java delegate linkage failure does not escape or stop later callbacks`() {
+ val adapter = SuperwallDelegateAdapter()
+ var didPresentCount = 0
+ adapter.javaDelegate =
+ object : SuperwallDelegateJava {
+ override fun willPresentPaywall(paywallInfo: PaywallInfo) {
+ throw NoClassDefFoundError("missing.JavaDelegateDependency")
+ }
+
+ override fun didPresentPaywall(paywallInfo: PaywallInfo) {
+ didPresentCount += 1
+ }
+ }
+
+ adapter.willPresentPaywall(paywallInfo)
+ adapter.didPresentPaywall(paywallInfo)
+
+ assertEquals(1, didPresentCount)
+ }
+
+ @Test
+ fun `java delegate exception does not escape or stop later callbacks`() {
+ val adapter = SuperwallDelegateAdapter()
+ var didPresentCount = 0
+ adapter.javaDelegate =
+ object : SuperwallDelegateJava {
+ override fun willPresentPaywall(paywallInfo: PaywallInfo) {
+ throw IllegalArgumentException("bad lazy value")
+ }
+
+ override fun didPresentPaywall(paywallInfo: PaywallInfo) {
+ didPresentCount += 1
+ }
+ }
+
+ adapter.willPresentPaywall(paywallInfo)
+ adapter.didPresentPaywall(paywallInfo)
+
+ assertEquals(1, didPresentCount)
+ }
+
+ @Test
+ fun `kotlin delegate remains the only target when both delegates are set`() {
+ val adapter = SuperwallDelegateAdapter()
+ var kotlinCount = 0
+ var javaCount = 0
+ adapter.kotlinDelegate =
+ object : SuperwallDelegate {
+ override fun willRedeemLink() {
+ kotlinCount += 1
+ throw IllegalArgumentException("kotlin failure")
+ }
+ }
+ adapter.javaDelegate =
+ object : SuperwallDelegateJava {
+ override fun willRedeemLink() {
+ javaCount += 1
+ }
+ }
+
+ adapter.willRedeemLink()
+
+ assertEquals(1, kotlinCount)
+ assertEquals(0, javaCount)
+ }
+
+ @Test
+ fun `virtual machine errors still escape the delegate boundary`() {
+ val adapter = SuperwallDelegateAdapter()
+ val fatalError = object : VirtualMachineError("fatal") {}
+ adapter.kotlinDelegate =
+ object : SuperwallDelegate {
+ override fun willRedeemLink() {
+ throw fatalError
+ }
+ }
+
+ val thrown = assertFailsWith { adapter.willRedeemLink() }
+
+ assertEquals(fatalError, thrown)
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/logger/LoggerTest.kt b/superwall/src/test/java/com/superwall/sdk/logger/LoggerTest.kt
index 6cff518a7..92f675401 100644
--- a/superwall/src/test/java/com/superwall/sdk/logger/LoggerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/logger/LoggerTest.kt
@@ -2,12 +2,19 @@ package com.superwall.sdk.logger
import com.superwall.sdk.And
import com.superwall.sdk.Given
+import com.superwall.sdk.Superwall
import com.superwall.sdk.Then
import com.superwall.sdk.When
import com.superwall.sdk.assertFalse
import com.superwall.sdk.assertTrue
+import com.superwall.sdk.config.options.SuperwallOptions
import com.superwall.sdk.delegate.SuperwallDelegate
import com.superwall.sdk.delegate.SuperwallDelegateAdapter
+import com.superwall.sdk.dependencies.DependencyContainer
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
import org.junit.After
import org.junit.Before
import org.junit.Test
@@ -242,4 +249,55 @@ class LoggerTest {
}
}
}
+
+ @Test
+ fun `keeps delivering when the configured delegate handleLog throws`() {
+ val adapter = SuperwallDelegateAdapter()
+ var deliveryCount = 0
+ adapter.kotlinDelegate =
+ object : SuperwallDelegate {
+ override fun handleLog(
+ level: String,
+ scope: String,
+ message: String?,
+ info: Map?,
+ error: Throwable?,
+ ) {
+ deliveryCount += 1
+ if (deliveryCount == 1) {
+ throw IllegalArgumentException("bad lazy value")
+ }
+ }
+ }
+
+ val dependencyContainer = mockk()
+ every { dependencyContainer.delegateAdapter } returns adapter
+ val superwall = mockk()
+ every { superwall.dependencyContainer } returns dependencyContainer
+ every { superwall.options } returns SuperwallOptions()
+
+ mockkObject(Superwall.Companion)
+ every { Superwall.instance } returns superwall
+ Superwall.initialized = true
+
+ try {
+ Logger.debug(
+ logLevel = LogLevel.error,
+ scope = LogScope.paywallView,
+ message = "delegate fails",
+ )
+ Logger.debug(
+ logLevel = LogLevel.error,
+ scope = LogScope.paywallView,
+ message = "delegate survives",
+ )
+ awaitLogQueue()
+
+ assertEquals(2, deliveryCount)
+ assertTrue(out.lines.any { it.contains("delegate survives") })
+ } finally {
+ Superwall.initialized = false
+ unmockkObject(Superwall.Companion)
+ }
+ }
}
diff --git a/superwall/src/test/java/com/superwall/sdk/misc/CurrentActivityTrackerTest.kt b/superwall/src/test/java/com/superwall/sdk/misc/CurrentActivityTrackerTest.kt
new file mode 100644
index 000000000..f46a99e56
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/misc/CurrentActivityTrackerTest.kt
@@ -0,0 +1,33 @@
+package com.superwall.sdk.misc
+
+import android.app.Activity
+import io.mockk.mockk
+import kotlin.test.Test
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+
+class CurrentActivityTrackerTest {
+ @Test
+ fun `stopping the current activity clears it`() {
+ val tracker = CurrentActivityTracker()
+ val activity = mockk(relaxed = true)
+
+ tracker.onActivityStarted(activity)
+ tracker.onActivityStopped(activity)
+
+ assertNull(tracker.getCurrentActivity())
+ }
+
+ @Test
+ fun `stopping an older activity does not clear the newer activity`() {
+ val tracker = CurrentActivityTracker()
+ val olderActivity = mockk(relaxed = true)
+ val newerActivity = mockk(relaxed = true)
+
+ tracker.onActivityStarted(olderActivity)
+ tracker.onActivityStarted(newerActivity)
+ tracker.onActivityStopped(olderActivity)
+
+ assertSame(newerActivity, tracker.getCurrentActivity())
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPresenterOperatorTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPresenterOperatorTest.kt
index eda2d481f..3a7c1d755 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPresenterOperatorTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPresenterOperatorTest.kt
@@ -1,5 +1,6 @@
package com.superwall.sdk.paywall.presentation.internal.operators
+import android.app.Activity
import com.superwall.sdk.Given
import com.superwall.sdk.Then
import com.superwall.sdk.When
@@ -18,6 +19,7 @@ import com.superwall.sdk.paywall.view.PaywallView
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
+import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
@@ -114,6 +116,49 @@ class GetPresenterOperatorTest {
}
}
+ @Test
+ fun `getPresenterIfNecessary emits error when activity is finishing`() =
+ runTest {
+ Given("a presentation request with a finishing activity") {
+ val paywallView = mockk()
+ val rulesOutcome =
+ RuleEvaluationOutcome(
+ triggerResult =
+ InternalTriggerResult.Paywall(
+ Experiment.presentById("abc"),
+ ),
+ )
+ val request = createRequest(PresentationRequestType.Presentation)
+ val publisher = MutableSharedFlow(replay = 1)
+ val activity = mockk(relaxed = true)
+ every { activity.isFinishing } returns true
+
+ val errorDeferred =
+ async {
+ publisher.first { it is PaywallState.PresentationError }
+ }
+
+ When("getPresenterIfNecessary executes") {
+ assertFailsWith {
+ getPresenterIfNecessary(
+ paywallView,
+ rulesOutcome,
+ request,
+ paywallStatePublisher = publisher,
+ attemptTriggerFire = { _, _ -> },
+ activity = { activity },
+ )
+ }
+
+ val errorState = withTimeout(1_000) { errorDeferred.await() }
+
+ Then("the invalid presenter is rejected") {
+ assertTrue(errorState is PaywallState.PresentationError)
+ }
+ }
+ }
+ }
+
@Test
fun `attemptTriggerFire tracks only when an event name exists and trigger succeeded`() =
runTest {
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallActivityLauncherTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallActivityLauncherTest.kt
new file mode 100644
index 000000000..1f315e651
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallActivityLauncherTest.kt
@@ -0,0 +1,90 @@
+package com.superwall.sdk.paywall.view
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import androidx.activity.ComponentActivity
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33], manifest = Config.NONE)
+class PaywallActivityLauncherTest {
+ @Test
+ fun `activity not found is returned as a structured launch failure`() {
+ val cause = ActivityNotFoundException("missing")
+ val context = mockk()
+ val intent = Intent()
+ every { context.startActivity(intent) } throws cause
+
+ val result = launchPaywallActivity(context, intent)
+
+ val error = assertIs(result.exceptionOrNull())
+ assertSame(cause, error.cause)
+ }
+
+ @Test
+ fun `framework runtime exception is returned as a structured launch failure`() {
+ val cause = NullPointerException("system_server WindowContainer failure")
+ val context = mockk()
+ val intent = Intent()
+ every { context.startActivity(intent) } throws cause
+
+ val result = launchPaywallActivity(context, intent)
+
+ val error = assertIs(result.exceptionOrNull())
+ assertSame(cause, error.cause)
+ }
+
+ @Test
+ fun `non activity context launches in a new task`() {
+ val context = mockk(relaxed = true)
+ val intent = Intent()
+
+ val result = launchPaywallActivity(context, intent)
+
+ assertTrue(result.isSuccess)
+ assertTrue(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0)
+ }
+
+ @Test
+ fun `finishing activity is rejected before startActivity`() {
+ val activity = mockk(relaxed = true)
+ val intent = Intent()
+ every { activity.isFinishing } returns true
+
+ val result = launchPaywallActivity(activity, intent)
+
+ assertIs(result.exceptionOrNull())
+ verify(exactly = 0) { activity.startActivity(any()) }
+ }
+
+ @Test
+ fun `stopped lifecycle activity is not a valid presenter`() {
+ val controller =
+ Robolectric
+ .buildActivity(TestLifecycleActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ val activity = controller.get()
+ assertTrue(activity.canPresentPaywall())
+
+ controller.pause().stop()
+
+ assertFalse(activity.canPresentPaywall())
+ }
+
+ class TestLifecycleActivity : ComponentActivity()
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/utilities/ErrorTrackingFilterTest.kt b/superwall/src/test/java/com/superwall/sdk/utilities/ErrorTrackingFilterTest.kt
new file mode 100644
index 000000000..77737445a
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/utilities/ErrorTrackingFilterTest.kt
@@ -0,0 +1,25 @@
+package com.superwall.sdk.utilities
+
+import com.superwall.sdk.billing.BillingError
+import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatusReason
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ErrorTrackingFilterTest {
+ @Test
+ fun billingErrorsAreNotLogged() {
+ assertFalse(BillingError.BillingNotAvailable("billing unavailable").shouldLog())
+ assertFalse(BillingError.WithCode(6, "transient billing error").shouldLog())
+ }
+
+ @Test
+ fun presentationStatusReasonsAreNotLogged() {
+ assertFalse(PaywallPresentationRequestStatusReason.NoConfig().shouldLog())
+ }
+
+ @Test
+ fun unexpectedExceptionsAreLogged() {
+ assertTrue(IllegalStateException("unexpected").shouldLog())
+ }
+}
diff --git a/version.env b/version.env
index 46d6905c8..8365f7172 100644
--- a/version.env
+++ b/version.env
@@ -1 +1 @@
-SUPERWALL_VERSION=2.8.1
+SUPERWALL_VERSION=2.8.2