Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-10 18:54:12 +03:00
parent fb69f0df7c
commit 935a967deb
18 changed files with 599 additions and 33 deletions

View file

@ -19,9 +19,11 @@ internal class AmplitudeABTestsManager(
private lateinit var client: ExperimentClient
private val logger = TangemLogger.withTag(TAG)
override fun init() {
if (::client.isInitialized) {
TangemLogger.w("AB Tests manager already initialized, skipping")
logger.w("AB Tests manager already initialized, skipping")
return
}
@ -40,7 +42,7 @@ internal class AmplitudeABTestsManager(
val allVariants = client.all()
logAllVariants(allVariants)
} catch (exception: Exception) {
TangemLogger.e("Failed to fetch AB test variants", exception)
logger.e("Failed to fetch AB test variants", exception)
}
}
}
@ -69,26 +71,25 @@ internal class AmplitudeABTestsManager(
}
private fun logAllVariants(allVariants: Map<String, com.amplitude.experiment.Variant>) {
TangemLogger.d("=".repeat(SEPARATOR_LENGTH))
TangemLogger.d("AB Tests: Fetched ${allVariants.size} variants")
TangemLogger.d("=".repeat(SEPARATOR_LENGTH))
if (allVariants.isEmpty()) {
TangemLogger.d("No variants available")
} else {
allVariants.entries.forEachIndexed { index, (key, variant) ->
TangemLogger.d("[${index + 1}/${allVariants.size}] Key: $key")
TangemLogger.d(" → Value: ${variant.value ?: "null"}")
TangemLogger.d(" → Payload: ${variant.payload ?: "null"}")
TangemLogger.d(" → Key: ${variant.key ?: "null"}")
TangemLogger.d("-".repeat(SEPARATOR_LENGTH))
val message = buildString {
appendLine("AB Tests: Fetched ${allVariants.size} variants")
if (allVariants.isEmpty()) {
append("No variants available")
} else {
allVariants.entries.forEachIndexed { index, (key, variant) ->
appendLine("[${index + 1}/${allVariants.size}] $key")
appendLine(" → value: ${variant.value ?: "null"}")
appendLine(" → key: ${variant.key ?: "null"}")
append(" → payload: ${variant.payload ?: "null"}")
if (index != allVariants.size - 1) appendLine()
}
}
}
TangemLogger.d("=".repeat(SEPARATOR_LENGTH))
logger.i(message)
}
private companion object {
const val SEPARATOR_LENGTH = 50
const val TAG = "AmplitudeABTestsManager"
}
}

View file

@ -79,6 +79,10 @@
"name": "TWI_1403_PUSH_NOTIFICATION_SETTINGS_ENABLED",
"version": "undefined"
},
{
"name": "AND_15596_ONBOARDING_PUSH_NOTIFICATION_DOUBLE_ASK_AB_ENABLED",
"version": "undefined"
},
{
"name": "AND_15310_ADD_FUNDS_STAGE1",
"version": "5.39"

View file

@ -1257,6 +1257,9 @@
<string name="push_notification_settings_title">Notification Settings</string>
<string name="push_notification_settings_transaction_alerts_subtitle">Real-time alerts for transactions, exchanges, and critical updates.</string>
<string name="push_notification_settings_transaction_alerts_title">Transaction Alerts</string>
<string name="push_notification_warning_sheet_button_enable">Enable notifications</string>
<string name="push_notification_warning_sheet_description">You won\'t receive notifications about your deposits, withdrawals, and transactions. You can turn them on anytime in Wallet Settings.</string>
<string name="push_notification_warning_sheet_title">Notifications disabled</string>
<string name="push_notifications_more_info">More info</string>
<string name="push_notifications_permission_alert_description">You can enable Notifications for Tangem in Settings.</string>
<string name="push_notifications_permission_alert_negative_button">Enable Later</string>
@ -2218,6 +2221,10 @@
<string name="warning_access_denied_message">Use %s or scan a card/ring to unlock access to your wallet</string>
<string name="warning_approval_in_progress_message">The permission-granting process is currently underway and will be completed shortly</string>
<string name="warning_approval_in_progress_title">Approval in Progress</string>
<string name="warning_backup_error_add_funds_message">This wallet has a backup issue. Contact Support to resolve it.</string>
<string name="warning_backup_error_add_funds_title">Adding funds is disabled</string>
<string name="warning_backup_error_attention_message">The backup process wasnt completed correctly, possibly due to an NFC connection issue or how the cards were tapped to the phone. Adding funds is unavailable until this is resolved.</string>
<string name="warning_backup_error_attention_title">Backup issue detected</string>
<string name="warning_backup_errors_message">Activation was not completed successfully. This may be due to an NFC issue or incorrect tapping. Please contact our Support team for assistance.</string>
<string name="warning_backup_errors_title">Activation error</string>
<string name="warning_beacon_chain_retirement_content">On December 3, 2024, the BEP-2 network was disabled by decision of the network developers and is no longer supported</string>

View file

@ -3,6 +3,7 @@ package com.tangem.core.ui.utils
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalInspectionMode
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@ -15,6 +16,8 @@ import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun requestPermission(permission: String, onAllow: () -> Unit, onDeny: () -> Unit): () -> Unit {
if (LocalInspectionMode.current) return {}
val permissionState = rememberPermissionState(
permission = permission,
onPermissionResult = { isGranted ->

View file

@ -0,0 +1,7 @@
package com.tangem.features.pushnotifications
interface PushNotificationsFeatureToggles {
/** Kill switch for the onboarding "Double Ask" A/B experiment (`twi_1403_onboarding_push_notification_double_ask`). */
val isOnboardingPushDoubleAskAbEnabled: Boolean
}

View file

@ -105,4 +105,46 @@ sealed class PushNotificationAnalyticEvents(
AnalyticsParam.ERROR_TYPE to errorType,
),
)
data class WarningScreenShown(
val source: AnalyticsParam.ScreensSources,
val variant: String,
) : PushNotificationAnalyticEvents(
event = "[Warning Screen] Shown",
params = mapOf(
WARNING_SCREEN_PARAM_VARIANT to variant,
WARNING_SCREEN_PARAM_ZONE to source.toWarningScreenZone(),
),
)
data class WarningScreenEnableTapped(
val source: AnalyticsParam.ScreensSources,
val variant: String,
) : PushNotificationAnalyticEvents(
event = "[Warning Screen] Enable Tapped",
params = mapOf(
WARNING_SCREEN_PARAM_VARIANT to variant,
WARNING_SCREEN_PARAM_ZONE to source.toWarningScreenZone(),
),
)
data class WarningScreenSkipTapped(
val source: AnalyticsParam.ScreensSources,
val variant: String,
) : PushNotificationAnalyticEvents(
event = "[Warning Screen] Skip Tapped",
params = mapOf(
WARNING_SCREEN_PARAM_VARIANT to variant,
WARNING_SCREEN_PARAM_ZONE to source.toWarningScreenZone(),
),
)
}
private const val WARNING_SCREEN_PARAM_VARIANT = "variant"
private const val WARNING_SCREEN_PARAM_ZONE = "zone"
private fun AnalyticsParam.ScreensSources.toWarningScreenZone(): String = when (this) {
AnalyticsParam.ScreensSources.Onboarding -> "onboarding"
AnalyticsParam.ScreensSources.Main -> "main"
else -> value
}

View file

@ -21,6 +21,7 @@ dependencies {
implementation(deps.compose.foundation)
implementation(deps.compose.accompanist.systemUiController)
implementation(deps.compose.accompanist.permission)
implementation(deps.lifecycle.compose)
/** Other dependencies */
implementation(deps.arrow.core)
@ -34,6 +35,7 @@ dependencies {
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.utils)
implementation(projects.core.abTests)
/** Common modules */
implementation(projects.common.routing)
@ -53,4 +55,10 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Test */
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -2,8 +2,10 @@ package com.tangem.features.pushnotifications.impl
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.NavigationBar3ButtonsScrim
@ -12,6 +14,8 @@ import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import com.tangem.features.pushnotifications.api.PushNotificationsParams
import com.tangem.features.pushnotifications.impl.model.PushNotificationsModel
import com.tangem.features.pushnotifications.impl.presentation.ui.PushNotificationsScreen
import com.tangem.features.pushnotifications.impl.presentation.ui.PushNotificationsUM
import com.tangem.features.pushnotifications.impl.presentation.ui.PushNotificationsDoubleAskSheetState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -26,12 +30,21 @@ internal class DefaultPushNotificationsComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val activity = LocalContext.current.findActivity()
val isDoubleAskSheetShown by model.isDoubleAskSheetShown.collectAsStateWithLifecycle()
BackHandler(onBack = { activity.finish() })
NavigationBar3ButtonsScrim()
PushNotificationsScreen(
isPushNotificationSettingsEnabled = model.isPushNotificationSettingsEnabled,
state = PushNotificationsUM(
isPushNotificationSettingsEnabled = model.isPushNotificationSettingsEnabled,
doubleAskSheet = PushNotificationsDoubleAskSheetState(
isShown = isDoubleAskSheetShown,
onEnableClick = model::onDoubleAskEnableClick,
onSkipClick = model::onDoubleAskSkipClick,
onDismiss = model::onDoubleAskDismiss,
),
),
onAllowClick = model::onAllowClick,
onLaterClick = model::onLaterClick,
onAllowPermission = model::onAllowPermission,

View file

@ -0,0 +1,16 @@
package com.tangem.features.pushnotifications.impl
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.pushnotifications.PushNotificationsFeatureToggles
import javax.inject.Inject
internal class DefaultPushNotificationsFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : PushNotificationsFeatureToggles {
override val isOnboardingPushDoubleAskAbEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(
FeatureToggles.AND_15596_ONBOARDING_PUSH_NOTIFICATION_DOUBLE_ASK_AB_ENABLED,
)
}

View file

@ -1,10 +1,12 @@
package com.tangem.features.pushnotifications.impl.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.pushnotifications.PushNotificationsFeatureToggles
import com.tangem.features.pushnotifications.api.PushNotificationsBottomSheetComponent
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import com.tangem.features.pushnotifications.impl.DefaultPushNotificationsBottomSheetComponent
import com.tangem.features.pushnotifications.impl.DefaultPushNotificationsComponent
import com.tangem.features.pushnotifications.impl.DefaultPushNotificationsFeatureToggles
import com.tangem.features.pushnotifications.impl.model.PushNotificationsModel
import dagger.Binds
import dagger.Module
@ -12,6 +14,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@ -29,4 +32,8 @@ internal interface PushNotificationsModule {
@IntoMap
@ClassKey(PushNotificationsModel::class)
fun bindModel(model: PushNotificationsModel): Model
@Binds
@Singleton
fun bindFeatureToggles(impl: DefaultPushNotificationsFeatureToggles): PushNotificationsFeatureToggles
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.pushnotifications.impl.domain
enum class DoubleAskVariant(val key: String) {
Off(key = "control"),
On(key = "treatment"),
;
companion object {
fun fromKey(value: String): DoubleAskVariant =
entries.firstOrNull { it.key.equals(value, ignoreCase = true) } ?: Off
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.features.pushnotifications.impl.domain
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.features.pushnotifications.PushNotificationsFeatureToggles
import javax.inject.Inject
class GetPushNotificationsDoubleAskVariantUseCase @Inject constructor(
private val pushNotificationsFeatureToggles: PushNotificationsFeatureToggles,
private val abTestsManager: ABTestsManager,
) {
operator fun invoke(): DoubleAskVariant {
if (!pushNotificationsFeatureToggles.isOnboardingPushDoubleAskAbEnabled) {
return DoubleAskVariant.Off
}
val variant = abTestsManager.getValue(AMPLITUDE_ID, DoubleAskVariant.Off.key)
return DoubleAskVariant.fromKey(variant)
}
private companion object {
const val AMPLITUDE_ID = "twi_1403_onboarding_push_notification_double_ask"
}
}

View file

@ -8,4 +8,10 @@ internal interface PushNotificationsClickIntents {
fun onAllowPermission()
fun onDenyPermission()
fun onDoubleAskEnableClick()
fun onDoubleAskSkipClick()
fun onDoubleAskDismiss()
}

View file

@ -18,9 +18,14 @@ import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase
import com.tangem.features.pushnotifications.api.PushNotificationsParams
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.features.pushnotifications.impl.domain.GetPushNotificationsDoubleAskVariantUseCase
import com.tangem.features.pushnotifications.impl.domain.DoubleAskVariant
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -39,6 +44,7 @@ internal class PushNotificationsModel @Inject constructor(
private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
private val accountsCRUDRepository: AccountsCRUDRepository,
private val getPushNotificationsDoubleAskVariantUseCase: GetPushNotificationsDoubleAskVariantUseCase,
) : Model(), PushNotificationsClickIntents {
val params: PushNotificationsParams = paramsContainer.require()
@ -51,6 +57,11 @@ internal class PushNotificationsModel @Inject constructor(
AppRoute.PushNotification.Source.Onboarding -> AnalyticsParam.ScreensSources.Onboarding
}
private val _isDoubleAskSheetShown = MutableStateFlow(false)
val isDoubleAskSheetShown: StateFlow<Boolean> = _isDoubleAskSheetShown.asStateFlow()
private var resolvedVariant: String = DoubleAskVariant.Off.key
init {
analyticHandler.send(PushNotificationAnalyticEvents.NotificationsScreenOpened(source))
}
@ -64,16 +75,48 @@ internal class PushNotificationsModel @Inject constructor(
override fun onLaterClick() {
analyticHandler.send(PushNotificationAnalyticEvents.ButtonLater(source))
modelScope.launch {
neverRequestPermissionUseCase(PUSH_PERMISSION)
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
if (params.isBottomSheet) {
notificationsRepository.setUserAllowToSubscribeOnPushNotifications(false)
} else {
params.nextRoute?.let { appRouter.push(it) }
}
params.modelCallbacks.onDenySystemPermission()
if (isOnWalletScreen()) {
modelScope.launch { proceedAfterLater() }
return
}
val variant = getPushNotificationsDoubleAskVariantUseCase()
resolvedVariant = variant.key
if (variant == DoubleAskVariant.On) {
analyticHandler.send(PushNotificationAnalyticEvents.WarningScreenShown(source, resolvedVariant))
_isDoubleAskSheetShown.value = true
} else {
modelScope.launch { proceedAfterLater() }
}
}
override fun onDoubleAskEnableClick() {
analyticHandler.send(PushNotificationAnalyticEvents.WarningScreenEnableTapped(source, resolvedVariant))
modelScope.launch {
notificationsRepository.setUserAllowToSubscribeOnPushNotifications(true)
}
}
override fun onDoubleAskSkipClick() {
analyticHandler.send(PushNotificationAnalyticEvents.WarningScreenSkipTapped(source, resolvedVariant))
modelScope.launch { proceedAfterLater() }
}
override fun onDoubleAskDismiss() {
_isDoubleAskSheetShown.value = false
}
private fun isOnWalletScreen(): Boolean =
params.isBottomSheet && params.source == AppRoute.PushNotification.Source.Main
private suspend fun proceedAfterLater() {
neverRequestPermissionUseCase(PUSH_PERMISSION)
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
if (params.isBottomSheet) {
notificationsRepository.setUserAllowToSubscribeOnPushNotifications(false)
} else {
params.nextRoute?.let { appRouter.push(it) }
}
params.modelCallbacks.onDenySystemPermission()
}
override fun onAllowPermission() {

View file

@ -1,20 +1,49 @@
package com.tangem.features.pushnotifications.impl.presentation.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM
import com.tangem.core.ui.components.bottomsheets.message.icon
import com.tangem.core.ui.components.bottomsheets.message.infoBlock
import com.tangem.core.ui.components.bottomsheets.message.messageBottomSheetUM
import com.tangem.core.ui.components.bottomsheets.message.onClick
import com.tangem.core.ui.components.bottomsheets.message.primaryButton
import com.tangem.core.ui.components.bottomsheets.message.secondaryButton
import com.tangem.core.ui.components.showcase.Showcase
import com.tangem.core.ui.components.showcase.model.ShowcaseButtonModel
import com.tangem.core.ui.components.showcase.model.ShowcaseItemModel
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.requestPermission
import com.tangem.feature.pushnotifications.impl.R
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import kotlinx.collections.immutable.persistentListOf
/**
* Holds the treatment-variant "Double Ask" bottom sheet state and callbacks for the onboarding soft-ask.
*/
@Immutable
internal data class PushNotificationsDoubleAskSheetState(
val isShown: Boolean,
val onEnableClick: () -> Unit,
val onSkipClick: () -> Unit,
val onDismiss: () -> Unit,
)
@Immutable
internal data class PushNotificationsUM(
val isPushNotificationSettingsEnabled: Boolean,
val doubleAskSheet: PushNotificationsDoubleAskSheetState,
)
@Composable
internal fun PushNotificationsScreen(
isPushNotificationSettingsEnabled: Boolean,
state: PushNotificationsUM,
onAllowClick: () -> Unit,
onLaterClick: () -> Unit,
onAllowPermission: () -> Unit,
@ -26,12 +55,12 @@ internal fun PushNotificationsScreen(
permission = PUSH_PERMISSION,
)
val argumentTwoTitleRes = if (isPushNotificationSettingsEnabled) {
val argumentTwoTitleRes = if (state.isPushNotificationSettingsEnabled) {
R.string.user_push_notification_agreement_argument_two_title_v2
} else {
R.string.user_push_notification_agreement_argument_two_title
}
val argumentTwoSubtitleRes = if (isPushNotificationSettingsEnabled) {
val argumentTwoSubtitleRes = if (state.isPushNotificationSettingsEnabled) {
R.string.user_push_notification_agreement_argument_two_subtitle_v2
} else {
R.string.user_push_notification_agreement_argument_two_subtitle
@ -65,4 +94,84 @@ internal fun PushNotificationsScreen(
),
modifier = Modifier.systemBarsPadding(),
)
if (state.doubleAskSheet.isShown) {
PushNotificationsDoubleAskBottomSheet(
onEnableClick = {
state.doubleAskSheet.onEnableClick()
requestPushPermission()
},
onSkipClick = state.doubleAskSheet.onSkipClick,
onDismiss = state.doubleAskSheet.onDismiss,
)
}
}
@Composable
private fun PushNotificationsDoubleAskBottomSheet(
onEnableClick: () -> Unit,
onSkipClick: () -> Unit,
onDismiss: () -> Unit,
) {
MessageBottomSheet(
state = messageBottomSheetUM {
infoBlock {
icon(com.tangem.core.ui.R.drawable.ic_attention_default_24) {
type = MessageBottomSheetUM.Icon.Type.Attention
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Attention
}
title = resourceReference(R.string.push_notification_warning_sheet_title)
body = resourceReference(R.string.push_notification_warning_sheet_description)
}
primaryButton {
text = resourceReference(R.string.push_notification_warning_sheet_button_enable)
onClick { onEnableClick() }
}
secondaryButton {
text = resourceReference(R.string.common_skip)
onClick { onSkipClick() }
}
},
onDismissRequest = onDismiss,
)
}
private fun previewState(isDoubleAskShown: Boolean) = PushNotificationsUM(
isPushNotificationSettingsEnabled = true,
doubleAskSheet = PushNotificationsDoubleAskSheetState(
isShown = isDoubleAskShown,
onEnableClick = {},
onSkipClick = {},
onDismiss = {},
),
)
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_PushNotificationsScreen() {
TangemThemePreview {
PushNotificationsScreen(
state = previewState(isDoubleAskShown = false),
onAllowClick = {},
onLaterClick = {},
onAllowPermission = {},
onDenyPermission = {},
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_PushNotificationsScreen_DoubleAsk() {
TangemThemePreview {
PushNotificationsScreen(
state = previewState(isDoubleAskShown = true),
onAllowClick = {},
onLaterClick = {},
onAllowPermission = {},
onDenyPermission = {},
)
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.features.pushnotifications.impl.domain
import com.google.common.truth.Truth.assertThat
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.features.pushnotifications.PushNotificationsFeatureToggles
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
internal class GetPushNotificationsDoubleAskVariantUseCaseTest {
private val featureToggles: PushNotificationsFeatureToggles = mockk()
private val abTestsManager: ABTestsManager = mockk()
private val useCase = GetPushNotificationsDoubleAskVariantUseCase(
pushNotificationsFeatureToggles = featureToggles,
abTestsManager = abTestsManager,
)
@Test
fun `GIVEN toggle disabled WHEN invoke THEN returns Off and AB not queried`() {
every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns false
val result = useCase()
assertThat(result).isEqualTo(DoubleAskVariant.Off)
verify(exactly = 0) { abTestsManager.getValue(any(), any()) }
}
@Test
fun `GIVEN toggle enabled AND AB returns treatment WHEN invoke THEN returns On`() {
every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns true
every { abTestsManager.getValue(KEY, "control") } returns "treatment"
val result = useCase()
assertThat(result).isEqualTo(DoubleAskVariant.On)
verify(exactly = 1) { abTestsManager.getValue(KEY, "control") }
}
@Test
fun `GIVEN toggle enabled AND AB returns control WHEN invoke THEN returns Off`() {
every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns true
every { abTestsManager.getValue(KEY, "control") } returns "control"
assertThat(useCase()).isEqualTo(DoubleAskVariant.Off)
}
@Test
fun `GIVEN toggle enabled AND AB returns unknown WHEN invoke THEN returns Off`() {
every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns true
every { abTestsManager.getValue(KEY, "control") } returns "unexpected_value"
assertThat(useCase()).isEqualTo(DoubleAskVariant.Off)
}
private companion object {
const val KEY = "twi_1403_onboarding_push_notification_double_ask"
}
}

View file

@ -0,0 +1,204 @@
package com.tangem.features.pushnotifications.impl.model
import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase
import com.tangem.domain.settings.NeverRequestPermissionUseCase
import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks
import com.tangem.features.pushnotifications.api.PushNotificationsParams
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.features.pushnotifications.impl.domain.GetPushNotificationsDoubleAskVariantUseCase
import com.tangem.features.pushnotifications.impl.domain.DoubleAskVariant
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class PushNotificationsModelTest {
private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase = mockk(relaxed = true)
private val neverToInitiallyAskPermissionUseCase: NeverToInitiallyAskPermissionUseCase = mockk(relaxed = true)
private val appRouter: AppRouter = mockk(relaxed = true)
private val analyticHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val notificationsRepository: NotificationsRepository = mockk(relaxed = true)
private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles = mockk(relaxed = true)
private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase =
mockk(relaxed = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true)
private val accountsCRUDRepository: AccountsCRUDRepository = mockk(relaxed = true)
private val getDoubleAskVariantUseCase: GetPushNotificationsDoubleAskVariantUseCase = mockk()
private val modelCallbacks: PushNotificationsModelCallbacks = mockk(relaxed = true)
@BeforeEach
fun setUp() {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.Off
}
@Test
fun `GIVEN onboarding treatment WHEN onLaterClick THEN double ask shown and not proceeded`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
val model = createModel(testScope = this)
advanceUntilIdle()
model.onLaterClick()
advanceUntilIdle()
assertThat(model.isDoubleAskSheetShown.value).isTrue()
verify {
analyticHandler.send(
match<PushNotificationAnalyticEvents.WarningScreenShown> {
it.variant == DoubleAskVariant.On.key
},
)
}
coVerify(exactly = 0) { neverRequestPermissionUseCase(any()) }
verify(exactly = 0) { modelCallbacks.onDenySystemPermission() }
}
@Test
fun `GIVEN onboarding control WHEN onLaterClick THEN proceeds without double ask`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.Off
val model = createModel(testScope = this)
advanceUntilIdle()
model.onLaterClick()
advanceUntilIdle()
assertThat(model.isDoubleAskSheetShown.value).isFalse()
coVerify { neverRequestPermissionUseCase(any()) }
coVerify { neverToInitiallyAskPermissionUseCase(any()) }
verify { modelCallbacks.onDenySystemPermission() }
verify(exactly = 0) {
analyticHandler.send(match<PushNotificationAnalyticEvents.WarningScreenShown> { true })
}
}
@Test
fun `GIVEN main bottom sheet WHEN onLaterClick THEN double ask not shown and variant not resolved`() = runTest {
val model = createModel(
testScope = this,
isBottomSheet = true,
source = AppRoute.PushNotification.Source.Main,
)
advanceUntilIdle()
model.onLaterClick()
advanceUntilIdle()
assertThat(model.isDoubleAskSheetShown.value).isFalse()
verify(exactly = 0) { getDoubleAskVariantUseCase() }
verify { modelCallbacks.onDenySystemPermission() }
}
@Test
fun `GIVEN double ask shown WHEN onDoubleAskEnableClick THEN enable tapped sent and not proceeded`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
val model = createModel(testScope = this)
advanceUntilIdle()
model.onLaterClick()
advanceUntilIdle()
model.onDoubleAskEnableClick()
advanceUntilIdle()
verify {
analyticHandler.send(match<PushNotificationAnalyticEvents.WarningScreenEnableTapped> { true })
}
coVerify { notificationsRepository.setUserAllowToSubscribeOnPushNotifications(true) }
verify(exactly = 0) { modelCallbacks.onDenySystemPermission() }
}
@Test
fun `GIVEN double ask shown WHEN onDoubleAskSkipClick THEN event sent and proceeded`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
val model = createModel(testScope = this)
advanceUntilIdle()
model.onLaterClick()
advanceUntilIdle()
model.onDoubleAskSkipClick()
advanceUntilIdle()
verify {
analyticHandler.send(match<PushNotificationAnalyticEvents.WarningScreenSkipTapped> { true })
}
coVerify { neverRequestPermissionUseCase(any()) }
verify { modelCallbacks.onDenySystemPermission() }
}
@Test
fun `GIVEN double ask shown WHEN onDoubleAskDismiss THEN sheet hidden and not proceeded`() = runTest {
every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On
val model = createModel(testScope = this)
advanceUntilIdle()
model.onLaterClick()
advanceUntilIdle()
model.onDoubleAskDismiss()
advanceUntilIdle()
assertThat(model.isDoubleAskSheetShown.value).isFalse()
verify(exactly = 0) { modelCallbacks.onDenySystemPermission() }
verify(exactly = 0) {
analyticHandler.send(match<PushNotificationAnalyticEvents.WarningScreenSkipTapped> { true })
}
}
private fun createModel(
testScope: TestScope,
isBottomSheet: Boolean = false,
source: AppRoute.PushNotification.Source = AppRoute.PushNotification.Source.Onboarding,
paramsContainer: ParamsContainer = MutableParamsContainer(
value = PushNotificationsParams(
isBottomSheet = isBottomSheet,
nextRoute = null,
modelCallbacks = modelCallbacks,
source = source,
),
),
): PushNotificationsModel {
return PushNotificationsModel(
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
neverRequestPermissionUseCase = neverRequestPermissionUseCase,
neverToInitiallyAskPermissionUseCase = neverToInitiallyAskPermissionUseCase,
appRouter = appRouter,
analyticHandler = analyticHandler,
notificationsRepository = notificationsRepository,
pushNotificationSettingsFeatureToggles = pushNotificationSettingsFeatureToggles,
setAllWalletPushNotificationPreferences = setAllWalletPushNotificationPreferences,
userWalletsListRepository = userWalletsListRepository,
accountsCRUDRepository = accountsCRUDRepository,
getPushNotificationsDoubleAskVariantUseCase = getDoubleAskVariantUseCase,
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

View file

@ -28,7 +28,7 @@ enum class BuildType(
BuildConfigField.LogEnabled(isEnabled = true),
BuildConfigField.TesterMenuAvailability(isEnabled = true),
BuildConfigField.MockDataSource(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = true),
),
),
@ -74,7 +74,7 @@ enum class BuildType(
BuildConfigField.LogEnabled(isEnabled = true),
BuildConfigField.TesterMenuAvailability(isEnabled = true),
BuildConfigField.MockDataSource(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = true),
),
),
@ -114,7 +114,7 @@ enum class BuildType(
BuildConfigField.LogEnabled(isEnabled = false),
BuildConfigField.TesterMenuAvailability(isEnabled = false),
BuildConfigField.MockDataSource(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = true),
),
),
;