Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub.

## 2.7.10

## Potentially impactful changes
- Billing errors on register are now wrapped in `SubscriptionStatusTimeout` errors
- This is used to clarify that the status is timing out due to billing errors. If you are depending on the `NoPaywallView` to distinguish between these, ensure you are checking for the proper status.

## Enhancements
- Adds onboarding analytics
- Adds prioritized preloading support
- Improved error handling

## Fixes
- Prevents paywalls from dismissing on return from deep link
- Fixes deadlock in `SerialTaskManager`
- Fix issues with bottom sheet on certain Samsung devices

## 2.7.9

## Fixes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,11 @@ fun PaywallComposable(
viewToRender
},
onRelease = {
viewToRender.beforeOnDestroy()
viewToRender.beforeOnDestroy(forceCleanup = true)
viewToRender.encapsulatingActivity = null

CoroutineScope(Dispatchers.Main).launch {
viewToRender.destroyed()
viewToRender.destroyed(forceCleanup = true)
viewToRender.cleanup()
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.superwall.sdk.analytics.internal.trackable

import com.superwall.sdk.analytics.superwall.SuperwallEvent
import com.superwall.sdk.paywall.view.webview.messaging.PageViewData
import com.superwall.sdk.analytics.superwall.TransactionProduct
import com.superwall.sdk.config.models.Survey
import com.superwall.sdk.config.models.SurveyOption
Expand Down Expand Up @@ -494,6 +495,27 @@ sealed class InternalSuperwallEvent(
}
}

class PaywallPageView(
val paywallInfo: PaywallInfo,
val data: PageViewData,
) : InternalSuperwallEvent(SuperwallEvent.PaywallPageView(paywallInfo, data)) {
override val audienceFilterParams: Map<String, Any>
get() = paywallInfo.audienceFilterParams()

override suspend fun getSuperwallParameters(): HashMap<String, Any> {
val params = HashMap(paywallInfo.eventParams())
params["page_node_id"] = data.pageNodeId
params["flow_position"] = data.flowPosition
params["page_name"] = data.pageName
params["navigation_node_id"] = data.navigationNodeId
params["navigation_type"] = data.navigationType
data.previousPageNodeId?.let { params["previous_page_node_id"] = it }
data.previousFlowPosition?.let { params["previous_flow_position"] = it }
data.timeOnPreviousPageMs?.let { params["time_on_previous_page_ms"] = it }
return params
}
}

class Transaction(
val state: State,
val paywallInfo: PaywallInfo,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.superwall.sdk.config.models.SurveyOption
import com.superwall.sdk.models.customer.CustomerInfo
import com.superwall.sdk.models.triggers.TriggerResult
import com.superwall.sdk.paywall.presentation.PaywallInfo
import com.superwall.sdk.paywall.view.webview.messaging.PageViewData
import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatus
import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatusReason
import com.superwall.sdk.paywall.view.webview.WebviewError
Expand Down Expand Up @@ -138,6 +139,15 @@ sealed class SuperwallEvent {
get() = "paywall_open"
}

// / When a page view occurs in a multi-page paywall.
data class PaywallPageView(
val paywallInfo: PaywallInfo,
val data: PageViewData,
) : SuperwallEvent() {
override val rawName: String
get() = SuperwallEvents.PaywallPageView.rawName
}

// / When a paywall is closed.
data class PaywallClose(
val paywallInfo: PaywallInfo,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ enum class SuperwallEvents(
PermissionDenied("permission_denied"),
PaywallPreloadStart("paywallPreload_start"),
PaywallPreloadComplete("paywallPreload_complete"),
PaywallPageView("paywall_page_view"),
TestModeModalOpen("testModeModal_open"),
TestModeModalClose("testModeModal_close"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay

class PaywallPreload(
val factory: Factory,
Expand Down Expand Up @@ -52,9 +53,35 @@ class PaywallPreload(
preloadingDisabled = config.preloadingDisabled,
)
val confirmedAssignments = storage.getConfirmedAssignments()

// If there's a prioritized campaign, preload its paywalls first.
var remainingTriggers = triggers
val prioritizedCampaignId = config.prioritizedCampaignId
if (prioritizedCampaignId != null) {
val prioritizedTriggers =
triggers.filter { trigger ->
trigger.rules.any { it.experimentGroupId == prioritizedCampaignId }
}.toSet()
if (prioritizedTriggers.isNotEmpty()) {
val prioritizedIds =
ConfigLogic.getAllActiveTreatmentPaywallIds(
triggers = prioritizedTriggers,
confirmedAssignments = confirmedAssignments,
unconfirmedAssignments = assignments.unconfirmedAssignments,
expressionEvaluator = expressionEvaluator,
)
preloadPaywalls(paywallIdentifiers = prioritizedIds)
remainingTriggers = triggers - prioritizedTriggers

// Delay before preloading the rest to avoid resource contention.
delay(5000)
}
}

// Then preload all remaining paywalls.
val paywallIds =
ConfigLogic.getAllActiveTreatmentPaywallIds(
triggers = triggers,
triggers = remainingTriggers,
confirmedAssignments = confirmedAssignments,
unconfirmedAssignments = assignments.unconfirmedAssignments,
expressionEvaluator = expressionEvaluator,
Expand Down
52 changes: 17 additions & 35 deletions superwall/src/main/java/com/superwall/sdk/misc/SerialTaskManager.kt
Original file line number Diff line number Diff line change
@@ -1,50 +1,32 @@
package com.superwall.sdk.misc

import com.superwall.sdk.logger.LogLevel
import com.superwall.sdk.logger.LogScope
import com.superwall.sdk.logger.Logger
import kotlinx.coroutines.*
import java.util.*
import java.util.LinkedList
import java.util.Queue
import kotlinx.coroutines.channels.Channel

class SerialTaskManager(
private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)),
private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO),
) {
private val taskQueue: Queue<suspend () -> Unit> = LinkedList()
private var currentTask: Deferred<Unit>? = null
private val channel = Channel<suspend () -> Unit>(Channel.UNLIMITED)

fun addTask(task: suspend () -> Unit) {
init {
coroutineScope.launch {
// Wait for the current task to finish if there is one
currentTask?.await()

// Add the task to the queue
taskQueue.offer(task)

// If there's only one task in the queue, start executing it
if (taskQueue.size == 1) {
executeNextTask()
for (task in channel) {
task()
}
}
}

private suspend fun executeNextTask() {
// Check if there are tasks in the queue
if (taskQueue.isEmpty()) {
return
}

// Get the next task from the queue
val nextTask = taskQueue.poll() ?: return

// Run the task and wait for it to complete
currentTask =
coroutineScope.async {
nextTask()
}
currentTask?.await()

// After the task completes, recursively execute the next task
if (taskQueue.isNotEmpty()) {
executeNextTask()
fun addTask(task: suspend () -> Unit) {
val result = channel.trySend(task)
if (result.isFailure) {
Logger.debug(
logLevel = LogLevel.error,
scope = LogScope.superwallCore,
message = "SerialTaskManager: failed to enqueue task — channel closed",
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ data class Config(
@SerialName("build_id") val buildId: String,
@SerialName("bundle_id_config") val bundleIdConfig: String? = null,
@SerialName("test_mode_user_ids") val testModeUserIds: List<TestStoreUser>? = null,
@SerialName("prioritized_campaign_id") val prioritizedCampaignId: String? = null,
) : SerializableEntity {
init {
locales = localizationConfig.locales.map { it.locale }.toSet()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,38 +17,76 @@ sealed class PaywallPresentationRequestStatus(
*/
sealed class PaywallPresentationRequestStatusReason(
val description: String,
val info: String,
) : Throwable() {
/** Trying to present paywall when debugger is launched. */
class DebuggerPresented : PaywallPresentationRequestStatusReason("debugger_presented")
class DebuggerPresented :
PaywallPresentationRequestStatusReason(
"debugger_presented",
"The paywall debugger is currently presented. Dismiss it before presenting a paywall.",
)

/** There's already a paywall presented. */
class PaywallAlreadyPresented : PaywallPresentationRequestStatusReason("paywall_already_presented")
class PaywallAlreadyPresented :
PaywallPresentationRequestStatusReason(
"paywall_already_presented",
"A paywall is already being presented. Dismiss it before presenting another one.",
)

/** The user is in a holdout group. */
data class Holdout(
val experiment: Experiment,
) : PaywallPresentationRequestStatusReason("holdout")
) : PaywallPresentationRequestStatusReason(
"holdout",
"The user is in a holdout group for experiment '${experiment.id}'.",
)

/** No rules defined in the campaign for the event matched. */
class NoAudienceMatch : PaywallPresentationRequestStatusReason("no_rule_match")
class NoAudienceMatch :
PaywallPresentationRequestStatusReason(
"no_rule_match",
"The user did not match any rules configured for this placement.",
)

/** The event provided was not found in any campaign on the dashboard. */
class PlacementNotFound : PaywallPresentationRequestStatusReason("event_not_found")
class PlacementNotFound :
PaywallPresentationRequestStatusReason(
"event_not_found",
"The placement was not found in any campaign on the Superwall dashboard.",
)

/** There was an error getting the paywall view. */
class NoPaywallView : PaywallPresentationRequestStatusReason("no_paywall_view_controller")
class NoPaywallView(
reason: String = "The paywall view could not be created. Check that the Android System WebView is enabled on the device.",
) : PaywallPresentationRequestStatusReason(
"no_paywall_view_controller",
reason,
)

/** There isn't a view to present the paywall on. */
class NoPresenter : PaywallPresentationRequestStatusReason("no_presenter")
class NoPresenter :
PaywallPresentationRequestStatusReason(
"no_presenter",
"There is no Activity to present the paywall on. Make sure an Activity is visible before registering a placement.",
)

/** The config hasn't been retrieved from the server in time. */
class NoConfig : PaywallPresentationRequestStatusReason("no_config")
class NoConfig :
PaywallPresentationRequestStatusReason(
"no_config",
"The Superwall config could not be retrieved in time. Check your network connection and that your API key is correct.",
)

/**
* The entitlement status timed out.
* The entitlement status timed out or Google Play Billing is not available.
* This happens when the entitlementStatus stays unknown for more than 5 seconds.
*/
class SubscriptionStatusTimeout : PaywallPresentationRequestStatusReason("subscription_status_timeout")
class SubscriptionStatusTimeout(
reason: String = "The entitlement status stayed 'unknown' for over 5 seconds. Ensure that Google Play Billing is available and if you're using a custom purchase controller, ensure you're setting the entitlement status on time.",
) : PaywallPresentationRequestStatusReason(
"subscription_status_timeout",
reason,
)
}

typealias PresentationPipelineError = PaywallPresentationRequestStatusReason
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ internal suspend fun evaluateRules(
val paywallId =
request.presentationInfo.identifier
?: return Result.failure(
PaywallPresentationRequestStatusReason.NoPaywallView(),
PaywallPresentationRequestStatusReason.NoPaywallView(
"No paywall identifier provided for debugger presentation."
),
)

Result.success(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.superwall.sdk.paywall.presentation.internal.operators

import com.superwall.sdk.billing.BillingError
import com.superwall.sdk.dependencies.DependencyContainer
import com.superwall.sdk.logger.LogLevel
import com.superwall.sdk.logger.LogScope
Expand Down Expand Up @@ -53,7 +54,7 @@ internal suspend fun getPaywallView(
return try {
val isForPresentation =
request.flags.type != PresentationRequestType.GetImplicitPresentationResult &&
request.flags.type != PresentationRequestType.GetPresentationResult
request.flags.type != PresentationRequestType.GetPresentationResult
val delegate = request.flags.type.paywallViewDelegateAdapter

val webviewExists = webViewExists()
Expand All @@ -77,11 +78,12 @@ internal suspend fun getPaywallView(
scope = LogScope.paywallPresentation,
message =
"Paywalls cannot be presented because the Android System WebView has been disabled" +
" by the user.",
" by the user.",
)
Result.failure(PaywallPresentationRequestStatusReason.NoPaywallView())
}
} catch (e: Throwable) {

Result.failure(presentationFailure(e, debugInfo, paywallStatePublisher))
}
}
Expand All @@ -99,5 +101,14 @@ private suspend fun presentationFailure(
error = error,
)
paywallStatePublisher?.emit(PaywallState.PresentationError(error))
return PaywallPresentationRequestStatusReason.NoPaywallView()
if (error is BillingError) {
return PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout(
"Google Play Billing is not available or has an error: ${error.message}"
)
}
return PaywallPresentationRequestStatusReason.NoPaywallView(
"The paywall view could not be created: ${error.message}"
)
}


Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@ internal suspend fun Superwall.logErrors(
Logger.debug(
logLevel = LogLevel.info,
scope = LogScope.paywallPresentation,
message = "Skipped paywall presentation: ${error.message}, ${error.stackTraceToString()}",
message = "Skipped paywall presentation: ${if (error is PaywallPresentationRequestStatusReason) "${error.description} - ${error.info}" else error.message}, ${error.stackTraceToString()}",
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,8 @@ class PaywallView(
}
}

fun beforeOnDestroy() {
if (state.isBrowserViewPresented) {
fun beforeOnDestroy(forceCleanup: Boolean = false) {
if (state.isBrowserViewPresented && !forceCleanup) {
return
}
factory.updatePaywallInfo(info)
Expand All @@ -408,8 +408,8 @@ class PaywallView(
.willDismissPaywall(info)
}

suspend fun destroyed() {
if (state.isBrowserViewPresented) {
suspend fun destroyed(forceCleanup: Boolean = false) {
if (state.isBrowserViewPresented && !forceCleanup) {
return
}

Expand Down
Loading
Loading