diff --git a/build.gradle b/build.gradle index a49e7619..8e67fa5a 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ buildscript { mavenCentral() maven { url "https://plugins.gradle.org/m2/" } maven { url "https://developer.huawei.com/repo/" } - maven { url "https://artifactory-external.vkpartner.ru/artifactory/maven" } + maven { url "https://nexus-external.vkteam.ru/repository/maven/" } } dependencies { classpath libs.bundles.buildscript.plugins @@ -16,7 +16,7 @@ allprojects { google() mavenCentral() maven { url "https://developer.huawei.com/repo/" } - maven { url "https://artifactory-external.vkpartner.ru/artifactory/maven" } + maven { url "https://nexus-external.vkteam.ru/repository/maven/" } maven { url "https://central.sonatype.com/repository/maven-snapshots/" } mavenLocal() } diff --git a/example/settings.gradle b/example/settings.gradle index f3f5a265..5467f346 100644 --- a/example/settings.gradle +++ b/example/settings.gradle @@ -10,7 +10,7 @@ dependencyResolutionManagement { repositories { google() maven { url 'https://developer.huawei.com/repo/' } - maven { url "https://artifactory-external.vkpartner.ru/artifactory/maven" } + maven { url "https://nexus-external.vkteam.ru/repository/maven/" } mavenCentral() mavenLocal() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt index bfd7fd08..16ee1047 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt @@ -322,7 +322,9 @@ internal fun InApp.firstOverlayVariant(): InAppType? = * cooldown all exist to hold shows back, and an `unlimited` in-app is outside that accounting in * both directions — it records nothing, and nothing recorded restrains it. */ -internal fun InApp.countsShows(): Boolean = frequency.delay !is Frequency.Delay.Unlimited +internal fun InApp.countsShows(): Boolean = frequency.countsShows() + +internal fun Frequency.countsShows(): Boolean = delay !is Frequency.Delay.Unlimited internal fun newConcurrentSet(): MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) @@ -333,7 +335,11 @@ internal fun newConcurrentSet(): MutableSet = * decision is taken by the caller and passed in as [isTagsFeatureEnabled]. */ internal fun InApp.gatedTags(isTagsFeatureEnabled: Boolean): Map? = - tags?.takeIf { it.isNotEmpty() && isTagsFeatureEnabled } + tags.gatedTags(isTagsFeatureEnabled) + +/** The same gate for a tags snapshot that travels without its [InApp] (embedded content). */ +internal fun Map?.gatedTags(isTagsFeatureEnabled: Boolean): Map? = + this?.takeIf { it.isNotEmpty() && isTagsFeatureEnabled } internal inline fun Queue.pollIf(predicate: (T) -> Boolean): T? { return peek()?.takeIf(predicate)?.let { poll() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index 28e07af5..fc5b53dd 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -1386,6 +1386,7 @@ public object Mindbox : MindboxLog { BackgroundWorkManager.cancelAllWork(context) MindboxPreferences.resetAppInfoUpdated() mindboxScope = createMindboxScope() + MindboxDI.appModule.mobileConfigRepositoryIfCreated?.startListening() MindboxDI.appModule.embeddedBlocksRegistryIfCreated?.startListening() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index b0c6d4ba..fbddc8d4 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -175,7 +175,13 @@ internal fun DataModule( ) } - override val mobileConfigRepository: MobileConfigRepository by lazy { + override val mobileConfigRepository: MobileConfigRepository + get() = mobileConfigRepositoryLazy.value + + override val mobileConfigRepositoryIfCreated: MobileConfigRepository? + get() = mobileConfigRepositoryLazy.takeIf { repository -> repository.isInitialized() }?.value + + private val mobileConfigRepositoryLazy = lazy { MobileConfigRepositoryImpl( inAppMapper = inAppMapper, mobileConfigSerializationManager = mobileConfigSerializationManager, @@ -241,7 +247,8 @@ internal fun DataModule( InAppFailureTrackerImpl( timeProvider = timeProvider, inAppRepository = inAppRepository, - featureToggleManager = featureToggleManager + featureToggleManager = featureToggleManager, + sessionStorageManager = sessionStorageManager, ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt index 36753134..a96095de 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt @@ -34,6 +34,7 @@ internal fun DomainModule( minIntervalBetweenShowsLimitChecker = minIntervalBetweenShowsLimitChecker, timeProvider = timeProvider, sessionStorageManager = sessionStorageManager, + inAppFailureTracker = inAppFailureTracker, ) } override val callbackInteractor: CallbackInteractor by lazy { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index 51b030cc..ab0158c0 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -70,6 +70,9 @@ internal interface DataModule : MindboxModule { val inAppImageSizeStorage: InAppImageSizeStorage val sessionStorageManager: SessionStorageManager val mobileConfigRepository: MobileConfigRepository + + /** The repository only if something already asked for it — never creates one. */ + val mobileConfigRepositoryIfCreated: MobileConfigRepository? val mobileConfigSerializationManager: MobileConfigSerializationManager val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager val webViewCachePolicy: InAppWebViewCachePolicy diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentController.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentController.kt index 453a573b..907b4f23 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentController.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentController.kt @@ -2,9 +2,15 @@ package cloud.mindbox.mobile_sdk.embedded import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.view.View import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.di.MindboxDI +import cloud.mindbox.mobile_sdk.gatedTags +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE +import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendFailureWithContext +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.WaitBudgetPhase import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.embedded.webview.EmbeddedUpdatableContentProvider @@ -12,7 +18,7 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.logger.mindboxLogW import cloud.mindbox.mobile_sdk.models.Milliseconds -import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.utils.Constants import cloud.mindbox.mobile_sdk.utils.loggingRunCatching @@ -22,27 +28,43 @@ import kotlinx.coroutines.flow.onEach import java.io.Closeable internal class EmbeddedBlockContentController( - private val placeSystemName: String? = null, + placeSystemName: String? = null, configTimeout: Milliseconds = Constants.Embedded.defaultConfigTimeout, private val readyTimeout: Milliseconds = Constants.WebView.readyTimeout, - private val providerFactory: (InAppType.Embedded, Timestamp) -> EmbeddedContentProvider?, + private val providerFactory: (InAppType.Embedded, Milliseconds) -> EmbeddedContentProvider?, private val blocksRegistry: () -> EmbeddedBlocksRegistry? = { - if (MindboxDI.isInitialized()) MindboxDI.appModule.embeddedBlocksRegistry else null + loggingRunCatching(defaultValue = null) { + if (MindboxDI.isInitialized()) MindboxDI.appModule.embeddedBlocksRegistry else null + } + }, + private val monotonicNow: () -> Milliseconds = { Milliseconds(SystemClock.elapsedRealtime()) }, + private val failureTracker: () -> InAppFailureTracker? = { + if (MindboxDI.isInitialized()) MindboxDI.appModule.inAppFailureTracker else null + }, + private val isTagsFeatureEnabled: () -> Boolean = { + MindboxDI.isInitialized() && MindboxDI.appModule.featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) + }, + private val hasConfig: () -> Boolean = { + loggingRunCatching(defaultValue = false) { + MindboxDI.isInitialized() && MindboxDI.appModule.mobileConfigRepositoryIfCreated?.hasConfig() == true + } }, - private val now: () -> Timestamp = { Timestamp(System.currentTimeMillis()) }, ) : EmbeddedBlockHandle { + private val placeSystemName: String? = placeSystemName?.trim()?.takeIf { it.isNotEmpty() } + var onStateChange: ((EmbeddedBlockState) -> Unit)? = null val contentView: View? get() = provider?.contentView override val isActive: Boolean - get() = isStarted && !isReleased + get() = isStarted && !isReleased && !hasGivenUp private var provider: EmbeddedContentProvider? = null private var isStarted = false private var isReleased = false + private var hasGivenUp = false private var lastReportedState: EmbeddedBlockState? = null private var registration: Closeable? = null @@ -54,6 +76,9 @@ internal class EmbeddedBlockContentController( /** What the shown page was built from — the "same content" dedup key. */ private var appliedDescriptor: PageDescriptor? = null + /** The snapshot of the applied content — the failure events' id and tags come from it. */ + private var appliedContent: InAppType.Embedded? = null + /** * The applied content, split the way the dedup needs it: [isSamePage] is the page's identity — * the winner and the address the page was built from — while [params] are data a live page can @@ -79,21 +104,23 @@ internal class EmbeddedBlockContentController( private var updateEpoch = 0 - /** When the user started waiting for the current attempt — the base of `timeToDisplay`. */ - private var attemptStartedAt: Timestamp? = null + private var attemptStartTick: Milliseconds? = null + + private var pendingSinceTick: Milliseconds? = null + + private var hasPendingDelivery = false private val mainHandler = Handler(Looper.getMainLooper()) private val configWaitDuration: Milliseconds = sanitizedConfigTimeout(configTimeout, placeSystemName) - // The budgets count the user's waiting time: paused with the block, the remainder preserved, - // the full budget restored only by a new attempt. private val configBudget = EmbeddedBlockWaitBudget(configWaitDuration, mainHandler) { onConfigTimeout() } private val readyBudget = EmbeddedBlockWaitBudget(readyTimeout, mainHandler) { onReadyTimeout() } fun start() { if (isReleased) return isStarted = true + hasGivenUp = false val place = placeSystemName ?: run { report(EmbeddedBlockState.Empty) return @@ -154,7 +181,18 @@ internal class EmbeddedBlockContentController( override fun onContentResolved(content: InAppType.Embedded?) { if (isReleased) return + if (hasGivenUp) { + mindboxLogI( + "[EmbeddedBlock] Content for '$placeSystemName' arrived after the block gave up " + + "waiting, dropping it; the next appearance on screen asks afresh" + ) + return + } 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() if (!isActive) { mindboxLogI("[EmbeddedBlock] Content for '$placeSystemName' arrived while paused, deferring it") pendingContent = content @@ -164,11 +202,29 @@ internal class EmbeddedBlockContentController( applyResolved(content) } + override fun onContentPending() { + if (isReleased || hasGivenUp) return + configBudget.reset() + hasPendingDelivery = true + pendingSinceTick = pendingSinceTick ?: monotonicNow() + } + + private fun settlePendingWindow() { + hasPendingDelivery = false + pendingSinceTick?.let { pendingSince -> + attemptStartTick = attemptStartTick?.let { started -> + Milliseconds(started.interval + (monotonicNow().interval - pendingSince.interval)) + } + } + pendingSinceTick = null + } + private fun applyResolved(content: InAppType.Embedded?) { if (content == null) { mindboxLogI("[EmbeddedBlock] Nothing to show for place '$placeSystemName'") dropProvider() appliedDescriptor = null + appliedContent = null report(EmbeddedBlockState.Empty) return } @@ -182,6 +238,7 @@ internal class EmbeddedBlockContentController( if (current != null && descriptor == appliedDescriptor && lastReportedState?.nothingToShow != true) { mindboxLogI("[EmbeddedBlock] Same winner ${content.inAppId} for '$placeSystemName', keeping the content") + refreshMetricsSnapshot(current, content) return } if (lastReportedState?.nothingToShow == true) { @@ -194,6 +251,7 @@ internal class EmbeddedBlockContentController( lastReportedState == EmbeddedBlockState.Ready ) { mindboxLogI("[EmbeddedBlock] Same winner ${content.inAppId} with new params, updating the content in place") + refreshMetricsSnapshot(current, content) val epoch = ++updateEpoch runCatching { current.updateParams(layer.params) { isUpdated -> @@ -201,6 +259,7 @@ internal class EmbeddedBlockContentController( if (isReleased || provider !== current || epoch != updateEpoch) return@post if (isUpdated) { appliedDescriptor = descriptor + appliedContent = content } else { mindboxLogW("[EmbeddedBlock] In-place update over the bridge failed, recreating the content") recreateProvider(content) @@ -216,15 +275,22 @@ internal class EmbeddedBlockContentController( recreateProvider(content) } + private fun refreshMetricsSnapshot(current: EmbeddedContentProvider, content: InAppType.Embedded) { + if (current !is EmbeddedUpdatableContentProvider) return + val applied = appliedContent + if (applied != null && applied.frequency == content.frequency && applied.tags == content.tags) return + mindboxLogI("[EmbeddedBlock] Same winner ${content.inAppId}: the config changed its frequency/tags, refreshing the snapshot") + loggingRunCatching { current.refreshMetricsSnapshot(content.frequency, content.tags) } + appliedContent = content + } + private fun recreateProvider(content: InAppType.Embedded) { dropProvider() - // A revival or a replacement is a new attempt; the first content of an attempt keeps the - // clock that started with the resolve, so the wait for the answer stays in the measure. if (lastReportedState != EmbeddedBlockState.Loading) { - attemptStartedAt = now() + attemptStartTick = monotonicNow() } - val startedAt = attemptStartedAt ?: now().also { freshStart -> attemptStartedAt = freshStart } - val created = loggingRunCatching(defaultValue = null) { providerFactory(content, startedAt) } ?: run { + val startTick = attemptStartTick ?: monotonicNow().also { freshStart -> attemptStartTick = freshStart } + val created = loggingRunCatching(defaultValue = null) { providerFactory(content, startTick) } ?: run { mindboxLogE("[EmbeddedBlock] Could not build content for ${content.inAppId}, reporting failure") report(EmbeddedBlockState.Failed) return @@ -232,10 +298,9 @@ internal class EmbeddedBlockContentController( provider = created appliedDescriptor = descriptorOf(content.inAppId, content.layers.filterIsInstance().first()) + appliedContent = content created.onStateChange = { state -> report(state) } if (isStarted) { - // The answer arrived and a page is being built: the wait changes its nature, so the - // budget starts over with the page's own — shorter — patience. readyBudget.reset() readyBudget.armIfNeeded() runCatching { created.start() }.onFailure { error -> @@ -248,10 +313,8 @@ internal class EmbeddedBlockContentController( private fun report(state: EmbeddedBlockState) { if (state !is EmbeddedBlockState.Loading) { - // The attempt is over, with an outcome: the page budget and the attempt clock restart - // with the next one. readyBudget.reset() - attemptStartedAt = null + attemptStartTick = null } if (state == lastReportedState) return lastReportedState = state @@ -293,10 +356,9 @@ internal class EmbeddedBlockContentController( loggingRunCatching { job.cancel() } } - /** The wait for the first answer begins: the shimmer, the config budget and the attempt clock. */ private fun beginWaitingForContent() { report(EmbeddedBlockState.Loading) - attemptStartedAt = attemptStartedAt ?: now() + attemptStartTick = attemptStartTick ?: monotonicNow() if (!hasEverResolved()) { configBudget.armIfNeeded() } @@ -305,13 +367,23 @@ internal class EmbeddedBlockContentController( private fun onConfigTimeout() { if (isReleased || hasEverResolved()) return mindboxLogW( - "[EmbeddedBlock] No config within ${configWaitDuration.interval}ms of waiting for " + - "'$placeSystemName', collapsing; a late config still expands the block" + "[EmbeddedBlock] No answer within ${configWaitDuration.interval}ms of waiting for " + + "'$placeSystemName', collapsing; a later answer is dropped, the next appearance " + + "on screen asks afresh" ) + hasGivenUp = true + configBudget.reset() + placeSystemName?.let { place -> + failureTracker()?.let { tracker -> + val phase = if (hasConfig()) WaitBudgetPhase.RESOLVE_PENDING else WaitBudgetPhase.CONFIG_MISSING + loggingRunCatching { tracker.sendWaitBudgetExceeded(place, configWaitDuration, phase) } + } + } report(EmbeddedBlockState.Empty) } - private fun hasEverResolved(): Boolean = provider != null || hasPendingContent || appliedDescriptor != null + private fun hasEverResolved(): Boolean = + provider != null || hasPendingContent || appliedDescriptor != null || hasPendingDelivery private fun onReadyTimeout() { if (!isStarted || provider == null) return @@ -319,6 +391,20 @@ internal class EmbeddedBlockContentController( "[EmbeddedBlock] Page for '$placeSystemName' stayed silent for " + "${readyTimeout.interval}ms of waiting, reporting failure", ) + appliedContent?.let { content -> + failureTracker()?.let { tracker -> + loggingRunCatching { + tracker.sendFailureWithContext( + inAppId = content.inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDescription = "The embedded block page stayed silent for " + + "${readyTimeout.interval}ms after the content was handed to it", + tags = content.tags.gatedTags(isTagsFeatureEnabled()), + ) + } + } + } + hasGivenUp = true loggingRunCatching { provider?.pause() } report(EmbeddedBlockState.Failed) } @@ -330,7 +416,6 @@ internal class EmbeddedBlockContentController( } private companion object { - /** A non-positive timeout would collapse every block before the config had a chance. */ fun sanitizedConfigTimeout(requested: Milliseconds, place: String?): Milliseconds { if (requested.interval > 0) return requested mindboxLogE( diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactory.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactory.kt index e9cbb884..10df04cd 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactory.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactory.kt @@ -6,7 +6,7 @@ import cloud.mindbox.mobile_sdk.embedded.webview.EmbeddedBlockWebViewHolder import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.logger.mindboxLogE -import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.models.Milliseconds internal object EmbeddedBlockContentFactory { @@ -14,7 +14,7 @@ internal object EmbeddedBlockContentFactory { fun createProvider( context: Context, content: InAppType.Embedded, - attemptStartedAt: Timestamp, + startTick: Milliseconds, ): EmbeddedContentProvider? { val layer = content.layers.filterIsInstance().firstOrNull() ?: run { mindboxLogE("[EmbeddedBlock] Winner ${content.inAppId} has no webview layer") @@ -22,9 +22,12 @@ internal object EmbeddedBlockContentFactory { } return EmbeddedBlockWebViewHolder( inAppId = content.inAppId, + placeSystemName = content.placeSystemName, layer = layer, context = context, - attemptStartedAt = attemptStartedAt, + frequency = content.frequency, + tags = content.tags, + startTick = startTick, ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockWaitBudget.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockWaitBudget.kt index 31211ca8..dd0d66ea 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockWaitBudget.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockWaitBudget.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.embedded import android.os.Handler import android.os.SystemClock import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching internal class EmbeddedBlockWaitBudget( private val duration: Milliseconds, @@ -17,7 +18,7 @@ internal class EmbeddedBlockWaitBudget( private val expireRunnable = Runnable { resumedAt = null consumedMs = duration.interval - onExpire() + loggingRunCatching { onExpire() } } private val remainingMs: Long diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistry.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistry.kt index 9816af39..d70581b3 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistry.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistry.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.embedded import android.os.Handler import android.os.Looper import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.EmbeddedResolveResult import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.logger.mindboxLogI @@ -12,6 +13,7 @@ import cloud.mindbox.mobile_sdk.utils.loggingRunCatching import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.io.Closeable import java.lang.ref.WeakReference @@ -24,6 +26,13 @@ internal interface EmbeddedBlockHandle { val isActive: Boolean fun onContentResolved(content: InAppType.Embedded?) + + /** + * A winner exists but its `delayTime` has not elapsed: the SDK has answered — the waiting + * budget stands down and the delay leaves the show's `timeToDisplay` — while the block + * keeps its loading skeleton until the delivery. + */ + fun onContentPending() {} } internal interface EmbeddedBlocksRegistry { @@ -49,6 +58,10 @@ internal class EmbeddedBlocksRegistryImpl( private val reResolveQueuedPlaces = mutableMapOf() + private class PendingDelay(val inAppId: String, val job: Job) + + private val delayJobsByPlace = mutableMapOf() + private val mainHandler = Handler(Looper.getMainLooper()) private var channelJobs: List = emptyList() @@ -72,7 +85,7 @@ internal class EmbeddedBlocksRegistryImpl( scope.launch { inAppInteractor.listenEmbeddedPlaceEvents().collect { placeEvent -> runOnMain { - onPlaceEvent(placeEvent.placeSystemName, placeEvent.triggerEvent) + onPlaceEvent(placeEvent.placeSystemName.trim(), placeEvent.triggerEvent) } } }, @@ -86,26 +99,28 @@ internal class EmbeddedBlocksRegistryImpl( } override fun register(placeSystemName: String, handle: EmbeddedBlockHandle): Closeable { + val place = placeSystemName.trim() runOnMain { restartChannelsIfDead() - handlesByPlace.getOrPut(placeSystemName) { mutableListOf() }.add(WeakReference(handle)) - mindboxLogI("[EmbeddedBlock] Block registered for place '$placeSystemName'") + handlesByPlace.getOrPut(place) { mutableListOf() }.add(WeakReference(handle)) + mindboxLogI("[EmbeddedBlock] Block registered for place '$place'") } return Closeable { runOnMain { - handlesByPlace[placeSystemName]?.removeAll { reference -> + handlesByPlace[place]?.removeAll { reference -> reference.get().let { registered -> registered === handle || registered == null } } - forgetPlaceIfEmpty(placeSystemName) - mindboxLogI("[EmbeddedBlock] Block unregistered from place '$placeSystemName'") + forgetPlaceIfEmpty(place) + mindboxLogI("[EmbeddedBlock] Block unregistered from place '$place'") } } } override fun onBlockAppeared(placeSystemName: String) { + val place = placeSystemName.trim() runOnMain { restartChannelsIfDead() - resolvePlace(placeSystemName) + resolvePlace(place) } } @@ -147,18 +162,25 @@ internal class EmbeddedBlocksRegistryImpl( return } val job = scopeProvider().launch { - val content = try { - inAppInteractor.selectInAppForPlace( - place, - triggerEvent ?: InAppEventType.EmbeddedPlaceRequested(place) + val resolved = try { + Result.success( + inAppInteractor.selectInAppForPlace( + place, + triggerEvent ?: InAppEventType.EmbeddedPlaceRequested(place) + ) ) } catch (cancellation: CancellationException) { throw cancellation - } catch (error: Exception) { + } catch (error: Throwable) { mindboxLogW("[EmbeddedBlock] Resolving place '$place' failed: $error") - null + Result.failure(error) + } + runOnMain { + resolved.fold( + onSuccess = { result -> handleResolved(place, result) }, + onFailure = { handleResolveFailure(place) }, + ) } - runOnMain { deliver(place, content) } } job.invokeOnCompletion { runOnMain { @@ -187,6 +209,60 @@ internal class EmbeddedBlocksRegistryImpl( } } + private fun handleResolved(place: String, result: EmbeddedResolveResult?) { + val delayTime = result?.delayTime?.takeIf { delay -> delay.interval > 0 } + if (result == null || delayTime == null) { + delayJobsByPlace.remove(place)?.job?.cancel() + deliver(place, result?.variant) + return + } + val running = delayJobsByPlace[place] + if (running != null && running.job.isActive && running.inAppId == result.variant.inAppId) { + mindboxLogI( + "[EmbeddedBlock] Winner ${result.variant.inAppId} for place '$place' is already " + + "waiting out its delay, keeping the running timer" + ) + notifyPending(place) + return + } + running?.job?.cancel() + mindboxLogI( + "[EmbeddedBlock] Winner ${result.variant.inAppId} for place '$place' waits its " + + "delayTime of ${delayTime.interval} ms before the delivery" + ) + notifyPending(place) + val job = scopeProvider().launch { + delay(delayTime.interval) + val self = coroutineContext[Job] + runOnMain { + if (delayJobsByPlace[place]?.job !== self) return@runOnMain + delayJobsByPlace.remove(place) + inAppInteractor.markEmbeddedDelayWaitedOut(place, result.variant.inAppId) + deliver(place, result.variant) + } + } + delayJobsByPlace[place] = PendingDelay(result.variant.inAppId, job) + } + + private fun handleResolveFailure(place: String) { + val running = delayJobsByPlace[place] + if (running != null && running.job.isActive) { + mindboxLogI( + "[EmbeddedBlock] Resolving place '$place' failed while winner ${running.inAppId} " + + "waits out its delay, keeping the running timer" + ) + notifyPending(place) + return + } + deliver(place, null) + } + + private fun notifyPending(place: String) { + liveHandles(place).forEach { handle -> + loggingRunCatching { handle.onContentPending() } + } + } + private fun deliver(place: String, content: InAppType.Embedded?) { val handles = liveHandles(place) if (handles.isEmpty()) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt index 1f3f0000..cd929169 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt @@ -14,8 +14,10 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.findViewTreeLifecycleOwner +import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.R import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.di.MindboxDI import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.logger.mindboxLogW @@ -58,11 +60,17 @@ public class MindboxEmbeddedBlockView internal constructor( placeSystemName: String?, configTimeout: Milliseconds? = null, private val contentController: EmbeddedBlockContentController = EmbeddedBlockContentController( - placeSystemName = placeSystemName.orNullIfEmpty(), + placeSystemName = placeSystemName.orNullIfBlank(), configTimeout = configTimeout ?: readConfigTimeout(context, attrs), providerFactory = { content, attemptStartedAt -> EmbeddedBlockContentFactory.createProvider(context, content, attemptStartedAt) }, + failureTracker = { + loggingRunCatching(defaultValue = null) { + Mindbox.initComponents(context) + MindboxDI.appModule.inAppFailureTracker + } + }, ), ) : FrameLayout(context, attrs) { @@ -87,7 +95,7 @@ public class MindboxEmbeddedBlockView internal constructor( timeoutMs: Long? = null, ) : this(context, null, placeSystemName, timeoutMs?.let(::Milliseconds)) - public val placeSystemName: String? = placeSystemName.orNullIfEmpty() + public val placeSystemName: String? = placeSystemName.orNullIfBlank() private var listener: MindboxEmbeddedBlockListener = DefaultListener private var appearanceObserver: ((MindboxEmbeddedBlockAppearance) -> Unit)? = null private var placeholderView: View? = null @@ -144,14 +152,10 @@ public class MindboxEmbeddedBlockView internal constructor( if (isReleased) return val next = listener ?: DefaultListener - // The same listener is not a new subscriber. A host rebinds it on every recycled row, and - // replaying an outcome it already heard would have it rebuild its layout again — which - // rebinds the listener again. if (next === this.listener) return this.listener = next if (listener == null) return - // A new subscriber has heard nothing yet, so the current outcome is still news to it. deliveredEvent = null scheduleDelivery() } @@ -447,7 +451,7 @@ public class MindboxEmbeddedBlockView internal constructor( } } -private fun String?.orNullIfEmpty(): String? = this?.takeIf { it.isNotEmpty() } +private fun String?.orNullIfBlank(): String? = this?.trim()?.takeIf { it.isNotEmpty() } private fun readPlaceSystemName(context: Context, attrs: AttributeSet?): String? { if (attrs == null) return null diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolder.kt index c24dabfc..807e77a7 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolder.kt @@ -43,9 +43,9 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.logger.mindboxLogW import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager +import cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.Milliseconds -import cloud.mindbox.mobile_sdk.models.Timestamp import cloud.mindbox.mobile_sdk.models.getShortUserAgent import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.utils.Constants @@ -58,9 +58,15 @@ import com.google.gson.JsonObject import com.google.gson.annotations.SerializedName import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.select import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONObject import java.util.concurrent.ConcurrentHashMap @@ -69,9 +75,13 @@ import java.util.concurrent.atomic.AtomicReference @OptIn(InternalMindboxApi::class) internal class EmbeddedBlockWebViewHolder( private val inAppId: String, + private val placeSystemName: String, @Volatile private var layer: Layer.WebViewLayer, private val context: Context, - private val attemptStartedAt: Timestamp, + @Volatile private var frequency: Frequency, + @Volatile private var tags: Map?, + private val startTick: Milliseconds, + private val ackBudget: Milliseconds = Constants.WebView.readyTimeout, ) : EmbeddedUpdatableContentProvider, MindboxWebPage { override var onStateChange: ((EmbeddedBlockState) -> Unit)? = null @@ -81,7 +91,9 @@ internal class EmbeddedBlockWebViewHolder( @Volatile private var webViewController: WebViewController? = null - @Volatile private var isActive = false + private val presence = MutableStateFlow(false) + + private val isActive: Boolean get() = presence.value @Volatile private var isReleased = false @@ -139,6 +151,8 @@ internal class EmbeddedBlockWebViewHolder( @Volatile private var didAccountForShow = false + @Volatile private var didReportShownContent = false + private val isUserPresent: Boolean get() = isActive && !isReleased private val heldFailure = AtomicReference(null) @@ -147,13 +161,16 @@ internal class EmbeddedBlockWebViewHolder( val failureReason: FailureReason, val errorDescription: String, val throwable: Throwable?, + val tags: Map?, ) @Volatile private var renderedTimeToDisplay: Milliseconds? = null + @Volatile private var pendingAckJob: Job? = null + override fun start() { if (isReleased) return - isActive = true + presence.value = true flushHeldFailure() if (!isLoadRequested) { isLoadRequested = true @@ -165,12 +182,13 @@ internal class EmbeddedBlockWebViewHolder( } override fun pause() { - isActive = false + presence.value = false } override fun release() { - isActive = false + presence.value = false isReleased = true + pendingAckJob?.cancel() unregisterFromBroadcasts() if (commonBridgeActionsLazy.isInitialized()) { commonBridgeActions.tearDown() @@ -185,21 +203,33 @@ internal class EmbeddedBlockWebViewHolder( webViewController = null } + override fun refreshMetricsSnapshot(frequency: Frequency, tags: Map?) { + this.frequency = frequency + this.tags = tags + } + override fun updateParams(params: Map, onResult: (Boolean) -> Unit) { val controller = webViewController ?: run { onResult(false) return } layer = layer.copy(params = params) - Mindbox.mindboxScope.launch { + didReportShownContent = false + pendingAckJob?.cancel() + pendingAckJob = Mindbox.mindboxScope.launch { val configuration: Configuration = DbManager.listenConfigurations().first() val payload = startPayload(configuration) val isUpdated = runCatching { - withTimeoutOrNull(Constants.WebView.readyTimeout.interval) { - sendActionAndAwaitResponse( - controller, - BridgeMessage.createAction(WebViewAction.INIT_DATA_UPDATED, payload) - ) + coroutineScope { + val answer = async { + sendActionAndAwaitResponse( + controller, + BridgeMessage.createAction(WebViewAction.INIT_DATA_UPDATED, payload) + ) + } + val response = answer.awaitWithForegroundBudget(ackBudget) + if (response == null) answer.cancel() + response } }.getOrNull() != null mindboxLogI("[EmbeddedBlock] initDataUpdated for $inAppId answered success=$isUpdated") @@ -246,7 +276,6 @@ internal class EmbeddedBlockWebViewHolder( }.onSuccess { response: String -> onContentPageLoaded(WebViewHtmlContent(baseUrl = layer.baseUrl ?: "", html = response)) }.onFailure { error -> - // A cancelled scope is teardown, not a page failure — no telemetry, no Failed. if (error is CancellationException) throw error reportLoadFailure("Failed to fetch HTML content for the embedded block", error) } @@ -281,9 +310,10 @@ internal class EmbeddedBlockWebViewHolder( ) if (error.isForMainFrame == true) { sendFailure( - failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + failureReason = FailureReason.WEBVIEW_LOAD_FAILED, errorDescription = "Embedded block WebView error: code=${error.code}, " + "description=${error.description}, url=${error.url}", + tags = gatedTags() ) report(EmbeddedBlockState.Failed) } @@ -374,7 +404,7 @@ internal class EmbeddedBlockWebViewHolder( "asked ids are not strings, skipping them" ) } - val showableIds = inAppInteractor.filterShowableInAppIds(requestedIds) + val showableIds = inAppInteractor.filterShowableInAppIds(inAppId, requestedIds) mindboxLogI( "[EmbeddedBlock] filterShowableInapps: ${requestedIds.size} id(s) asked, " + "${showableIds.size} allowed" @@ -384,63 +414,66 @@ internal class EmbeddedBlockWebViewHolder( private fun handleContentRenderedAction(message: BridgeMessage.Request): String { hasPageAnswered = true + if (didReportShownContent) { + mindboxLogI("[EmbeddedBlock] The page reported itself again with nothing asked of it, ignoring: the block is already shown") + return BridgeMessage.SUCCESS_PAYLOAD + } val raw: Any? = runCatching { JSONObject(message.payload ?: BridgeMessage.EMPTY_PAYLOAD).get("count") }.getOrNull() - // A count of items the page drew, not the size of a collection: a fraction or a negative - // number is a page bug, and rounding or hiding one would decide the block's fate on the - // page's behalf — the refusal is what lands the bug in the metrics instead of passing - // for an empty feed. val number = raw as? Number ?: throw refusedContentReport("missing or non-numeric 'count'") val count = number.toWholeIntOrNull() ?: throw refusedContentReport("'count' must be a whole number of items, got $number") if (count < 0) throw refusedContentReport("'count' must not be negative, got $count") - mindboxLogI("[EmbeddedBlock] Page rendered $count feed element(s)") + mindboxLogI("[EmbeddedBlock] Page rendered $count content element(s)") if (count == 0) { report(EmbeddedBlockState.Empty) return BridgeMessage.SUCCESS_PAYLOAD } - renderedTimeToDisplay = timeProvider.elapsedSince(attemptStartedAt) + didReportShownContent = true + renderedTimeToDisplay = timeProvider.monotonicElapsedSince(startTick) report(EmbeddedBlockState.Ready) if (isActive) accountForShow() return BridgeMessage.SUCCESS_PAYLOAD } - /** JS numbers arrive as [Int] or [Double]; a whole [Double] is still a count of items. */ private fun Number.toWholeIntOrNull(): Int? = when (this) { is Int -> this is Double -> toInt().takeIf { whole -> whole.toDouble() == this } else -> null } - /** - * `contentRendered` is the page's only statement about itself, so an unusable one leaves a - * reserved space nobody can vouch for: the block fails, the backend hears why, and the thrown - * refusal reaches the page as an error response instead of a success it would take for the truth. - */ private fun refusedContentReport(reason: String): Throwable { mindboxLogE("[EmbeddedBlock] contentRendered refused: $reason") sendFailure( failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "The embedded block page reported contentRendered with an unusable payload: $reason", + tags = gatedTags() ) report(EmbeddedBlockState.Failed) return IllegalArgumentException(reason) } /** - * The block drew something, so its in-app was shown. Reported once per content instance; the - * once-per-session rule lives in the interactor, where the session state is. + * The block drew something the user can see, so its in-app was shown. Reported once per + * content instance; the content-change rule lives in the interactor's place slot, where + * the session state is. Off screen the show waits — [start] re-asks when the block returns. */ private fun accountForShow() { if (didAccountForShow) return + if (!isUserPresent) { + mindboxLogI("[EmbeddedBlock] Content rendered off screen, the show waits for the block to return") + return + } didAccountForShow = true - val timeToDisplay = renderedTimeToDisplay ?: timeProvider.elapsedSince(attemptStartedAt) + val timeToDisplay = renderedTimeToDisplay ?: timeProvider.monotonicElapsedSince(startTick) Mindbox.mindboxScope.launch { loggingRunCatchingSuspending { - inAppInteractor.recordBlockShow(inAppId, timeToDisplay, gatedTags()) + inAppInteractor.recordBlockShow(placeSystemName, inAppId, frequency, timeToDisplay, gatedTags()) } + }.invokeOnCompletion { cause -> + if (cause is CancellationException) didAccountForShow = false } } @@ -476,6 +509,7 @@ internal class EmbeddedBlockWebViewHolder( } lastLoadedContent = content hasPageAnswered = false + didReportShownContent = false controller.loadContent(content) } @@ -483,7 +517,7 @@ internal class EmbeddedBlockWebViewHolder( val controller = webViewController ?: return val content = lastLoadedContent ?: return mindboxLogI( - "[EmbeddedBlock] Retrying feed content load with cache bypassed " + + "[EmbeddedBlock] Retrying block content load with cache bypassed " + "(${noCacheRetryPolicy.lastHttpErrorDetail})" ) controller.setCacheBypass(true) @@ -494,13 +528,14 @@ internal class EmbeddedBlockWebViewHolder( failureReason: FailureReason, errorDescription: String, throwable: Throwable? = null, + tags: Map? = null, ) { if (!isActive) { mindboxLogI( "[EmbeddedBlock] $failureReason for $inAppId happened off screen, holding the " + "report until the block is looked at" ) - heldFailure.compareAndSet(null, HeldFailure(failureReason, errorDescription, throwable)) + heldFailure.compareAndSet(null, HeldFailure(failureReason, errorDescription, throwable, tags)) if (isActive) flushHeldFailure() return } @@ -509,7 +544,7 @@ internal class EmbeddedBlockWebViewHolder( failureReason = failureReason, errorDescription = errorDescription, throwable = throwable, - tags = null + tags = tags ) } @@ -520,7 +555,7 @@ internal class EmbeddedBlockWebViewHolder( failureReason = held.failureReason, errorDescription = held.errorDescription, throwable = held.throwable, - tags = null + tags = held.tags ) } @@ -529,6 +564,7 @@ internal class EmbeddedBlockWebViewHolder( failureReason = FailureReason.WEBVIEW_LOAD_FAILED, errorDescription = description, throwable = throwable, + tags = gatedTags() ) webViewController?.executeOnViewThread { report(EmbeddedBlockState.Failed) } ?: mainHandler.post { if (!isReleased) report(EmbeddedBlockState.Failed) } @@ -539,6 +575,42 @@ internal class EmbeddedBlockWebViewHolder( if (isActive) onStateChange?.invoke(state) } + private suspend fun Deferred.awaitWithForegroundBudget(budget: Milliseconds): T? { + var remaining = budget + while (true) { + if (!presence.value) { + if (settlesBeforePresenceTurns(lookedAt = true)) return await() + mindboxLogI( + "[EmbeddedBlock] Back on screen with a data push still unconfirmed — " + + "waiting out the remaining ${remaining.interval}ms" + ) + continue + } + if (remaining.interval <= 0L) return null + val spendStartedTick = timeProvider.monotonicMillis() + when (withTimeoutOrNull(remaining.interval) { settlesBeforePresenceTurns(lookedAt = false) }) { + null -> return null + true -> return await() + false -> remaining = Milliseconds( + (remaining.interval - timeProvider.monotonicElapsedSince(spendStartedTick).interval) + .coerceAtLeast(0L) + ) + } + } + } + + private suspend fun Job.settlesBeforePresenceTurns(lookedAt: Boolean): Boolean = coroutineScope { + val presenceTurned = async { presence.first { isLookedAt -> isLookedAt == lookedAt } } + try { + select { + this@settlesBeforePresenceTurns.onJoin { true } + presenceTurned.onJoin { false } + } + } finally { + presenceTurned.cancel() + } + } + private suspend fun sendActionAndAwaitResponse( controller: WebViewController, message: BridgeMessage.Request, @@ -636,10 +708,8 @@ internal class EmbeddedBlockWebViewHolder( pendingResponsesById.clear() } - /** Tags of this block's in-app, gated by the feature toggle — as the overlay path does. */ - private fun gatedTags(): Map? = sessionStorageManager.currentSessionInApps - .firstOrNull { inApp -> inApp.id == inAppId } - ?.gatedTags(featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE)) + private fun gatedTags(): Map? = + tags.gatedTags(featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE)) private data class InAppIdsPayload( @SerializedName("inappIds") diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedUpdatableContentProvider.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedUpdatableContentProvider.kt index 8a5b2283..0c367334 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedUpdatableContentProvider.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedUpdatableContentProvider.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.embedded.webview import cloud.mindbox.mobile_sdk.embedded.EmbeddedContentProvider +import cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency /** * An updatable provider can refresh its content in place over the bridge (`initDataUpdated`) @@ -10,4 +11,6 @@ import cloud.mindbox.mobile_sdk.embedded.EmbeddedContentProvider internal interface EmbeddedUpdatableContentProvider : EmbeddedContentProvider { fun updateParams(params: Map, onResult: (Boolean) -> Unit) + + fun refreshMetricsSnapshot(frequency: Frequency, tags: Map?) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt index 580338fd..5dc17a20 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt @@ -4,8 +4,12 @@ import cloud.mindbox.mobile_sdk.convertToString import cloud.mindbox.mobile_sdk.convertToZonedDateTimeAtUTC import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.WaitBudgetPhase import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppRepository import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.millisToTimeSpan +import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.models.operation.request.EmbeddedBlockShowFailure import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import cloud.mindbox.mobile_sdk.utils.TimeProvider @@ -15,7 +19,8 @@ import java.util.concurrent.CopyOnWriteArrayList internal class InAppFailureTrackerImpl( private val timeProvider: TimeProvider, private val inAppRepository: InAppRepository, - private val featureToggleManager: FeatureToggleManager + private val featureToggleManager: FeatureToggleManager, + private val sessionStorageManager: SessionStorageManager, ) : InAppFailureTracker { private val failures = CopyOnWriteArrayList() @@ -32,7 +37,7 @@ internal class InAppFailureTrackerImpl( mindboxLogI("Feature $SEND_INAPP_SHOW_ERROR_FEATURE is off. Skip send failures") return } - inAppRepository.sendInAppShowFailure(failures.toList()) + inAppRepository.sendInAppShowErrors(failures.toList()) failures.clear() } @@ -41,7 +46,7 @@ internal class InAppFailureTrackerImpl( mindboxLogI("Feature $SEND_INAPP_SHOW_ERROR_FEATURE is off. Skip send failure") return } - inAppRepository.sendInAppShowFailure(listOf(failure)) + inAppRepository.sendInAppShowErrors(listOf(failure)) } override fun sendFailure( @@ -93,6 +98,31 @@ internal class InAppFailureTrackerImpl( failures.clear() } + override fun sendWaitBudgetExceeded(placeSystemName: String, waitedFor: Milliseconds, phase: WaitBudgetPhase) { + if (!featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE)) { + mindboxLogI("Feature $SEND_INAPP_SHOW_ERROR_FEATURE is off. Skip send wait budget failure") + return + } + if (!sessionStorageManager.waitBudgetReportedPlaces.add(placeSystemName)) { + mindboxLogI("Place '$placeSystemName' already reported its exceeded wait budget this session") + return + } + mindboxLogI("The SDK stayed silent past the block's budget, sending the place-named failure") + val timestamp = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) + .convertToZonedDateTimeAtUTC() + .convertToString() + inAppRepository.sendInAppShowErrors( + listOf( + EmbeddedBlockShowFailure( + placeSystemName = placeSystemName, + failureReason = FailureReason.WAIT_BUDGET_EXCEEDED, + errorDetails = "phase=${phase.wireName}; waited=${waitedFor.interval.millisToTimeSpan()}", + dateTimeUtc = timestamp, + ) + ) + ) + } + companion object { private const val COUNT_OF_CHARS_IN_ERROR_DETAILS = 1000 } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt index 675e549e..f0f4f10b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt @@ -2,10 +2,10 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.fromJsonTyped import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager -import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppErrorsWrapper import cloud.mindbox.mobile_sdk.models.operation.request.InAppClickRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowRequest -import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowError import cloud.mindbox.mobile_sdk.models.operation.request.InAppTargetingRequest import cloud.mindbox.mobile_sdk.toJsonTyped import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler @@ -54,11 +54,11 @@ internal class InAppSerializationManagerImpl(private val gson: Gson) : InAppSeri } } - override fun serializeToInAppShowFailuresString( - inAppShowFailures: List + override fun serializeToInAppShowErrorsString( + inAppShowErrors: List ): String { return loggingRunCatching("") { - gson.toJsonTyped(InAppFailuresWrapper(inAppShowFailures)) + gson.toJsonTyped(InAppErrorsWrapper(inAppShowErrors)) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt index 66260b74..551b1682 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt @@ -22,13 +22,17 @@ internal class SessionStorageManager(private val timeProvider: TimeProvider) { var operationalInApps: ConcurrentHashMap> = ConcurrentHashMap() var inAppMessageShownInSession: MutableList = CopyOnWriteArrayList() - /** - * In-apps whose embedded block has already reported `Inapp.Show` this session. Separate from - * [inAppMessageShownInSession]: that one is not written for `unlimited`, while the show - * operation ships once per session whatever the frequency. - */ - val blockShowsReportedInSession: MutableSet = newConcurrentSet() + val embeddedLastShownByPlace: ConcurrentHashMap = ConcurrentHashMap() + + val embeddedLastTargetedByPlace: ConcurrentHashMap = ConcurrentHashMap() + val placeTargetingReportedInSession: MutableSet = newConcurrentSet() + + val embeddedDelaysWaitedOut: MutableSet = newConcurrentSet() + + val requestedInAppTargetingReportedInSession: MutableSet = newConcurrentSet() + + val waitBudgetReportedPlaces: MutableSet = newConcurrentSet() var customerSegmentationFetchStatus: CustomerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED var geoFetchStatus: GeoFetchStatus = GeoFetchStatus.GEO_NOT_FETCHED @@ -92,8 +96,12 @@ internal class SessionStorageManager(private val timeProvider: TimeProvider) { unShownOperationalInApps.clear() operationalInApps.clear() inAppMessageShownInSession.clear() - blockShowsReportedInSession.clear() + embeddedLastShownByPlace.clear() + embeddedLastTargetedByPlace.clear() placeTargetingReportedInSession.clear() + embeddedDelaysWaitedOut.clear() + requestedInAppTargetingReportedInSession.clear() + waitBudgetReportedPlaces.clear() customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED geoFetchStatus = GeoFetchStatus.GEO_NOT_FETCHED inAppProductSegmentations.clear() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt index c3377977..f49bd458 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt @@ -275,11 +275,13 @@ internal class InAppMapper { is PayloadDto.EmbeddedDto -> { InAppType.Embedded( inAppId = inAppDto.id, - placeSystemName = payloadDto.placeSystemName!!, + placeSystemName = payloadDto.placeSystemName!!.trim(), layers = mapBackgroundLayers( payloadDto.content?.background?.layers ?.filterIsInstance() ), + frequency = Frequency(getDelay(inAppDto.frequency)), + tags = inAppDto.tags?.takeIf { it.isNotEmpty() }, ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt index 9a9c3a58..72007494 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt @@ -10,7 +10,7 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.Timestamp -import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowError import cloud.mindbox.mobile_sdk.newConcurrentSet import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider @@ -133,11 +133,11 @@ internal class InAppRepositoryImpl( } } - override fun sendInAppShowFailure(failures: List) { - failures + override fun sendInAppShowErrors(errors: List) { + errors .takeIf { it.isNotEmpty() } ?.let { - inAppSerializationManager.serializeToInAppShowFailuresString(failures) + inAppSerializationManager.serializeToInAppShowErrorsString(errors) .takeIf { it.isNotBlank() } ?.let { operationBody -> MindboxEventManager.inAppShowFailure(context, operationBody) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt index df473ae2..fb361e1b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt @@ -27,6 +27,7 @@ import cloud.mindbox.mobile_sdk.models.TimeSpan import cloud.mindbox.mobile_sdk.models.operation.response.* import cloud.mindbox.mobile_sdk.monitoring.data.validators.MonitoringValidator import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -64,8 +65,15 @@ internal class MobileConfigRepositoryImpl( private val configUpdates = MutableSharedFlow(extraBufferCapacity = 1) + private var configSubscription: Job? = null + init { - Mindbox.mindboxScope.launch { + startListening() + } + + override fun startListening() { + if (configSubscription?.isActive == true) return + configSubscription = Mindbox.mindboxScope.launch { MindboxPreferences.inAppConfigFlow .collectLatest { configString -> processConfigUpdate(configString) @@ -121,6 +129,8 @@ internal class MobileConfigRepositoryImpl( override fun listenConfigUpdates(): Flow = configUpdates + override fun hasConfig(): Boolean = configState.value != null + override suspend fun getMonitoringSection() = getConfig().monitoring override suspend fun getOperations() = getConfig().operations diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidator.kt index ebc9764d..77e1a35d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidator.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidator.kt @@ -20,7 +20,7 @@ internal class EmbeddedVariantValidator( ) return false } - if (item.placeSystemName.isNullOrEmpty()) { + if (item.placeSystemName.isNullOrBlank()) { mindboxLogW("InApp is invalid. Embedded variant has no placeSystemName") return false } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppFilteringManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppFilteringManagerImpl.kt index ca03f82a..0cdb7b79 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppFilteringManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppFilteringManagerImpl.kt @@ -53,13 +53,14 @@ internal class InAppFilteringManagerImpl( inApps: List, placeSystemName: String ): List { + val requestedPlace = placeSystemName.trim() return inApps.filter { inApp -> inApp.embeddedVariants().any { variant -> - val matches = variant.placeSystemName == placeSystemName - if (!matches && variant.placeSystemName.equals(placeSystemName, ignoreCase = true)) { + val matches = variant.placeSystemName == requestedPlace + if (!matches && variant.placeSystemName.equals(requestedPlace, ignoreCase = true)) { mindboxLogW( "Place names differ only in letter case: config has " + - "'${variant.placeSystemName}', the block asked for '$placeSystemName'. " + + "'${variant.placeSystemName}', the block asked for '$requestedPlace'. " + "The comparison is case-sensitive, the candidate is skipped" ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt index 2766e964..83ee9e28 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt @@ -4,10 +4,13 @@ import cloud.mindbox.mobile_sdk.InitializeLock import cloud.mindbox.mobile_sdk.abtests.InAppABTestLogic import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.checkers.Checker +import cloud.mindbox.mobile_sdk.inapp.domain.models.DisplayConditions import cloud.mindbox.mobile_sdk.inapp.domain.models.EmbeddedPlaceEvent +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.EmbeddedResolveResult import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppToShow import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppEventManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFilteringManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFrequencyManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppProcessingManager @@ -46,13 +49,12 @@ internal class InAppInteractorImpl( private val maxInappsPerDayLimitChecker: Checker, private val minIntervalBetweenShowsLimitChecker: Checker, private val timeProvider: TimeProvider, - private val sessionStorageManager: SessionStorageManager + private val sessionStorageManager: SessionStorageManager, + private val inAppFailureTracker: InAppFailureTracker, ) : InAppInteractor, MindboxLog { private val inAppTargetingChannel = Channel(Channel.UNLIMITED) - // A page request is not an operation: with the synthetic event name and no body, - // operation-node targetings never match — "no operation is happening right now". private val placeRequestTargetingData = TargetingDataWrapper(InAppEventType.EmbeddedPlaceRequested.EVENT_NAME) @@ -61,7 +63,15 @@ internal class InAppInteractorImpl( .let { inApps -> inAppRepository.saveCurrentSessionInApps(inApps) for (inApp in inApps) { - for (operation in inApp.targeting.getOperationsSet()) { + val operations = inApp.targeting.getOperationsSet() + if (inApp.displayConditions == DisplayConditions.DIRECT_CALL && operations.isNotEmpty()) { + logW( + "In-app ${inApp.id} is direct-call only but its targeting listens to " + + "operations $operations: a dead combination — it never shows by " + + "the operation and never sends its targeting" + ) + } + for (operation in operations) { inAppRepository.saveOperationalInApp(operation.lowercase(), inApp) } } @@ -115,44 +125,76 @@ internal class InAppInteractorImpl( override suspend fun selectInAppForPlace( placeSystemName: String, triggerEvent: InAppEventType, - ): InAppType.Embedded? { + ): EmbeddedResolveResult? { + val requestedPlace = placeSystemName.trim() val inApps = mobileConfigRepository.getInAppsSection() inAppRepository.saveCurrentSessionInApps(inApps) - // The same chain as the event path: the display style does not change "when to show". - val candidates = abTestFilteredInApps(inApps) - .let { inAppFilteringManager.filterEmbeddedInAppsByPlace(it, placeSystemName) } - val winner = chooseAmongCandidates( - logLabel = "Place '$placeSystemName'", - candidates = candidates, - triggerEvent = triggerEvent, - selectVariant = { candidate -> - candidate.form.variants - .filterIsInstance() - .firstOrNull { variant -> variant.placeSystemName == placeSystemName } - } - ) - ?: run { - logI("Place '$placeSystemName': nothing to show") - return null - } + val candidates = inAppFilteringManager.filterEmbeddedInAppsByPlace(inApps, requestedPlace) + .let { inAppFilteringManager.filterOutDirectCallInApps(it) } + val matched = candidates.filter { candidate -> + inAppProcessingManager.matchesTargeting(candidate, triggerEvent) + } + logI("Place '$requestedPlace': ${matched.size} of ${candidates.size} candidate(s) matched targeting") + val inAppsPool = inAppABTestLogic.getInAppsPool(inApps.map { inApp -> inApp.id }) + val winner = inAppFilteringManager.filterABTestsInApps(matched, inAppsPool) + .let { inAppFrequencyManager.filterInAppsFrequency(it) } + .sortByPriority() + .firstOrNull { candidate -> candidate.embeddedVariantFor(requestedPlace) != null } + sendPlaceTargetings(requestedPlace, matched, winner) + if (winner == null) { + logI("Place '$requestedPlace': nothing to show") + inAppFailureTracker.sendCollectedFailures() + return null + } + inAppFailureTracker.clearFailures() if (!areShowLimitsAllowed(winner)) { - logI("Place '$placeSystemName': in-app ${winner.id} is blocked by the show limits") + logI("Place '$requestedPlace': in-app ${winner.id} is blocked by the show limits") return null } - // Place resolves repeat for reasons that offer nothing new — the block reappears, a config - // lands, an operation passes by — so the winner is offered once per session, and only once - // it has actually been given the place (after the limits). - if (sessionStorageManager.placeTargetingReportedInSession.add(winner.id)) { - inAppProcessingManager.sendTargetedInApp(winner) - } else { - logI("Place '$placeSystemName': in-app ${winner.id} already sent its targeting this session") + val variant = winner.embeddedVariantFor(requestedPlace) ?: return null + val delayTime = winner.delayTime?.takeIf { delay -> + delay.interval > 0 && waitedOutDelayKey(requestedPlace, winner.id) !in sessionStorageManager.embeddedDelaysWaitedOut } - return winner.form.variants - .filterIsInstance() - .firstOrNull { variant -> variant.placeSystemName == placeSystemName } + if (winner.delayTime != null && delayTime == null) { + logI("Place '$requestedPlace': in-app ${winner.id} waits no delay (already waited out this session or zero)") + } + return EmbeddedResolveResult( + variant = variant, + delayTime = delayTime, + ) } + override fun markEmbeddedDelayWaitedOut(placeSystemName: String, inAppId: String) { + sessionStorageManager.embeddedDelaysWaitedOut.add(waitedOutDelayKey(placeSystemName.trim(), inAppId)) + } + + private fun waitedOutDelayKey(place: String, inAppId: String): String = "$place|$inAppId" + + private fun sendPlaceTargetings(place: String, matched: List, winner: InApp?) { + for (inApp in matched) { + if (inApp.id == winner?.id) { + if (sessionStorageManager.embeddedLastTargetedByPlace.put(place, inApp.id) == inApp.id) { + logI("Place '$place': winner ${inApp.id} is the last targeted here, no second targeting") + } else { + sessionStorageManager.placeTargetingReportedInSession.add(inApp.id) + inAppProcessingManager.sendTargetedInApp(inApp) + } + } else { + if (sessionStorageManager.placeTargetingReportedInSession.add(inApp.id)) { + inAppProcessingManager.sendTargetedInApp(inApp) + } else { + logI("Place '$place': in-app ${inApp.id} already sent its targeting this session") + } + } + } + } + + private fun InApp.embeddedVariantFor(place: String): InAppType.Embedded? = + form.variants + .filterIsInstance() + .firstOrNull { variant -> variant.placeSystemName == place } + private suspend fun abTestFilteredInApps(inApps: List): List = inAppFilteringManager.filterABTestsInApps(inApps, inAppABTestLogic.getInAppsPool(inApps.map { it.id })) @@ -200,40 +242,62 @@ internal class InAppInteractorImpl( return InAppToShow(inApp, variant) } - override suspend fun filterShowableInAppIds(inAppIds: List): List { + override suspend fun filterShowableInAppIds(hostInAppId: String, inAppIds: List): List { if (inAppIds.isEmpty()) return emptyList() val inApps = mobileConfigRepository.getInAppsSection() val inAppsPool = inAppABTestLogic.getInAppsPool(inApps.map { it.id }) - val showableById = inAppFilteringManager.filterABTestsInApps(inApps, inAppsPool) - .distinctBy { inApp -> inApp.id } - .associateBy { inApp -> inApp.id } - return inAppIds.filter { id -> - val inApp = showableById[id] ?: run { - logI("Requested id $id is not in the config (or filtered by sdkVersion/ab-tests), cutting it") - return@filter false + val showableIds = inAppFilteringManager.filterABTestsInApps(inApps, inAppsPool) + .map { inApp -> inApp.id } + .toSet() + val fullById = inApps.distinctBy { inApp -> inApp.id }.associateBy { inApp -> inApp.id } + val matchedById = mutableMapOf() + for (id in inAppIds.distinct()) { + val inApp = fullById[id] + if (inApp == null) { + logI("Requested id $id is not in the config (or filtered by sdkVersion), cutting it") + continue } if (inApp.firstOverlayVariant() == null) { - logI("Requested id $id has no overlay variant to draw, cutting it (no feed inside a feed)") + logI("Requested id $id has no overlay variant to draw, cutting it") + continue + } + matchedById[id] = matchesRequestedTargeting(inApp) + } + sendRequestedTargetings(hostInAppId, fullById, matchedById) + return inAppIds.filter { id -> + if (matchedById[id] != true) return@filter false + if (id !in showableIds) { + logI("Requested id $id is filtered by ab-tests, cutting it") return@filter false } - if (!inAppFrequencyManager.isAllowedByFrequency(inApp)) { + if (!inAppFrequencyManager.isAllowedByFrequency(fullById.getValue(id))) { logI("Requested id $id is blocked by its frequency, cutting it") return@filter false } - runCatching { - inApp.targeting.fetchTargetingInfo(placeRequestTargetingData) - inApp.targeting.checkTargeting(placeRequestTargetingData) + true + } + } + + private fun sendRequestedTargetings(hostInAppId: String, fullById: Map, matchedById: Map) { + for ((id, matches) in matchedById) { + if (!matches) continue + if (sessionStorageManager.requestedInAppTargetingReportedInSession.add("$hostInAppId|$id")) { + inAppProcessingManager.sendTargetedInApp(fullById.getValue(id)) } - .getOrElse { error -> - logI("Requested id $id targeting could not be checked ($error), cutting it") - false - } - .also { matches -> if (!matches) logI("Requested id $id targeting did not match, cutting it") } - }.onEach { id -> - inAppProcessingManager.sendTargetedInApp(showableById.getValue(id)) } } + private suspend fun matchesRequestedTargeting(inApp: InApp): Boolean = + runCatching { + inApp.targeting.fetchTargetingInfo(placeRequestTargetingData) + inApp.targeting.checkTargeting(placeRequestTargetingData) + } + .getOrElse { error -> + logI("Requested id ${inApp.id} targeting could not be checked ($error), cutting it") + false + } + .also { matches -> if (!matches) logI("Requested id ${inApp.id} targeting did not match, cutting it") } + override fun areShowAndFrequencyLimitsAllowed(inApp: InApp): Boolean = inAppFrequencyManager.isAllowedByFrequency(inApp) && areShowLimitsAllowed(inApp) @@ -249,26 +313,27 @@ internal class InAppInteractorImpl( private suspend fun findInAppById(inAppId: String): InApp? = mobileConfigRepository.getInAppsSection().firstOrNull { inApp -> inApp.id == inAppId } - override suspend fun recordBlockShow( + override fun recordBlockShow( + placeSystemName: String, inAppId: String, + frequency: Frequency, timeToDisplay: Milliseconds, tags: Map?, ) { - val inApp = findInAppById(inAppId) - ?: run { - logI("No in-app with id $inAppId to report a block show for") - return - } - recordShowCounters(inApp, timeProvider.currentTimestamp()) - // A view the host recreated (a rotation, a return to the screen) draws the same content - // again; the funnel counts one show per session, and this set outlives the views — it is - // cleared with the session itself. - if (!sessionStorageManager.blockShowsReportedInSession.add(inAppId)) { - logI("Block of in-app $inAppId already reported its show this session") + val place = placeSystemName.trim() + val lastShown = sessionStorageManager.embeddedLastShownByPlace.put(place, inAppId) + if (lastShown == inAppId) { + logI("Place '$place': the block re-drew in-app $inAppId it already showed, nothing to report") return } - // The operation is unconditional — it states what the user saw, and how often the in-app - // may appear has no bearing on whether it just did. Only the counters obey the frequency. + if (frequency.countsShows()) { + logI("Counting a show of in-app $inAppId (frequency ${frequency.delay})") + inAppRepository.setInAppShown(inAppId) + inAppRepository.saveShownInApp(inAppId, timeProvider.currentTimestamp().ms) + inAppRepository.saveInAppStateChangeTime(timeProvider.currentTimestamp()) + } else { + logI("In-app $inAppId has unlimited frequency, nothing to count") + } logI("In-app $inAppId sends its show, timeToDisplay=${timeToDisplay.interval} ms") inAppRepository.sendInAppShown(inAppId, timeToDisplay.interval.millisToTimeSpan(), tags) } @@ -286,9 +351,6 @@ internal class InAppInteractorImpl( return } recordShowCounters(inApp, timeStamp.toTimestamp()) - // The cooldown measures how often the SDK interrupts on its own. Only an overlay show - // moves it (a block interrupts nothing), and an unlimited show does not move it either — - // unlimited is outside the show accounting in both directions. if (inApp.countsShows()) { inAppRepository.saveInAppStateChangeTime(timeStamp.toTimestamp()) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt index 15218b38..f82b5122 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt @@ -97,7 +97,7 @@ internal class InAppProcessingManagerImpl( error = throwable ) } - MindboxLoggerImpl.e(this, "Error fetching geo", throwable) + mindboxLogE("Error fetching geo", throwable) } is CustomerSegmentationError -> { @@ -115,7 +115,7 @@ internal class InAppProcessingManagerImpl( } else -> { - MindboxLoggerImpl.e(this, throwable.message ?: "", throwable) + mindboxLogE(throwable.message ?: "", throwable) inAppFailureTracker.sendFailure( inAppId = inApp.id, failureReason = FailureReason.UNKNOWN_ERROR, @@ -210,6 +210,58 @@ internal class InAppProcessingManagerImpl( ) } + override suspend fun matchesTargeting(inApp: InApp, triggerEvent: InAppEventType): Boolean { + val data = getTargetingData(triggerEvent) + val tags = inApp.gatedTags(isTagsFeatureEnabled()) + var isTargetingErrorOccurred = false + var targetingCheck = false + runCatching { + inApp.targeting.fetchTargetingInfo(data) + targetingCheck = inApp.targeting.checkTargeting(data) + }.onFailure { throwable -> + when (throwable) { + is GeoError -> { + isTargetingErrorOccurred = true + inAppGeoRepository.setGeoStatus(GeoFetchStatus.GEO_FETCH_ERROR) + if (throwable.shouldTrackTargetingError()) { + inAppTargetingErrorRepository.saveError( + key = TargetingErrorKey.Geo, + error = throwable + ) + } + mindboxLogE("Error fetching geo", throwable) + } + + is CustomerSegmentationError -> { + isTargetingErrorOccurred = true + inAppSegmentationRepository.setCustomerSegmentationStatus( + CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + ) + if (throwable.shouldTrackTargetingError()) { + inAppTargetingErrorRepository.saveError( + key = TargetingErrorKey.CustomerSegmentation, + error = throwable + ) + } + handleCustomerSegmentationErrorLog(throwable) + } + + else -> { + mindboxLogE(throwable.message ?: "", throwable) + inAppFailureTracker.sendFailure( + inAppId = inApp.id, + failureReason = FailureReason.UNKNOWN_ERROR, + errorDetails = "Unknown exception when checking target ${throwable.message}. ${throwable.cause?.getVolleyErrorDetails() ?: "volleyError=null"}", + tags = tags + ) + } + } + } + if (isTargetingErrorOccurred) return matchesTargeting(inApp, triggerEvent) + trackTargetingErrorIfAny(inApp, data, tags) + return targetingCheck + } + private fun getTargetingData(triggerEvent: InAppEventType): TargetingData { val ordinalEvent = triggerEvent as? InAppEventType.OrdinalEvent diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt index d05faa18..0b15bd12 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors import cloud.mindbox.mobile_sdk.inapp.domain.models.EmbeddedPlaceEvent +import cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.models.InAppEventType @@ -18,22 +19,26 @@ internal interface InAppInteractor { fun listenConfigUpdates(): Flow /** - * Resolves content for an embedded place through the common selection (A/B pool, place, - * `directCall`, frequency, priority, targeting) plus the show limits — the display style - * does not change "when to show". Only the `isInAppActive` lock and the delayed queue stay - * out: those are overlay machinery. The pull side passes + * Resolves content for an embedded place. The targeting pass runs first — place filter, + * then targeting, over the full list (the cut A/B branch and the frequency-blocked + * included, `directCall` out) — and sends targeting for everyone who matched: the losers once per + * session, the winner through the place's "last targeted" slot (its show goes by the + * "last shown" one, so the pair assembles itself). The + * show itself still picks one winner through the A/B pool, the frequency, the priority + * and the show limits — parity with the overlay. Only the `isInAppActive` lock and the + * delayed queue stay out: those are overlay machinery. The pull side passes * [InAppEventType.EmbeddedPlaceRequested] as [triggerEvent]; the push side passes the * matched operation. Suspends until the config arrives. */ suspend fun selectInAppForPlace( placeSystemName: String, triggerEvent: InAppEventType, - ): InAppType.Embedded? + ): EmbeddedResolveResult? /** * The in-app with [inAppId] and its overlay variant for a direct call: no restriction — * frequency, limits, `displayConditions`, targeting, the A/B pool — is checked. A drawn - * circle must open. Returns `null` only for an unknown id, an id filtered out by + * element must open. Returns `null` only for an unknown id, an id filtered out by * `sdkVersion`, or a form with no overlay variant (embedded is drawn inside the host layout). */ suspend fun getInAppToShowById(inAppId: String): InAppToShow? @@ -49,18 +54,20 @@ internal interface InAppInteractor { fun listenEmbeddedPlaceEvents(): Flow /** - * The single place where stories are cut: the answer to the page's `filterShowableInapps`. + * The single place where the page's requested ids are cut: the answer to `filterShowableInapps`. * Keeps the ids whose in-apps exist in the version- and A/B-filtered list, pass the - * frequency rule (an exhausted non-unlimited story loses its circle, `unlimited` always + * frequency rule (an exhausted non-unlimited in-app drops out of the answer, `unlimited` always * passes — decision 17.08), match targeting (no network fetches — the page waits * 3 seconds) and are not embedded. `directCall` and the show limits are deliberately not - * checked: a drawn circle must open. + * checked: a drawn element must open. * - * Every id kept sends `Inapp.Targeting` with every answer, deliberately without a dedup — - * the story funnel counts the proposed circles, not the opened ones (decision 16.08). A tap - * reports nothing here — the story is not drawn yet. + * The answer mirrors the request, duplicates included. `Inapp.Targeting` goes out at the + * moment the SDK computes the answer (delivery does not matter), over the **full** list — + * a requested id in the cut A/B branch keeps its funnel denominator — and once per session per + * `host in-app + requested id` pair: a repeated request reports only the new ones. A tap + * reports nothing here — the in-app is not shown yet. */ - suspend fun filterShowableInAppIds(inAppIds: List): List + suspend fun filterShowableInAppIds(hostInAppId: String, inAppIds: List): List suspend fun processEventAndConfig(): Flow> @@ -72,20 +79,24 @@ internal interface InAppInteractor { ) /** - * The embedded block drew its content: sends `Inapp.Show` unconditionally and records the - * show counters — the session list and the show history — only when the frequency counts - * shows at all (`unlimited` has no counter to keep). The shared cooldown between overlay - * shows is left alone: the block interrupts nothing. The operation ships **once per session - * per in-app**, so a view the host recreated (a rotation, a return to the screen) reports no - * second show. [timeToDisplay] is everything the user waited through: the resolve, the page - * load and its own pipeline. + * The embedded block drew its content. Compared against the place's "last shown" slot: + * a changed in-app ships the `Inapp.Show` half of the pair and — for a frequency that + * counts shows at all — writes the history and moves the shared cooldown, exactly like an + * overlay show; the same in-app repeated (a rotation, a recreated page) stays silent, in + * counters too. Everything comes from the snapshot the content carries — the config may + * have moved on since the resolve. [tags] arrive already gated by the caller. */ - suspend fun recordBlockShow( + fun recordBlockShow( + placeSystemName: String, inAppId: String, + frequency: Frequency, timeToDisplay: Milliseconds, tags: Map?, ) + /** The winner's `delayTime` elapsed on this place: a later resolve this session hands it out with no delay. */ + fun markEmbeddedDelayWaitedOut(placeSystemName: String, inAppId: String) + fun sendInAppClicked(inAppId: String, tags: Map?) suspend fun fetchMobileConfig() @@ -105,3 +116,12 @@ internal data class InAppToShow( val inApp: InApp, val variant: InAppType, ) + +/** + * A resolved place: the content to render and the winner's show delay — the campaign's choice, + * applied by the registry before the delivery. + */ +internal data class EmbeddedResolveResult( + val variant: InAppType.Embedded, + val delayTime: Milliseconds?, +) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt index 7d53d8da..f1644fcf 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason internal interface InAppFailureTracker { @@ -21,4 +22,11 @@ internal interface InAppFailureTracker { fun sendCollectedFailures() fun clearFailures() + + fun sendWaitBudgetExceeded(placeSystemName: String, waitedFor: Milliseconds, phase: WaitBudgetPhase) +} + +internal enum class WaitBudgetPhase(val wireName: String) { + CONFIG_MISSING("config_missing"), + RESOLVE_PENDING("resolve_pending"), } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppProcessingManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppProcessingManager.kt index 555836b4..0e1dcda3 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppProcessingManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppProcessingManager.kt @@ -20,4 +20,6 @@ internal interface InAppProcessingManager { /** Sends `Inapp.Targeting` without re-checking anything: the caller has already matched targeting. */ fun sendTargetedInApp(inApp: InApp) + + suspend fun matchesTargeting(inApp: InApp, triggerEvent: InAppEventType): Boolean } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt index d10e48bc..4bdd5149 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt @@ -1,6 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers -import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowError internal interface InAppSerializationManager { @@ -14,7 +14,7 @@ internal interface InAppSerializationManager { fun serializeToInAppClickActionString(inAppId: String, tags: Map?): String - fun serializeToInAppShowFailuresString(inAppShowFailures: List): String + fun serializeToInAppShowErrorsString(inAppShowErrors: List): String fun deserializeToShownInApps(shownInApps: String): Set } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt index 9431c83e..4842d7a8 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt @@ -3,7 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.Timestamp -import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowError import kotlinx.coroutines.flow.Flow internal interface InAppRepository { @@ -37,7 +37,7 @@ internal interface InAppRepository { fun sendUserTargeted(inAppId: String, tags: Map?) - fun sendInAppShowFailure(failures: List) + fun sendInAppShowErrors(errors: List) fun setInAppShown(inAppId: String) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/MobileConfigRepository.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/MobileConfigRepository.kt index c49a6fd3..3ce96ecf 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/MobileConfigRepository.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/MobileConfigRepository.kt @@ -10,6 +10,10 @@ import kotlinx.coroutines.flow.Flow internal interface MobileConfigRepository { fun listenConfigUpdates(): Flow + fun startListening() + + fun hasConfig(): Boolean + suspend fun fetchMobileConfig() suspend fun getInAppsSection(): List diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppConfig.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppConfig.kt index 1ef6d3db..d3b90bda 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppConfig.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppConfig.kt @@ -108,6 +108,8 @@ internal sealed class InAppType(open val inAppId: String) { override val inAppId: String, val placeSystemName: String, val layers: List, + val frequency: Frequency, + val tags: Map?, ) : InAppType(inAppId) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppErrorsWrapper.kt similarity index 58% rename from sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt rename to sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppErrorsWrapper.kt index c1426329..0d7a6c61 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppErrorsWrapper.kt @@ -1,8 +1,9 @@ package cloud.mindbox.mobile_sdk.inapp.domain.models -import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import com.google.gson.annotations.SerializedName +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowError -internal data class InAppFailuresWrapper( - @SerializedName("failures") val failures: List +internal data class InAppErrorsWrapper( + @SerializedName("errors") + val errors: List ) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt index 2426e5aa..138ea9fc 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt @@ -113,6 +113,7 @@ internal class InAppMessageManagerImpl( } override fun showInAppById(inAppId: String, extraParams: Map) { + val tapTick = timeProvider.monotonicMillis() inAppScope.launch { val inAppToShow = inAppInteractor.getInAppToShowById(inAppId) ?: run { mindboxLogI("Nothing to show for in-app $inAppId") @@ -120,7 +121,7 @@ internal class InAppMessageManagerImpl( } val (inApp, variant) = inAppToShow val tags = inApp.gatedTags(featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE)) - val callbacks = ShowCallbacks(inApp, variant, tags, preparedTime = Milliseconds(0L)) + val callbacks = ShowCallbacks(inApp, variant, tags, preparedTime = timeProvider.monotonicElapsedSince(tapTick)) withContext(Dispatchers.Main) { inAppMessageViewDisplayer.showInAppMessageNow( inAppType = variant, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppOperationRequests.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppOperationRequests.kt index b1ea0ea8..81adb777 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppOperationRequests.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppOperationRequests.kt @@ -25,6 +25,8 @@ internal data class InAppTargetingRequest( val tags: Map? = null ) +internal sealed interface InAppShowError + internal data class InAppShowFailure( @SerializedName("inappId") val inAppId: String, @@ -35,8 +37,31 @@ internal data class InAppShowFailure( @SerializedName("dateTimeUtc") val dateTimeUtc: String, @SerializedName("tags") - val tags: Map? = null -) + val tags: Map? = null, + @SerializedName("${"$"}type") + val type: String = INAPP_SHOW_FAILURE_TYPE +) : InAppShowError { + internal companion object { + const val INAPP_SHOW_FAILURE_TYPE = "inappShowFailure" + } +} + +internal data class EmbeddedBlockShowFailure( + @SerializedName("placeSystemName") + val placeSystemName: String, + @SerializedName("failureReason") + val failureReason: FailureReason, + @SerializedName("errorDetails") + val errorDetails: String?, + @SerializedName("dateTimeUtc") + val dateTimeUtc: String, + @SerializedName("${"$"}type") + val type: String = EMBEDDED_BLOCK_SHOW_FAILURE_TYPE +) : InAppShowError { + internal companion object { + const val EMBEDDED_BLOCK_SHOW_FAILURE_TYPE = "embeddedBlockShowFailure" + } +} internal enum class FailureReason(val value: String) { @SerializedName("image_download_failed") @@ -60,6 +85,9 @@ internal enum class FailureReason(val value: String) { @SerializedName("webview_presentation_failed") WEBVIEW_PRESENTATION_FAILED("webview_presentation_failed"), + @SerializedName("wait_budget_exceeded") + WAIT_BUDGET_EXCEEDED("wait_budget_exceeded"), + @SerializedName("unknown_error") UNKNOWN_ERROR("unknown_error") } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/TimeProvider.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/TimeProvider.kt index 2313be07..1a3b202e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/TimeProvider.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/TimeProvider.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.utils +import android.os.SystemClock import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.Timestamp import cloud.mindbox.mobile_sdk.models.toTimestamp @@ -10,6 +11,10 @@ internal interface TimeProvider { fun currentTimestamp(): Timestamp fun elapsedSince(startTimeMillis: Timestamp): Milliseconds + + fun monotonicMillis(): Milliseconds + + fun monotonicElapsedSince(startTick: Milliseconds): Milliseconds } internal class SystemTimeProvider : TimeProvider { @@ -18,4 +23,9 @@ internal class SystemTimeProvider : TimeProvider { override fun currentTimestamp() = System.currentTimeMillis().toTimestamp() override fun elapsedSince(startTimeMillis: Timestamp): Milliseconds = Milliseconds(currentTimeMillis() - startTimeMillis.ms) + + override fun monotonicMillis(): Milliseconds = Milliseconds(SystemClock.elapsedRealtime()) + + override fun monotonicElapsedSince(startTick: Milliseconds): Milliseconds = + Milliseconds(monotonicMillis().interval - startTick.interval) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/MindboxTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/MindboxTest.kt index 9be1cb9d..8cfe6665 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/MindboxTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/MindboxTest.kt @@ -217,33 +217,37 @@ class MindboxTest { fun `firstInitialization call appInfoUpdate`() = runTest { val uuid = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff") mockkStatic(UUID::class) - every { UUID.randomUUID() } returns uuid - coEvery { firstProvider.getAdsIdentification(context) } returns "adsId" - coEvery { secondProvider.getAdsIdentification(context) } returns "adsId" - coEvery { thirdProvider.getAdsIdentification(context) } returns "adsId" - every { MindboxPreferences.pushTokens } returns mapOf() - - Mindbox.firstInitialization(context, mockk(relaxed = true)) - - verify(exactly = 1) { - MindboxEventManager.appInstalled( - context, - InitData( - isNotificationsEnabled = true, - externalDeviceUUID = "", - instanceId = uuid.toString(), - version = 0, - subscribe = false, - installationId = "", - ianaTimeZone = null, - tokens = listOf( - TokenData(token = "tokenFCM", notificationProvider = "FCM"), - TokenData(token = "tokenHMS", notificationProvider = "HMS"), - TokenData(token = "tokenRuStore", notificationProvider = "RuStore"), + try { + every { UUID.randomUUID() } returns uuid + coEvery { firstProvider.getAdsIdentification(context) } returns "adsId" + coEvery { secondProvider.getAdsIdentification(context) } returns "adsId" + coEvery { thirdProvider.getAdsIdentification(context) } returns "adsId" + every { MindboxPreferences.pushTokens } returns mapOf() + + Mindbox.firstInitialization(context, mockk(relaxed = true)) + + verify(exactly = 1) { + MindboxEventManager.appInstalled( + context, + InitData( + isNotificationsEnabled = true, + externalDeviceUUID = "", + instanceId = uuid.toString(), + version = 0, + subscribe = false, + installationId = "", + ianaTimeZone = null, + tokens = listOf( + TokenData(token = "tokenFCM", notificationProvider = "FCM"), + TokenData(token = "tokenHMS", notificationProvider = "HMS"), + TokenData(token = "tokenRuStore", notificationProvider = "RuStore"), + ), ), - ), - any() - ) + any() + ) + } + } finally { + unmockkStatic(UUID::class) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt index d9fbdee5..c40e0b6d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt @@ -104,17 +104,283 @@ class EmbeddedBlockContentControllerTest { } @Test - fun `content arriving after the timeout still expands the block`() { + fun `pending winner disarms the waiting budget and keeps the skeleton`() { + // A delayed winner is the SDK's answer, not its silence: the 30s budget stands down + // while the block stays in the loading state until the delivery. + val controller = controller(configTimeout = Milliseconds(30_000L)) + controller.start() + + blocksRegistry.lastHandle?.onContentPending() + idleFor(Duration.ofMillis(30_001L)) + + assertEquals(EmbeddedBlockState.Loading, states.last()) + } + + @Test + fun `the delay window leaves the attempt clock`() { + var clock = 1_000L + var receivedStart: Long? = null + val controller = EmbeddedBlockContentController( + placeSystemName = "main-screen-top", + configTimeout = Milliseconds(30_000L), + providerFactory = { _, startTick -> + receivedStart = startTick.interval + FakeProvider() + }, + blocksRegistry = { blocksRegistry }, + monotonicNow = { Milliseconds(clock) }, + ).apply { onStateChange = { state -> states.add(state) } } + + controller.start() + // The campaign's delay begins at 2s and delivers at 7s: those five seconds are the + // campaign's choice, not the user's wait for the SDK — the clock base slides past them. + clock = 2_000L + blocksRegistry.lastHandle?.onContentPending() + clock = 7_000L + blocksRegistry.pushContent("main-screen-top", content) + + assertEquals(6_000L, receivedStart) + } + + @Test + fun `the delay window leaves the attempt clock of a block that was off screen`() { + var clock = 1_000L + var receivedStart: Long? = null + val controller = EmbeddedBlockContentController( + placeSystemName = "main-screen-top", + configTimeout = Milliseconds(30_000L), + providerFactory = { _, startTick -> + receivedStart = startTick.interval + FakeProvider() + }, + blocksRegistry = { blocksRegistry }, + monotonicNow = { Milliseconds(clock) }, + ).apply { onStateChange = { state -> states.add(state) } } + controller.start() + controller.pause() + + clock = 2_000L + blocksRegistry.lastHandle?.onContentPending() + clock = 7_000L + blocksRegistry.pushContent("main-screen-top", content) + clock = 8_000L + controller.start() + + assertEquals(6_000L, receivedStart) + } + + @Test + fun `a padded place name is normalized for the registry and the failure report`() { + val tracker = io.mockk.mockk(relaxed = true) + val controller = EmbeddedBlockContentController( + placeSystemName = " main-screen-top ", + configTimeout = Milliseconds(50L), + providerFactory = { _, _ -> FakeProvider() }, + blocksRegistry = { blocksRegistry }, + failureTracker = { tracker }, + ).apply { onStateChange = { state -> states.add(state) } } + controller.start() + + idleFor(Duration.ofMillis(51L)) + + assertEquals(listOf("main-screen-top"), blocksRegistry.appearedPlaces) + io.mockk.verify(exactly = 1) { + tracker.sendWaitBudgetExceeded( + "main-screen-top", + Milliseconds(50L), + cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.WaitBudgetPhase.CONFIG_MISSING, + ) + } + } + + @Test + fun `config timeout ships the anonymous ShowFailure`() { + val tracker = io.mockk.mockk(relaxed = true) + val controller = EmbeddedBlockContentController( + placeSystemName = "main-screen-top", + configTimeout = Milliseconds(50L), + providerFactory = { _, _ -> FakeProvider() }, + blocksRegistry = { blocksRegistry }, + failureTracker = { tracker }, + ).apply { onStateChange = { state -> states.add(state) } } + controller.start() + + idleFor(Duration.ofMillis(51L)) + + // The SDK stayed silent for the whole budget: the fact ships with no in-app to name. + io.mockk.verify(exactly = 1) { + tracker.sendWaitBudgetExceeded( + "main-screen-top", + Milliseconds(50L), + cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.WaitBudgetPhase.CONFIG_MISSING, + ) + } + assertEquals(EmbeddedBlockState.Empty, states.last()) + } + + @Test + fun `a config present but a silent resolve ships the resolve_pending phase`() { + val tracker = io.mockk.mockk(relaxed = true) + val controller = EmbeddedBlockContentController( + placeSystemName = "main-screen-top", + configTimeout = Milliseconds(50L), + providerFactory = { _, _ -> FakeProvider() }, + blocksRegistry = { blocksRegistry }, + failureTracker = { tracker }, + hasConfig = { true }, + ).apply { onStateChange = { state -> states.add(state) } } + controller.start() + + idleFor(Duration.ofMillis(51L)) + + io.mockk.verify(exactly = 1) { + tracker.sendWaitBudgetExceeded( + "main-screen-top", + Milliseconds(50L), + cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.WaitBudgetPhase.RESOLVE_PENDING, + ) + } + } + + @Test + fun `a page silent past its budget ships presentation_failed with the snapshot tags`() { + val tracker = io.mockk.mockk(relaxed = true) + val silentProvider = object : EmbeddedContentProvider { + override var onStateChange: ((EmbeddedBlockState) -> Unit)? = null + override val contentView: View? = null + + override fun start() = Unit + + override fun pause() = Unit + + override fun release() = Unit + } + val controller = EmbeddedBlockContentController( + placeSystemName = "main-screen-top", + configTimeout = Milliseconds(30_000L), + readyTimeout = Milliseconds(100L), + providerFactory = { _, _ -> silentProvider }, + blocksRegistry = { blocksRegistry }, + failureTracker = { tracker }, + isTagsFeatureEnabled = { true }, + ).apply { onStateChange = { state -> states.add(state) } } + controller.start() + + blocksRegistry.pushContent("main-screen-top", content.copy(tags = mapOf("a" to "b"))) + idleFor(Duration.ofMillis(101L)) + + io.mockk.verify(exactly = 1) { + tracker.sendFailure( + inAppId = "embedded-id", + failureReason = cloud.mindbox.mobile_sdk.models.operation.request.FailureReason.PRESENTATION_FAILED, + errorDetails = any(), + tags = mapOf("a" to "b"), + ) + } + assertEquals(EmbeddedBlockState.Failed, states.last()) + } + + @Test + fun `an armed pending delivery keeps the budget quiet across leave and return`() { + val tracker = io.mockk.mockk(relaxed = true) + val controller = EmbeddedBlockContentController( + placeSystemName = "main-screen-top", + configTimeout = Milliseconds(50L), + providerFactory = { _, _ -> FakeProvider() }, + blocksRegistry = { blocksRegistry }, + failureTracker = { tracker }, + ).apply { onStateChange = { state -> states.add(state) } } + controller.start() + blocksRegistry.lastHandle?.onContentPending() + + // The block leaves and returns while the winner waits out its delay: the SDK has + // answered — the re-armed budget must not fire a false "the SDK stayed silent". + controller.pause() + controller.start() + idleFor(Duration.ofMillis(51L)) + + io.mockk.verify(exactly = 0) { tracker.sendWaitBudgetExceeded(any(), any(), any()) } + assertEquals(EmbeddedBlockState.Loading, states.last()) + } + + @Test + fun `content arriving after the timeout is dropped and the block stays collapsed`() { val controller = controller(configTimeout = Milliseconds(30_000L)) controller.start() idleFor(Duration.ofMillis(30_001L)) assertEquals(EmbeddedBlockState.Empty, states.last()) + blocksRegistry.lastHandle?.onContentPending() blocksRegistry.pushContent("main-screen-top", content) + assertEquals(EmbeddedBlockState.Empty, states.last()) + assertTrue(createdProviders.isEmpty()) + } + + @Test + fun `a block that gave up waiting is inactive for the registry until it comes back`() { + val controller = controller(configTimeout = Milliseconds(30_000L)) + controller.start() + assertEquals(true, blocksRegistry.lastHandle?.isActive) + + idleFor(Duration.ofMillis(30_001L)) + assertEquals(false, blocksRegistry.lastHandle?.isActive) + + controller.pause() + controller.start() + + assertEquals(true, blocksRegistry.lastHandle?.isActive) + assertEquals(listOf("main-screen-top", "main-screen-top"), blocksRegistry.appearedPlaces) + } + + @Test + fun `returning after the timeout asks afresh with the whole budget`() { + val controller = controller(configTimeout = Milliseconds(50L)) + controller.start() + idleFor(Duration.ofMillis(51L)) + assertEquals(EmbeddedBlockState.Empty, states.last()) + controller.pause() + + controller.start() + idleFor(Duration.ofMillis(40L)) + assertEquals(EmbeddedBlockState.Loading, states.last()) + + blocksRegistry.pushContent("main-screen-top", content) assertEquals(EmbeddedBlockState.Ready, states.last()) } + @Test + fun `a page silent past its budget gives the block up until it comes back`() { + var builtPages = 0 + val silentProvider = object : EmbeddedContentProvider { + override var onStateChange: ((EmbeddedBlockState) -> Unit)? = null + override val contentView: View? = null + + override fun start() = Unit + + override fun pause() = Unit + + override fun release() = Unit + } + val controller = EmbeddedBlockContentController( + placeSystemName = "main-screen-top", + configTimeout = Milliseconds(30_000L), + readyTimeout = Milliseconds(100L), + providerFactory = { _, _ -> silentProvider.also { builtPages++ } }, + blocksRegistry = { blocksRegistry }, + ).apply { onStateChange = { state -> states.add(state) } } + controller.start() + blocksRegistry.pushContent("main-screen-top", content) + idleFor(Duration.ofMillis(101L)) + assertEquals(EmbeddedBlockState.Failed, states.last()) + + blocksRegistry.pushContent("main-screen-top", content) + + assertEquals(1, builtPages) + assertEquals(false, blocksRegistry.lastHandle?.isActive) + assertEquals(EmbeddedBlockState.Failed, states.last()) + } + @Test fun `host resource is not consulted here — the timeout is a constructor value`() { // The view reads the integer resource / XML attribute; the controller only obeys it. @@ -304,12 +570,12 @@ class EmbeddedBlockContentControllerTest { val controller = EmbeddedBlockContentController( placeSystemName = "main-screen-top", configTimeout = Milliseconds(30_000L), - providerFactory = { _, attemptStartedAt -> - receivedStart = attemptStartedAt.ms + providerFactory = { _, startTick -> + receivedStart = startTick.interval FakeProvider() }, blocksRegistry = { blocksRegistry }, - now = { cloud.mindbox.mobile_sdk.models.Timestamp(clock) }, + monotonicNow = { Milliseconds(clock) }, ).apply { onStateChange = { state -> states.add(state) } } controller.start() @@ -330,6 +596,53 @@ class EmbeddedBlockContentControllerTest { assertEquals(null, blocksRegistry.lastHandle) } + @Test + fun `a re-resolve that changes only frequency or tags refreshes the snapshot without a rebuild`() { + var snapshotFrequency: cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency? = null + var snapshotTags: Map? = null + var builtPages = 0 + val updatableProvider = object : EmbeddedUpdatableContentProvider { + override var onStateChange: ((EmbeddedBlockState) -> Unit)? = null + override val contentView: View? = null + + override fun start() { + onStateChange?.invoke(EmbeddedBlockState.Ready) + } + + override fun pause() = Unit + + override fun release() = Unit + + override fun refreshMetricsSnapshot(frequency: cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency, tags: Map?) { + snapshotFrequency = frequency + snapshotTags = tags + } + + override fun updateParams(params: Map, onResult: (Boolean) -> Unit) = onResult(true) + } + val controller = EmbeddedBlockContentController( + placeSystemName = "main-screen-top", + configTimeout = Milliseconds(30_000L), + providerFactory = { _, _ -> updatableProvider.also { builtPages++ } }, + blocksRegistry = { blocksRegistry }, + ).apply { onStateChange = { state -> states.add(state) } } + controller.start() + blocksRegistry.pushContent("main-screen-top", content) + + val changed = content.copy( + frequency = cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency( + cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency.Delay.OneTimePerSession + ), + tags = mapOf("a" to "b"), + ) + blocksRegistry.pushContent("main-screen-top", changed) + + assertEquals(1, builtPages) + assertEquals(changed.frequency, snapshotFrequency) + assertEquals(mapOf("a" to "b"), snapshotTags) + assertEquals(EmbeddedBlockState.Ready, states.last()) + } + @Test fun `same winner with new params updates the content in place`() { var updatedParams: Map? = null @@ -345,6 +658,8 @@ class EmbeddedBlockContentControllerTest { override fun release() = Unit + override fun refreshMetricsSnapshot(frequency: cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency, tags: Map?) = Unit + override fun updateParams(params: Map, onResult: (Boolean) -> Unit) { updatedParams = params onResult(true) @@ -360,11 +675,11 @@ class EmbeddedBlockContentControllerTest { blocksRegistry.pushContent("main-screen-top", content) val refreshedLayer = (content.layers.single() as Layer.WebViewLayer) - .copy(params = mapOf("stories" to "[]")) + .copy(params = mapOf("items" to "[]")) blocksRegistry.pushContent("main-screen-top", content.copy(layers = listOf(refreshedLayer))) // The webview stays; only the new params travel over the bridge. - assertEquals(mapOf("stories" to "[]"), updatedParams) + assertEquals(mapOf("items" to "[]"), updatedParams) } @Test @@ -399,6 +714,8 @@ class EmbeddedBlockContentControllerTest { override fun release() = Unit + override fun refreshMetricsSnapshot(frequency: cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency, tags: Map?) = Unit + override fun updateParams(params: Map, onResult: (Boolean) -> Unit) { updatedParams = params onResult(true) @@ -416,7 +733,7 @@ class EmbeddedBlockContentControllerTest { blocksRegistry.pushContent("main-screen-top", content) val movedLayer = (content.layers.single() as Layer.WebViewLayer) - .copy(contentUrl = "https://static.example/another-page.html", params = mapOf("stories" to "[]")) + .copy(contentUrl = "https://static.example/another-page.html", params = mapOf("items" to "[]")) blocksRegistry.pushContent("main-screen-top", content.copy(layers = listOf(movedLayer))) assertEquals(2, updatables.size) @@ -458,7 +775,7 @@ class EmbeddedBlockContentControllerTest { @Test fun `a block that drew nothing asks for its content again on return`() { - // The page answered `contentRendered {count: 0}` — every story was filtered out, expired + // The page answered `contentRendered {count: 0}` — every element was filtered out, expired // or not targeted at this customer. None of those reasons outlives the screen, so the // remembered "empty" must not either: iOS rebuilds here, and so do we. val controller = controller() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactoryTest.kt index c092badf..534995c8 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactoryTest.kt @@ -3,7 +3,7 @@ package cloud.mindbox.mobile_sdk.embedded import androidx.test.core.app.ApplicationProvider import cloud.mindbox.mobile_sdk.embedded.webview.EmbeddedBlockWebViewHolder import cloud.mindbox.mobile_sdk.models.InAppStub -import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.models.Milliseconds import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -14,11 +14,11 @@ import org.robolectric.RobolectricTestRunner class EmbeddedBlockContentFactoryTest { @Test - fun `resolved embedded content becomes a feed webview holder`() { + fun `resolved embedded content becomes a block webview holder`() { val provider = EmbeddedBlockContentFactory.createProvider( ApplicationProvider.getApplicationContext(), InAppStub.getEmbedded(), - attemptStartedAt = Timestamp(0L), + startTick = Milliseconds(0L), ) assertTrue(provider is EmbeddedBlockWebViewHolder) @@ -29,7 +29,7 @@ class EmbeddedBlockContentFactoryTest { val provider = EmbeddedBlockContentFactory.createProvider( ApplicationProvider.getApplicationContext(), InAppStub.getEmbedded().copy(layers = emptyList()), - attemptStartedAt = Timestamp(0L), + startTick = Milliseconds(0L), ) assertNull(provider) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockWaitBudgetTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockWaitBudgetTest.kt new file mode 100644 index 00000000..5793428f --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockWaitBudgetTest.kt @@ -0,0 +1,29 @@ +package cloud.mindbox.mobile_sdk.embedded + +import android.os.Handler +import android.os.Looper +import cloud.mindbox.mobile_sdk.models.Milliseconds +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.time.Duration + +@RunWith(RobolectricTestRunner::class) +class EmbeddedBlockWaitBudgetTest { + + @Test + fun `a crashing expiry callback is contained`() { + var fired = 0 + val budget = EmbeddedBlockWaitBudget(Milliseconds(50L), Handler(Looper.getMainLooper())) { + fired++ + error("expiry boom") + } + + budget.armIfNeeded() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(51L)) + + assertEquals(1, fired) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistryTest.kt index a25870f3..bdce3c4d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistryTest.kt @@ -2,14 +2,17 @@ package cloud.mindbox.mobile_sdk.embedded import android.os.Looper import cloud.mindbox.mobile_sdk.inapp.domain.models.EmbeddedPlaceEvent +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.EmbeddedResolveResult import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.models.EventType import cloud.mindbox.mobile_sdk.models.InAppEventType +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.InAppStub import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel @@ -36,10 +39,15 @@ class EmbeddedBlocksRegistryTest { private class RecordingHandle(override var isActive: Boolean = true) : EmbeddedBlockHandle { val received = mutableListOf() + var pendingCount = 0 override fun onContentResolved(content: InAppType.Embedded?) { received.add(content) } + + override fun onContentPending() { + pendingCount++ + } } private val interactor: InAppInteractor = mockk(relaxed = true) @@ -69,7 +77,7 @@ class EmbeddedBlocksRegistryTest { @Test fun `block appearance pulls content for its place`() { - coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns content + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns EmbeddedResolveResult(content, null) val handle = RecordingHandle() val controller = controller() controller.register(place, handle) @@ -83,7 +91,7 @@ class EmbeddedBlocksRegistryTest { @Test fun `two blocks on the same place both receive the same content`() { - coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns content + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns EmbeddedResolveResult(content, null) val first = RecordingHandle() val second = RecordingHandle() val controller = controller() @@ -102,8 +110,8 @@ class EmbeddedBlocksRegistryTest { @Test fun `operation matched to a registered place resolves with that operation as the trigger`() { - val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("story-operation")) - coEvery { interactor.selectInAppForPlace(place, operation) } returns content + val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("block-operation")) + coEvery { interactor.selectInAppForPlace(place, operation) } returns EmbeddedResolveResult(content, null) val handle = RecordingHandle() val controller = controller() controller.register(place, handle) @@ -118,7 +126,7 @@ class EmbeddedBlocksRegistryTest { @Test fun `operation for an unregistered place is dropped without a resolve`() { - val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("story-operation")) + val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("block-operation")) val handle = RecordingHandle() val controller = controller() controller.register(place, handle) @@ -163,8 +171,8 @@ class EmbeddedBlocksRegistryTest { @Test fun `operation for a paused place is skipped and the next appearance resolves fresh`() { - val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("story-operation")) - coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns content + val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("block-operation")) + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns EmbeddedResolveResult(content, null) val handle = RecordingHandle(isActive = false) val controller = controller() controller.register(place, handle) @@ -182,8 +190,8 @@ class EmbeddedBlocksRegistryTest { @Test fun `unregistered handle stops receiving content`() { - val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("story-operation")) - coEvery { interactor.selectInAppForPlace(place, operation) } returns content + val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("block-operation")) + coEvery { interactor.selectInAppForPlace(place, operation) } returns EmbeddedResolveResult(content, null) val handle = RecordingHandle() val controller = controller() val registration = controller.register(place, handle) @@ -201,7 +209,7 @@ class EmbeddedBlocksRegistryTest { fun `controller never calls selection itself`() { // The registry routes; the selection lives in the interactor. The only domain entries // the controller touches are selectInAppForPlace and the push flow subscription. - coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns content + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns EmbeddedResolveResult(content, null) val controller = controller() controller.register(place, RecordingHandle()) idleMain() @@ -213,12 +221,116 @@ class EmbeddedBlocksRegistryTest { coVerify(exactly = 1) { interactor.listenEmbeddedPlaceEvents() } coVerify(exactly = 1) { interactor.listenConfigUpdates() } coVerify(exactly = 0) { interactor.getInAppToShowById(any()) } - coVerify(exactly = 0) { interactor.filterShowableInAppIds(any()) } + coVerify(exactly = 0) { interactor.filterShowableInAppIds(any(), any()) } + } + + @Test + fun `winner with delayTime is announced as pending and delivered after the delay`() { + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns + EmbeddedResolveResult(content, Milliseconds(5_000L)) + val handle = RecordingHandle() + val controller = controller() + controller.register(place, handle) + idleMain() + + controller.onBlockAppeared(place) + idleMain() + + // The SDK has answered: the block hears "pending" at once, the content waits out the delay. + assertEquals(1, handle.pendingCount) + assertEquals(emptyList(), handle.received) + + scope.testScheduler.advanceTimeBy(5_001L) + scope.testScheduler.runCurrent() + idleMain() + + assertEquals(listOf(content), handle.received) + } + + @Test + fun `the same winner re-selected keeps the running delay timer`() { + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns + EmbeddedResolveResult(content, Milliseconds(5_000L)) + val handle = RecordingHandle() + val controller = controller() + controller.register(place, handle) + idleMain() + + controller.onBlockAppeared(place) + idleMain() + scope.testScheduler.advanceTimeBy(3_000L) + scope.testScheduler.runCurrent() + // The block left and returned mid-delay: the same winner re-selected must not restart + // the countdown, or a frequently revisited block would wait forever. + controller.onBlockAppeared(place) + idleMain() + + assertEquals(2, handle.pendingCount) + assertEquals(emptyList(), handle.received) + + scope.testScheduler.advanceTimeBy(2_001L) + scope.testScheduler.runCurrent() + idleMain() + + // Delivered on the original schedule — five seconds after the first selection. + assertEquals(listOf(content), handle.received) + } + + @Test + fun `a delay that elapsed while the block was away is delivered at once and not waited out again`() { + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns + EmbeddedResolveResult(content, Milliseconds(5_000L)) andThen EmbeddedResolveResult(content, null) + val handle = RecordingHandle() + val controller = controller() + val registration = controller.register(place, handle) + idleMain() + controller.onBlockAppeared(place) + idleMain() + + // The block is detached mid-delay: the clock keeps running for the place, and its end + // is what the session remembers — not the block that happened to be there. + registration.close() + idleMain() + scope.testScheduler.advanceTimeBy(5_001L) + scope.testScheduler.runCurrent() + idleMain() + + verify(exactly = 1) { interactor.markEmbeddedDelayWaitedOut(place, content.inAppId) } + assertTrue(handle.received.isEmpty()) + + controller.register(place, handle) + idleMain() + controller.onBlockAppeared(place) + idleMain() + + assertEquals(listOf(content), handle.received) + } + + @Test + fun `a newer resolve outcome supersedes a winner still waiting out its delay`() { + val delayed = InAppStub.getEmbedded().copy(inAppId = "delayed") + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns + EmbeddedResolveResult(delayed, Milliseconds(5_000L)) andThen EmbeddedResolveResult(content, null) + val handle = RecordingHandle() + val controller = controller() + controller.register(place, handle) + idleMain() + + controller.onBlockAppeared(place) + idleMain() + // A new resolve lands while the old winner still waits: its outcome replaces the timer. + controller.onBlockAppeared(place) + idleMain() + scope.testScheduler.advanceTimeBy(10_000L) + scope.testScheduler.runCurrent() + idleMain() + + assertEquals(listOf(content), handle.received) } @Test fun `new config re-resolves places with an active block`() { - coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns content + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns EmbeddedResolveResult(content, null) val handle = RecordingHandle(isActive = true) val controller = controller() controller.register(place, handle) @@ -232,7 +344,7 @@ class EmbeddedBlocksRegistryTest { @Test fun `invalidation for a paused place is skipped and the next appearance resolves fresh`() { - coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns content + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns EmbeddedResolveResult(content, null) val handle = RecordingHandle(isActive = false) val controller = controller() controller.register(place, handle) @@ -263,12 +375,69 @@ class EmbeddedBlocksRegistryTest { assertEquals(listOf(null), handle.received) } + @Test + fun `an Error inside the resolve is delivered as nothing to show, not left to the budget`() { + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } throws StackOverflowError() + val handle = RecordingHandle() + val controller = controller() + controller.register(place, handle) + idleMain() + + controller.onBlockAppeared(place) + idleMain() + + assertEquals(listOf(null), handle.received) + } + + @Test + fun `resolve failure while a winner waits out its delay keeps the running timer`() { + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns + EmbeddedResolveResult(content, Milliseconds(5_000L)) andThenThrows IllegalStateException("boom") + val handle = RecordingHandle() + val controller = controller() + controller.register(place, handle) + idleMain() + + controller.onBlockAppeared(place) + idleMain() + controller.onBlockAppeared(place) + idleMain() + + assertEquals(2, handle.pendingCount) + assertEquals(emptyList(), handle.received) + + scope.testScheduler.advanceTimeBy(5_001L) + scope.testScheduler.runCurrent() + idleMain() + + assertEquals(listOf(content), handle.received) + verify(exactly = 1) { interactor.markEmbeddedDelayWaitedOut(place, content.inAppId) } + } + + @Test + fun `pending is announced to paused handles too so the delay leaves their clock`() { + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns + EmbeddedResolveResult(content, Milliseconds(5_000L)) + val active = RecordingHandle() + val paused = RecordingHandle(isActive = false) + val controller = controller() + controller.register(place, active) + controller.register(place, paused) + idleMain() + + controller.onBlockAppeared(place) + idleMain() + + assertEquals(1, active.pendingCount) + assertEquals(1, paused.pendingCount) + } + @Test fun `operation queued while resolving keeps its trigger for the second pass`() { - val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("story-operation")) - val firstResolveGate = CompletableDeferred() + val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("block-operation")) + val firstResolveGate = CompletableDeferred() coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } coAnswers { firstResolveGate.await() } - coEvery { interactor.selectInAppForPlace(place, operation) } returns content + coEvery { interactor.selectInAppForPlace(place, operation) } returns EmbeddedResolveResult(content, null) val handle = RecordingHandle() val controller = controller() controller.register(place, handle) @@ -289,9 +458,9 @@ class EmbeddedBlocksRegistryTest { @Test fun `channels resubscribe after the SDK scope is recreated`() { - val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("story-operation")) - coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns content - coEvery { interactor.selectInAppForPlace(place, operation) } returns content + val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("block-operation")) + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns EmbeddedResolveResult(content, null) + coEvery { interactor.selectInAppForPlace(place, operation) } returns EmbeddedResolveResult(content, null) val handle = RecordingHandle() val controller = controller() controller.register(place, handle) @@ -316,7 +485,7 @@ class EmbeddedBlocksRegistryTest { @Test fun `resubscribing re-resolves what may have changed while the channels were dead`() { - coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns content + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns EmbeddedResolveResult(content, null) val handle = RecordingHandle() val controller = controller() controller.register(place, handle) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewLookupTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewLookupTest.kt index 053b779b..bac68fab 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewLookupTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewLookupTest.kt @@ -24,6 +24,14 @@ import java.time.Duration @RunWith(RobolectricTestRunner::class) class MindboxEmbeddedBlockViewLookupTest { + @org.junit.Test + fun `a place name with surrounding whitespace is normalized at the view boundary`() { + val activity = org.robolectric.Robolectric.buildActivity(android.app.Activity::class.java).setup().get() + val view = MindboxEmbeddedBlockView(activity, " main-screen-top ") + + org.junit.Assert.assertEquals("main-screen-top", view.placeSystemName) + } + private class RecordingListener : MindboxEmbeddedBlockListener { val events = mutableListOf() @@ -177,15 +185,13 @@ class MindboxEmbeddedBlockViewLookupTest { } @Test - fun `a place name of spaces is a name like any other`() { - // Only emptiness is checked: the name goes to the config as it was given, padding and all, - // and a place nobody named that way simply never resolves. + fun `a blank place name is the same as none`() { val view = MindboxEmbeddedBlockView(activity, " ") attach(view) - assertEquals(" ", view.placeSystemName) - assertEquals(View.VISIBLE, view.visibility) + assertNull(view.placeSystemName) + assertEquals(View.GONE, view.visibility) } @Test diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt index e90344f1..00b711d3 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt @@ -21,8 +21,8 @@ import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.InAppStub +import cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency import cloud.mindbox.mobile_sdk.models.Milliseconds -import cloud.mindbox.mobile_sdk.models.Timestamp import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider import com.google.gson.JsonObject @@ -40,7 +40,12 @@ import io.mockk.verify import kotlinx.coroutines.flow.flowOf import org.json.JSONTokener import org.junit.After +import cloud.mindbox.mobile_sdk.Mindbox +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -49,10 +54,10 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf /** - * The feed holder against the real stories-page protocol, driven through the real bridge - * transport (`SdkBridge` js interface): `ready` answers with `stories` as a JSON array, + * The block holder against the real page protocol, driven through the real bridge + * transport (`SdkBridge` js interface): `ready` answers with `items` as a JSON array, * `filterShowableInapps` returns the interactor's subset, `contentRendered {count}` is the - * readiness signal, and `initDataUpdated` refreshes the feed without recreating the webview. + * readiness signal, and `initDataUpdated` refreshes the page without recreating the webview. */ @RunWith(RobolectricTestRunner::class) class EmbeddedBlockWebViewHolderTest { @@ -69,6 +74,8 @@ class EmbeddedBlockWebViewHolderTest { private var elapsed = 0L private val timeProvider: SystemTimeProvider = mockk { every { elapsedSince(any()) } answers { Milliseconds(elapsed) } + every { monotonicMillis() } answers { Milliseconds(elapsed) } + every { monotonicElapsedSince(any()) } answers { Milliseconds(elapsed) } } private val permissionManager: PermissionManager = mockk { every { getCameraPermissionStatus() } returns PermissionStatus.DENIED @@ -105,13 +112,16 @@ class EmbeddedBlockWebViewHolderTest { every { versionName } returns "1.0" } ) - coEvery { gatewayManager.fetchWebViewContent(any()) } returns "feed" + coEvery { gatewayManager.fetchWebViewContent(any()) } returns "block" holder = EmbeddedBlockWebViewHolder( inAppId = "embedded-id", + placeSystemName = "main-screen-top", layer = InAppStub.getEmbeddedWebViewLayer(), context = application, - attemptStartedAt = Timestamp(0L), + frequency = Frequency(Frequency.Delay.Unlimited), + tags = null, + startTick = Milliseconds(0L), ) holder.onStateChange = { state -> states.add(state) } } @@ -181,38 +191,38 @@ class EmbeddedBlockWebViewHolderTest { startAndAwaitPageLoad() val loaded = shadowOf(webView).lastLoadDataWithBaseURL - assertEquals("feed", loaded?.data) - assertEquals("https://feed.local/base", loaded?.baseUrl) + assertEquals("block", loaded?.data) + assertEquals("https://blocks.local/base", loaded?.baseUrl) } @Test - fun `ready response contains stories as json array not string`() { + fun `ready response contains items as json array not string`() { startAndAwaitPageLoad() postFromPage(request(action = "ready", payload = "{}")) await { lastOutgoingMessage()?.get("action")?.asString == "ready" } val payload = lastOutgoingPayload()!! - assertTrue(payload.get("stories").isJsonArray) + assertTrue(payload.get("items").isJsonArray) assertEquals( - "story-1", - payload.getAsJsonArray("stories").get(0).asJsonObject.get("inAppId").asString + "inapp-1", + payload.getAsJsonArray("items").get(0).asJsonObject.get("inAppId").asString ) assertEquals("endpoint-id", payload.get("endpointId").asString) } @Test fun `filterShowableInapps returns subset from the interactor`() { - coEvery { inAppInteractor.filterShowableInAppIds(listOf("story-1", "story-2")) } returns - listOf("story-1") + coEvery { inAppInteractor.filterShowableInAppIds("embedded-id", listOf("inapp-1", "inapp-2")) } returns + listOf("inapp-1") startAndAwaitPageLoad() - postFromPage(request(action = "filterShowableInapps", payload = """{"inappIds":["story-1","story-2"]}""")) + postFromPage(request(action = "filterShowableInapps", payload = """{"inappIds":["inapp-1","inapp-2"]}""")) await { lastOutgoingMessage()?.get("action")?.asString == "filterShowableInapps" } val payload = lastOutgoingPayload()!! assertEquals(1, payload.getAsJsonArray("inappIds").size()) - assertEquals("story-1", payload.getAsJsonArray("inappIds").get(0).asString) + assertEquals("inapp-1", payload.getAsJsonArray("inappIds").get(0).asString) } @Test @@ -224,24 +234,24 @@ class EmbeddedBlockWebViewHolderTest { // A refusal the page can retry, not an empty answer it would take for the truth. assertEquals("error", lastOutgoingMessage()!!.get("type").asString) - coVerify(exactly = 0) { inAppInteractor.filterShowableInAppIds(any()) } + coVerify(exactly = 0) { inAppInteractor.filterShowableInAppIds(any(), any()) } } @Test fun `filterShowableInapps skips non-string ids and answers the rest`() { - coEvery { inAppInteractor.filterShowableInAppIds(listOf("story-1")) } returns listOf("story-1") + coEvery { inAppInteractor.filterShowableInAppIds("embedded-id", listOf("inapp-1")) } returns listOf("inapp-1") startAndAwaitPageLoad() - postFromPage(request(action = "filterShowableInapps", payload = """{"inappIds":["story-1",7]}""")) + postFromPage(request(action = "filterShowableInapps", payload = """{"inappIds":["inapp-1",7]}""")) await { lastOutgoingMessage()?.get("action")?.asString == "filterShowableInapps" } assertEquals("response", lastOutgoingMessage()!!.get("type").asString) - assertEquals("story-1", lastOutgoingPayload()!!.getAsJsonArray("inappIds").get(0).asString) + assertEquals("inapp-1", lastOutgoingPayload()!!.getAsJsonArray("inappIds").get(0).asString) } @Test fun `contentRendered with positive count switches state to Ready and counts the show`() { - coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs startAndAwaitPageLoad() postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) @@ -250,95 +260,205 @@ class EmbeddedBlockWebViewHolderTest { assertTrue(holder.contentView != null) // Content on screen is a show, counted like any other in-app's; the frequency decides // inside the interactor whether there is anything to write. - coVerify(timeout = 5_000L) { inAppInteractor.recordBlockShow("embedded-id", any(), any()) } + verify(timeout = 5_000L) { inAppInteractor.recordBlockShow("main-screen-top", "embedded-id", any(), any(), any()) } + } + + @Test + fun `contentRendered off screen waits for the block to return before counting the show`() { + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs + startAndAwaitPageLoad() + + holder.pause() + postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) + await { lastOutgoingMessage()?.get("action")?.asString == "contentRendered" } + + // Nobody is looking: the render is acknowledged, the show is not reported. + verify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } + + holder.start() + + // The block returned to the screen — the user sees the content now. + verify(exactly = 1, timeout = 5_000L) { inAppInteractor.recordBlockShow("main-screen-top", "embedded-id", any(), any(), any()) } + } + + @Test + fun `a show whose recording died with the sdk scope is retried when the block returns`() { + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs + val originalScope = Mindbox.mindboxScope + try { + startAndAwaitPageLoad() + setMindboxScope(CoroutineScope(Dispatchers.Unconfined).also { it.cancel() }) + + postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) + await { lastOutgoingMessage()?.get("action")?.asString == "contentRendered" } + verify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } + + setMindboxScope(originalScope) + holder.pause() + holder.start() + + verify(exactly = 1, timeout = 5_000L) { inAppInteractor.recordBlockShow("main-screen-top", "embedded-id", any(), any(), any()) } + } finally { + setMindboxScope(originalScope) + } + } + + private fun setMindboxScope(scope: CoroutineScope) { + Mindbox::class.java.getDeclaredField("mindboxScope") + .apply { isAccessible = true } + .set(Mindbox, scope) } @Test fun `contentRendered with zero count switches state to Empty and reports nothing`() { - coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs startAndAwaitPageLoad() postFromPage(request(action = "contentRendered", payload = """{"count":0}""")) await { states.lastOrNull() == EmbeddedBlockState.Empty } - coVerify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any()) } + verify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } } @Test fun `contentRendered without a readable count fails the block and refuses the page`() { - coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs startAndAwaitPageLoad() postFromPage(request(action = "contentRendered", payload = """{"count":"many"}""")) await { states.lastOrNull() == EmbeddedBlockState.Failed } - coVerify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any()) } + verify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } // The page must hear the refusal too: a success response would pass for the truth. assertEquals("error", lastOutgoingMessage()!!.get("type").asString) } @Test fun `contentRendered with a negative count fails the block instead of passing for empty`() { - coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs startAndAwaitPageLoad() postFromPage(request(action = "contentRendered", payload = """{"count":-1}""")) await { states.lastOrNull() == EmbeddedBlockState.Failed } - coVerify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any()) } + verify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } assertEquals("error", lastOutgoingMessage()!!.get("type").asString) } @Test fun `contentRendered with a fractional count is refused rather than rounded`() { - coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs startAndAwaitPageLoad() postFromPage(request(action = "contentRendered", payload = """{"count":2.5}""")) await { states.lastOrNull() == EmbeddedBlockState.Failed } - coVerify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any()) } + verify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } assertEquals("error", lastOutgoingMessage()!!.get("type").asString) } @Test fun `contentRendered with a whole double count is a valid report`() { - coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs startAndAwaitPageLoad() postFromPage(request(action = "contentRendered", payload = """{"count":3.0}""")) await { states.lastOrNull() == EmbeddedBlockState.Ready } - coVerify(timeout = 5_000L) { inAppInteractor.recordBlockShow("embedded-id", any(), any()) } + verify(timeout = 5_000L) { inAppInteractor.recordBlockShow("main-screen-top", "embedded-id", any(), any(), any()) } + } + + @Test + fun `the show is accounted with the refreshed snapshot`() { + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs + startAndAwaitPageLoad() + + val refreshed = Frequency(Frequency.Delay.OneTimePerSession) + holder.refreshMetricsSnapshot(refreshed, mapOf("k" to "v")) + postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) + + verify(exactly = 1, timeout = 5_000L) { + inAppInteractor.recordBlockShow("main-screen-top", "embedded-id", refreshed, any(), any()) + } } @Test fun `the show is reported once per content instance`() { - coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs startAndAwaitPageLoad() postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) await { states.lastOrNull() == EmbeddedBlockState.Ready } postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) - coVerify(exactly = 1, timeout = 5_000L) { inAppInteractor.recordBlockShow("embedded-id", any(), any()) } + verify(exactly = 1, timeout = 5_000L) { inAppInteractor.recordBlockShow("main-screen-top", "embedded-id", any(), any(), any()) } + } + + @Test + fun `a repeated contentRendered with a zero count does not un-show a shown block`() { + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs + startAndAwaitPageLoad() + postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) + await { states.lastOrNull() == EmbeddedBlockState.Ready } + + postFromPage(request(action = "contentRendered", payload = """{"count":0}""", id = "again")) + await { lastOutgoingMessage()?.get("id")?.asString == "again" } + + assertEquals(EmbeddedBlockState.Ready, states.last()) + assertEquals("response", lastOutgoingMessage()!!.get("type").asString) + } + + @Test + fun `a repeated contentRendered without a readable count is ignored once the block is shown`() { + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs + startAndAwaitPageLoad() + postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) + await { states.lastOrNull() == EmbeddedBlockState.Ready } + + postFromPage(request(action = "contentRendered", payload = """{"count":"many"}""", id = "again")) + await { lastOutgoingMessage()?.get("id")?.asString == "again" } + + assertEquals(EmbeddedBlockState.Ready, states.last()) + assertEquals("response", lastOutgoingMessage()!!.get("type").asString) + verify(exactly = 0) { + MindboxDI.appModule.inAppFailureTracker.sendFailure(any(), any(), any(), any()) + } + } + + @Test + fun `a data push reopens the window for the page's next report`() { + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs + startAndAwaitPageLoad() + postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) + await { states.lastOrNull() == EmbeddedBlockState.Ready } + + holder.updateParams(mapOf("items" to "[]")) {} + await { lastOutgoingMessage()?.get("action")?.asString == "initDataUpdated" } + val push = lastOutgoingMessage()!! + postFromPage( + """{"type":"response","action":"initDataUpdated","payload":"{\"success\":true}",""" + + """"id":${push.get("id")},"version":1,"timestamp":2}""" + ) + postFromPage(request(action = "contentRendered", payload = """{"count":0}""", id = "after-push")) + + await { states.lastOrNull() == EmbeddedBlockState.Empty } } @Test fun `showInApp from the page is acknowledged and reports nothing`() { // The ack says the request was handed over, never that a window opened: the block's own // show accounting must not be spent on a tap. - coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs startAndAwaitPageLoad() postFromPage( - request(action = "showInApp", payload = """{"inappId":"story-1","index":0,"params":{}}""") + request(action = "showInApp", payload = """{"inappId":"inapp-1","index":0,"params":{}}""") ) await { lastOutgoingMessage()?.get("action")?.asString == "showInApp" } assertEquals("response", lastOutgoingMessage()?.get("type")?.asString) assertTrue(lastOutgoingPayload()!!.get("success").asBoolean) - coVerify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any()) } + verify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } } @Test @@ -348,16 +468,16 @@ class EmbeddedBlockWebViewHolderTest { postFromPage( request( action = "showInApp", - payload = """{"inappId":"story-1","index":0,"sourceInappId":"feed-id","params":{"title":"Сториз 1","record":{"rank":3}}}""" + payload = """{"inappId":"inapp-1","index":0,"sourceInappId":"host-id","params":{"title":"Заголовок 1","record":{"rank":3}}}""" ) ) await { lastOutgoingMessage()?.get("action")?.asString == "showInApp" } verify(exactly = 1) { inAppMessageManager.showInAppById( - "story-1", + "inapp-1", mapOf( - "title" to JsonPrimitive("Сториз 1"), + "title" to JsonPrimitive("Заголовок 1"), "record" to JsonParser.parseString("""{"rank":3}"""), ) ) @@ -370,13 +490,13 @@ class EmbeddedBlockWebViewHolderTest { // The whole envelope is one JSON document: payload is a plain object, not a quoted string. postFromPage( - """{"type":"request","action":"showInApp","payload":{"inappId":"story-1","params":{"a":1}},"id":"req-obj","version":1,"timestamp":1}""" + """{"type":"request","action":"showInApp","payload":{"inappId":"inapp-1","params":{"a":1}},"id":"req-obj","version":1,"timestamp":1}""" ) await { lastOutgoingMessage()?.get("action")?.asString == "showInApp" } assertEquals("response", lastOutgoingMessage()?.get("type")?.asString) verify(exactly = 1) { - inAppMessageManager.showInAppById("story-1", mapOf("a" to JsonPrimitive(1))) + inAppMessageManager.showInAppById("inapp-1", mapOf("a" to JsonPrimitive(1))) } } @@ -396,7 +516,7 @@ class EmbeddedBlockWebViewHolderTest { startAndAwaitPageLoad() holder.pause() - postFromPage(request(action = "showInApp", payload = """{"inappId":"story-1"}""")) + postFromPage(request(action = "showInApp", payload = """{"inappId":"inapp-1"}""")) await { lastOutgoingMessage()?.get("action")?.asString == "showInApp" } assertEquals("error", lastOutgoingMessage()?.get("type")?.asString) @@ -409,7 +529,7 @@ class EmbeddedBlockWebViewHolderTest { postFromPage(request(action = "contentRendered", payload = """{"count":-1}""", id = "bad")) await { states.lastOrNull() == EmbeddedBlockState.Failed } - postFromPage(request(action = "showInApp", payload = """{"inappId":"story-1"}""")) + postFromPage(request(action = "showInApp", payload = """{"inappId":"inapp-1"}""")) await { lastOutgoingMessage()?.get("action")?.asString == "showInApp" } assertEquals("response", lastOutgoingMessage()?.get("type")?.asString) @@ -420,9 +540,9 @@ class EmbeddedBlockWebViewHolderTest { fun `the old checkInappsTargeting name is not spoken anymore`() { startAndAwaitPageLoad() - postFromPage(request(action = "checkInappsTargeting", payload = """{"inappIds":["story-1"]}""")) + postFromPage(request(action = "checkInappsTargeting", payload = """{"inappIds":["inapp-1"]}""")) - coVerify(exactly = 0) { inAppInteractor.filterShowableInAppIds(any()) } + coVerify(exactly = 0) { inAppInteractor.filterShowableInAppIds(any(), any()) } assertTrue(lastOutgoingMessage()?.get("action")?.asString != "checkInappsTargeting") } @@ -462,7 +582,7 @@ class EmbeddedBlockWebViewHolderTest { startAndAwaitPageLoad() postFromPage( - request(action = "localState.set", payload = """{"data":{"inapp.completed.story-1":"rev-1"}}""") + request(action = "localState.set", payload = """{"data":{"inapp.completed.inapp-1":"rev-1"}}""") ) await { lastOutgoingMessage()?.get("action")?.asString == "localState.set" } @@ -657,12 +777,12 @@ class EmbeddedBlockWebViewHolderTest { } @Test - fun `initDataUpdated refreshes the feed without recreating the webview`() { + fun `initDataUpdated refreshes the page without recreating the webview`() { startAndAwaitPageLoad() val viewBeforeUpdate = webView var updateResult: Boolean? = null - holder.updateParams(mapOf("stories" to """[{"inAppId":"story-2"}]""")) { isUpdated -> + holder.updateParams(mapOf("items" to """[{"inAppId":"inapp-2"}]""")) { isUpdated -> updateResult = isUpdated } await { lastOutgoingMessage()?.get("action")?.asString == "initDataUpdated" } @@ -671,10 +791,10 @@ class EmbeddedBlockWebViewHolderTest { // The push carries the whole start payload — the same envelope `ready` is answered with — // not only the params (contract shared with iOS). val payload = lastOutgoingPayload()!! - assertTrue(payload.get("stories").isJsonArray) + assertTrue(payload.get("items").isJsonArray) assertEquals( - "story-2", - payload.getAsJsonArray("stories").get(0).asJsonObject.get("inAppId").asString + "inapp-2", + payload.getAsJsonArray("items").get(0).asJsonObject.get("inAppId").asString ) assertEquals("endpoint-id", payload.get("endpointId").asString) @@ -688,6 +808,110 @@ class EmbeddedBlockWebViewHolderTest { assertTrue(viewBeforeUpdate === webView) } + private fun rebuildHolder(ackBudget: Milliseconds) { + holder.release() + states.clear() + holder = EmbeddedBlockWebViewHolder( + inAppId = "embedded-id", + placeSystemName = "main-screen-top", + layer = InAppStub.getEmbeddedWebViewLayer(), + context = application, + frequency = Frequency(Frequency.Delay.Unlimited), + tags = null, + startTick = Milliseconds(0L), + ackBudget = ackBudget, + ) + holder.onStateChange = { state -> states.add(state) } + } + + private fun answerDataPush(id: com.google.gson.JsonElement) { + postFromPage( + """{"type":"response","action":"initDataUpdated","payload":"{\"success\":true}",""" + + """"id":$id,"version":1,"timestamp":2}""" + ) + } + + @Test + fun `a data push confirmed off screen still succeeds`() { + startAndAwaitPageLoad() + holder.pause() + + var updateResult: Boolean? = null + holder.updateParams(mapOf("items" to "[]")) { isUpdated -> updateResult = isUpdated } + await { lastOutgoingMessage()?.get("action")?.asString == "initDataUpdated" } + + answerDataPush(lastOutgoingMessage()!!.get("id")) + + await { updateResult != null } + assertEquals(true, updateResult) + } + + @Test + fun `a data push does not spend its ack budget off screen`() { + rebuildHolder(ackBudget = Milliseconds(1_000L)) + startAndAwaitPageLoad() + holder.pause() + + var updateResult: Boolean? = null + holder.updateParams(mapOf("items" to "[]")) { isUpdated -> updateResult = isUpdated } + await { lastOutgoingMessage()?.get("action")?.asString == "initDataUpdated" } + val push = lastOutgoingMessage()!! + + Thread.sleep(2_000L) + assertNull(updateResult) + + holder.start() + answerDataPush(push.get("id")) + + await { updateResult != null } + assertEquals(true, updateResult) + } + + @Test + fun `the ack budget is spent by watched time and survives a pause`() { + rebuildHolder(ackBudget = Milliseconds(1_000L)) + startAndAwaitPageLoad() + + var updateResult: Boolean? = null + holder.updateParams(mapOf("items" to "[]")) { isUpdated -> updateResult = isUpdated } + await { lastOutgoingMessage()?.get("action")?.asString == "initDataUpdated" } + + holder.pause() + Thread.sleep(2_000L) + assertNull(updateResult) + + holder.start() + + await { updateResult != null } + assertEquals(false, updateResult) + } + + @Test + fun `a second data push abandons the first wait`() { + startAndAwaitPageLoad() + + var firstResult: Boolean? = null + holder.updateParams(mapOf("items" to "[]")) { isUpdated -> firstResult = isUpdated } + await { lastOutgoingMessage()?.get("action")?.asString == "initDataUpdated" } + + var secondResult: Boolean? = null + holder.updateParams(mapOf("items" to """[{"inAppId":"inapp-2"}]""")) { isUpdated -> + secondResult = isUpdated + } + await { firstResult != null } + assertEquals(false, firstResult) + + await { + lastOutgoingPayload()?.getAsJsonArray("items")?.any { item -> + item.asJsonObject.get("inAppId")?.asString == "inapp-2" + } == true + } + answerDataPush(lastOutgoingMessage()!!.get("id")) + + await { secondResult != null } + assertEquals(true, secondResult) + } + @Test fun `an action the block does not perform is acknowledged, not refused`() { startAndAwaitPageLoad() @@ -727,7 +951,7 @@ class EmbeddedBlockWebViewHolderTest { fun `updateParams before the page exists reports failure`() { var updateResult: Boolean? = null - holder.updateParams(mapOf("stories" to "[]")) { isUpdated -> updateResult = isUpdated } + holder.updateParams(mapOf("items" to "[]")) { isUpdated -> updateResult = isUpdated } assertEquals(false, updateResult) } @@ -736,7 +960,7 @@ class EmbeddedBlockWebViewHolderTest { fun `local state get is served from the store`() { startAndAwaitPageLoad() - postFromPage(request(action = "localState.get", payload = """{"keys":["inapp.completed.story-1"]}""")) + postFromPage(request(action = "localState.get", payload = """{"keys":["inapp.completed.inapp-1"]}""")) await { lastOutgoingMessage()?.get("action")?.asString == "localState.get" } assertEquals("response", lastOutgoingMessage()?.get("type")?.asString) @@ -744,7 +968,7 @@ class EmbeddedBlockWebViewHolderTest { @Test fun `a refusal off screen is held until the block comes back`() { - coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + coEvery { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs startAndAwaitPageLoad() holder.pause() @@ -761,24 +985,24 @@ class EmbeddedBlockWebViewHolderTest { @Test fun `a page that rendered off screen counts its show when the block comes back`() { - coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + coEvery { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs startAndAwaitPageLoad() holder.pause() postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) - coVerify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any()) } + coVerify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } holder.start() coVerify(exactly = 1, timeout = 5_000L) { - inAppInteractor.recordBlockShow("embedded-id", any(), any()) + inAppInteractor.recordBlockShow("main-screen-top", "embedded-id", any(), any(), any()) } } @Test fun `the counted show carries the time the render took, not the time off screen`() { - coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + coEvery { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs startAndAwaitPageLoad() holder.pause() @@ -789,7 +1013,7 @@ class EmbeddedBlockWebViewHolderTest { holder.start() coVerify(exactly = 1, timeout = 5_000L) { - inAppInteractor.recordBlockShow("embedded-id", Milliseconds(1_000L), any()) + inAppInteractor.recordBlockShow("main-screen-top", "embedded-id", any(), Milliseconds(1_000L), any()) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt index 6a062ed2..e3104c0b 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt @@ -1,8 +1,12 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.WaitBudgetPhase import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppRepository +import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.models.operation.request.EmbeddedBlockShowFailure import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowError import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import cloud.mindbox.mobile_sdk.utils.TimeProvider import io.mockk.every @@ -24,13 +28,16 @@ internal class InAppFailureTrackerImplTest { private val currentTimeMillis = 1707523200000L private val expectedTimestamp = "2024-02-10T00:00:00Z" + private fun List.asFailures(): List = filterIsInstance() + @Before fun onTestStart() { every { timeProvider.currentTimeMillis() } returns currentTimeMillis inAppFailureTracker = InAppFailureTrackerImpl( timeProvider = timeProvider, inAppRepository = inAppRepository, - featureToggleManager = featureToggleManager + featureToggleManager = featureToggleManager, + sessionStorageManager = SessionStorageManager(timeProvider), ) } @@ -44,13 +51,13 @@ internal class InAppFailureTrackerImplTest { errorDetails = "error" ) - verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } + verify(exactly = 0) { inAppRepository.sendInAppShowErrors(any()) } } @Test fun `sendFailure sends immediately when feature toggle is enabled`() { every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true - val slot = slot>() + val slot = slot>() inAppFailureTracker.sendFailure( inAppId = inAppId, @@ -58,8 +65,8 @@ internal class InAppFailureTrackerImplTest { errorDetails = "error" ) - verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } - val captured = slot.captured + verify(exactly = 1) { inAppRepository.sendInAppShowErrors(capture(slot)) } + val captured = slot.captured.asFailures() assertEquals(1, captured.size) assertEquals(inAppId, captured[0].inAppId) assertEquals(FailureReason.PRESENTATION_FAILED, captured[0].failureReason) @@ -77,13 +84,13 @@ internal class InAppFailureTrackerImplTest { errorDetails = "error" ) - verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } + verify(exactly = 0) { inAppRepository.sendInAppShowErrors(any()) } } @Test fun `collectFailure does not add duplicate when same inAppId already tracked`() { every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true - val slot = slot>() + val slot = slot>() inAppFailureTracker.collectFailure( inAppId = inAppId, @@ -97,8 +104,8 @@ internal class InAppFailureTrackerImplTest { ) inAppFailureTracker.sendCollectedFailures() - verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } - val captured = slot.captured + verify(exactly = 1) { inAppRepository.sendInAppShowErrors(capture(slot)) } + val captured = slot.captured.asFailures() assertEquals(1, captured.size) assertEquals(FailureReason.PRESENTATION_FAILED, captured[0].failureReason) } @@ -107,7 +114,7 @@ internal class InAppFailureTrackerImplTest { fun `sendFailure truncates errorDetails to 1000 chars`() { every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true val longErrorDetails = "a".repeat(1500) - val slot = slot>() + val slot = slot>() inAppFailureTracker.sendFailure( inAppId = inAppId, @@ -115,15 +122,15 @@ internal class InAppFailureTrackerImplTest { errorDetails = longErrorDetails ) - verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } - assertEquals("a".repeat(1000), slot.captured[0].errorDetails) + verify(exactly = 1) { inAppRepository.sendInAppShowErrors(capture(slot)) } + assertEquals("a".repeat(1000), slot.captured.asFailures()[0].errorDetails) } @Test fun `collectFailure truncates errorDetails to 1000 chars`() { every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true val longErrorDetails = "a".repeat(1500) - val slot = slot>() + val slot = slot>() inAppFailureTracker.collectFailure( inAppId = inAppId, @@ -132,14 +139,14 @@ internal class InAppFailureTrackerImplTest { ) inAppFailureTracker.sendCollectedFailures() - verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } - assertEquals("a".repeat(1000), slot.captured[0].errorDetails) + verify(exactly = 1) { inAppRepository.sendInAppShowErrors(capture(slot)) } + assertEquals("a".repeat(1000), slot.captured.asFailures()[0].errorDetails) } @Test fun `sendCollectedFailures sends all failures when feature toggle is enabled`() { every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true - val slot = slot>() + val slot = slot>() inAppFailureTracker.collectFailure( inAppId = "inApp1", @@ -153,8 +160,8 @@ internal class InAppFailureTrackerImplTest { ) inAppFailureTracker.sendCollectedFailures() - verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } - val captured = slot.captured + verify(exactly = 1) { inAppRepository.sendInAppShowErrors(capture(slot)) } + val captured = slot.captured.asFailures() assertEquals(2, captured.size) assertEquals(1, captured.count { it.inAppId == "inApp1" && it.failureReason == FailureReason.PRESENTATION_FAILED }) assertEquals(1, captured.count { it.inAppId == "inApp2" && it.failureReason == FailureReason.IMAGE_DOWNLOAD_FAILED }) @@ -172,7 +179,7 @@ internal class InAppFailureTrackerImplTest { inAppFailureTracker.sendCollectedFailures() inAppFailureTracker.sendCollectedFailures() - verify(exactly = 1) { inAppRepository.sendInAppShowFailure(any()) } + verify(exactly = 1) { inAppRepository.sendInAppShowErrors(any()) } } @Test @@ -186,7 +193,7 @@ internal class InAppFailureTrackerImplTest { ) inAppFailureTracker.sendCollectedFailures() - verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } + verify(exactly = 0) { inAppRepository.sendInAppShowErrors(any()) } } @Test @@ -201,14 +208,14 @@ internal class InAppFailureTrackerImplTest { inAppFailureTracker.clearFailures() inAppFailureTracker.sendCollectedFailures() - verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } + verify(exactly = 0) { inAppRepository.sendInAppShowErrors(any()) } } @Test fun `sendFailure forwards tags to the failure`() { every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true val tags = mapOf("templateType" to "Popup") - val slot = slot>() + val slot = slot>() inAppFailureTracker.sendFailure( inAppId = inAppId, @@ -217,14 +224,14 @@ internal class InAppFailureTrackerImplTest { tags = tags ) - verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } - assertEquals(tags, slot.captured[0].tags) + verify(exactly = 1) { inAppRepository.sendInAppShowErrors(capture(slot)) } + assertEquals(tags, slot.captured.asFailures()[0].tags) } @Test fun `collectFailure keeps each failures own tags`() { every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true - val slot = slot>() + val slot = slot>() inAppFailureTracker.collectFailure( inAppId = "inApp1", @@ -240,8 +247,8 @@ internal class InAppFailureTrackerImplTest { ) inAppFailureTracker.sendCollectedFailures() - verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } - val captured = slot.captured + verify(exactly = 1) { inAppRepository.sendInAppShowErrors(capture(slot)) } + val captured = slot.captured.asFailures() assertEquals(mapOf("templateType" to "Popup"), captured.first { it.inAppId == "inApp1" }.tags) assertEquals(null, captured.first { it.inAppId == "inApp2" }.tags) } @@ -249,7 +256,7 @@ internal class InAppFailureTrackerImplTest { @Test fun `sendFailure with null errorDetails`() { every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true - val slot = slot>() + val slot = slot>() inAppFailureTracker.sendFailure( inAppId = inAppId, @@ -257,8 +264,37 @@ internal class InAppFailureTrackerImplTest { errorDetails = null ) - verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } - assertEquals(null, slot.captured[0].errorDetails) - assertEquals(inAppId, slot.captured[0].inAppId) + verify(exactly = 1) { inAppRepository.sendInAppShowErrors(capture(slot)) } + assertEquals(null, slot.captured.asFailures()[0].errorDetails) + assertEquals(inAppId, slot.captured.asFailures()[0].inAppId) + } + + @Test + fun `sendWaitBudgetExceeded ships the place-named fact once per place a session`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val sent = mutableListOf>() + + inAppFailureTracker.sendWaitBudgetExceeded("main-screen-top", Milliseconds(30_000L), WaitBudgetPhase.CONFIG_MISSING) + inAppFailureTracker.sendWaitBudgetExceeded("main-screen-top", Milliseconds(30_000L), WaitBudgetPhase.RESOLVE_PENDING) + inAppFailureTracker.sendWaitBudgetExceeded("another-place", Milliseconds(30_000L), WaitBudgetPhase.RESOLVE_PENDING) + + verify(exactly = 2) { inAppRepository.sendInAppShowErrors(capture(sent)) } + val first = sent.first().single() as EmbeddedBlockShowFailure + assertEquals("main-screen-top", first.placeSystemName) + assertEquals("phase=config_missing; waited=00:00:30.0000000", first.errorDetails) + val failure = sent.last().single() as EmbeddedBlockShowFailure + assertEquals("another-place", failure.placeSystemName) + assertEquals(FailureReason.WAIT_BUDGET_EXCEEDED, failure.failureReason) + assertEquals("phase=resolve_pending; waited=00:00:30.0000000", failure.errorDetails) + assertEquals(expectedTimestamp, failure.dateTimeUtc) + } + + @Test + fun `sendWaitBudgetExceeded respects the feature toggle`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns false + + inAppFailureTracker.sendWaitBudgetExceeded("main-screen-top", Milliseconds(30_000L), WaitBudgetPhase.CONFIG_MISSING) + + verify(exactly = 0) { inAppRepository.sendInAppShowErrors(any()) } } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt index f690d7f9..9517f1e1 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt @@ -1,7 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager -import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppErrorsWrapper import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import cloud.mindbox.mobile_sdk.models.operation.request.InAppClickRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowRequest @@ -193,26 +193,46 @@ internal class InAppSerializationManagerTest { } @Test - fun `serializeToInAppShowFailuresString returns valid JSON string`() { - val inAppShowFailures = listOf( + fun `serializeToInAppShowErrorsString pins the polymorphic errors contract`() { + val errors = listOf( InAppShowFailure( inAppId = inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDetails = "error", + dateTimeUtc = "2024-02-10T00:00:00Z", + tags = mapOf("templateType" to "Popup") + ), + cloud.mindbox.mobile_sdk.models.operation.request.EmbeddedBlockShowFailure( + placeSystemName = "main-screen-top", + failureReason = FailureReason.WAIT_BUDGET_EXCEEDED, + errorDetails = "phase=config_missing; waited=00:00:30.0000000", dateTimeUtc = "2024-02-10T00:00:00Z" ) ) - val expectedJson = Gson().toJson(InAppFailuresWrapper(inAppShowFailures)) - val actualJson = inAppSerializationManager.serializeToInAppShowFailuresString(inAppShowFailures) + val actual = com.google.gson.JsonParser.parseString( + inAppSerializationManager.serializeToInAppShowErrorsString(errors) + ).asJsonObject - assertEquals(expectedJson, actualJson) + // One polymorphic array; the legacy `failures` never ships again. + val expected = com.google.gson.JsonParser.parseString( + """ + {"errors":[ + {"inappId":"validInAppId","failureReason":"presentation_failed","errorDetails":"error", + "dateTimeUtc":"2024-02-10T00:00:00Z","tags":{"templateType":"Popup"},"${'$'}type":"inappShowFailure"}, + {"placeSystemName":"main-screen-top","failureReason":"wait_budget_exceeded", + "errorDetails":"phase=config_missing; waited=00:00:30.0000000", + "dateTimeUtc":"2024-02-10T00:00:00Z","${'$'}type":"embeddedBlockShowFailure"} + ]} + """.trimIndent() + ).asJsonObject + assertEquals(expected, actual) } @Test - fun `serializeToInAppShowFailuresString returns empty string when exception occurs`() { + fun `serializeToInAppShowErrorsString returns empty string when exception occurs`() { val gson: Gson = mockk() - val inAppShowFailures = listOf( + val inAppShowErrors = listOf( InAppShowFailure( inAppId = inAppId, failureReason = FailureReason.UNKNOWN_ERROR, @@ -221,11 +241,11 @@ internal class InAppSerializationManagerTest { ) ) every { - gson.toJson(any(), object : TypeToken() {}.type) + gson.toJson(any(), object : TypeToken() {}.type) } throws RuntimeException("Serialization error") inAppSerializationManager = InAppSerializationManagerImpl(gson) - val actualJson = inAppSerializationManager.serializeToInAppShowFailuresString(inAppShowFailures) + val actualJson = inAppSerializationManager.serializeToInAppShowErrorsString(inAppShowErrors) assertEquals("", actualJson) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManagerTest.kt index b9d16745..f3ec6278 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManagerTest.kt @@ -109,6 +109,12 @@ class SessionStorageManagerTest { operationalInApps["test"] = mutableListOf(mockk()) inAppMessageShownInSession.add("test1") inAppMessageShownInSession.add("test2") + embeddedLastShownByPlace["main-screen-top"] = "in-app-1" + embeddedLastTargetedByPlace["main-screen-top"] = "in-app-2" + placeTargetingReportedInSession.add("in-app-3") + requestedInAppTargetingReportedInSession.add("host|inapp") + embeddedDelaysWaitedOut.add("main-screen-top|in-app-1") + waitBudgetReportedPlaces.add("main-screen-top") customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS geoFetchStatus = GeoFetchStatus.GEO_FETCH_SUCCESS processedProductSegmentations["testSystem" to "testValue"] = ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS @@ -129,6 +135,13 @@ class SessionStorageManagerTest { assertTrue(sessionStorageManager.unShownOperationalInApps.isEmpty()) assertTrue(sessionStorageManager.operationalInApps.isEmpty()) assertTrue(sessionStorageManager.inAppMessageShownInSession.isEmpty()) + // The block-event memory dies with the session, every cell together. + assertTrue(sessionStorageManager.embeddedLastShownByPlace.isEmpty()) + assertTrue(sessionStorageManager.embeddedLastTargetedByPlace.isEmpty()) + assertTrue(sessionStorageManager.placeTargetingReportedInSession.isEmpty()) + assertTrue(sessionStorageManager.requestedInAppTargetingReportedInSession.isEmpty()) + assertTrue(sessionStorageManager.embeddedDelaysWaitedOut.isEmpty()) + assertTrue(sessionStorageManager.waitBudgetReportedPlaces.isEmpty()) assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED, sessionStorageManager.customerSegmentationFetchStatus) assertEquals(GeoFetchStatus.GEO_NOT_FETCHED, sessionStorageManager.geoFetchStatus) assertTrue(sessionStorageManager.processedProductSegmentations.isEmpty()) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/EmbeddedContractSerializationTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/EmbeddedContractSerializationTest.kt index 0fae9b8d..5873b3e9 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/EmbeddedContractSerializationTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/serialization/EmbeddedContractSerializationTest.kt @@ -64,9 +64,9 @@ class EmbeddedContractSerializationTest { "placeSystemName":"main-screen-top", "content":{"background":{"layers":[{ "${'$'}type":"webview", - "baseUrl":"https://feed.local/base", - "contentUrl":"https://feed.local/stories.html", - "params":{"stories":[{"inAppId":"story-1"}]} + "baseUrl":"https://blocks.local/base", + "contentUrl":"https://blocks.local/items.html", + "params":{"items":[{"inAppId":"inapp-1"}]} }]}} }]} """.trimIndent() @@ -78,11 +78,11 @@ class EmbeddedContractSerializationTest { assertEquals("main-screen-top", variant.placeSystemName) assertEquals(1, variant.content?.background?.layers?.size) // Structured param values survive as JSON strings and are re-hydrated on the way to - // the page — the feed must receive `stories` as an array. + // the page — it must receive `items` as an array. assertEquals( - """[{"inAppId":"story-1"}]""", + """[{"inAppId":"inapp-1"}]""", variant.content?.background?.layers?.filterIsInstance() - ?.single()?.params?.get("stories") + ?.single()?.params?.get("items") ) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/EmbeddedMapperTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/EmbeddedMapperTest.kt index 42bebb0c..cfcc08e7 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/EmbeddedMapperTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/EmbeddedMapperTest.kt @@ -78,7 +78,7 @@ class EmbeddedMapperTest { } @Test - fun `place system name is mapped as it is`() { + fun `place system name is trimmed on mapping`() { val dto = baseDto.copy( form = FormDto(variants = listOf(InAppStub.getEmbeddedDto().copy(placeSystemName = " main-screen-top "))) ) @@ -86,7 +86,7 @@ class EmbeddedMapperTest { val variant = mapper.mapToInAppConfig(config(dto)).inApps.single() .form.variants.single() as InAppType.Embedded - assertEquals(" main-screen-top ", variant.placeSystemName) + assertEquals("main-screen-top", variant.placeSystemName) } @Test diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt index aa2cc870..24e50aa2 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt @@ -145,6 +145,32 @@ class InAppRepositoryTest { } } + @Test + fun `send in app show errors ships the serialized errors body`() { + val serializedString = """{"errors":[{}]}""" + every { inAppSerializationManager.serializeToInAppShowErrorsString(any()) } returns serializedString + + inAppRepository.sendInAppShowErrors( + listOf( + cloud.mindbox.mobile_sdk.models.operation.request.EmbeddedBlockShowFailure( + placeSystemName = "main-screen-top", + failureReason = cloud.mindbox.mobile_sdk.models.operation.request.FailureReason.WAIT_BUDGET_EXCEEDED, + errorDetails = null, + dateTimeUtc = "2024-02-10T00:00:00Z" + ) + ) + ) + + verify(exactly = 1) { MindboxEventManager.inAppShowFailure(context, serializedString) } + } + + @Test + fun `send in app show errors sends nothing for an empty list`() { + inAppRepository.sendInAppShowErrors(emptyList()) + + verify(exactly = 0) { MindboxEventManager.inAppShowFailure(any(), any()) } + } + @Test fun `send in app clicked success`() { val testInAppId = "testInAppId" diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt index 4d5a2fdc..ccf6ce61 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt @@ -1,6 +1,13 @@ package cloud.mindbox.mobile_sdk.inapp.data.repositories +import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import cloud.mindbox.mobile_sdk.models.TimeSpan import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponseBlank import io.mockk.* @@ -70,7 +77,67 @@ internal class MobileConfigRepositoryImplTest { verify(exactly = 1) { inAppMapper.mapToInAppDto(any(), null, any(), any(), any(), any()) } } - private fun createRepository(): MobileConfigRepositoryImpl { + @Test + fun `hasConfig is false until a config has been provided`() { + assertFalse(repository.hasConfig()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `startListening re-arms the config subscription killed with the sdk scope`() = runTest { + val originalScope = Mindbox.mindboxScope + try { + MindboxPreferences.inAppConfigFlow.resetReplayCache() + setMindboxScope(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val revived = createRepository() + + // The soft reinitialization: the scope dies with the subscription inside it. + Mindbox.mindboxScope.cancel() + setMindboxScope(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + revived.startListening() + MindboxPreferences.inAppConfigFlow.emit("{}") + + assertTrue(revived.hasConfig()) + } finally { + setMindboxScope(originalScope) + MindboxPreferences.inAppConfigFlow.resetReplayCache() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `an emission that deserializes to nothing still concludes the wait with an empty config`() = runTest { + // The fetch-failed fallback re-emits whatever is stored — an empty string when there is + // no cache. That emission must answer the waiters with an empty config at once (the + // block collapses fast, no wait_budget), never leave them hanging on configState. + val originalScope = Mindbox.mindboxScope + try { + MindboxPreferences.inAppConfigFlow.resetReplayCache() + setMindboxScope(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val repository = createRepository(deserializedBlank = null) + repository.startListening() + assertFalse(repository.hasConfig()) + + MindboxPreferences.inAppConfigFlow.emit("") + + assertTrue(repository.hasConfig()) + assertTrue(repository.getInAppsSection().isEmpty()) + } finally { + setMindboxScope(originalScope) + MindboxPreferences.inAppConfigFlow.resetReplayCache() + } + } + + private fun setMindboxScope(scope: CoroutineScope) { + Mindbox::class.java.getDeclaredField("mindboxScope") + .apply { isAccessible = true } + .set(Mindbox, scope) + } + + private fun createRepository( + deserializedBlank: InAppConfigResponseBlank? = mockk(), + ): MobileConfigRepositoryImpl { return MobileConfigRepositoryImpl( inAppMapper = inAppMapper, timeSpanPositiveValidator = TimeSpanPositiveValidator(), @@ -83,6 +150,7 @@ internal class MobileConfigRepositoryImplTest { }, mobileConfigSerializationManager = mockk(relaxed = true) { every { deserializeToInAppTargetingDto(any()) } returns mockk() + every { deserializeToConfigDtoBlank(any()) } returns deserializedBlank }, monitoringValidator = mockk(relaxed = true), abTestValidator = mockk(relaxed = true), diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidatorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidatorTest.kt index ca9b1498..62f80453 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidatorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidatorTest.kt @@ -45,8 +45,8 @@ class EmbeddedVariantValidatorTest { } @Test - fun `a place system name of spaces is a name`() { - assertTrue(validator.isValid(valid.copy(placeSystemName = " "))) + fun `a place system name of spaces is not a name`() { + assertFalse(validator.isValid(valid.copy(placeSystemName = " "))) } @Test @@ -100,7 +100,7 @@ class EmbeddedVariantValidatorTest { @Test fun `params are not validated`() { - // Whatever is inside params — missing stories, junk values — the variant stays valid. + // Whatever is inside params — missing keys, junk values — the variant stays valid. val layer = valid.content!!.background!!.layers!! .single() as BackgroundDto.LayerDto.WebViewLayerDto val variant = valid.copy( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedFilteringManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedFilteringManagerTest.kt index 4eb90263..4ee57875 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedFilteringManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedFilteringManagerTest.kt @@ -36,12 +36,12 @@ class EmbeddedFilteringManagerTest { } @Test - fun `place comparison takes the padding as part of the name`() { + fun `place comparison trims whitespace on both sides`() { val embedded = embeddedInApp() val result = manager.filterEmbeddedInAppsByPlace(listOf(embedded), " main-screen-top ") - assertTrue(result.isEmpty()) + assertEquals(listOf(embedded), result) } @Test diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedResolveInteractorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedResolveInteractorTest.kt index bf0b413e..dc468bf2 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedResolveInteractorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedResolveInteractorTest.kt @@ -4,6 +4,7 @@ import app.cash.turbine.test import cloud.mindbox.mobile_sdk.abtests.InAppABTestLogic import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.checkers.Checker +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppProcessingManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfigRepository @@ -44,7 +45,7 @@ import org.junit.Test /** * The MOBILE-333 resolve trio: content for a place (pull and push), content by id for the - * future direct call, and the feed answer to `filterShowableInapps`. Presentation limits and + * future direct call, and the dictionary answer to `filterShowableInapps`. Presentation limits and * the lock are structurally absent from these paths — the paired negative tests pin that. */ @ExperimentalCoroutinesApi @@ -80,6 +81,9 @@ class EmbeddedResolveInteractorTest { @RelaxedMockK private lateinit var sessionStorageManager: SessionStorageManager + @RelaxedMockK + private lateinit var inAppFailureTracker: InAppFailureTracker + private lateinit var frequencyManager: InAppFrequencyManagerImpl private lateinit var interactor: InAppInteractorImpl @@ -104,12 +108,18 @@ class EmbeddedResolveInteractorTest { minIntervalBetweenShowsLimitChecker = minIntervalBetweenShowsLimitChecker, timeProvider = timeProvider, sessionStorageManager = sessionStorageManager, + inAppFailureTracker = inAppFailureTracker, ) every { timeProvider.currentTimestamp() } returns now every { inAppRepository.getShownInApps() } returns emptyMap() every { inAppProcessingManager.sendTargetedInApp(any()) } just runs coEvery { inAppProcessingManager.sendTargetedInApp(any(), any()) } just runs every { sessionStorageManager.placeTargetingReportedInSession } returns ConcurrentHashMap.newKeySet() + every { sessionStorageManager.requestedInAppTargetingReportedInSession } returns ConcurrentHashMap.newKeySet() + every { sessionStorageManager.embeddedLastShownByPlace } returns ConcurrentHashMap() + every { sessionStorageManager.embeddedLastTargetedByPlace } returns ConcurrentHashMap() + every { sessionStorageManager.embeddedDelaysWaitedOut } returns ConcurrentHashMap.newKeySet() + coEvery { inAppProcessingManager.matchesTargeting(any(), any()) } returns true every { maxInappsPerSessionLimitChecker.check() } returns true every { maxInappsPerDayLimitChecker.check() } returns true every { minIntervalBetweenShowsLimitChecker.check() } returns true @@ -153,7 +163,7 @@ class EmbeddedResolveInteractorTest { fun `selectInAppForPlace returns embedded content for known place`() = runTest { givenConfig(embeddedInApp(), modalInApp()) - val content = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + val content = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))?.variant assertEquals("embedded-id", content?.inAppId) assertEquals(place, content?.placeSystemName) @@ -173,17 +183,42 @@ class EmbeddedResolveInteractorTest { embeddedInApp(id = "priority", isPriority = true), ) - val content = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + val content = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))?.variant assertEquals("priority", content?.inAppId) } @Test - fun `selectInAppForPlace does not delay content when winner has delayTime`() = runTest { + fun `selectInAppForPlace hands the winner delayTime to the caller instead of waiting`() = runTest { givenConfig(embeddedInApp().copy(delayTime = Milliseconds(7_200_000L))) - // The content comes back right away — there is nothing to wait with on the pull path. - assertEquals("embedded-id", interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))?.inAppId) + // The resolve itself never waits: the registry owns the delay. + val result = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + assertEquals("embedded-id", result?.variant?.inAppId) + assertEquals(7_200_000L, result?.delayTime?.interval) + } + + @Test + fun `selectInAppForPlace hands out no delay once the winner waited it out this session`() = runTest { + givenConfig(embeddedInApp().copy(delayTime = Milliseconds(7_200_000L))) + + interactor.markEmbeddedDelayWaitedOut(place, "embedded-id") + val result = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + assertEquals("embedded-id", result?.variant?.inAppId) + assertNull(result?.delayTime) + } + + @Test + fun `a waited-out delay is per place and per in-app`() = runTest { + givenConfig(embeddedInApp().copy(delayTime = Milliseconds(7_200_000L))) + + interactor.markEmbeddedDelayWaitedOut("other-place", "embedded-id") + interactor.markEmbeddedDelayWaitedOut(place, "other-in-app") + val result = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + assertEquals(7_200_000L, result?.delayTime?.interval) } @Test @@ -194,11 +229,13 @@ class EmbeddedResolveInteractorTest { } @Test - fun `selectInAppForPlace skips candidate outside ab pool`() = runTest { + fun `selectInAppForPlace shows nothing outside the ab pool but still sends its targeting`() = runTest { + // The cut A/B branch keeps its funnel denominator: no show, yet the offer goes out. givenConfig(embeddedInApp()) coEvery { inAppABTestLogic.getInAppsPool(any()) } returns emptySet() assertNull(interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))) + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(match { it.id == "embedded-id" }) } } @Test @@ -206,24 +243,26 @@ class EmbeddedResolveInteractorTest { givenConfig(embeddedInApp()) coEvery { inAppABTestLogic.getInAppsPool(any()) } returns setOf("embedded-id") - assertEquals("embedded-id", interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))?.inAppId) + assertEquals("embedded-id", interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))?.variant?.inAppId) } @Test fun `selectInAppForPlace filters by frequency like every other path`() = runTest { givenConfig(embeddedInApp()) - assertEquals("embedded-id", interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))?.inAppId) + assertEquals("embedded-id", interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))?.variant?.inAppId) verify(exactly = 1) { frequencyManager.filterInAppsFrequency(any()) } } @Test fun `selectInAppForPlace skips a candidate its frequency already blocks`() = runTest { // A lifetime-frequency block that has been shown before is done — exactly like a modal. + // Its offer still ships: the frequency holds the show back, not the funnel. givenConfig(embeddedInApp()) every { inAppRepository.getShownInApps() } returns mapOf("embedded-id" to listOf(1L)) assertNull(interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))) + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(any()) } } @Test @@ -250,7 +289,7 @@ class EmbeddedResolveInteractorTest { givenConfig(embeddedInApp(isPriority = true)) every { maxInappsPerDayLimitChecker.check() } returns false - assertEquals("embedded-id", interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))?.inAppId) + assertEquals("embedded-id", interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))?.variant?.inAppId) } @Test @@ -263,18 +302,18 @@ class EmbeddedResolveInteractorTest { every { maxInappsPerSessionLimitChecker.check() } returns false every { minIntervalBetweenShowsLimitChecker.check() } returns false - assertEquals("embedded-id", interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))?.inAppId) + assertEquals("embedded-id", interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))?.variant?.inAppId) verify(exactly = 0) { maxInappsPerSessionLimitChecker.check() } } @Test fun `targeting catch-up never covers directCall in-apps`() = runTest { - // iOS decision 14.08, mirrored: a story answers no event, so the catch-up must not + // iOS decision 14.08, mirrored: a direct-call in-app answers no event, so the catch-up must not // send Inapp.Targeting for it — that would inflate the funnel on every start. Its // targeting ships once, with the explicit show. - val story = modalInApp(id = "story").copy(displayConditions = DisplayConditions.DIRECT_CALL) + val directCall = modalInApp(id = "direct-call").copy(displayConditions = DisplayConditions.DIRECT_CALL) val ordinary = modalInApp(id = "ordinary") - givenConfig(story, ordinary) + givenConfig(directCall, ordinary) coEvery { inAppRepository.getTargetedInApps() } returns emptyMap() every { inAppRepository.listenInAppEvents() } returns flowOf(InAppEventType.AppStartup) coEvery { inAppProcessingManager.sendTargetedInApp(any(), any()) } just runs @@ -284,7 +323,7 @@ class EmbeddedResolveInteractorTest { advanceUntilIdle() job.cancel() - coVerify(exactly = 0) { inAppProcessingManager.sendTargetedInApp(story, any()) } + coVerify(exactly = 0) { inAppProcessingManager.sendTargetedInApp(directCall, any()) } coVerify(atLeast = 1) { inAppProcessingManager.sendTargetedInApp(ordinary, any()) } } @@ -327,28 +366,136 @@ class EmbeddedResolveInteractorTest { val first = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) val second = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) - assertEquals("embedded-id", first?.inAppId) - assertEquals("embedded-id", second?.inAppId) + assertEquals("embedded-id", first?.variant?.inAppId) + assertEquals("embedded-id", second?.variant?.inAppId) verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(any()) } } @Test - fun `selectInAppForPlace sends no targeting for a winner the show limits block`() = runTest { + fun `selectInAppForPlace sends the winner targeting even when the show limits block it`() = runTest { + // Parity with the overlay: the offer is reported before the budgets decide whether it + // may actually appear — and the blocked pass consumes the winner's offer slot. givenConfig(embeddedInApp()) every { maxInappsPerSessionLimitChecker.check() } returns false assertNull(interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))) - verify(exactly = 0) { inAppProcessingManager.sendTargetedInApp(any()) } + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(any()) } every { maxInappsPerSessionLimitChecker.check() } returns true interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(any()) } } + @Test + fun `selectInAppForPlace sends targeting for the losers once per session and pairs the winner with its slot`() = runTest { + // Two candidates matched: one wins the place, the loser still keeps its denominator. + givenConfig( + embeddedInApp(id = "winner", isPriority = true), + embeddedInApp(id = "loser"), + ) + + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + // The loser goes through the once-per-session set, the winner through the "last + // targeted" slot: one event each however many times the place re-resolves. + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(match { it.id == "winner" }) } + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(match { it.id == "loser" }) } + } + + @Test + fun `a winner that loses a later pass to a stronger candidate is not targeted again`() = runTest { + givenConfig( + embeddedInApp(id = "usual"), + embeddedInApp(id = "stronger", isPriority = true), + ) + coEvery { inAppProcessingManager.matchesTargeting(match { it.id == "stronger" }, any()) } returns false + + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + coEvery { inAppProcessingManager.matchesTargeting(match { it.id == "stronger" }, any()) } returns true + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(match { it.id == "usual" }) } + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(match { it.id == "stronger" }) } + } + + @Test + fun `selectInAppForPlace targets the winner again on every change of the winner, 1 to 2 to 1`() = runTest { + val first = embeddedInApp(id = "first") + val second = embeddedInApp(id = "second") + + givenConfig(first) + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + givenConfig(second) + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + givenConfig(first) + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + // The slot compares with the last targeted, not with a session set: the return counts. + verify(exactly = 2) { inAppProcessingManager.sendTargetedInApp(match { it.id == "first" }) } + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(match { it.id == "second" }) } + } + + @Test + fun `selectInAppForPlace sends no targeting for a candidate whose targeting did not match`() = runTest { + givenConfig(embeddedInApp(id = "matched"), embeddedInApp(id = "unmatched")) + coEvery { inAppProcessingManager.matchesTargeting(match { it.id == "unmatched" }, any()) } returns false + + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(match { it.id == "matched" }) } + verify(exactly = 0) { inAppProcessingManager.sendTargetedInApp(match { it.id == "unmatched" }) } + } + + @Test + fun `selectInAppForPlace ships the collected failures only when the place stays empty`() = runTest { + givenConfig(embeddedInApp()) + coEvery { inAppProcessingManager.matchesTargeting(any(), any()) } returns false + + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + verify(exactly = 1) { inAppFailureTracker.sendCollectedFailures() } + verify(exactly = 0) { inAppFailureTracker.clearFailures() } + } + + @Test + fun `selectInAppForPlace drops the pass failures once somebody won the place`() = runTest { + // Parity with the overlay pass: a winner — even one the limits then hold back — answers + // "why nothing was shown", so the buffer of the pass is discarded, not sent. + givenConfig(embeddedInApp()) + every { maxInappsPerSessionLimitChecker.check() } returns false + + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + verify(exactly = 1) { inAppFailureTracker.clearFailures() } + verify(exactly = 0) { inAppFailureTracker.sendCollectedFailures() } + } + + @Test + fun `selectInAppForPlace never evaluates a directCall candidate`() = runTest { + givenConfig(embeddedInApp(id = "direct").copy(displayConditions = DisplayConditions.DIRECT_CALL)) + + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + coVerify(exactly = 0) { inAppProcessingManager.matchesTargeting(any(), any()) } + verify(exactly = 0) { inAppProcessingManager.sendTargetedInApp(any()) } + } + + @Test + fun `filterShowableInAppIds sends targeting for the same id again for a different host`() = runTest { + // The pair is host + id: the same in-app proposed by two different hosts is two offers. + givenConfig(modalInApp(id = "inapp-1")) + + interactor.filterShowableInAppIds("host-form", listOf("inapp-1")) + interactor.filterShowableInAppIds("other-host", listOf("inapp-1")) + + verify(exactly = 2) { inAppProcessingManager.sendTargetedInApp(any()) } + } + @Test fun `place resolve with an operation trigger dedups with the pull`() = runTest { givenConfig(embeddedInApp()) - val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("story-operation")) + val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("block-operation")) interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) interactor.selectInAppForPlace(place, triggerEvent = operation) @@ -359,16 +506,16 @@ class EmbeddedResolveInteractorTest { @Test fun `selectInAppForPlace passes the push trigger to the selection`() = runTest { givenConfig(embeddedInApp()) - val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("story-operation")) + val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("block-operation")) interactor.selectInAppForPlace(place, triggerEvent = operation) - coVerify { inAppProcessingManager.chooseInAppToShow(any(), operation, any()) } + coVerify { inAppProcessingManager.matchesTargeting(any(), operation) } } @Test fun `getInAppToShowById ignores every restriction`() = runTest { - // directCall, already shown, whatever frequency — a drawn circle must open. + // directCall, already shown, whatever frequency — a drawn element must open. givenConfig( modalInApp(id = "restricted").copy(displayConditions = DisplayConditions.DIRECT_CALL) ) @@ -457,51 +604,51 @@ class EmbeddedResolveInteractorTest { @Test fun `filterShowableInAppIds picks the first duplicate id like the direct call does`() = runTest { - // Two in-apps share an id across sdkVersion ranges: the circle decision and the tap + // Two in-apps share an id across sdkVersion ranges: the dictionary decision and the tap // must talk about the same in-app — the first one. givenConfig(modalInApp(id = "dup"), modalInApp(id = "dup")) - assertEquals(listOf("dup"), interactor.filterShowableInAppIds(listOf("dup"))) + assertEquals(listOf("dup"), interactor.filterShowableInAppIds("host-form", listOf("dup"))) } @Test fun `filterShowableInAppIds keeps all valid ids`() = runTest { - givenConfig(modalInApp(id = "story-1"), modalInApp(id = "story-2")) + givenConfig(modalInApp(id = "inapp-1"), modalInApp(id = "inapp-2")) assertEquals( - listOf("story-1", "story-2"), - interactor.filterShowableInAppIds(listOf("story-1", "story-2")) + listOf("inapp-1", "inapp-2"), + interactor.filterShowableInAppIds("host-form", listOf("inapp-1", "inapp-2")) ) } @Test fun `filterShowableInAppIds cuts unknown id`() = runTest { - givenConfig(modalInApp(id = "story-1")) + givenConfig(modalInApp(id = "inapp-1")) - assertEquals(emptyList(), interactor.filterShowableInAppIds(listOf("ghost"))) + assertEquals(emptyList(), interactor.filterShowableInAppIds("host-form", listOf("ghost"))) } @Test fun `filterShowableInAppIds cuts id with unmatched targeting`() = runTest { - // An operation node never matches the feed answer — no operation is happening. + // An operation node never matches the dictionary answer — no operation is happening. givenConfig( - modalInApp(id = "story-1").copy(targeting = InAppStub.getTargetingOperationNode()) + modalInApp(id = "inapp-1").copy(targeting = InAppStub.getTargetingOperationNode()) ) - assertEquals(emptyList(), interactor.filterShowableInAppIds(listOf("story-1"))) + assertEquals(emptyList(), interactor.filterShowableInAppIds("host-form", listOf("inapp-1"))) } @Test fun `filterShowableInAppIds fetches the targeting dependencies before checking`() = runTest { - // A segment-targeted story is answerable only from fetched data. The feed question is - // the only path that ever evaluates a directCall story's targeting, so it has to fetch + // A segment-targeted in-app is answerable only from fetched data. The dictionary question is + // the only path that ever evaluates a directCall in-app's targeting, so it has to fetch // for itself — the session status and the repository mutexes keep it one network trip. val targeting = mockk() coEvery { targeting.fetchTargetingInfo(any()) } just runs every { targeting.checkTargeting(any()) } returns true - givenConfig(modalInApp(id = "story-1").copy(targeting = targeting)) + givenConfig(modalInApp(id = "inapp-1").copy(targeting = targeting)) - assertEquals(listOf("story-1"), interactor.filterShowableInAppIds(listOf("story-1"))) + assertEquals(listOf("inapp-1"), interactor.filterShowableInAppIds("host-form", listOf("inapp-1"))) coVerify(exactly = 1) { targeting.fetchTargetingInfo(any()) } } @@ -511,49 +658,50 @@ class EmbeddedResolveInteractorTest { // is never "allowed". val targeting = mockk() coEvery { targeting.fetchTargetingInfo(any()) } throws RuntimeException("offline") - givenConfig(modalInApp(id = "story-1").copy(targeting = targeting)) + givenConfig(modalInApp(id = "inapp-1").copy(targeting = targeting)) - assertEquals(emptyList(), interactor.filterShowableInAppIds(listOf("story-1"))) + assertEquals(emptyList(), interactor.filterShowableInAppIds("host-form", listOf("inapp-1"))) verify(exactly = 0) { targeting.checkTargeting(any()) } } @Test fun `filterShowableInAppIds cuts an id its frequency already blocks`() = runTest { // The frequency rule is the same on every selection path: the stub frequency is - // once/lifetime, and a recorded show exhausts it — the circle is not proposed. - givenConfig(modalInApp(id = "story-1")) - every { inAppRepository.getShownInApps() } returns mapOf("story-1" to listOf(1L)) + // once/lifetime, and a recorded show exhausts it — the id is not proposed. + givenConfig(modalInApp(id = "inapp-1")) + every { inAppRepository.getShownInApps() } returns mapOf("inapp-1" to listOf(1L)) - assertEquals(emptyList(), interactor.filterShowableInAppIds(listOf("story-1"))) - verify(exactly = 0) { inAppProcessingManager.sendTargetedInApp(any()) } + assertEquals(emptyList(), interactor.filterShowableInAppIds("host-form", listOf("inapp-1"))) + // The exhausted frequency holds the id out of the answer, not out of the funnel. + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(any()) } } @Test fun `filterShowableInAppIds keeps an unlimited id regardless of show history`() = runTest { givenConfig( - modalInApp(id = "story-1").copy(frequency = Frequency(Frequency.Delay.Unlimited)) + modalInApp(id = "inapp-1").copy(frequency = Frequency(Frequency.Delay.Unlimited)) ) - every { inAppRepository.getShownInApps() } returns mapOf("story-1" to listOf(1L)) + every { inAppRepository.getShownInApps() } returns mapOf("inapp-1" to listOf(1L)) - assertEquals(listOf("story-1"), interactor.filterShowableInAppIds(listOf("story-1"))) + assertEquals(listOf("inapp-1"), interactor.filterShowableInAppIds("host-form", listOf("inapp-1"))) } @Test fun `filterShowableInAppIds does not check the show limits`() = runTest { - // The limits belong to the overlay show; the feed shows nothing itself. - givenConfig(modalInApp(id = "story-1")) + // The limits belong to the overlay show; the dictionary shows nothing itself. + givenConfig(modalInApp(id = "inapp-1")) every { maxInappsPerSessionLimitChecker.check() } returns false - assertEquals(listOf("story-1"), interactor.filterShowableInAppIds(listOf("story-1"))) + assertEquals(listOf("inapp-1"), interactor.filterShowableInAppIds("host-form", listOf("inapp-1"))) } @Test fun `filterShowableInAppIds cuts id of embedded in-app`() = runTest { - givenConfig(embeddedInApp(id = "feed-itself"), modalInApp(id = "story-1")) + givenConfig(embeddedInApp(id = "embedded-itself"), modalInApp(id = "inapp-1")) assertEquals( - listOf("story-1"), - interactor.filterShowableInAppIds(listOf("feed-itself", "story-1")) + listOf("inapp-1"), + interactor.filterShowableInAppIds("host-form", listOf("embedded-itself", "inapp-1")) ) } @@ -564,50 +712,64 @@ class EmbeddedResolveInteractorTest { assertEquals( listOf("in-pool"), - interactor.filterShowableInAppIds(listOf("in-pool", "out-of-pool")) + interactor.filterShowableInAppIds("host-form", listOf("in-pool", "out-of-pool")) ) } @Test fun `filterShowableInAppIds does not check directCall`() = runTest { - // directCall is the standard marker of a story — checking it would empty the feed. + // directCall is the standard marker of a dictionary-drawn in-app — checking it would empty the answer. givenConfig( - modalInApp(id = "story-1").copy(displayConditions = DisplayConditions.DIRECT_CALL) + modalInApp(id = "inapp-1").copy(displayConditions = DisplayConditions.DIRECT_CALL) ) - assertEquals(listOf("story-1"), interactor.filterShowableInAppIds(listOf("story-1"))) + assertEquals(listOf("inapp-1"), interactor.filterShowableInAppIds("host-form", listOf("inapp-1"))) } @Test - fun `filterShowableInAppIds sends targeting for every allowed id`() = runTest { - givenConfig(modalInApp(id = "story-1"), modalInApp(id = "story-2"), modalInApp(id = "cut")) - coEvery { inAppABTestLogic.getInAppsPool(any()) } returns setOf("story-1", "story-2") + fun `filterShowableInAppIds sends targeting for every asked id that matches, the ab-cut included`() = runTest { + // The answer is cut by the pool; the offer is not — the cut branch keeps its denominator. + givenConfig(modalInApp(id = "inapp-1"), modalInApp(id = "inapp-2"), modalInApp(id = "cut")) + coEvery { inAppABTestLogic.getInAppsPool(any()) } returns setOf("inapp-1", "inapp-2") - interactor.filterShowableInAppIds(listOf("story-1", "story-2", "cut")) + val answer = interactor.filterShowableInAppIds("host-form", listOf("inapp-1", "inapp-2", "cut")) - verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(match { it.id == "story-1" }) } - verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(match { it.id == "story-2" }) } - verify(exactly = 2) { inAppProcessingManager.sendTargetedInApp(any()) } + assertEquals(listOf("inapp-1", "inapp-2"), answer) + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(match { it.id == "inapp-1" }) } + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(match { it.id == "inapp-2" }) } + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(match { it.id == "cut" }) } + verify(exactly = 3) { inAppProcessingManager.sendTargetedInApp(any()) } } @Test - fun `filterShowableInAppIds sends targeting again on every answer`() = runTest { - // The funnel counts the proposed circles: no dedup, every answer sends again. - givenConfig(modalInApp(id = "story-1")) + fun `filterShowableInAppIds sends targeting once per host and id pair a session`() = runTest { + // A repeated answer offers nothing new: one Inapp.Targeting per host|id pair. + givenConfig(modalInApp(id = "inapp-1")) - interactor.filterShowableInAppIds(listOf("story-1")) - interactor.filterShowableInAppIds(listOf("story-1")) + interactor.filterShowableInAppIds("host-form", listOf("inapp-1")) + interactor.filterShowableInAppIds("host-form", listOf("inapp-1")) - verify(exactly = 2) { inAppProcessingManager.sendTargetedInApp(any()) } + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(any()) } + } + + @Test + fun `filterShowableInAppIds sends one targeting for a duplicated id and mirrors the duplicate`() = runTest { + // The answer mirrors the request — the page owns its list's shape; the event is one. + givenConfig(modalInApp(id = "inapp-1")) + + val answer = interactor.filterShowableInAppIds("host-form", listOf("inapp-1", "inapp-1")) + + assertEquals(listOf("inapp-1", "inapp-1"), answer) + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(any()) } } @Test fun `filterShowableInAppIds sends no targeting for a cut id`() = runTest { givenConfig( - modalInApp(id = "story-1").copy(targeting = InAppStub.getTargetingOperationNode()) + modalInApp(id = "inapp-1").copy(targeting = InAppStub.getTargetingOperationNode()) ) - interactor.filterShowableInAppIds(listOf("story-1", "ghost")) + interactor.filterShowableInAppIds("host-form", listOf("inapp-1", "ghost")) verify(exactly = 0) { inAppProcessingManager.sendTargetedInApp(any()) } } @@ -660,54 +822,58 @@ class EmbeddedResolveInteractorTest { } @Test - fun `recordBlockShow sends the Inapp Show and counts the show`() = runTest { - givenConfig(embeddedInApp()) - every { sessionStorageManager.blockShowsReportedInSession } returns mutableSetOf() - - interactor.recordBlockShow("embedded-id", Milliseconds(1_500L), mapOf("a" to "b")) + fun `recordBlockShow sends the Inapp Show, counts the show and moves the cooldown`() { + interactor.recordBlockShow(place, "embedded-id", InAppStub.getInApp().frequency, Milliseconds(1_500L), mapOf("a" to "b")) verify { inAppRepository.sendInAppShown("embedded-id", "00:00:01.5000000", mapOf("a" to "b")) } verify { inAppRepository.setInAppShown("embedded-id") } + verify { inAppRepository.saveShownInApp("embedded-id", now.ms) } + // Parity in both directions: a counted block show also moves the shared cooldown. + verify { inAppRepository.saveInAppStateChangeTime(now) } } @Test - fun `recordBlockShow sends the Inapp Show once per session`() = runTest { - // A rotation or a return to the screen draws the same content again — one show per session. - givenConfig(embeddedInApp()) - every { sessionStorageManager.blockShowsReportedInSession } returns mutableSetOf() - - interactor.recordBlockShow("embedded-id", Milliseconds(1_000L), null) - interactor.recordBlockShow("embedded-id", Milliseconds(1_000L), null) + fun `recordBlockShow stays silent while the place slot holds the same content`() { + // A rotation or a recreated page draws the same content again — no second pair, no + // second count. A changed in-app writes the slot over and speaks again. + interactor.recordBlockShow(place, "embedded-id", InAppStub.getInApp().frequency, Milliseconds(1_000L), null) + interactor.recordBlockShow(place, "embedded-id", InAppStub.getInApp().frequency, Milliseconds(1_000L), null) verify(exactly = 1) { inAppRepository.sendInAppShown(any(), any(), any()) } + verify(exactly = 1) { inAppRepository.setInAppShown(any()) } + + interactor.recordBlockShow(place, "embedded-2", InAppStub.getInApp().frequency, Milliseconds(1_000L), null) + interactor.recordBlockShow(place, "embedded-id", InAppStub.getInApp().frequency, Milliseconds(1_000L), null) + + // 1 -> 2 -> 1 is three shows: the slot compares with the last shown, not a session set. + verify(exactly = 3) { inAppRepository.sendInAppShown(any(), any(), any()) } } @Test - fun `recordBlockShow sends the Inapp Show for an unlimited block that writes no counters`() = runTest { - givenConfig(embeddedInApp().copy(frequency = Frequency(Frequency.Delay.Unlimited))) - every { sessionStorageManager.blockShowsReportedInSession } returns mutableSetOf() - - interactor.recordBlockShow("embedded-id", Milliseconds(0L), null) + fun `recordBlockShow sends the Inapp Show for an unlimited block that writes no counters`() { + interactor.recordBlockShow(place, "embedded-id", Frequency(Frequency.Delay.Unlimited), Milliseconds(0L), null) verify { inAppRepository.sendInAppShown(any(), any(), any()) } verify(exactly = 0) { inAppRepository.setInAppShown(any()) } verify(exactly = 0) { inAppRepository.saveShownInApp(any(), any()) } + verify(exactly = 0) { inAppRepository.saveInAppStateChangeTime(any()) } } @Test - fun `recordBlockShow ignores an unknown id`() = runTest { - givenConfig(embeddedInApp()) + fun `recordBlockShow needs no config - the snapshot carries everything`() { + // The config may have moved on since the resolve — the user still saw this content. + coEvery { mobileConfigRepository.getInAppsSection() } returns emptyList() - interactor.recordBlockShow("ghost", Milliseconds(0L), null) + interactor.recordBlockShow(place, "gone-from-config", InAppStub.getInApp().frequency, Milliseconds(0L), null) - verify(exactly = 0) { inAppRepository.sendInAppShown(any(), any(), any()) } + verify(exactly = 1) { inAppRepository.sendInAppShown("gone-from-config", any(), any()) } } @Test fun `live operation matched to an embedded place emits a place event`() = runTest { val embedded = embeddedInApp() givenConfig(embedded) - val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("story-operation")) + val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("block-operation")) every { inAppRepository.listenLiveInAppEvents() } returns flowOf(operation) every { inAppRepository.getOperationalInAppsByOperation(operation.name) } returns listOf(embedded) @@ -760,7 +926,7 @@ class EmbeddedResolveInteractorTest { fun `selectInAppForPlace hands out the embedded variant of a mixed form`() = runTest { givenConfig(mixedInApp()) - val content = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + val content = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place))?.variant assertEquals("mixed-id", content?.inAppId) assertEquals(place, content?.placeSystemName) @@ -770,7 +936,7 @@ class EmbeddedResolveInteractorTest { fun `filterShowableInAppIds keeps an id whose form also has an overlay variant`() = runTest { givenConfig(mixedInApp()) - val result = interactor.filterShowableInAppIds(listOf("mixed-id")) + val result = interactor.filterShowableInAppIds("host-form", listOf("mixed-id")) assertEquals(listOf("mixed-id"), result) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt index a3f3484d..43f92e87 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt @@ -110,7 +110,8 @@ class InAppInteractorImplTest { maxInappsPerDayLimitChecker, minIntervalBetweenShowsLimitChecker, timeProvider, - sessionStorageManager + sessionStorageManager, + inAppFailureTracker ) coEvery { mobileConfigRepository.getInAppsSection() } returns emptyList() @@ -199,7 +200,8 @@ class InAppInteractorImplTest { maxInappsPerDayLimitChecker, minIntervalBetweenShowsLimitChecker, timeProvider, - sessionStorageManager + sessionStorageManager, + inAppFailureTracker ) coEvery { mobileConfigRepository.getInAppsSection() } returns inAppsFromConfig diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt index b1b9b31d..28dd90e3 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt @@ -63,7 +63,10 @@ internal class InAppMessageManagerTest { private val testDispatcher = StandardTestDispatcher() - private val timeProvider = mockk() + private val timeProvider = mockk { + every { monotonicMillis() } returns Milliseconds(0L) + every { monotonicElapsedSince(any()) } returns Milliseconds(0L) + } private val featureToggleManager = mockk() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerShowNowTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerShowNowTest.kt index 8b51d5ac..65b931b2 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerShowNowTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerShowNowTest.kt @@ -234,7 +234,7 @@ internal class InAppMessageViewDisplayerShowNowTest { fun `the requested show empties the queue, and that is the decision`() { // `showInAppMessageNow` goes through `closeInApp()`, which drops whatever the pipeline had // queued behind the active show. Pinned as a conscious trade-off, not an accident: an - // in-app surfacing on top of the story the user has just opened would be worse than a + // in-app surfacing on top of the in-app the user has just opened would be worse than a // queue that was cleared when they asked for something else. givenForegroundActivity() setCurrentHolder(activeHolder(mockk(relaxUnitFun = true))) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BridgeMessagePayloadTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BridgeMessagePayloadTest.kt index 5eb1e83f..ef818d65 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BridgeMessagePayloadTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/BridgeMessagePayloadTest.kt @@ -18,17 +18,17 @@ internal class BridgeMessagePayloadTest { @Test fun `string payload passes through unchanged`() { - val message = request(""""{\"inappId\":\"story-1\"}"""") + val message = request(""""{\"inappId\":\"inapp-1\"}"""") - assertEquals("""{"inappId":"story-1"}""", message.payload) + assertEquals("""{"inappId":"inapp-1"}""", message.payload) } @Test fun `object payload is kept as its json text`() { - val message = request("""{"inappId":"story-1","params":{"a":1}}""") + val message = request("""{"inappId":"inapp-1","params":{"a":1}}""") assertEquals( - JsonParser.parseString("""{"inappId":"story-1","params":{"a":1}}"""), + JsonParser.parseString("""{"inappId":"inapp-1","params":{"a":1}}"""), JsonParser.parseString(message.payload!!) ) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorParamsTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorParamsTest.kt index fc9e0f82..7ff561b5 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorParamsTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/DataCollectorParamsTest.kt @@ -24,8 +24,8 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner /** - * AC35: a config param whose value is JSON must reach the page as a structure — the stories - * page checks `Array.isArray(stories)` and silently draws nothing for a string. Plain string + * AC35: a config param whose value is JSON must reach the page as a structure — the + * page checks `Array.isArray(items)` and silently draws nothing for a string. Plain string * params keep their type (the regression pair). */ @RunWith(RobolectricTestRunner::class) @@ -64,14 +64,14 @@ class DataCollectorParamsTest { } @Test - fun `stories value that is a json array is sent as an array`() { - val payload = collect(mapOf("stories" to """[{"inAppId":"story-1"},{"inAppId":"story-2"}]""")) + fun `items value that is a json array is sent as an array`() { + val payload = collect(mapOf("items" to """[{"inAppId":"inapp-1"},{"inAppId":"inapp-2"}]""")) - assertTrue(payload.get("stories").isJsonArray) - assertEquals(2, payload.getAsJsonArray("stories").size()) + assertTrue(payload.get("items").isJsonArray) + assertEquals(2, payload.getAsJsonArray("items").size()) assertEquals( - "story-1", - payload.getAsJsonArray("stories").get(0).asJsonObject.get("inAppId").asString + "inapp-1", + payload.getAsJsonArray("items").get(0).asJsonObject.get("inAppId").asString ) } @@ -189,7 +189,7 @@ class DataCollectorParamsTest { @Test fun `extra param values keep their json types`() { - val record = JsonParser.parseString("""{"title":"Сториз 1","rank":3}""") + val record = JsonParser.parseString("""{"title":"Заголовок 1","rank":3}""") val payload = collect( params = emptyMap(), extraParams = mapOf( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt index b2371cbc..c77b33bf 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/MotionServiceBehaviorTest.kt @@ -29,6 +29,11 @@ private class FakeTimeProvider(private var nowMs: Long = 0L) : TimeProvider { override fun elapsedSince(startTimeMillis: Timestamp): Milliseconds = Milliseconds(nowMs - startTimeMillis.ms) + override fun monotonicMillis(): Milliseconds = Milliseconds(nowMs) + + override fun monotonicElapsedSince(startTick: Milliseconds): Milliseconds = + Milliseconds(nowMs - startTick.interval) + fun advanceBy(ms: Long) { nowMs += ms } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewCommonBridgeActionsTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewCommonBridgeActionsTest.kt index 0cab2f2f..65472323 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewCommonBridgeActionsTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewCommonBridgeActionsTest.kt @@ -164,7 +164,7 @@ class WebViewCommonBridgeActionsTest { } // The author already holds the answer; everyone else learns without being asked — this is - // how a feed greys a ring while the story that wrote it is still on top. + // how a page dims an element while the in-app that wrote it is still on top. verify { webPageRegistry.broadcast(WebViewAction.LOCAL_STATE_CHANGED, answer, excludingAuthor = host.hostPage) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt index 7efb3fb2..e37ad9d2 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/InAppStub.kt @@ -315,16 +315,18 @@ internal class InAppStub { ) fun getEmbeddedWebViewLayer() = Layer.WebViewLayer( - baseUrl = "https://feed.local/base", - contentUrl = "https://feed.local/stories.html", + baseUrl = "https://blocks.local/base", + contentUrl = "https://blocks.local/items.html", type = "webview", - params = mapOf("stories" to "[{\"inAppId\":\"story-1\"}]") + params = mapOf("items" to "[{\"inAppId\":\"inapp-1\"}]") ) fun getEmbedded() = InAppType.Embedded( inAppId = "embedded-id", placeSystemName = "main-screen-top", - layers = listOf(getEmbeddedWebViewLayer()) + layers = listOf(getEmbeddedWebViewLayer()), + frequency = Frequency(Frequency.Delay.Unlimited), + tags = null, ) fun getEmbeddedDto() = PayloadDto.EmbeddedDto( @@ -332,10 +334,10 @@ internal class InAppStub { background = BackgroundDto( layers = listOf( BackgroundDto.LayerDto.WebViewLayerDto( - baseUrl = "https://feed.local/base", - contentUrl = "https://feed.local/stories.html", + baseUrl = "https://blocks.local/base", + contentUrl = "https://blocks.local/items.html", type = "webview", - params = mapOf("stories" to "[]") + params = mapOf("items" to "[]") ) ) )