From 520b50bd1b63196fe3f8d0842a8f26c7e569f63d Mon Sep 17 00:00:00 2001 From: sozinov Date: Wed, 7 Oct 2026 15:51:09 +0300 Subject: [PATCH 1/6] MOBILE-458: Fetch segmentations and geo before a page asks about its in-apps The stories page waits 3 s for filterShowableInapps, and the segmentations of its circles were fetched lazily on that question, so a slow CheckCustomerSegments made the page give up and the whole ribbon collapse. Like iOS, the place resolve now fetches the session's segmentations and geo in parallel before handing the content over (only when the place has candidates), and a web-page overlay does the same after APP_STARTED is released. The page is answered from the session cache. - Segmentation and geo repositories cache a failed fetch for the session, so the page path no longer re-hits the network for every circle and question. - The page's question goes through matchesRequestedTargeting, the place check without failure reporting. --- .../cloud/mindbox/mobile_sdk/Extensions.kt | 10 ++ .../repositories/InAppGeoRepositoryImpl.kt | 15 +- .../InAppSegmentationRepositoryImpl.kt | 21 ++- .../inapp/domain/InAppInteractorImpl.kt | 18 +-- .../domain/InAppProcessingManagerImpl.kt | 99 +++++++++----- .../managers/InAppProcessingManager.kt | 4 + .../repositories/InAppGeoRepositoryTest.kt | 14 ++ .../InAppSegmentationRepositoryTest.kt | 16 +++ .../domain/EmbeddedResolveInteractorTest.kt | 65 ++++----- .../inapp/domain/InAppInteractorImplTest.kt | 51 +++++++ .../domain/InAppProcessingManagerTest.kt | 128 ++++++++++++++++++ 11 files changed, 348 insertions(+), 93 deletions(-) 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 7d090e039..9b96f928a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt @@ -308,6 +308,16 @@ internal fun List.sortByPriority(): List { internal fun InApp.firstOverlayVariant(): InAppType? = form.variants.firstOrNull { variant -> variant !is InAppType.Embedded } +internal fun InAppType.hostsWebPage(): Boolean { + val layers = when (this) { + is InAppType.WebView -> layers + is InAppType.ModalWindow -> layers + is InAppType.Snackbar -> layers + is InAppType.Embedded -> layers + } + return layers.any { layer -> layer is Layer.WebViewLayer } +} + /** * Whether shows of this in-app are written down: the show history, the session list and the * cooldown all exist to hold shows back, and an `unlimited` in-app is outside that accounting in diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryImpl.kt index 928618816..a122fbbea 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryImpl.kt @@ -5,6 +5,7 @@ import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.GeoSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository +import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoError import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoFetchStatus import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoTargeting import cloud.mindbox.mobile_sdk.managers.DbManager @@ -26,15 +27,17 @@ internal class InAppGeoRepositoryImpl( private val geoMutex = Mutex() override suspend fun fetchGeo() = geoMutex.withLock { - if (sessionStorageManager.state.geoFetchStatus == GeoFetchStatus.GEO_FETCH_SUCCESS) { + if (sessionStorageManager.state.geoFetchStatus != GeoFetchStatus.GEO_NOT_FETCHED) { return@withLock } val configuration = DbManager.listenConfigurations().first() - val geoTargeting = inAppMapper.mapGeoTargetingDtoToGeoTargeting( - geoTargetingDto = gatewayManager.checkGeoTargeting( - configuration = configuration - ) - ) + val geoTargetingDto = try { + gatewayManager.checkGeoTargeting(configuration = configuration) + } catch (error: GeoError) { + sessionStorageManager.state.geoFetchStatus = GeoFetchStatus.GEO_FETCH_ERROR + throw error + } + val geoTargeting = inAppMapper.mapGeoTargetingDtoToGeoTargeting(geoTargetingDto = geoTargetingDto) MindboxPreferences.inAppGeo = geoSerializationManager.serializeToGeoString(geoTargeting) sessionStorageManager.state.geoFetchStatus = GeoFetchStatus.GEO_FETCH_SUCCESS diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt index e0d68abcc..bf7d13f26 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.data.repositories import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository +import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationError import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationFetchStatus import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationInApp import cloud.mindbox.mobile_sdk.inapp.domain.models.ProductSegmentationFetchStatus @@ -24,8 +25,8 @@ internal class InAppSegmentationRepositoryImpl( private val customerSegmentationsMutex = Mutex() override suspend fun fetchCustomerSegmentations() = customerSegmentationsMutex.withLock { - if (sessionStorageManager.state.customerSegmentationFetchStatus == - CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + if (sessionStorageManager.state.customerSegmentationFetchStatus != + CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED ) { return@withLock } @@ -43,12 +44,18 @@ internal class InAppSegmentationRepositoryImpl( "Request segmentations" ) val configuration = DbManager.listenConfigurations().first() - val response = gatewayManager.checkCustomerSegmentations( - configuration = configuration, - segmentationCheckRequest = inAppMapper.mapToCustomerSegmentationCheckRequest( - sessionStorageManager.state.currentSessionInApps + val response = try { + gatewayManager.checkCustomerSegmentations( + configuration = configuration, + segmentationCheckRequest = inAppMapper.mapToCustomerSegmentationCheckRequest( + sessionStorageManager.state.currentSessionInApps + ) ) - ) + } catch (error: CustomerSegmentationError) { + sessionStorageManager.state.customerSegmentationFetchStatus = + CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + throw error + } sessionStorageManager.state.inAppCustomerSegmentations = inAppMapper.mapToSegmentationCheck(response) sessionStorageManager.state.customerSegmentationFetchStatus = 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 c72993fe1..a02aba47a 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 @@ -22,7 +22,6 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfi 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.inapp.domain.models.TargetingDataWrapper import cloud.mindbox.mobile_sdk.logger.MindboxLog import cloud.mindbox.mobile_sdk.millisToTimeSpan import cloud.mindbox.mobile_sdk.models.Milliseconds @@ -33,6 +32,7 @@ import cloud.mindbox.mobile_sdk.models.PlaceKey import cloud.mindbox.mobile_sdk.models.toTimestamp import cloud.mindbox.mobile_sdk.countsShows import cloud.mindbox.mobile_sdk.firstOverlayVariant +import cloud.mindbox.mobile_sdk.hostsWebPage import cloud.mindbox.mobile_sdk.sortByPriority import cloud.mindbox.mobile_sdk.utils.TimeProvider import kotlinx.coroutines.channels.Channel @@ -54,9 +54,6 @@ internal class InAppInteractorImpl( private val inAppTargetingChannel = Channel(Channel.UNLIMITED) - private val placeRequestTargetingData = - TargetingDataWrapper(InAppEventType.EmbeddedPlaceRequested.EVENT_NAME) - override suspend fun processEventAndConfig(): Flow> { val inApps: List = mobileConfigRepository.getInAppsSection() .let { inApps -> @@ -113,6 +110,9 @@ internal class InAppInteractorImpl( inApp?.let { sessionStorageManager.state.inAppTriggerEvent = event } + if (inApp?.firstOverlayVariant()?.hostsWebPage() == true) { + inAppProcessingManager.prefetchTargetingDependencies(inAppRepository.getCurrentSessionInApps()) + } inApp?.let { inapp -> inapp to timeProvider.elapsedSince(triggerTimeMillis) } } .onEach { pair -> @@ -134,6 +134,7 @@ internal class InAppInteractorImpl( val trigger = placeTrigger(placeSystemName, triggerEvent) val candidates = inAppFilteringManager.filterEmbeddedInAppsByPlace(inApps, placeSystemName) .let { inAppFilteringManager.filterOutDirectCallInApps(it) } + if (candidates.isNotEmpty()) inAppProcessingManager.prefetchTargetingDependencies(inApps) val matched = candidates.filter { candidate -> inAppProcessingManager.matchesTargeting(candidate, trigger) } @@ -302,14 +303,7 @@ internal class InAppInteractorImpl( } 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 - } + inAppProcessingManager.matchesRequestedTargeting(inApp) .also { matches -> if (!matches) logI("Requested id ${inApp.id} targeting did not match, cutting it") } override fun reservePlaceShow(placeSystemName: PlaceKey, content: InAppType.Embedded): Boolean { 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 f82b5122f..181672e9b 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 @@ -39,6 +39,9 @@ internal class InAppProcessingManagerImpl( "CheckCustomerSegments requires customer" } + private val requestedTargetingData = + TargetingDataWrapper(InAppEventType.EmbeddedPlaceRequested.EVENT_NAME) + private fun isTagsFeatureEnabled(): Boolean = featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) @@ -210,8 +213,13 @@ internal class InAppProcessingManagerImpl( ) } - override suspend fun matchesTargeting(inApp: InApp, triggerEvent: InAppEventType): Boolean { - val data = getTargetingData(triggerEvent) + override suspend fun matchesTargeting(inApp: InApp, triggerEvent: InAppEventType): Boolean = + matchesTargeting(inApp, getTargetingData(triggerEvent), collectsFailures = true) + + override suspend fun matchesRequestedTargeting(inApp: InApp): Boolean = + matchesTargeting(inApp, requestedTargetingData, collectsFailures = false) + + private suspend fun matchesTargeting(inApp: InApp, data: TargetingData, collectsFailures: Boolean): Boolean { val tags = inApp.gatedTags(isTagsFeatureEnabled()) var isTargetingErrorOccurred = false var targetingCheck = false @@ -219,35 +227,12 @@ internal class InAppProcessingManagerImpl( 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) + if (throwable is CancellationException) throw throwable + if (handleTargetingFetchError(throwable)) { + isTargetingErrorOccurred = true + } else { + mindboxLogE(throwable.message ?: "", throwable) + if (collectsFailures) { inAppFailureTracker.sendFailure( inAppId = inApp.id, failureReason = FailureReason.UNKNOWN_ERROR, @@ -257,11 +242,59 @@ internal class InAppProcessingManagerImpl( } } } - if (isTargetingErrorOccurred) return matchesTargeting(inApp, triggerEvent) - trackTargetingErrorIfAny(inApp, data, tags) + if (isTargetingErrorOccurred) return matchesTargeting(inApp, data, collectsFailures) + if (collectsFailures) trackTargetingErrorIfAny(inApp, data, tags) return targetingCheck } + override suspend fun prefetchTargetingDependencies(inApps: List) { + val fetchesSegmentations = inAppSegmentationRepository.getCustomerSegmentationFetched() == + CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED && + inApps.any { inApp -> inApp.targeting.hasSegmentationNode() } + val fetchesGeo = inAppGeoRepository.getGeoFetchedStatus() == GeoFetchStatus.GEO_NOT_FETCHED && + inApps.any { inApp -> inApp.targeting.hasGeoNode() } + if (!fetchesSegmentations && !fetchesGeo) return + mindboxLogI("Prefetching targeting dependencies: segmentations=$fetchesSegmentations, geo=$fetchesGeo") + coroutineScope { + if (fetchesSegmentations) launch { fetchTargetingDependency { inAppSegmentationRepository.fetchCustomerSegmentations() } } + if (fetchesGeo) launch { fetchTargetingDependency { inAppGeoRepository.fetchGeo() } } + } + } + + private fun handleTargetingFetchError(error: Throwable): Boolean = when (error) { + is GeoError -> { + inAppGeoRepository.setGeoStatus(GeoFetchStatus.GEO_FETCH_ERROR) + if (error.shouldTrackTargetingError()) { + inAppTargetingErrorRepository.saveError(key = TargetingErrorKey.Geo, error = error) + } + mindboxLogE("Error fetching geo", error) + true + } + + is CustomerSegmentationError -> { + inAppSegmentationRepository.setCustomerSegmentationStatus( + CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + ) + if (error.shouldTrackTargetingError()) { + inAppTargetingErrorRepository.saveError(key = TargetingErrorKey.CustomerSegmentation, error = error) + } + handleCustomerSegmentationErrorLog(error) + true + } + + else -> false + } + + private suspend fun fetchTargetingDependency(fetch: suspend () -> Unit) { + try { + fetch() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + if (!handleTargetingFetchError(error)) mindboxLogE("Error prefetching targeting dependencies", error) + } + } + 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/managers/InAppProcessingManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppProcessingManager.kt index 0e1dcda31..7d21cd5a0 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 @@ -22,4 +22,8 @@ internal interface InAppProcessingManager { fun sendTargetedInApp(inApp: InApp) suspend fun matchesTargeting(inApp: InApp, triggerEvent: InAppEventType): Boolean + + suspend fun matchesRequestedTargeting(inApp: InApp): Boolean + + suspend fun prefetchTargetingDependencies(inApps: List) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt index 3beadf4b1..a41853c50 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt @@ -5,6 +5,7 @@ import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionState import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.GeoSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoError import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoFetchStatus import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager @@ -22,6 +23,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -204,4 +206,16 @@ internal class InAppGeoRepositoryTest { every { sessionStorageManager.state } throws Error() assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, inAppGeoRepository.getGeoFetchedStatus()) } + + @Test + fun `failed geo fetch is cached for the session`() = runTest { + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkGeoTargeting(configuration = configuration) } throws GeoError(VolleyError("timeout")) + + assertTrue(runCatching { inAppGeoRepository.fetchGeo() }.exceptionOrNull() is GeoError) + inAppGeoRepository.fetchGeo() + + assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionState.geoFetchStatus) + coVerify(exactly = 1) { gatewayManager.checkGeoTargeting(any()) } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt index b248bd52f..9aea9d4be 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.data.repositories import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionState import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper +import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationError import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationFetchStatus import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationInApp import cloud.mindbox.mobile_sdk.inapp.domain.models.ProductSegmentationFetchStatus @@ -256,4 +257,19 @@ class InAppSegmentationRepositoryTest { val actualResult = inAppSegmentationRepository.getCustomerSegmentations() assertEquals(expectedResult, actualResult) } + + @Test + fun `failed customer segmentations fetch is cached for the session`() = runTest { + sessionStorageManager.state.currentSessionInApps = mutableListOf(InAppStub.getInApp()) + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { + gatewayManager.checkCustomerSegmentations(any(), any()) + } throws CustomerSegmentationError(VolleyError("timeout")) + + assertTrue(runCatching { inAppSegmentationRepository.fetchCustomerSegmentations() }.exceptionOrNull() is CustomerSegmentationError) + inAppSegmentationRepository.fetchCustomerSegmentations() + + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionStorageManager.state.customerSegmentationFetchStatus) + coVerify(exactly = 1) { gatewayManager.checkCustomerSegmentations(any(), any()) } + } } 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 b102b0962..81cd3b7a5 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 @@ -20,7 +20,6 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.DisplayConditions import cloud.mindbox.mobile_sdk.inapp.domain.models.Form 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.TreeTargeting import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.models.EventType import cloud.mindbox.mobile_sdk.models.InAppEventType @@ -31,12 +30,12 @@ import cloud.mindbox.mobile_sdk.utils.TimeProvider import io.mockk.Called import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.RelaxedMockK import io.mockk.junit4.MockKRule import io.mockk.just -import io.mockk.mockk import io.mockk.mockkObject import io.mockk.runs import io.mockk.unmockkObject @@ -120,6 +119,8 @@ class EmbeddedResolveInteractorTest { every { inAppProcessingManager.sendTargetedInApp(any()) } just runs coEvery { inAppProcessingManager.sendTargetedInApp(any(), any()) } just runs coEvery { inAppProcessingManager.matchesTargeting(any(), any()) } returns true + coEvery { inAppProcessingManager.prefetchTargetingDependencies(any()) } just runs + coEvery { inAppProcessingManager.matchesRequestedTargeting(any()) } returns true every { showBudgetManager.reserve(any(), any(), any(), any()) } returns ShowReservationOutcome.GRANTED every { showBudgetManager.commit(any(), any(), any(), any()) } just runs every { showBudgetManager.release(any()) } just runs @@ -686,38 +687,10 @@ class EmbeddedResolveInteractorTest { @Test fun `filterShowableInAppIds cuts id with unmatched targeting`() = runTest { - // An operation node never matches the dictionary answer — no operation is happening. - givenConfig( - modalInApp(id = "inapp-1").copy(targeting = InAppStub.getTargetingOperationNode()) - ) - - assertEquals(emptyList(), interactor.filterShowableInAppIds("host-form", listOf("inapp-1"))) - } - - @Test - fun `filterShowableInAppIds fetches the targeting dependencies before checking`() = runTest { - // 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 = "inapp-1").copy(targeting = targeting)) - - assertEquals(listOf("inapp-1"), interactor.filterShowableInAppIds("host-form", listOf("inapp-1"))) - coVerify(exactly = 1) { targeting.fetchTargetingInfo(any()) } - } - - @Test - fun `filterShowableInAppIds cuts the id whose dependencies could not be fetched`() = runTest { - // Fail closed: a fetch that failed leaves the targeting unverifiable, and unverified - // is never "allowed". - val targeting = mockk() - coEvery { targeting.fetchTargetingInfo(any()) } throws RuntimeException("offline") - givenConfig(modalInApp(id = "inapp-1").copy(targeting = targeting)) + givenConfig(modalInApp(id = "inapp-1")) + coEvery { inAppProcessingManager.matchesRequestedTargeting(match { it.id == "inapp-1" }) } returns false assertEquals(emptyList(), interactor.filterShowableInAppIds("host-form", listOf("inapp-1"))) - verify(exactly = 0) { targeting.checkTargeting(any()) } } @Test @@ -821,9 +794,8 @@ class EmbeddedResolveInteractorTest { @Test fun `filterShowableInAppIds sends no targeting for a cut id`() = runTest { - givenConfig( - modalInApp(id = "inapp-1").copy(targeting = InAppStub.getTargetingOperationNode()) - ) + givenConfig(modalInApp(id = "inapp-1")) + coEvery { inAppProcessingManager.matchesRequestedTargeting(match { it.id == "inapp-1" }) } returns false interactor.filterShowableInAppIds("host-form", listOf("inapp-1", "ghost")) @@ -1097,5 +1069,28 @@ class EmbeddedResolveInteractorTest { private fun EmbeddedResolveOutcome.contentOrNull(): EmbeddedResolveOutcome.Content? = this as? EmbeddedResolveOutcome.Content + @Test + fun `selectInAppForPlace fetches the whole config's targeting dependencies before matching the place`() = runTest { + val config = listOf(embeddedInApp(), modalInApp(id = "story-1")) + givenConfig(*config.toTypedArray()) + + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + coVerifyOrder { + inAppRepository.saveCurrentSessionInApps(config) + inAppProcessingManager.prefetchTargetingDependencies(config) + inAppProcessingManager.matchesTargeting(match { it.id == "embedded-id" }, any()) + } + } + + @Test + fun `selectInAppForPlace fetches no targeting dependencies for a place without candidates`() = runTest { + givenConfig(embeddedInApp(), modalInApp()) + + interactor.selectInAppForPlace(PlaceKey.of("no-such-place"), InAppEventType.EmbeddedPlaceRequested(PlaceKey.of("no-such-place"))) + + coVerify(exactly = 0) { inAppProcessingManager.prefetchTargetingDependencies(any()) } + } + private fun EmbeddedResolveOutcome.variantOrNull(): InAppType.Embedded? = contentOrNull()?.variant } 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 43fbdcb41..a922f37a3 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 @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain import app.cash.turbine.test +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.InAppContentFetcher @@ -17,6 +18,8 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppReposi import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppTargetingErrorRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfigRepository +import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.InAppStub @@ -274,4 +277,52 @@ class InAppInteractorImplTest { cancelAndIgnoreRemainingEvents() } } + + private fun givenEventWinner(variant: InAppType): InApp { + val winner = InAppStub.getInApp().copy( + id = "winner", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy(variants = listOf(variant)) + ) + coEvery { mobileConfigRepository.getInAppsSection() } returns listOf(winner) + every { inAppFilteringManager.filterUnShownInAppsByEvent(any(), any()) } returns listOf(winner) + every { inAppFilteringManager.filterOutNonOverlayInApps(any()) } answers { firstArg() } + every { inAppFilteringManager.filterOutDirectCallInApps(any()) } answers { firstArg() } + coEvery { inAppFrequencyManager.filterInAppsFrequency(any()) } answers { firstArg() } + coEvery { inAppProcessingManager.chooseInAppToShow(any(), any(), any()) } returns winner + coEvery { inAppProcessingManager.sendTargetedInApp(any(), any()) } just runs + coEvery { inAppProcessingManager.prefetchTargetingDependencies(any()) } just runs + return winner + } + + @Test + fun `overlay winner hosting a web page fetches the session's targeting dependencies after the app start is released`() = runTest { + val sessionInApps = listOf(InAppStub.getInApp().copy(id = "story")) + every { inAppRepository.getCurrentSessionInApps() } returns sessionInApps + givenEventWinner(InAppStub.getWebView().copy(inAppId = "winner")) + mockkObject(InitializeLock) + try { + interactor.processEventAndConfig().test { + awaitItem() + coVerifyOrder { + InitializeLock.complete(InitializeLock.State.APP_STARTED) + inAppProcessingManager.prefetchTargetingDependencies(sessionInApps) + } + cancelAndIgnoreRemainingEvents() + } + } finally { + unmockkObject(InitializeLock) + } + } + + @Test + fun `overlay winner without a web page fetches no targeting dependencies`() = runTest { + givenEventWinner(InAppStub.getModalWindow().copy(inAppId = "winner")) + + interactor.processEventAndConfig().test { + awaitItem() + coVerify(exactly = 0) { inAppProcessingManager.prefetchTargetingDependencies(any()) } + cancelAndIgnoreRemainingEvents() + } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt index 540151b84..e57c02053 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt @@ -26,6 +26,7 @@ import com.android.volley.VolleyError import com.google.gson.Gson import io.mockk.* import io.mockk.junit4.MockKRule +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert.* @@ -1258,4 +1259,131 @@ internal class InAppProcessingManagerTest { ) } } + + @Test + fun `prefetch fetches segmentations and geo when the config targets them`() = runTest { + every { mockkInAppSegmentationRepository.getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED + every { mockkInAppGeoRepository.getGeoFetchedStatus() } returns GeoFetchStatus.GEO_NOT_FETCHED + + inAppProcessingManager.prefetchTargetingDependencies( + listOf( + inAppTargeting("story-segment", InAppStub.getTargetingSegmentNode()), + inAppTargeting("story-city", InAppStub.getTargetingCityNode()), + ) + ) + + coVerify(exactly = 1) { mockkInAppSegmentationRepository.fetchCustomerSegmentations() } + coVerify(exactly = 1) { mockkInAppGeoRepository.fetchGeo() } + } + + @Test + fun `prefetch fetches nothing when no in-app targets segmentations or geo`() = runTest { + every { mockkInAppSegmentationRepository.getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED + every { mockkInAppGeoRepository.getGeoFetchedStatus() } returns GeoFetchStatus.GEO_NOT_FETCHED + + inAppProcessingManager.prefetchTargetingDependencies(listOf(inAppTargeting("story", InAppStub.getTargetingTrueNode()))) + + coVerify(exactly = 0) { mockkInAppSegmentationRepository.fetchCustomerSegmentations() } + coVerify(exactly = 0) { mockkInAppGeoRepository.fetchGeo() } + } + + @Test + fun `prefetch fetches nothing the session already fetched`() = runTest { + every { mockkInAppGeoRepository.getGeoFetchedStatus() } returns GeoFetchStatus.GEO_FETCH_ERROR + + inAppProcessingManager.prefetchTargetingDependencies( + listOf( + inAppTargeting("story-segment", InAppStub.getTargetingSegmentNode()), + inAppTargeting("story-city", InAppStub.getTargetingCityNode()), + ) + ) + + coVerify(exactly = 0) { mockkInAppSegmentationRepository.fetchCustomerSegmentations() } + coVerify(exactly = 0) { mockkInAppGeoRepository.fetchGeo() } + } + + @Test + fun `prefetch keeps a failed segmentation fetch for the session and its show failure`() = runTest { + setupTestSegmentationRepositoryForErrorScenario() + coEvery { + inAppSegmentationRepositoryTestImpl.fetchCustomerSegmentations() + } throws CustomerSegmentationError(VolleyError(NetworkResponse(500, null, false, 0, emptyList()))) + + inAppProcessingManagerTestImpl.prefetchTargetingDependencies(listOf(inAppTargeting("story-segment", InAppStub.getTargetingSegmentNode()))) + + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionStorageManager.state.customerSegmentationFetchStatus) + assertNotNull(inAppTargetingErrorRepository.getError(TargetingErrorKey.CustomerSegmentation)) + } + + @Test + fun `prefetch fetches geo even when the segmentations fail`() = runTest { + every { mockkInAppSegmentationRepository.getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED + every { mockkInAppSegmentationRepository.setCustomerSegmentationStatus(any()) } just runs + coEvery { mockkInAppSegmentationRepository.fetchCustomerSegmentations() } throws CustomerSegmentationError(VolleyError("timeout")) + every { mockkInAppGeoRepository.getGeoFetchedStatus() } returns GeoFetchStatus.GEO_NOT_FETCHED + + inAppProcessingManager.prefetchTargetingDependencies( + listOf( + inAppTargeting("story-segment", InAppStub.getTargetingSegmentNode()), + inAppTargeting("story-city", InAppStub.getTargetingCityNode()), + ) + ) + + coVerify(exactly = 1) { mockkInAppGeoRepository.fetchGeo() } + verify(exactly = 1) { + mockkInAppSegmentationRepository.setCustomerSegmentationStatus(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR) + } + verify(exactly = 0) { mockkInAppGeoRepository.setGeoStatus(any()) } + } + + @Test + fun `requested targeting fetches the dependencies before checking`() = runTest { + val targeting = mockk() + coEvery { targeting.fetchTargetingInfo(any()) } just runs + every { targeting.checkTargeting(any()) } returns true + + assertTrue(inAppProcessingManager.matchesRequestedTargeting(inAppTargeting("story", targeting))) + coVerifyOrder { + targeting.fetchTargetingInfo(any()) + targeting.checkTargeting(any()) + } + } + + @Test + fun `requested targeting cuts an unverifiable id without reporting a failure`() = runTest { + val targeting = mockk() + coEvery { targeting.fetchTargetingInfo(any()) } throws RuntimeException("offline") + + assertFalse(inAppProcessingManager.matchesRequestedTargeting(inAppTargeting("story", targeting))) + verify(exactly = 0) { targeting.checkTargeting(any()) } + verify(exactly = 0) { inAppFailureTracker.sendFailure(any(), any(), any(), any()) } + } + + @Test + fun `requested targeting re-checks from the session cache once a segmentation fetch failed`() = runTest { + setupTestSegmentationRepositoryForErrorScenario() + setDIModule(mockkInAppGeoRepository, inAppSegmentationRepositoryTestImpl) + val segmentOrTrue = TreeTargeting.UnionNode( + type = "", + nodes = listOf(InAppStub.getTargetingSegmentNode(), InAppStub.getTargetingTrueNode()) + ) + + assertTrue(inAppProcessingManagerTestImpl.matchesRequestedTargeting(inAppTargeting("story", segmentOrTrue))) + coVerify(exactly = 1) { inAppSegmentationRepositoryTestImpl.fetchCustomerSegmentations() } + verify(exactly = 0) { inAppFailureTracker.collectFailure(any(), any(), any(), any()) } + } + + @Test + fun `requested targeting passes cancellation through without reporting a failure`() = runTest { + val targeting = mockk() + coEvery { targeting.fetchTargetingInfo(any()) } throws CancellationException("page closed") + + assertTrue( + runCatching { inAppProcessingManager.matchesRequestedTargeting(inAppTargeting("story", targeting)) } + .exceptionOrNull() is CancellationException + ) + verify(exactly = 0) { inAppFailureTracker.sendFailure(any(), any(), any(), any()) } + } + + private fun inAppTargeting(id: String, targeting: TreeTargeting): InApp = InAppStub.getInApp().copy(id = id, targeting = targeting) } From 8f9dc5fa3186ce387b9003dc9594b3ce03dc655a Mon Sep 17 00:00:00 2001 From: sozinov Date: Wed, 7 Oct 2026 15:51:10 +0300 Subject: [PATCH 2/6] MOBILE-0000: Ease the embedded block reveal in and out like iOS --- .../mobile_sdk/embedded/EmbeddedBlockRevealAnimation.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockRevealAnimation.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockRevealAnimation.kt index 0fe86ddc8..6e76c05e3 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockRevealAnimation.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockRevealAnimation.kt @@ -5,6 +5,7 @@ import android.animation.AnimatorListenerAdapter import android.animation.ValueAnimator import android.os.Build import android.view.View +import android.view.animation.PathInterpolator import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.utils.Constants @@ -19,10 +20,13 @@ internal open class EmbeddedBlockRevealAnimation( val isEnabled: Boolean get() = areSystemAnimationsEnabled() + private val revealInterpolator = PathInterpolator(0.42f, 0f, 0.58f, 1f) + open fun crossfade(incoming: View, outgoing: View?, onEnd: () -> Unit): Animator = ValueAnimator.ofFloat(0f, 1f).apply { incoming.alpha = 0f this.duration = this@EmbeddedBlockRevealAnimation.duration.interval + interpolator = revealInterpolator addUpdateListener { animator -> val shown = animator.animatedValue as Float incoming.alpha = shown @@ -43,6 +47,7 @@ internal open class EmbeddedBlockRevealAnimation( open fun growHeight(view: View, targetHeight: Int, onEnd: () -> Unit): Animator = ValueAnimator.ofInt(0, targetHeight).apply { this.duration = this@EmbeddedBlockRevealAnimation.duration.interval + interpolator = revealInterpolator addUpdateListener { animator -> view.layoutParams?.let { params -> params.height = animator.animatedValue as Int From ed8675e6f3aa6d03dbc80479b626733c26c823f7 Mon Sep 17 00:00:00 2001 From: sozinov Date: Thu, 8 Oct 2026 10:13:20 +0300 Subject: [PATCH 3/6] MOBILE-458: Bound segment and geo fetches and keep them in their own session --- .../cloud/mindbox/mobile_sdk/Extensions.kt | 10 -- .../repositories/InAppGeoRepositoryImpl.kt | 14 ++- .../InAppSegmentationRepositoryImpl.kt | 32 +++--- .../inapp/domain/InAppInteractorImpl.kt | 14 ++- .../domain/InAppProcessingManagerImpl.kt | 4 - .../mobile_sdk/managers/GatewayManager.kt | 74 +++++++------- .../mindbox/mobile_sdk/utils/Constants.kt | 1 + .../repositories/InAppGeoRepositoryTest.kt | 25 +++++ .../InAppSegmentationRepositoryTest.kt | 27 ++++++ .../domain/InAppProcessingManagerTest.kt | 97 ++++++++++++++++--- 10 files changed, 211 insertions(+), 87 deletions(-) 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 9b96f928a..7d090e039 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt @@ -308,16 +308,6 @@ internal fun List.sortByPriority(): List { internal fun InApp.firstOverlayVariant(): InAppType? = form.variants.firstOrNull { variant -> variant !is InAppType.Embedded } -internal fun InAppType.hostsWebPage(): Boolean { - val layers = when (this) { - is InAppType.WebView -> layers - is InAppType.ModalWindow -> layers - is InAppType.Snackbar -> layers - is InAppType.Embedded -> layers - } - return layers.any { layer -> layer is Layer.WebViewLayer } -} - /** * Whether shows of this in-app are written down: the show history, the session list and the * cooldown all exist to hold shows back, and an `unlimited` in-app is outside that accounting in diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryImpl.kt index a122fbbea..03e0610f3 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryImpl.kt @@ -12,6 +12,9 @@ import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler +import cloud.mindbox.mobile_sdk.utils.Constants +import com.android.volley.TimeoutError +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -27,20 +30,23 @@ internal class InAppGeoRepositoryImpl( private val geoMutex = Mutex() override suspend fun fetchGeo() = geoMutex.withLock { - if (sessionStorageManager.state.geoFetchStatus != GeoFetchStatus.GEO_NOT_FETCHED) { + val state = sessionStorageManager.state + if (state.geoFetchStatus != GeoFetchStatus.GEO_NOT_FETCHED) { return@withLock } val configuration = DbManager.listenConfigurations().first() val geoTargetingDto = try { - gatewayManager.checkGeoTargeting(configuration = configuration) + withTimeoutOrNull(Constants.targetingFetchWaitLimit.interval) { + gatewayManager.checkGeoTargeting(configuration = configuration) + } ?: throw GeoError(TimeoutError()) } catch (error: GeoError) { - sessionStorageManager.state.geoFetchStatus = GeoFetchStatus.GEO_FETCH_ERROR + state.geoFetchStatus = GeoFetchStatus.GEO_FETCH_ERROR throw error } val geoTargeting = inAppMapper.mapGeoTargetingDtoToGeoTargeting(geoTargetingDto = geoTargetingDto) MindboxPreferences.inAppGeo = geoSerializationManager.serializeToGeoString(geoTargeting) - sessionStorageManager.state.geoFetchStatus = GeoFetchStatus.GEO_FETCH_SUCCESS + state.geoFetchStatus = GeoFetchStatus.GEO_FETCH_SUCCESS } override fun setGeoStatus(status: GeoFetchStatus) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt index bf7d13f26..f908e2064 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt @@ -12,6 +12,9 @@ import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler +import cloud.mindbox.mobile_sdk.utils.Constants +import com.android.volley.TimeoutError +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -25,18 +28,16 @@ internal class InAppSegmentationRepositoryImpl( private val customerSegmentationsMutex = Mutex() override suspend fun fetchCustomerSegmentations() = customerSegmentationsMutex.withLock { - if (sessionStorageManager.state.customerSegmentationFetchStatus != - CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED - ) { + val state = sessionStorageManager.state + if (state.customerSegmentationFetchStatus != CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED) { return@withLock } - if (sessionStorageManager.state.currentSessionInApps.isEmpty()) { + if (state.currentSessionInApps.isEmpty()) { MindboxLoggerImpl.d( this, "No unshown inapps. Do not request segmentations" ) - sessionStorageManager.state.customerSegmentationFetchStatus = - CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + state.customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR return@withLock } MindboxLoggerImpl.d( @@ -45,21 +46,18 @@ internal class InAppSegmentationRepositoryImpl( ) val configuration = DbManager.listenConfigurations().first() val response = try { - gatewayManager.checkCustomerSegmentations( - configuration = configuration, - segmentationCheckRequest = inAppMapper.mapToCustomerSegmentationCheckRequest( - sessionStorageManager.state.currentSessionInApps + withTimeoutOrNull(Constants.targetingFetchWaitLimit.interval) { + gatewayManager.checkCustomerSegmentations( + configuration = configuration, + segmentationCheckRequest = inAppMapper.mapToCustomerSegmentationCheckRequest(state.currentSessionInApps) ) - ) + } ?: throw CustomerSegmentationError(TimeoutError()) } catch (error: CustomerSegmentationError) { - sessionStorageManager.state.customerSegmentationFetchStatus = - CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + state.customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR throw error } - sessionStorageManager.state.inAppCustomerSegmentations = - inAppMapper.mapToSegmentationCheck(response) - sessionStorageManager.state.customerSegmentationFetchStatus = - CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + state.inAppCustomerSegmentations = inAppMapper.mapToSegmentationCheck(response) + state.customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS return@withLock } 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 a02aba47a..16bada4ad 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 @@ -21,6 +21,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppReposi import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfigRepository 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.Layer import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.logger.MindboxLog import cloud.mindbox.mobile_sdk.millisToTimeSpan @@ -32,7 +33,6 @@ import cloud.mindbox.mobile_sdk.models.PlaceKey import cloud.mindbox.mobile_sdk.models.toTimestamp import cloud.mindbox.mobile_sdk.countsShows import cloud.mindbox.mobile_sdk.firstOverlayVariant -import cloud.mindbox.mobile_sdk.hostsWebPage import cloud.mindbox.mobile_sdk.sortByPriority import cloud.mindbox.mobile_sdk.utils.TimeProvider import kotlinx.coroutines.channels.Channel @@ -110,7 +110,7 @@ internal class InAppInteractorImpl( inApp?.let { sessionStorageManager.state.inAppTriggerEvent = event } - if (inApp?.firstOverlayVariant()?.hostsWebPage() == true) { + if (inApp?.overlayHostsWebPage() == true) { inAppProcessingManager.prefetchTargetingDependencies(inAppRepository.getCurrentSessionInApps()) } inApp?.let { inapp -> inapp to timeProvider.elapsedSince(triggerTimeMillis) } @@ -198,6 +198,16 @@ internal class InAppInteractorImpl( } } + private fun InApp.overlayHostsWebPage(): Boolean { + val layers = when (val variant = firstOverlayVariant()) { + is InAppType.WebView -> variant.layers + is InAppType.ModalWindow -> variant.layers + is InAppType.Snackbar -> variant.layers + else -> return false + } + return layers.any { layer -> layer is Layer.WebViewLayer } + } + private fun InApp.embeddedVariantFor(place: PlaceKey): InAppType.Embedded? = form.variants .filterIsInstance() 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 181672e9b..d13ffcd31 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 @@ -263,7 +263,6 @@ internal class InAppProcessingManagerImpl( private fun handleTargetingFetchError(error: Throwable): Boolean = when (error) { is GeoError -> { - inAppGeoRepository.setGeoStatus(GeoFetchStatus.GEO_FETCH_ERROR) if (error.shouldTrackTargetingError()) { inAppTargetingErrorRepository.saveError(key = TargetingErrorKey.Geo, error = error) } @@ -272,9 +271,6 @@ internal class InAppProcessingManagerImpl( } is CustomerSegmentationError -> { - inAppSegmentationRepository.setCustomerSegmentationStatus( - CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR - ) if (error.shouldTrackTargetingError()) { inAppTargetingErrorRepository.saveError(key = TargetingErrorKey.CustomerSegmentation, error = error) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt index c2dbd38f9..3461aa1d3 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt @@ -347,23 +347,23 @@ internal class GatewayManager(private val mindboxServiceGenerator: MindboxServic } ?: false suspend fun checkGeoTargeting(configuration: Configuration): GeoTargetingDto { - return suspendCoroutine { continuation -> - mindboxServiceGenerator.addToRequestQueue( - MindboxRequest( - Request.Method.GET, - "${SdkValidation.toBaseUrl(configuration.domain)}/geo", - configuration, - null, - { response -> - continuation.resumeFromJson( - json = response.toString() - ) - }, - { error -> - continuation.resumeWithException(GeoError(error)) - } - ) + return suspendCancellableCoroutine { continuation -> + val request = MindboxRequest( + Request.Method.GET, + "${SdkValidation.toBaseUrl(configuration.domain)}/geo", + configuration, + null, + { response -> + continuation.resumeFromJson( + json = response.toString() + ) + }, + { error -> + continuation.resumeWithException(GeoError(error)) + } ) + continuation.invokeOnCancellation { request.cancel() } + mindboxServiceGenerator.addToRequestQueue(request) } } @@ -400,28 +400,28 @@ internal class GatewayManager(private val mindboxServiceGenerator: MindboxServic configuration: Configuration, segmentationCheckRequest: SegmentationCheckRequest, ): SegmentationCheckResponse { - return suspendCoroutine { continuation -> - mindboxServiceGenerator.addToRequestQueue( - MindboxRequest( - Request.Method.POST, - getCustomerSegmentationsUrl(configuration), - configuration, - convertBodyToJson( - gson.toJson( - segmentationCheckRequest, - SegmentationCheckRequest::class.java - ) - )!!, - { response -> - continuation.resumeFromJson( - json = response.toString() - ) - }, - { error -> - continuation.resumeWithException(CustomerSegmentationError(error)) - } - ) + return suspendCancellableCoroutine { continuation -> + val request = MindboxRequest( + Request.Method.POST, + getCustomerSegmentationsUrl(configuration), + configuration, + convertBodyToJson( + gson.toJson( + segmentationCheckRequest, + SegmentationCheckRequest::class.java + ) + )!!, + { response -> + continuation.resumeFromJson( + json = response.toString() + ) + }, + { error -> + continuation.resumeWithException(CustomerSegmentationError(error)) + } ) + continuation.invokeOnCancellation { request.cancel() } + mindboxServiceGenerator.addToRequestQueue(request) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt index 122c1d429..e7a003dfe 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt @@ -11,6 +11,7 @@ internal object Constants { internal const val APP_UID_NAME = "app_uid" internal const val SCHEME_PACKAGE = "package" internal const val SDK_VERSION_CODE = 4 + internal val targetingFetchWaitLimit = Milliseconds(15_000L) internal object WebView { internal val readyTimeout = Milliseconds(7_000L) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt index a41853c50..7894a8232 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt @@ -18,6 +18,7 @@ import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.OverrideMockKs import io.mockk.junit4.MockKRule import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.flow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -218,4 +219,28 @@ internal class InAppGeoRepositoryTest { assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionState.geoFetchStatus) coVerify(exactly = 1) { gatewayManager.checkGeoTargeting(any()) } } + + @Test + fun `a geo fetch the session reset outlived writes into its own session only`() = runTest { + val nextSession = SessionState() + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkGeoTargeting(configuration = configuration) } answers { + every { sessionStorageManager.state } returns nextSession + throw GeoError(VolleyError("timeout")) + } + + runCatching { inAppGeoRepository.fetchGeo() } + + assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionState.geoFetchStatus) + assertEquals(GeoFetchStatus.GEO_NOT_FETCHED, nextSession.geoFetchStatus) + } + + @Test + fun `a geo request the queue dropped without an answer ends as a cached fetch error`() = runTest { + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkGeoTargeting(configuration = configuration) } coAnswers { awaitCancellation() } + + assertTrue(runCatching { inAppGeoRepository.fetchGeo() }.exceptionOrNull() is GeoError) + assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionState.geoFetchStatus) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt index 9aea9d4be..e0e4dc06b 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt @@ -17,6 +17,7 @@ import com.android.volley.VolleyError import io.mockk.* import io.mockk.impl.annotations.MockK import io.mockk.junit4.MockKRule +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.flow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -272,4 +273,30 @@ class InAppSegmentationRepositoryTest { assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionStorageManager.state.customerSegmentationFetchStatus) coVerify(exactly = 1) { gatewayManager.checkCustomerSegmentations(any(), any()) } } + + @Test + fun `a fetch the session reset outlived writes into its own session only`() = runTest { + sessionStorageManager.state.currentSessionInApps = mutableListOf(InAppStub.getInApp()) + val nextSession = SessionState() + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkCustomerSegmentations(any(), any()) } answers { + every { sessionStorageManager.state } returns nextSession + throw CustomerSegmentationError(VolleyError("timeout")) + } + + runCatching { inAppSegmentationRepository.fetchCustomerSegmentations() } + + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionState.customerSegmentationFetchStatus) + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED, nextSession.customerSegmentationFetchStatus) + } + + @Test + fun `a request the queue dropped without an answer ends as a cached fetch error`() = runTest { + sessionStorageManager.state.currentSessionInApps = mutableListOf(InAppStub.getInApp()) + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkCustomerSegmentations(any(), any()) } coAnswers { awaitCancellation() } + + assertTrue(runCatching { inAppSegmentationRepository.fetchCustomerSegmentations() }.exceptionOrNull() is CustomerSegmentationError) + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionState.customerSegmentationFetchStatus) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt index e57c02053..e4da7b7fd 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt @@ -27,6 +27,9 @@ import com.google.gson.Gson import io.mockk.* import io.mockk.junit4.MockKRule import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert.* @@ -1302,23 +1305,30 @@ internal class InAppProcessingManagerTest { coVerify(exactly = 0) { mockkInAppGeoRepository.fetchGeo() } } + private val serverError = VolleyError(NetworkResponse(500, null, false, 0, emptyList())) + + private fun failSegmentationsLikeTheRepository(error: Throwable) { + sessionStorageManager.state.customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED + every { inAppSegmentationRepositoryTestImpl.getCustomerSegmentationFetched() } answers { callOriginal() } + coEvery { inAppSegmentationRepositoryTestImpl.fetchCustomerSegmentations() } answers { + sessionStorageManager.state.customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + throw error + } + setDIModule(mockkInAppGeoRepository, inAppSegmentationRepositoryTestImpl) + } + @Test - fun `prefetch keeps a failed segmentation fetch for the session and its show failure`() = runTest { - setupTestSegmentationRepositoryForErrorScenario() - coEvery { - inAppSegmentationRepositoryTestImpl.fetchCustomerSegmentations() - } throws CustomerSegmentationError(VolleyError(NetworkResponse(500, null, false, 0, emptyList()))) + fun `prefetch keeps a failed segmentation fetch's show failure details`() = runTest { + failSegmentationsLikeTheRepository(CustomerSegmentationError(serverError)) inAppProcessingManagerTestImpl.prefetchTargetingDependencies(listOf(inAppTargeting("story-segment", InAppStub.getTargetingSegmentNode()))) - assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionStorageManager.state.customerSegmentationFetchStatus) assertNotNull(inAppTargetingErrorRepository.getError(TargetingErrorKey.CustomerSegmentation)) } @Test fun `prefetch fetches geo even when the segmentations fail`() = runTest { every { mockkInAppSegmentationRepository.getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED - every { mockkInAppSegmentationRepository.setCustomerSegmentationStatus(any()) } just runs coEvery { mockkInAppSegmentationRepository.fetchCustomerSegmentations() } throws CustomerSegmentationError(VolleyError("timeout")) every { mockkInAppGeoRepository.getGeoFetchedStatus() } returns GeoFetchStatus.GEO_NOT_FETCHED @@ -1330,10 +1340,29 @@ internal class InAppProcessingManagerTest { ) coVerify(exactly = 1) { mockkInAppGeoRepository.fetchGeo() } - verify(exactly = 1) { - mockkInAppSegmentationRepository.setCustomerSegmentationStatus(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR) + } + + @Test + fun `prefetch runs the segmentation and geo fetches at the same time`() = runTest { + val segmentationsAnswered = CompletableDeferred() + every { mockkInAppSegmentationRepository.getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED + coEvery { mockkInAppSegmentationRepository.fetchCustomerSegmentations() } coAnswers { segmentationsAnswered.await() } + every { mockkInAppGeoRepository.getGeoFetchedStatus() } returns GeoFetchStatus.GEO_NOT_FETCHED + + val prefetch = launch { + inAppProcessingManager.prefetchTargetingDependencies( + listOf( + inAppTargeting("story-segment", InAppStub.getTargetingSegmentNode()), + inAppTargeting("story-city", InAppStub.getTargetingCityNode()), + ) + ) } - verify(exactly = 0) { mockkInAppGeoRepository.setGeoStatus(any()) } + runCurrent() + + coVerify(exactly = 1) { mockkInAppGeoRepository.fetchGeo() } + assertTrue(prefetch.isActive) + segmentationsAnswered.complete(Unit) + prefetch.join() } @Test @@ -1349,6 +1378,11 @@ internal class InAppProcessingManagerTest { } } + @Test + fun `requested targeting never matches an operation node - the page asks outside any operation`() = runTest { + assertFalse(inAppProcessingManager.matchesRequestedTargeting(inAppTargeting("story", InAppStub.getTargetingOperationNode()))) + } + @Test fun `requested targeting cuts an unverifiable id without reporting a failure`() = runTest { val targeting = mockk() @@ -1360,9 +1394,8 @@ internal class InAppProcessingManagerTest { } @Test - fun `requested targeting re-checks from the session cache once a segmentation fetch failed`() = runTest { - setupTestSegmentationRepositoryForErrorScenario() - setDIModule(mockkInAppGeoRepository, inAppSegmentationRepositoryTestImpl) + fun `requested targeting re-checks from the session cache and collects no failure`() = runTest { + failSegmentationsLikeTheRepository(CustomerSegmentationError(serverError)) val segmentOrTrue = TreeTargeting.UnionNode( type = "", nodes = listOf(InAppStub.getTargetingSegmentNode(), InAppStub.getTargetingTrueNode()) @@ -1373,6 +1406,44 @@ internal class InAppProcessingManagerTest { verify(exactly = 0) { inAppFailureTracker.collectFailure(any(), any(), any(), any()) } } + @Test + fun `place targeting cut by a failed segmentation fetch collects its show failure`() = runTest { + failSegmentationsLikeTheRepository(CustomerSegmentationError(serverError)) + + assertFalse(inAppProcessingManagerTestImpl.matchesTargeting(inAppTargeting("ribbon", InAppStub.getTargetingSegmentNode()), event)) + verify(exactly = 1) { + inAppFailureTracker.collectFailure( + inAppId = "ribbon", + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = any(), + tags = any() + ) + } + } + + @Test + fun `place targeting reports an unknown error and cuts the candidate`() = runTest { + val targeting = mockk(relaxed = true) + coEvery { targeting.fetchTargetingInfo(any()) } throws RuntimeException("broken") + + assertFalse(inAppProcessingManager.matchesTargeting(inAppTargeting("ribbon", targeting), event)) + verify(exactly = 1) { + inAppFailureTracker.sendFailure(inAppId = "ribbon", failureReason = FailureReason.UNKNOWN_ERROR, errorDetails = any(), tags = any()) + } + } + + @Test + fun `place targeting passes cancellation through without reporting a failure`() = runTest { + val targeting = mockk() + coEvery { targeting.fetchTargetingInfo(any()) } throws CancellationException("place left the screen") + + assertTrue( + runCatching { inAppProcessingManager.matchesTargeting(inAppTargeting("ribbon", targeting), event) } + .exceptionOrNull() is CancellationException + ) + verify(exactly = 0) { inAppFailureTracker.sendFailure(any(), any(), any(), any()) } + } + @Test fun `requested targeting passes cancellation through without reporting a failure`() = runTest { val targeting = mockk() From fa9643fd3426152f532523880c3ae60b43a5830c Mon Sep 17 00:00:00 2001 From: sozinov Date: Thu, 8 Oct 2026 18:56:27 +0300 Subject: [PATCH 4/6] MOBILE-458: Re-resolve a place whose session was reset mid-fetch, keep cancellation out of ShowFailure - A place pass that fetched its targeting dependencies into one session and would have matched candidates against the next one now resolves again, once, for the new session instead of answering the block from mixed state. - Cancellation surfacing inside chooseInAppToShow's network wait (reachable now that the gateway is cancellable) is rethrown before the UNKNOWN_ERROR branch, so no false Inapp.ShowFailure goes out. - Tests: success twins for the session-reset cases, two callers on one mutex, cancellation inside the lock, Volley request cancelled with the coroutine. --- .../InAppSegmentationRepositoryImpl.kt | 1 - .../inapp/domain/InAppInteractorImpl.kt | 15 +++- .../domain/InAppProcessingManagerImpl.kt | 2 + .../repositories/InAppGeoRepositoryTest.kt | 48 +++++++++++++ .../InAppSegmentationRepositoryTest.kt | 72 ++++++++++++++++++- .../domain/EmbeddedResolveInteractorTest.kt | 46 ++++++++++++ .../domain/InAppProcessingManagerTest.kt | 10 +++ .../mobile_sdk/managers/GatewayManagerTest.kt | 18 +++++ 8 files changed, 209 insertions(+), 3 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt index f908e2064..86009c3e3 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt @@ -37,7 +37,6 @@ internal class InAppSegmentationRepositoryImpl( this, "No unshown inapps. Do not request segmentations" ) - state.customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR return@withLock } MindboxLoggerImpl.d( 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 16bada4ad..fc090b5b4 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 @@ -125,16 +125,29 @@ internal class InAppInteractorImpl( override suspend fun selectInAppForPlace( placeSystemName: PlaceKey, triggerEvent: InAppEventType, + ): EmbeddedResolveOutcome = selectInAppForPlace(placeSystemName, triggerEvent, isRetryAfterSessionReset = false) + + private suspend fun selectInAppForPlace( + placeSystemName: PlaceKey, + triggerEvent: InAppEventType, + isRetryAfterSessionReset: Boolean, ): EmbeddedResolveOutcome { val inApps = mobileConfigRepository.getInAppsSectionIfAvailable() ?: run { logI("Place '$placeSystemName': the config is unavailable, the SDK has no answer for the place") return EmbeddedResolveOutcome.ConfigUnavailable } + val session = sessionStorageManager.state inAppRepository.saveCurrentSessionInApps(inApps) val trigger = placeTrigger(placeSystemName, triggerEvent) val candidates = inAppFilteringManager.filterEmbeddedInAppsByPlace(inApps, placeSystemName) .let { inAppFilteringManager.filterOutDirectCallInApps(it) } - if (candidates.isNotEmpty()) inAppProcessingManager.prefetchTargetingDependencies(inApps) + if (candidates.isNotEmpty()) { + inAppProcessingManager.prefetchTargetingDependencies(inApps) + if (sessionStorageManager.state !== session && !isRetryAfterSessionReset) { + logI("Place '$placeSystemName': the session was reset while its targeting dependencies were fetched, resolving again for the new session") + return selectInAppForPlace(placeSystemName, triggerEvent, isRetryAfterSessionReset = true) + } + } val matched = candidates.filter { candidate -> inAppProcessingManager.matchesTargeting(candidate, trigger) } 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 d13ffcd31..e0ae47d99 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 @@ -91,6 +91,8 @@ internal class InAppProcessingManagerImpl( targetingCheck = inApp.targeting.checkTargeting(data) }.onFailure { throwable -> when (throwable) { + is CancellationException -> throw throwable + is GeoError -> { isTargetingErrorOccurred = true inAppGeoRepository.setGeoStatus(GeoFetchStatus.GEO_FETCH_ERROR) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt index 7894a8232..83ccef76d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt @@ -1,5 +1,8 @@ package cloud.mindbox.mobile_sdk.inapp.data.repositories +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.launch +import kotlinx.coroutines.CompletableDeferred import android.content.Context import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionState import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager @@ -243,4 +246,49 @@ internal class InAppGeoRepositoryTest { assertTrue(runCatching { inAppGeoRepository.fetchGeo() }.exceptionOrNull() is GeoError) assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionState.geoFetchStatus) } + + @Test + fun `a geo fetch the session reset outlived writes its success into its own session only`() = runTest { + val nextSession = SessionState() + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + every { configuration.domain } returns "" + val geoTargetingDto = GeoTargetingStub.getGeoTargetingDto() + val geoTargeting = GeoTargetingStub.getGeoTargeting() + coEvery { gatewayManager.checkGeoTargeting(configuration = configuration) } answers { + every { sessionStorageManager.state } returns nextSession + geoTargetingDto + } + coEvery { inAppMapper.mapGeoTargetingDtoToGeoTargeting(geoTargetingDto) } returns geoTargeting + every { geoSerializationManager.serializeToGeoString(geoTargeting) } returns "{}" + + inAppGeoRepository.fetchGeo() + + assertEquals(GeoFetchStatus.GEO_FETCH_SUCCESS, sessionState.geoFetchStatus) + assertEquals(GeoFetchStatus.GEO_NOT_FETCHED, nextSession.geoFetchStatus) + } + + @Test + fun `two callers on a cold cache make one geo request and both see its result`() = runTest { + val answer = CompletableDeferred() + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + every { configuration.domain } returns "" + val geoTargetingDto = GeoTargetingStub.getGeoTargetingDto() + val geoTargeting = GeoTargetingStub.getGeoTargeting() + coEvery { gatewayManager.checkGeoTargeting(configuration = configuration) } coAnswers { + answer.await() + geoTargetingDto + } + coEvery { inAppMapper.mapGeoTargetingDtoToGeoTargeting(geoTargetingDto) } returns geoTargeting + every { geoSerializationManager.serializeToGeoString(geoTargeting) } returns "{}" + + val first = launch { inAppGeoRepository.fetchGeo() } + val second = launch { inAppGeoRepository.fetchGeo() } + runCurrent() + answer.complete(Unit) + first.join() + second.join() + + coVerify(exactly = 1) { gatewayManager.checkGeoTargeting(any()) } + assertEquals(GeoFetchStatus.GEO_FETCH_SUCCESS, sessionState.geoFetchStatus) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt index e0e4dc06b..f2ae39727 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt @@ -1,5 +1,8 @@ package cloud.mindbox.mobile_sdk.inapp.data.repositories +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.launch +import kotlinx.coroutines.CompletableDeferred import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionState import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper @@ -83,7 +86,7 @@ class InAppSegmentationRepositoryTest { fun `request customer segmentations no inApps`() = runTest { sessionStorageManager.state.currentSessionInApps = mutableListOf() inAppSegmentationRepository.fetchCustomerSegmentations() - assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionStorageManager.state.customerSegmentationFetchStatus) + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED, sessionStorageManager.state.customerSegmentationFetchStatus) coVerify(exactly = 0) { gatewayManager.checkCustomerSegmentations( @@ -299,4 +302,71 @@ class InAppSegmentationRepositoryTest { assertTrue(runCatching { inAppSegmentationRepository.fetchCustomerSegmentations() }.exceptionOrNull() is CustomerSegmentationError) assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionState.customerSegmentationFetchStatus) } + + @Test + fun `a session the reset just started is not marked failed before its in-apps arrive`() = runTest { + sessionStorageManager.state.currentSessionInApps = mutableListOf(InAppStub.getInApp()) + val nextSession = SessionState() + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkCustomerSegmentations(any(), any()) } answers { + every { sessionStorageManager.state } returns nextSession + SegmentationCheckInAppStub.getSegmentationCheckResponse().copy("Success", listOf()) + } + + inAppSegmentationRepository.fetchCustomerSegmentations() + inAppSegmentationRepository.fetchCustomerSegmentations() + + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS, sessionState.customerSegmentationFetchStatus) + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED, nextSession.customerSegmentationFetchStatus) + + nextSession.currentSessionInApps = listOf(InAppStub.getInApp()) + inAppSegmentationRepository.fetchCustomerSegmentations() + + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS, nextSession.customerSegmentationFetchStatus) + coVerify(exactly = 2) { gatewayManager.checkCustomerSegmentations(any(), any()) } + } + + @Test + fun `two callers on a cold cache make one segmentation request and both see its result`() = runTest { + sessionStorageManager.state.currentSessionInApps = mutableListOf(InAppStub.getInApp()) + val answer = CompletableDeferred() + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkCustomerSegmentations(any(), any()) } coAnswers { + answer.await() + SegmentationCheckInAppStub.getSegmentationCheckResponse().copy("Success", listOf()) + } + + val first = launch { inAppSegmentationRepository.fetchCustomerSegmentations() } + val second = launch { inAppSegmentationRepository.fetchCustomerSegmentations() } + runCurrent() + answer.complete(Unit) + first.join() + second.join() + + coVerify(exactly = 1) { gatewayManager.checkCustomerSegmentations(any(), any()) } + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS, sessionState.customerSegmentationFetchStatus) + } + + @Test + fun `a caller cancelled while it holds the lock leaves the status not fetched for the next caller`() = runTest { + sessionStorageManager.state.currentSessionInApps = mutableListOf(InAppStub.getInApp()) + var dropsTheRequest = true + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkCustomerSegmentations(any(), any()) } coAnswers { + if (dropsTheRequest) awaitCancellation() + SegmentationCheckInAppStub.getSegmentationCheckResponse().copy("Success", listOf()) + } + + val cancelled = launch { inAppSegmentationRepository.fetchCustomerSegmentations() } + runCurrent() + cancelled.cancel() + cancelled.join() + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED, sessionState.customerSegmentationFetchStatus) + + dropsTheRequest = false + inAppSegmentationRepository.fetchCustomerSegmentations() + + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS, sessionState.customerSegmentationFetchStatus) + coVerify(exactly = 2) { gatewayManager.checkCustomerSegmentations(any(), any()) } + } } 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 81cd3b7a5..2d4d7e7b2 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 @@ -1092,5 +1092,51 @@ class EmbeddedResolveInteractorTest { coVerify(exactly = 0) { inAppProcessingManager.prefetchTargetingDependencies(any()) } } + @Test + fun `selectInAppForPlace resolves again for the new session when the session was reset during the prefetch`() = runTest { + val config = listOf(embeddedInApp(), modalInApp(id = "story-1")) + givenConfig(*config.toTypedArray()) + val nextSession = SessionState() + var prefetches = 0 + coEvery { inAppProcessingManager.prefetchTargetingDependencies(any()) } coAnswers { + if (prefetches++ == 0) every { sessionStorageManager.state } returns nextSession + } + + val content = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)).variantOrNull() + + assertEquals("embedded-id", content?.inAppId) + coVerifyOrder { + inAppRepository.saveCurrentSessionInApps(config) + inAppProcessingManager.prefetchTargetingDependencies(config) + inAppRepository.saveCurrentSessionInApps(config) + inAppProcessingManager.prefetchTargetingDependencies(config) + inAppProcessingManager.matchesTargeting(match { it.id == "embedded-id" }, any()) + } + coVerify(exactly = 2) { inAppProcessingManager.prefetchTargetingDependencies(any()) } + } + + @Test + fun `selectInAppForPlace resolves again only once however often the session is reset`() = runTest { + givenConfig(embeddedInApp(), modalInApp(id = "story-1")) + coEvery { inAppProcessingManager.prefetchTargetingDependencies(any()) } coAnswers { + every { sessionStorageManager.state } returns SessionState() + } + + val content = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)).variantOrNull() + + assertEquals("embedded-id", content?.inAppId) + coVerify(exactly = 2) { inAppProcessingManager.prefetchTargetingDependencies(any()) } + } + + @Test + fun `selectInAppForPlace resolves once while the session stays the same`() = runTest { + givenConfig(embeddedInApp(), modalInApp(id = "story-1")) + + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + coVerify(exactly = 1) { inAppProcessingManager.prefetchTargetingDependencies(any()) } + verify(exactly = 1) { inAppRepository.saveCurrentSessionInApps(any()) } + } + private fun EmbeddedResolveOutcome.variantOrNull(): InAppType.Embedded? = contentOrNull()?.variant } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt index e4da7b7fd..4da1093c8 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt @@ -1456,5 +1456,15 @@ internal class InAppProcessingManagerTest { verify(exactly = 0) { inAppFailureTracker.sendFailure(any(), any(), any(), any()) } } + @Test + fun `choose inApp to show passes cancellation through without reporting a failure`() = runTest { + val targeting = mockk(relaxed = true) + coEvery { targeting.fetchTargetingInfo(any()) } throws CancellationException("session expired") + + runCatching { inAppProcessingManager.chooseInAppToShow(listOf(inAppTargeting("overlay", targeting)), event) } + + verify(exactly = 0) { inAppFailureTracker.sendFailure(any(), any(), any(), any()) } + } + private fun inAppTargeting(id: String, targeting: TreeTargeting): InApp = InAppStub.getInApp().copy(id = id, targeting = targeting) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt index c87da918b..2d4dd97d2 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt @@ -1,5 +1,11 @@ package cloud.mindbox.mobile_sdk.managers +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.launch +import io.mockk.verify +import io.mockk.slot +import cloud.mindbox.mobile_sdk.models.MindboxRequest import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.network.MindboxServiceGenerator import cloud.mindbox.mobile_sdk.repository.MindboxPreferences @@ -340,4 +346,16 @@ class GatewayManagerTest { .split('=') .last() } + + @Test + fun `checkGeoTargeting cancels its Volley request when the waiting coroutine is cancelled`() = runTest { + val job = launch { gatewayManager.checkGeoTargeting(mockConfiguration) } + runCurrent() + job.cancel() + job.join() + + val request = slot() + verify { mockMindboxServiceGenerator.addToRequestQueue(capture(request)) } + assertTrue(request.captured.isCanceled) + } } From 193627b5e9257bd9fa3af5a0ae86a82f451dbc22 Mon Sep 17 00:00:00 2001 From: sozinov Date: Thu, 8 Oct 2026 19:21:55 +0300 Subject: [PATCH 5/6] MOBILE-458: Mirror the targeting-fetch tests across geo and segmentations - Place targeting that matches from the session cache collects nothing. - Geo caller cancelled while holding the lock leaves the next caller a fetch. - checkCustomerSegmentations cancels its Volley request with the coroutine. - The chooseInAppToShow cancellation test is named for what it asserts. --- .../repositories/InAppGeoRepositoryTest.kt | 27 +++++++++++++++++++ .../domain/InAppProcessingManagerTest.kt | 15 ++++++++++- .../mobile_sdk/managers/GatewayManagerTest.kt | 15 +++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt index 83ccef76d..61430f2a3 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt @@ -291,4 +291,31 @@ internal class InAppGeoRepositoryTest { coVerify(exactly = 1) { gatewayManager.checkGeoTargeting(any()) } assertEquals(GeoFetchStatus.GEO_FETCH_SUCCESS, sessionState.geoFetchStatus) } + + @Test + fun `a geo caller cancelled while it holds the lock leaves the status not fetched for the next caller`() = runTest { + var dropsTheRequest = true + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + every { configuration.domain } returns "" + val geoTargetingDto = GeoTargetingStub.getGeoTargetingDto() + val geoTargeting = GeoTargetingStub.getGeoTargeting() + coEvery { gatewayManager.checkGeoTargeting(configuration = configuration) } coAnswers { + if (dropsTheRequest) awaitCancellation() + geoTargetingDto + } + coEvery { inAppMapper.mapGeoTargetingDtoToGeoTargeting(geoTargetingDto) } returns geoTargeting + every { geoSerializationManager.serializeToGeoString(geoTargeting) } returns "{}" + + val cancelled = launch { inAppGeoRepository.fetchGeo() } + runCurrent() + cancelled.cancel() + cancelled.join() + assertEquals(GeoFetchStatus.GEO_NOT_FETCHED, sessionState.geoFetchStatus) + + dropsTheRequest = false + inAppGeoRepository.fetchGeo() + + assertEquals(GeoFetchStatus.GEO_FETCH_SUCCESS, sessionState.geoFetchStatus) + coVerify(exactly = 2) { gatewayManager.checkGeoTargeting(any()) } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt index 4da1093c8..25bf3ebc1 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt @@ -1457,7 +1457,7 @@ internal class InAppProcessingManagerTest { } @Test - fun `choose inApp to show passes cancellation through without reporting a failure`() = runTest { + fun `choose inApp to show sends no UNKNOWN_ERROR failure on cancellation`() = runTest { val targeting = mockk(relaxed = true) coEvery { targeting.fetchTargetingInfo(any()) } throws CancellationException("session expired") @@ -1466,5 +1466,18 @@ internal class InAppProcessingManagerTest { verify(exactly = 0) { inAppFailureTracker.sendFailure(any(), any(), any(), any()) } } + @Test + fun `place targeting matches from the session cache and collects nothing`() = runTest { + val inSegment = InAppStub.getTargetingSegmentNode().copy( + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ) + + assertTrue(inAppProcessingManager.matchesTargeting(inAppTargeting("ribbon", inSegment), event)) + coVerify(exactly = 0) { mockkInAppSegmentationRepository.fetchCustomerSegmentations() } + verify(exactly = 0) { inAppFailureTracker.collectFailure(any(), any(), any(), any()) } + } + private fun inAppTargeting(id: String, targeting: TreeTargeting): InApp = InAppStub.getInApp().copy(id = id, targeting = targeting) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt index 2d4dd97d2..93fc466c0 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.managers +import cloud.mindbox.mobile_sdk.models.operation.request.SegmentationCheckRequest import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.launch @@ -358,4 +359,18 @@ class GatewayManagerTest { verify { mockMindboxServiceGenerator.addToRequestQueue(capture(request)) } assertTrue(request.captured.isCanceled) } + + @Test + fun `checkCustomerSegmentations cancels its Volley request when the waiting coroutine is cancelled`() = runTest { + val job = launch { + gatewayManager.checkCustomerSegmentations(mockConfiguration, SegmentationCheckRequest(segmentations = emptyList())) + } + runCurrent() + job.cancel() + job.join() + + val request = slot() + verify { mockMindboxServiceGenerator.addToRequestQueue(capture(request)) } + assertTrue(request.captured.isCanceled) + } } From 57ae40f2b3559c35dec3fb28997bb76794e5680a Mon Sep 17 00:00:00 2001 From: sozinov Date: Thu, 8 Oct 2026 21:55:42 +0300 Subject: [PATCH 6/6] MOBILE-458: Close the session-reset windows the follow-up review named - The page's question saves the available config's in-apps into the session before checking, so a page asking right after a reset keeps its segment ids. - A place pass checks the session identity again once the winner is picked, right before its targetings go out, drops the failures the abandoned pass collected, and resolves again as a plain place request of the new session instead of replaying the old session's operation. - sendTargetedInApp(inApp, event) rethrows cancellation like chooseInAppToShow. - The cache-hit test runs on the real repository; the cancellation test makes the targeting pass so "no targeting sent" is proved by the cancellation. --- .../inapp/domain/InAppInteractorImpl.kt | 18 ++++- .../domain/InAppProcessingManagerImpl.kt | 2 + .../domain/EmbeddedResolveInteractorTest.kt | 68 +++++++++++++++++++ .../domain/InAppProcessingManagerTest.kt | 54 ++++++++++++++- 4 files changed, 137 insertions(+), 5 deletions(-) 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 fc090b5b4..4db660388 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 @@ -144,8 +144,7 @@ internal class InAppInteractorImpl( if (candidates.isNotEmpty()) { inAppProcessingManager.prefetchTargetingDependencies(inApps) if (sessionStorageManager.state !== session && !isRetryAfterSessionReset) { - logI("Place '$placeSystemName': the session was reset while its targeting dependencies were fetched, resolving again for the new session") - return selectInAppForPlace(placeSystemName, triggerEvent, isRetryAfterSessionReset = true) + return resolveAgainForNewSession(placeSystemName, "while its targeting dependencies were fetched") } } val matched = candidates.filter { candidate -> @@ -158,6 +157,9 @@ internal class InAppInteractorImpl( .let { inAppFrequencyManager.filterInAppsFrequency(it) } .sortByPriority() .firstOrNull { candidate -> candidate.embeddedVariantFor(placeSystemName) != null } + if (sessionStorageManager.state !== session && !isRetryAfterSessionReset) { + return resolveAgainForNewSession(placeSystemName, "while its candidates were checked") + } sendPlaceTargetings(placeSystemName, matched, winner) if (winner == null) { @@ -176,6 +178,12 @@ internal class InAppInteractorImpl( return EmbeddedResolveOutcome.Content(variant = variant, delayTime = delayTime) } + private suspend fun resolveAgainForNewSession(placeSystemName: PlaceKey, moment: String): EmbeddedResolveOutcome { + logI("Place '$placeSystemName': the session was reset $moment, resolving again as a place request of the new session") + inAppFailureTracker.clearFailures() + return selectInAppForPlace(placeSystemName, InAppEventType.EmbeddedPlaceRequested(placeSystemName), isRetryAfterSessionReset = true) + } + private fun placeTrigger(place: PlaceKey, triggerEvent: InAppEventType): InAppEventType { if (triggerEvent is InAppEventType.OrdinalEvent) { sessionStorageManager.state.embeddedLastOperationByPlace[place] = triggerEvent @@ -282,7 +290,11 @@ internal class InAppInteractorImpl( override suspend fun filterShowableInAppIds(hostInAppId: String, inAppIds: List): List { if (inAppIds.isEmpty()) return emptyList() - val inApps = mobileConfigRepository.getInAppsSection() + val inApps = mobileConfigRepository.getInAppsSectionIfAvailable() ?: run { + logI("Page of $hostInAppId asks about ${inAppIds.size} id(s) while the config is unavailable, cutting them all") + return emptyList() + } + inAppRepository.saveCurrentSessionInApps(inApps) val inAppsPool = inAppABTestLogic.getInAppsPool(inApps.map { it.id }) val showableIds = inAppFilteringManager.filterABTestsInApps(inApps, inAppsPool) .map { inApp -> inApp.id } 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 e0ae47d99..ab22b70cc 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 @@ -180,6 +180,8 @@ internal class InAppProcessingManagerImpl( inApp.targeting.fetchTargetingInfo(data) }.onFailure { throwable -> when (throwable) { + is CancellationException -> throw throwable + is GeoError -> { isTargetingErrorOccurred = true inAppGeoRepository.setGeoStatus(GeoFetchStatus.GEO_FETCH_ERROR) 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 2d4d7e7b2..9e5caeb75 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 @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.EmbeddedResolveOutcome import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.models.PlaceKey +import org.junit.Assert.assertSame import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.ShowReservationOutcome @@ -1138,5 +1139,72 @@ class EmbeddedResolveInteractorTest { verify(exactly = 1) { inAppRepository.saveCurrentSessionInApps(any()) } } + @Test + fun `filterShowableInAppIds saves the config's in-apps into the session before checking`() = runTest { + val config = listOf(modalInApp(id = "inapp-1")) + givenConfig(*config.toTypedArray()) + + interactor.filterShowableInAppIds("host-form", listOf("inapp-1")) + + coVerifyOrder { + inAppRepository.saveCurrentSessionInApps(config) + inAppProcessingManager.matchesRequestedTargeting(match { it.id == "inapp-1" }) + } + } + + @Test + fun `selectInAppForPlace resolves again when the session was reset while its candidates were checked`() = runTest { + givenConfig(embeddedInApp(), modalInApp(id = "story-1")) + val nextSession = SessionState() + var checks = 0 + coEvery { inAppProcessingManager.matchesTargeting(any(), any()) } coAnswers { + if (checks++ == 0) every { sessionStorageManager.state } returns nextSession + true + } + + val content = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)).variantOrNull() + + assertEquals("embedded-id", content?.inAppId) + coVerify(exactly = 2) { inAppProcessingManager.prefetchTargetingDependencies(any()) } + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(any()) } + } + + @Test + fun `selectInAppForPlace resolves again as a plain place request, leaving the old session's operation behind`() = runTest { + givenConfig(embeddedInApp(), modalInApp(id = "story-1")) + val operation = InAppEventType.OrdinalEvent(EventType.SyncOperation("viewProduct"), null) + val nextSession = SessionState() + var prefetches = 0 + coEvery { inAppProcessingManager.prefetchTargetingDependencies(any()) } coAnswers { + if (prefetches++ == 0) every { sessionStorageManager.state } returns nextSession + } + + interactor.selectInAppForPlace(place, operation) + + coVerify(exactly = 1) { inAppProcessingManager.matchesTargeting(any(), ofType()) } + coVerify(exactly = 0) { inAppProcessingManager.matchesTargeting(any(), operation) } + assertFalse(nextSession.embeddedLastOperationByPlace.containsKey(place)) + assertSame(operation, sessionState.embeddedLastOperationByPlace[place]) + } + + @Test + fun `selectInAppForPlace drops the failures the abandoned pass collected before resolving again`() = runTest { + givenConfig(embeddedInApp(), modalInApp(id = "story-1")) + val nextSession = SessionState() + var checks = 0 + coEvery { inAppProcessingManager.matchesTargeting(any(), any()) } coAnswers { + if (checks++ == 0) every { sessionStorageManager.state } returns nextSession + true + } + + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + coVerifyOrder { + inAppProcessingManager.matchesTargeting(any(), any()) + inAppFailureTracker.clearFailures() + inAppProcessingManager.prefetchTargetingDependencies(any()) + } + } + private fun EmbeddedResolveOutcome.variantOrNull(): InAppType.Embedded? = contentOrNull()?.variant } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt index 25bf3ebc1..c2562ad63 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.domain +import kotlinx.coroutines.flow.flowOf import android.content.Context import cloud.mindbox.mobile_sdk.di.MindboxDI import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE @@ -17,6 +18,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppReposi import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppTargetingErrorRepository import cloud.mindbox.mobile_sdk.inapp.domain.models.* +import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.models.* import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason @@ -1468,16 +1470,64 @@ internal class InAppProcessingManagerTest { @Test fun `place targeting matches from the session cache and collects nothing`() = runTest { + sessionStorageManager.state.customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + sessionStorageManager.state.inAppCustomerSegmentations = SegmentationCheckWrapper( + "Success", + listOf(SegmentationCheckInAppStub.getCustomerSegmentation().copy(segmentation = "segmentationEI", segment = "segmentEI")) + ) + setDIModule(mockkInAppGeoRepository, inAppSegmentationRepositoryTestImpl) val inSegment = InAppStub.getTargetingSegmentNode().copy( kind = Kind.POSITIVE, segmentationExternalId = "segmentationEI", segmentExternalId = "segmentEI" ) - assertTrue(inAppProcessingManager.matchesTargeting(inAppTargeting("ribbon", inSegment), event)) - coVerify(exactly = 0) { mockkInAppSegmentationRepository.fetchCustomerSegmentations() } + assertTrue(inAppProcessingManagerTestImpl.matchesTargeting(inAppTargeting("ribbon", inSegment), event)) + coVerify(exactly = 0) { gatewayManager.checkCustomerSegmentations(any(), any()) } verify(exactly = 0) { inAppFailureTracker.collectFailure(any(), any(), any(), any()) } } + @Test + fun `send targeted inApp passes cancellation through and sends no targeting`() = runTest { + val targeting = mockk(relaxed = true) + coEvery { targeting.fetchTargetingInfo(any()) } throws CancellationException("session expired") + every { targeting.checkTargeting(any()) } returns true + + val error = runCatching { inAppProcessingManager.sendTargetedInApp(inAppTargeting("overlay", targeting), event) }.exceptionOrNull() + + assertTrue(error is CancellationException) + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } + } + + @Test + fun `a page question on a fresh session fetches the segmentations once and keeps the circle`() = runTest { + sessionStorageManager.state.currentSessionInApps = listOf(InAppStub.getInApp().copy(id = "story")) + every { inAppSegmentationRepositoryTestImpl.getCustomerSegmentationFetched() } answers { callOriginal() } + coEvery { inAppSegmentationRepositoryTestImpl.fetchCustomerSegmentations() } answers { callOriginal() } + every { inAppSegmentationRepositoryTestImpl.getCustomerSegmentations() } answers { callOriginal() } + mockkObject(DbManager) + try { + coEvery { DbManager.listenConfigurations() } returns flowOf(mockk(relaxed = true)) + coEvery { gatewayManager.checkCustomerSegmentations(any(), any()) } returns + SegmentationCheckInAppStub.getSegmentationCheckResponse().copy("Success", listOf()) + every { inAppMapper.mapToSegmentationCheck(any()) } returns SegmentationCheckWrapper( + "Success", + listOf(SegmentationCheckInAppStub.getCustomerSegmentation().copy(segmentation = "segmentationEI", segment = "segmentEI")) + ) + setDIModule(mockkInAppGeoRepository, inAppSegmentationRepositoryTestImpl) + val inSegment = InAppStub.getTargetingSegmentNode().copy( + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ) + + assertTrue(inAppProcessingManagerTestImpl.matchesRequestedTargeting(inAppTargeting("story", inSegment))) + assertTrue(inAppProcessingManagerTestImpl.matchesRequestedTargeting(inAppTargeting("story", inSegment))) + coVerify(exactly = 1) { gatewayManager.checkCustomerSegmentations(any(), any()) } + } finally { + unmockkObject(DbManager) + } + } + private fun inAppTargeting(id: String, targeting: TreeTargeting): InApp = InAppStub.getInApp().copy(id = id, targeting = targeting) }