Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ internal fun PresentationModule(
}

private val embeddedBlocksRegistryLazy: Lazy<EmbeddedBlocksRegistry> = lazy {
EmbeddedBlocksRegistryImpl(inAppInteractor = inAppInteractor)
EmbeddedBlocksRegistryImpl(inAppInteractor = inAppInteractor, monotonicNow = timeProvider::monotonicMillis)
}

override val embeddedBlocksRegistry: EmbeddedBlocksRegistry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,11 @@ internal class EmbeddedBlockContentController(
private var registration: Closeable? = null
private var configJob: Job? = null

private var pendingContent: InAppType.Embedded? = null
private var hasPendingContent = false
private class PendingAnswer(val content: InAppType.Embedded?, val selectionTime: Milliseconds)

private var pendingAnswer: PendingAnswer? = null
private val hasPendingAnswer: Boolean
get() = pendingAnswer != null

/** What the shown page was built from — the "same content" dedup key. */
private var appliedDescriptor: PageDescriptor? = null
Expand Down Expand Up @@ -112,8 +115,6 @@ internal class EmbeddedBlockContentController(

private var attemptStartTick: Milliseconds? = null

private var pendingSinceTick: Milliseconds? = null

private var hasPendingDelivery = false

private val mainHandler = Handler(Looper.getMainLooper())
Expand All @@ -138,10 +139,9 @@ internal class EmbeddedBlockContentController(
appliedDescriptor = null
}

if (hasPendingContent) {
val deferred = pendingContent
clearPendingContent()
applyResolved(deferred)
pendingAnswer?.let { deferred ->
pendingAnswer = null
applyResolved(deferred.content, deferred.selectionTime)
}

provider?.let { current ->
Expand All @@ -154,7 +154,7 @@ internal class EmbeddedBlockContentController(
beginWaitingForContent()
return
}
if (provider == null && !hasPendingContent) {
if (provider == null && !hasPendingAnswer) {
beginWaitingForContent()
}
blocksRegistry()?.onBlockAppeared(place)
Expand All @@ -181,27 +181,25 @@ internal class EmbeddedBlockContentController(
notifyContentDropped()
}

override fun onContentResolved(content: InAppType.Embedded?) {
override fun onContentResolved(content: InAppType.Embedded?, selectionTime: Milliseconds) {
if (!acceptAnswer("Content")) return
if (!isActive) {
mindboxLogI("[EmbeddedBlock] Content for '$placeSystemName' arrived while paused, deferring it")
pendingContent = content
hasPendingContent = true
pendingAnswer = PendingAnswer(content, selectionTime)
return
}
applyResolved(content)
applyResolved(content, selectionTime)
}

override fun onContentPending() {
if (isReleased || hasGivenUp) return
configBudget.reset()
hasPendingDelivery = true
pendingSinceTick = pendingSinceTick ?: monotonicNow()
}

override fun onConfigUnavailable() {
if (!acceptAnswer("The SDK's answer")) return
clearPendingContent()
pendingAnswer = null
forgetAppliedContent()
mindboxLogW("[EmbeddedBlock] The SDK has no config to answer for '$placeSystemName', reporting failure")
sendWaitBudgetExceeded(
Expand All @@ -222,35 +220,17 @@ internal class EmbeddedBlockContentController(
return false
}
configBudget.reset()
// The pending window closes at the delivery, not at the application: a delivery deferred
// while the block is off screen keeps the off-screen span inside the measure — only the
// campaign's delay leaves it.
settlePendingWindow()
hasPendingDelivery = false
return true
}

private fun clearPendingContent() {
pendingContent = null
hasPendingContent = false
}

private fun sendWaitBudgetExceeded(waited: Milliseconds, phase: WaitBudgetPhase) {
val place = placeSystemName ?: return
val tracker = resolveFailureTracker() ?: return
loggingRunCatching { tracker.sendPlaceWaitBudgetExceeded(place, waited, phase) }
}

private fun settlePendingWindow() {
hasPendingDelivery = false
pendingSinceTick?.let { pendingSince ->
attemptStartTick = attemptStartTick?.let { started ->
Milliseconds(started.interval + elapsedSince(pendingSince).interval)
}
}
pendingSinceTick = null
}

private fun applyResolved(content: InAppType.Embedded?) {
private fun applyResolved(content: InAppType.Embedded?, selectionTime: Milliseconds) {
if (content == null) {
mindboxLogI("[EmbeddedBlock] Nothing to show for place '$placeSystemName'")
forgetAppliedContent()
Expand All @@ -271,7 +251,7 @@ internal class EmbeddedBlockContentController(
}
if (lastReportedState?.nothingToShow == true) {
mindboxLogI("[EmbeddedBlock] Delivery for a collapsed block '$placeSystemName', rebuilding the content")
recreateProvider(content)
recreateProvider(content, selectionTime)
return
}
if (current is EmbeddedUpdatableContentProvider &&
Expand All @@ -290,17 +270,17 @@ internal class EmbeddedBlockContentController(
appliedContent = content
} else {
mindboxLogW("[EmbeddedBlock] In-place update over the bridge failed, recreating the content")
recreateProvider(content)
recreateProvider(content, selectionTime)
}
}
}
}.onFailure { error ->
mindboxLogW("[EmbeddedBlock] In-place update crashed ($error), recreating the content")
recreateProvider(content)
recreateProvider(content, selectionTime)
}
return
}
recreateProvider(content)
recreateProvider(content, selectionTime)
}

private fun refreshMetricsSnapshot(current: EmbeddedContentProvider, content: InAppType.Embedded) {
Expand All @@ -312,13 +292,12 @@ internal class EmbeddedBlockContentController(
appliedContent = content
}

private fun recreateProvider(content: InAppType.Embedded) {
private fun recreateProvider(content: InAppType.Embedded, selectionTime: Milliseconds) {
dropProvider()
if (lastReportedState != EmbeddedBlockState.Loading) {
if (lastReportedState != EmbeddedBlockState.Loading || attemptStartTick == null) {
attemptStartTick = monotonicNow()
}
val startTick = attemptStartTick ?: monotonicNow().also { freshStart -> attemptStartTick = freshStart }
val created = loggingRunCatching(defaultValue = null) { providerFactory(content, startTick) } ?: run {
val created = loggingRunCatching(defaultValue = null) { providerFactory(content, selectionTime) } ?: run {
failInternally(content, "Could not build content for ${content.inAppId}")
return
}
Expand All @@ -337,7 +316,7 @@ internal class EmbeddedBlockContentController(
override val isHoldingContent: Boolean
get() = lastReportedState is EmbeddedBlockState.Loading ||
lastReportedState == EmbeddedBlockState.Ready ||
hasPendingContent
hasPendingAnswer

private fun onProviderState(state: EmbeddedBlockState) {
if (state is EmbeddedBlockState.Ready && provider?.contentView == null) {
Expand Down Expand Up @@ -462,7 +441,7 @@ internal class EmbeddedBlockContentController(
}

private fun hasEverResolved(): Boolean =
provider != null || hasPendingContent || appliedDescriptor != null || hasPendingDelivery
provider != null || hasPendingAnswer || appliedDescriptor != null || hasPendingDelivery

private fun onReadyTimeout() {
if (!isStarted || provider == null) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal object EmbeddedBlockContentFactory {
fun createProvider(
context: Context,
content: InAppType.Embedded,
startTick: Milliseconds,
selectionTime: Milliseconds,
): EmbeddedContentProvider? {
val layer = content.layers.filterIsInstance<Layer.WebViewLayer>().firstOrNull() ?: run {
mindboxLogE("[EmbeddedBlock] Winner ${content.inAppId} has no webview layer")
Expand All @@ -27,7 +27,7 @@ internal object EmbeddedBlockContentFactory {
context = context,
frequency = content.frequency,
tags = content.tags,
startTick = startTick,
selectionTime = selectionTime,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package cloud.mindbox.mobile_sdk.embedded

import cloud.mindbox.mobile_sdk.models.Milliseconds

internal class EmbeddedBlockStopwatch(
private val now: () -> Milliseconds,
) {

private var settled = Milliseconds(0L)
private var runningSince: Milliseconds? = null

val elapsed: Milliseconds
get() = Milliseconds(settled.interval + (runningSince?.let(::spanSince)?.interval ?: 0L))

fun resume() {
if (runningSince == null) runningSince = now()
}

fun pause() {
val since = runningSince ?: return
settled = Milliseconds(settled.interval + spanSince(since).interval)
runningSince = null
}

private fun spanSince(since: Milliseconds): Milliseconds =
Milliseconds((now().interval - since.interval).coerceAtLeast(0L))
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType
import cloud.mindbox.mobile_sdk.logger.mindboxLogI
import cloud.mindbox.mobile_sdk.logger.mindboxLogW
import cloud.mindbox.mobile_sdk.models.InAppEventType
import cloud.mindbox.mobile_sdk.models.Milliseconds
import cloud.mindbox.mobile_sdk.models.PlaceKey
import cloud.mindbox.mobile_sdk.utils.loggingRunCatching
import kotlinx.coroutines.CancellationException
Expand All @@ -28,7 +29,7 @@ internal interface EmbeddedBlockHandle {

val isHoldingContent: Boolean

fun onContentResolved(content: InAppType.Embedded?)
fun onContentResolved(content: InAppType.Embedded?, selectionTime: Milliseconds)

fun onContentPending() {}

Expand All @@ -50,6 +51,7 @@ internal class EmbeddedBlocksRegistryImpl(
private val inAppInteractor: InAppInteractor,
// Read on every use, never captured: the SDK scope is recreated on a soft reinitialization.
private val scopeProvider: () -> CoroutineScope = { Mindbox.mindboxScope },
private val monotonicNow: () -> Milliseconds,
) : EmbeddedBlocksRegistry {

// Weak on purpose: a host with no lifecycle owner to say goodbye — a plain Dialog, a
Expand All @@ -60,7 +62,11 @@ internal class EmbeddedBlocksRegistryImpl(

private val reResolveQueuedPlaces = mutableMapOf<PlaceKey, InAppEventType?>()

private class PendingDelay(val inAppId: String, val job: Job)
private class PendingDelay(
val job: Job,
var winner: InAppType.Embedded,
var selectionTime: Milliseconds,
)

private val delayJobsByPlace = mutableMapOf<PlaceKey, PendingDelay>()

Expand Down Expand Up @@ -170,6 +176,7 @@ internal class EmbeddedBlocksRegistryImpl(
reResolveQueuedPlaces[place] = triggerEvent ?: reResolveQueuedPlaces[place]
return
}
val requestedAt = monotonicNow()
val job = scopeProvider().launch {
val resolved = try {
Result.success(
Expand All @@ -184,10 +191,11 @@ internal class EmbeddedBlocksRegistryImpl(
mindboxLogW("[EmbeddedBlock] Resolving place '$place' failed: $error")
Result.failure(error)
}
val selectionTime = Milliseconds(monotonicNow().interval - requestedAt.interval)
runOnMain {
resolved.fold(
onSuccess = { result -> handleResolved(place, result) },
onFailure = { handleResolveFailure(place) },
onSuccess = { result -> handleResolved(place, result, selectionTime) },
onFailure = { handleResolveFailure(place, selectionTime) },
)
}
}
Expand Down Expand Up @@ -218,27 +226,29 @@ internal class EmbeddedBlocksRegistryImpl(
}
}

private fun handleResolved(place: PlaceKey, outcome: EmbeddedResolveOutcome) {
private fun handleResolved(place: PlaceKey, outcome: EmbeddedResolveOutcome, selectionTime: Milliseconds) {
when (outcome) {
is EmbeddedResolveOutcome.Content -> handleContent(place, outcome)
EmbeddedResolveOutcome.Empty -> deliverNow(place, null)
is EmbeddedResolveOutcome.Content -> handleContent(place, outcome, selectionTime)
EmbeddedResolveOutcome.Empty -> deliverNow(place, null, selectionTime)
EmbeddedResolveOutcome.ConfigUnavailable -> handleConfigUnavailable(place)
}
}

private fun handleContent(place: PlaceKey, outcome: EmbeddedResolveOutcome.Content) {
private fun handleContent(place: PlaceKey, outcome: EmbeddedResolveOutcome.Content, selectionTime: Milliseconds) {
val winner = outcome.variant
val delayTime = outcome.delayTime?.takeIf { delay -> delay.interval > 0 }
if (delayTime == null) {
deliverNow(place, winner)
deliverNow(place, winner, selectionTime)
return
}
val running = delayJobsByPlace[place]
if (running != null && running.job.isActive && running.inAppId == winner.inAppId) {
if (running != null && running.job.isActive && running.winner.inAppId == winner.inAppId) {
mindboxLogI(
"[EmbeddedBlock] Winner ${winner.inAppId} for place '$place' is already " +
"waiting out its delay, keeping the running timer"
"waiting out its delay, keeping the running timer for the fresh answer"
)
running.winner = winner
running.selectionTime = selectionTime
notifyPending(place)
return
}
Expand All @@ -252,18 +262,18 @@ internal class EmbeddedBlocksRegistryImpl(
delay(delayTime.interval)
val self = coroutineContext[Job]
runOnMain {
if (delayJobsByPlace[place]?.job !== self) return@runOnMain
val due = delayJobsByPlace[place]?.takeIf { pending -> pending.job === self } ?: return@runOnMain
delayJobsByPlace.remove(place)
inAppInteractor.markEmbeddedDelayWaitedOut(place, winner.inAppId)
deliver(place, winner)
inAppInteractor.markEmbeddedDelayWaitedOut(place, due.winner.inAppId)
deliver(place, due.winner, due.selectionTime)
}
}
delayJobsByPlace[place] = PendingDelay(winner.inAppId, job)
delayJobsByPlace[place] = PendingDelay(job, winner, selectionTime)
}

private fun handleResolveFailure(place: PlaceKey) {
private fun handleResolveFailure(place: PlaceKey, selectionTime: Milliseconds) {
if (keepsRunningDelay(place, "Resolving place '$place' failed")) return
deliver(place, null)
deliver(place, null, selectionTime)
}

private fun handleConfigUnavailable(place: PlaceKey) {
Expand All @@ -273,14 +283,14 @@ internal class EmbeddedBlocksRegistryImpl(

private fun keepsRunningDelay(place: PlaceKey, what: String): Boolean {
val running = delayJobsByPlace[place]?.takeIf { pending -> pending.job.isActive } ?: return false
mindboxLogI("[EmbeddedBlock] $what while winner ${running.inAppId} waits out its delay, keeping the running timer")
mindboxLogI("[EmbeddedBlock] $what while winner ${running.winner.inAppId} waits out its delay, keeping the running timer")
notifyPending(place)
return true
}

private fun deliverNow(place: PlaceKey, content: InAppType.Embedded?) {
private fun deliverNow(place: PlaceKey, content: InAppType.Embedded?, selectionTime: Milliseconds) {
delayJobsByPlace.remove(place)?.job?.cancel()
deliver(place, content)
deliver(place, content, selectionTime)
}

private fun deliverConfigUnavailable(place: PlaceKey) {
Expand All @@ -302,7 +312,7 @@ internal class EmbeddedBlocksRegistryImpl(
}
}

private fun deliver(place: PlaceKey, content: InAppType.Embedded?) {
private fun deliver(place: PlaceKey, content: InAppType.Embedded?, selectionTime: Milliseconds) {
val handles = liveHandles(place)
if (handles.isEmpty()) {
mindboxLogW("[EmbeddedBlock] No block is registered for place '$place', dropping the content")
Expand All @@ -323,7 +333,7 @@ internal class EmbeddedBlocksRegistryImpl(
}
if (delivered == null) inAppInteractor.releasePlaceShow(place)
handles.forEach { handle ->
loggingRunCatching { handle.onContentResolved(delivered) }
loggingRunCatching { handle.onContentResolved(delivered, selectionTime) }
}
}

Expand Down
Loading
Loading