diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/common/usecase/UpiLiteBannerRotationUseCase.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/common/usecase/UpiLiteBannerRotationUseCase.kt new file mode 100644 index 0000000000..80689c10d2 --- /dev/null +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/common/usecase/UpiLiteBannerRotationUseCase.kt @@ -0,0 +1,64 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.pay.common.usecase + +import com.navi.base.cache.model.NaviCacheEntity +import com.navi.base.cache.repository.NaviCacheRepository +import com.navi.pay.management.lite.models.view.UpiLiteBannerDisplayType +import javax.inject.Inject + +class UpiLiteBannerRotationUseCase +@Inject +constructor(private val naviCacheRepository: NaviCacheRepository) { + companion object { + private const val UPI_LITE_BANNER_POSITION_KEY = "upiLiteBannerPosition" + private const val UPI_LITE_BANNERS_COUNT = 3 + private const val BANNER_ROTATION_INTERVAL = 2 + private const val TOTAL_VISIT_COUNT_PER_ROTATION = + UPI_LITE_BANNERS_COUNT * BANNER_ROTATION_INTERVAL + } + + /** + * Returns the current UPI Lite banner display type based on the rotation position and advances + * the position counter for the next call. + */ + suspend fun execute(): UpiLiteBannerDisplayType { + val currentPosition = getCurrentPosition() + val bannerType = getBannerTypeForPosition(position = currentPosition) + moveToNextPosition(currentPosition = currentPosition) + return bannerType + } + + private fun getBannerTypeForPosition(position: Int): UpiLiteBannerDisplayType { + return when (position / BANNER_ROTATION_INTERVAL) { + 0 -> UpiLiteBannerDisplayType.FAST_TRANSACTION + 1 -> UpiLiteBannerDisplayType.SIMPLIFY_BANK_STATEMENTS + else -> UpiLiteBannerDisplayType.BANK_SERVER_DOWN + } + } + + private suspend fun getCurrentPosition(): Int { + return naviCacheRepository + .get(UPI_LITE_BANNER_POSITION_KEY, 0) + ?.value + ?.toIntOrNull() + ?.mod(TOTAL_VISIT_COUNT_PER_ROTATION) ?: 0 + } + + private suspend fun moveToNextPosition(currentPosition: Int) { + val nextPosition = (currentPosition + 1) % TOTAL_VISIT_COUNT_PER_ROTATION + + naviCacheRepository.save( + NaviCacheEntity( + key = UPI_LITE_BANNER_POSITION_KEY, + value = nextPosition.toString(), + version = 0, + ) + ) + } +} diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/models/view/UpiLiteUspExperimentVariant.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/models/view/UpiLiteBannerDisplayType.kt similarity index 86% rename from android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/models/view/UpiLiteUspExperimentVariant.kt rename to android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/models/view/UpiLiteBannerDisplayType.kt index 809ab8fab0..7216060cd9 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/models/view/UpiLiteUspExperimentVariant.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/models/view/UpiLiteBannerDisplayType.kt @@ -7,7 +7,7 @@ package com.navi.pay.management.lite.models.view -enum class UpiLiteUspExperimentVariant { +enum class UpiLiteBannerDisplayType { DEFAULT, FAST_TRANSACTION, BANK_SERVER_DOWN, diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/ui/UpiLiteSection.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/ui/UpiLiteSection.kt index e0585f57e8..3a09648f10 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/ui/UpiLiteSection.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/ui/UpiLiteSection.kt @@ -108,7 +108,7 @@ import com.navi.pay.common.utils.SnackBarPredefinedConfig import com.navi.pay.management.common.sendmoney.model.view.BankAccountsState import com.navi.pay.management.lite.models.NaviPayUpiLiteConfig import com.navi.pay.management.lite.models.view.AmountChipEntity -import com.navi.pay.management.lite.models.view.UpiLiteUspExperimentVariant +import com.navi.pay.management.lite.models.view.UpiLiteBannerDisplayType import com.navi.pay.management.lite.util.AddBalanceButtonSource import com.navi.pay.management.lite.util.SetPinButtonSource import com.navi.pay.management.lite.util.UpiLiteMainCtaState @@ -198,10 +198,9 @@ fun UpiLiteSection( upiLiteViewModel.liteMandateTopUpAmount.collectAsStateWithLifecycle() val maximumTopUpAmountAllowed by upiLiteViewModel.maximumTopUpAmountAllowed.collectAsStateWithLifecycle() - val naviPayUpiLiteConfig by upiLiteViewModel.naviPayUpiLiteConfig.collectAsStateWithLifecycle() val isMainCtaEnabled by upiLiteViewModel.isMainCtaEnabled.collectAsStateWithLifecycle() - val upiLiteUspExperimentVariant by - upiLiteViewModel.upiLiteUspExperimentVariant.collectAsStateWithLifecycle() + val upiLiteBannerDisplayType by + upiLiteViewModel.upiLiteBannerDisplayType.collectAsStateWithLifecycle() val scrollState = rememberScrollState() val linkedAccountWithActiveLiteAccount by @@ -243,8 +242,7 @@ fun UpiLiteSection( onBackClick = onBackClick, onMenuOptionClicked = onMenuOptionClicked, isLiteInSyncedState = isLiteInSyncedState, - naviPayUpiLiteConfig = naviPayUpiLiteConfig, - upiLiteUspExperimentVariant = upiLiteUspExperimentVariant, + upiLiteBannerDisplayType = upiLiteBannerDisplayType, isRefreshBalanceLottieVisible = isRefreshBalanceLottieVisible, onRefreshBalanceButtonClicked = upiLiteViewModel::onRefreshBalanceButtonClicked, @@ -667,27 +665,17 @@ fun UpiLiteBannerSection( onBackClick: () -> Unit, onMenuOptionClicked: () -> Unit, isLiteInSyncedState: Boolean, - naviPayUpiLiteConfig: NaviPayUpiLiteConfig, - upiLiteUspExperimentVariant: UpiLiteUspExperimentVariant, + upiLiteBannerDisplayType: UpiLiteBannerDisplayType, isRefreshBalanceLottieVisible: Boolean, onRefreshBalanceButtonClicked: () -> Unit, ) { if (!isUserOnboarded) { - if (upiLiteUspExperimentVariant == UpiLiteUspExperimentVariant.DEFAULT) { - UpiLiteNonOnboardedMultipleUspBanner( - onHelpCtaClicked = onHelpCtaClicked, - onBackClick = onBackClick, - onMenuOptionClicked = onMenuOptionClicked, - naviPayUpiLiteConfig = naviPayUpiLiteConfig, - ) - } else { - UpiLiteNonOnboardedSingleUspBanner( - onHelpCtaClicked = onHelpCtaClicked, - onBackClick = onBackClick, - onMenuOptionClicked = onMenuOptionClicked, - upiLiteExperimentVariant = upiLiteUspExperimentVariant, - ) - } + UpiLiteNonOnboardedSingleUspBanner( + onHelpCtaClicked = onHelpCtaClicked, + onBackClick = onBackClick, + onMenuOptionClicked = onMenuOptionClicked, + upiLiteBannerDisplayType = upiLiteBannerDisplayType, + ) } else if (isLiteBalanceLow) { UpiLiteLowBalanceBanner( onHelpCtaClicked = onHelpCtaClicked, @@ -980,7 +968,7 @@ fun UpiLiteNonOnboardedSingleUspBanner( onHelpCtaClicked: () -> Unit, onBackClick: () -> Unit, onMenuOptionClicked: () -> Unit, - upiLiteExperimentVariant: UpiLiteUspExperimentVariant, + upiLiteBannerDisplayType: UpiLiteBannerDisplayType, ) { Column( modifier = @@ -1000,10 +988,10 @@ fun UpiLiteNonOnboardedSingleUspBanner( horizontalAlignment = Alignment.CenterHorizontally, ) { Column { - when (upiLiteExperimentVariant) { - UpiLiteUspExperimentVariant.FAST_TRANSACTION -> FastPaymentsUspText() - UpiLiteUspExperimentVariant.BANK_SERVER_DOWN -> BankServerDownText() - UpiLiteUspExperimentVariant.SIMPLIFY_BANK_STATEMENTS -> BankStatementFullText() + when (upiLiteBannerDisplayType) { + UpiLiteBannerDisplayType.FAST_TRANSACTION -> FastPaymentsUspText() + UpiLiteBannerDisplayType.BANK_SERVER_DOWN -> BankServerDownText() + UpiLiteBannerDisplayType.SIMPLIFY_BANK_STATEMENTS -> BankStatementFullText() else -> { FastPaymentsUspText() } diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/viewmodel/UpiLiteViewModel.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/viewmodel/UpiLiteViewModel.kt index 2dfaf48bef..b66e29ffa5 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/viewmodel/UpiLiteViewModel.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/lite/viewmodel/UpiLiteViewModel.kt @@ -25,7 +25,6 @@ import com.navi.common.R as CommonR import com.navi.common.di.CoroutineDispatcherProvider import com.navi.common.network.models.RepoResult import com.navi.common.network.models.isSuccessWithData -import com.navi.common.usecase.LitmusExperimentsUseCase import com.navi.common.utils.CommonUtils.getDisplayableAmount import com.navi.common.utils.CommonUtils.isAmountValid import com.navi.common.utils.Constants @@ -60,6 +59,7 @@ import com.navi.pay.common.usecase.LiteAccountSyncUseCase import com.navi.pay.common.usecase.LocationUseCase import com.navi.pay.common.usecase.NaviPayConfigUseCase import com.navi.pay.common.usecase.UpiLiteBalanceUseCase +import com.navi.pay.common.usecase.UpiLiteBannerRotationUseCase import com.navi.pay.common.usecase.UpiLiteExperimentationUseCase import com.navi.pay.common.usecase.UpiRequestIdUseCase import com.navi.pay.common.utils.DeviceInfoProvider @@ -99,9 +99,9 @@ import com.navi.pay.management.lite.models.view.InitialTopUpStatus import com.navi.pay.management.lite.models.view.InitialTopUpStatus.Companion.isPendingState import com.navi.pay.management.lite.models.view.UPILiteAccountStatus import com.navi.pay.management.lite.models.view.UPILiteEntity +import com.navi.pay.management.lite.models.view.UpiLiteBannerDisplayType import com.navi.pay.management.lite.models.view.UpiLiteBottomSheetStateHolder import com.navi.pay.management.lite.models.view.UpiLiteDefaultEnteredAmountExperimentData -import com.navi.pay.management.lite.models.view.UpiLiteUspExperimentVariant import com.navi.pay.management.lite.repository.UPILiteRepository import com.navi.pay.management.lite.util.AddBalanceButtonSource import com.navi.pay.management.lite.util.DisableUpiLiteStatus @@ -155,7 +155,6 @@ import com.navi.pay.utils.DEFAULT_UPI_MODE import com.navi.pay.utils.KEY_UPI_LITE_ACTIVE_ACCOUNT_INFO import com.navi.pay.utils.KEY_UPI_LITE_MANDATE_INFO import com.navi.pay.utils.LITE_MANDATE -import com.navi.pay.utils.LITMUS_EXPERIMENT_NAVIPAY_UPI_LITE_USP import com.navi.pay.utils.MONEY_ADDED_TO_UPI_LITE import com.navi.pay.utils.NAVI_PAY_DEFAULT_MCC import com.navi.pay.utils.NAVI_PAY_PURPLE_CTA_LOADER_LOTTIE @@ -231,7 +230,7 @@ constructor( private val paymentStatusRepository: PaymentStatusRepository, private val upiLiteExperimentationUseCase: UpiLiteExperimentationUseCase, val accountListCheckBalanceUseCase: AccountListCheckBalanceUseCase, - private val litmusExperimentsUseCase: LitmusExperimentsUseCase, + private val upiLiteBannerRotationUseCase: UpiLiteBannerRotationUseCase, private val naviPayPspManager: NaviPayPspManager, @NaviPayGsonBuilder private val gson: Gson, private val arcNudgeUseCase: ArcNudgeUseCase, @@ -413,8 +412,8 @@ constructor( private val noOfAutoTopUpSupportedBanks = MutableStateFlow(0) val isAutoTopUpSetUp = MutableStateFlow(false) - private val _upiLiteUspExperimentVariant = MutableStateFlow(UpiLiteUspExperimentVariant.DEFAULT) - val upiLiteUspExperimentVariant = _upiLiteUspExperimentVariant.asStateFlow() + private val _upiLiteBannerDisplayType = MutableStateFlow(UpiLiteBannerDisplayType.DEFAULT) + val upiLiteBannerDisplayType = _upiLiteBannerDisplayType.asStateFlow() private var previousLinkedAccountsSize = emptyList() @@ -756,18 +755,17 @@ constructor( ) val statusBarColor = - combine(uiState, isUpiLiteBalanceLow, upiLiteUspExperimentVariant, isUserOnboarded) { + combine(uiState, isUpiLiteBalanceLow, upiLiteBannerDisplayType, isUserOnboarded) { uiState, isUpiLiteBalanceLow, - upiLiteExperimentVariant, + upiLiteBannerDisplayType, isUserOnboarded -> if (uiState != UpiLiteUiState.MAIN_SCREEN) { R.color.navi_pay_status_bar_default_color } else if (isUpiLiteBalanceLow) { R.color.navi_pay_bg_alt } else if ( - !isUserOnboarded && - upiLiteExperimentVariant != UpiLiteUspExperimentVariant.DEFAULT + !isUserOnboarded && upiLiteBannerDisplayType != UpiLiteBannerDisplayType.DEFAULT ) { R.color.navi_pay_status_bar_lottie_green } else { @@ -971,7 +969,7 @@ constructor( init { fetchNaviPayUpiLiteConfig() setLitmusExperimentValues() - setUpiLiteUspExperimentValues() + setUpUpiLiteBannerDisplayType() getLiteMandateInfo() updateNaviPaySessionAttributes() getLiteInfoIfActiveAccountIsPresent() @@ -1169,26 +1167,9 @@ constructor( } } - private fun setUpiLiteUspExperimentValues() { + private fun setUpUpiLiteBannerDisplayType() { viewModelScope.launch(coroutineDispatcherProvider.io) { - val upiLiteUspExperimentVariantInfo = - litmusExperimentsUseCase - .execute(experimentName = LITMUS_EXPERIMENT_NAVIPAY_UPI_LITE_USP) - ?.variant - - val upiLiteUspExperimentVariantType = - if (upiLiteUspExperimentVariantInfo?.enabled == false) { - UpiLiteUspExperimentVariant.DEFAULT - } else { - when (upiLiteUspExperimentVariantInfo?.name) { - "v1_fast_txns" -> UpiLiteUspExperimentVariant.FAST_TRANSACTION - "v2_bank_downtime" -> UpiLiteUspExperimentVariant.BANK_SERVER_DOWN - "v3_clean_statement" -> UpiLiteUspExperimentVariant.SIMPLIFY_BANK_STATEMENTS - else -> UpiLiteUspExperimentVariant.DEFAULT - } - } - - _upiLiteUspExperimentVariant.update { upiLiteUspExperimentVariantType } + _upiLiteBannerDisplayType.update { upiLiteBannerRotationUseCase.execute() } } } diff --git a/android/navi-pay/src/test/kotlin/com/navi/pay/common/UpiLiteBannerRotationUseCaseTest.kt b/android/navi-pay/src/test/kotlin/com/navi/pay/common/UpiLiteBannerRotationUseCaseTest.kt new file mode 100644 index 0000000000..f0352691b9 --- /dev/null +++ b/android/navi-pay/src/test/kotlin/com/navi/pay/common/UpiLiteBannerRotationUseCaseTest.kt @@ -0,0 +1,166 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.pay.common + +import com.navi.base.cache.model.NaviCacheEntity +import com.navi.base.cache.repository.NaviCacheRepository +import com.navi.pay.common.usecase.UpiLiteBannerRotationUseCase +import com.navi.pay.management.lite.models.view.UpiLiteBannerDisplayType +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class UpiLiteBannerRotationUseCaseTest { + + private lateinit var naviCacheRepository: NaviCacheRepository + private lateinit var useCase: UpiLiteBannerRotationUseCase + + @Before + fun setUp() { + naviCacheRepository = mockk(relaxed = true) + useCase = UpiLiteBannerRotationUseCase(naviCacheRepository) + } + + @Test + fun `execute returns FAST_TRANSACTION for positions 0 and 1`() = runBlocking { + coEvery { naviCacheRepository.get(key = "upiLiteBannerPosition", version = 0) } returns + NaviCacheEntity(key = "upiLiteBannerPosition", value = "0", version = 0) + + val result = useCase.execute() + + assertEquals(UpiLiteBannerDisplayType.FAST_TRANSACTION, result) + coVerify { naviCacheRepository.save(match { it.value == "1" }) } + + coEvery { naviCacheRepository.get(key = "upiLiteBannerPosition", version = 0) } returns + NaviCacheEntity(key = "upiLiteBannerPosition", value = "1", version = 0) + + val result2 = useCase.execute() + + assertEquals(UpiLiteBannerDisplayType.FAST_TRANSACTION, result2) + coVerify { naviCacheRepository.save(match { it.value == "2" }) } + } + + @Test + fun `execute returns SIMPLIFY_BANK_STATEMENTS for positions 2 and 3`() = runBlocking { + coEvery { naviCacheRepository.get(key = "upiLiteBannerPosition", version = 0) } returns + NaviCacheEntity(key = "upiLiteBannerPosition", value = "2", version = 0) + + val result = useCase.execute() + + assertEquals(UpiLiteBannerDisplayType.SIMPLIFY_BANK_STATEMENTS, result) + coVerify { naviCacheRepository.save(match { it.value == "3" }) } + + coEvery { naviCacheRepository.get(key = "upiLiteBannerPosition", version = 0) } returns + NaviCacheEntity(key = "upiLiteBannerPosition", value = "3", version = 0) + + val result2 = useCase.execute() + + assertEquals(UpiLiteBannerDisplayType.SIMPLIFY_BANK_STATEMENTS, result2) + coVerify { naviCacheRepository.save(match { it.value == "4" }) } + } + + @Test + fun `execute returns BANK_SERVER_DOWN for positions 4 and 5`() = runBlocking { + coEvery { naviCacheRepository.get(key = "upiLiteBannerPosition", version = 0) } returns + NaviCacheEntity(key = "upiLiteBannerPosition", value = "4", version = 0) + + val result = useCase.execute() + + assertEquals(UpiLiteBannerDisplayType.BANK_SERVER_DOWN, result) + coVerify { naviCacheRepository.save(match { it.value == "5" }) } + + coEvery { naviCacheRepository.get(key = "upiLiteBannerPosition", version = 0) } returns + NaviCacheEntity(key = "upiLiteBannerPosition", value = "5", version = 0) + + val result2 = useCase.execute() + + assertEquals(UpiLiteBannerDisplayType.BANK_SERVER_DOWN, result2) + coVerify { naviCacheRepository.save(match { it.value == "0" }) } + } + + @Test + fun `execute cycles correctly through all positions`() = runBlocking { + val expectedBannerTypes = + listOf( + UpiLiteBannerDisplayType.FAST_TRANSACTION, + UpiLiteBannerDisplayType.FAST_TRANSACTION, + UpiLiteBannerDisplayType.SIMPLIFY_BANK_STATEMENTS, + UpiLiteBannerDisplayType.SIMPLIFY_BANK_STATEMENTS, + UpiLiteBannerDisplayType.BANK_SERVER_DOWN, + UpiLiteBannerDisplayType.BANK_SERVER_DOWN, + ) + + for (i in 0..5) { + coEvery { naviCacheRepository.get(key = "upiLiteBannerPosition", version = 0) } returns + NaviCacheEntity(key = "upiLiteBannerPosition", value = i.toString(), version = 0) + + val result = useCase.execute() + + assertEquals(expectedBannerTypes[i], result) + val expectedNextPosition = if (i == 5) 0 else i + 1 + coVerify { + naviCacheRepository.save(match { it.value == expectedNextPosition.toString() }) + } + } + } + + @Test + fun `execute handles null repository result`() = runBlocking { + coEvery { naviCacheRepository.get(key = "upiLiteBannerPosition", version = 0) } returns null + + val result = useCase.execute() + + assertEquals(UpiLiteBannerDisplayType.FAST_TRANSACTION, result) + coVerify { naviCacheRepository.save(match { it.value == "1" }) } + } + + @Test + fun `execute handles non-numeric repository value`() = runBlocking { + coEvery { naviCacheRepository.get(key = "upiLiteBannerPosition", version = 0) } returns + NaviCacheEntity(key = "upiLiteBannerPosition", value = "not_a_number", version = 0) + + val result = useCase.execute() + + assertEquals(UpiLiteBannerDisplayType.FAST_TRANSACTION, result) + coVerify { naviCacheRepository.save(match { it.value == "1" }) } + } + + @Test + fun `execute handles position values larger than cycle length`() = runBlocking { + coEvery { naviCacheRepository.get(key = "upiLiteBannerPosition", version = 0) } returns + NaviCacheEntity(key = "upiLiteBannerPosition", value = "12", version = 0) + + val result = useCase.execute() + + assertEquals(UpiLiteBannerDisplayType.FAST_TRANSACTION, result) + coVerify { naviCacheRepository.save(match { it.value == "1" }) } + + coEvery { naviCacheRepository.get(key = "upiLiteBannerPosition", version = 0) } returns + NaviCacheEntity(key = "upiLiteBannerPosition", value = "16", version = 0) + + val result2 = useCase.execute() + + assertEquals(UpiLiteBannerDisplayType.BANK_SERVER_DOWN, result2) + coVerify { naviCacheRepository.save(match { it.value == "5" }) } + } + + @Test + fun `execute handles negative position values`() = runBlocking { + coEvery { naviCacheRepository.get(key = "upiLiteBannerPosition", version = 0) } returns + NaviCacheEntity(key = "upiLiteBannerPosition", value = "-1", version = 0) + + val result = useCase.execute() + + assertEquals(UpiLiteBannerDisplayType.BANK_SERVER_DOWN, result) + coVerify { naviCacheRepository.save(match { it.value == "0" }) } + } +}