diff --git a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt index 7869bdbc27..c72ac7fe40 100644 --- a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt +++ b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt @@ -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) { - 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" } } \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index e8883a97a4..722e694df5 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -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" diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 0c188411c0..3c128bf724 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1257,6 +1257,9 @@ Notification Settings Real-time alerts for transactions, exchanges, and critical updates. Transaction Alerts + Enable notifications + You won\'t receive notifications about your deposits, withdrawals, and transactions. You can turn them on anytime in Wallet Settings. + Notifications disabled More info You can enable Notifications for Tangem in Settings. Enable Later @@ -2218,6 +2221,10 @@ Use %s or scan a card/ring to unlock access to your wallet The permission-granting process is currently underway and will be completed shortly Approval in Progress + This wallet has a backup issue. Contact Support to resolve it. + Adding funds is disabled + The backup process wasn’t 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. + Backup issue detected Activation was not completed successfully. This may be due to an NFC issue or incorrect tapping. Please contact our Support team for assistance. Activation error On December 3, 2024, the BEP-2 network was disabled by decision of the network developers and is no longer supported diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/RequestPermission.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/RequestPermission.kt index 9e0910e239..8843eabaec 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/RequestPermission.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/RequestPermission.kt @@ -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 -> diff --git a/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/PushNotificationsFeatureToggles.kt b/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/PushNotificationsFeatureToggles.kt new file mode 100644 index 0000000000..ab66489854 --- /dev/null +++ b/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/PushNotificationsFeatureToggles.kt @@ -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 +} \ No newline at end of file diff --git a/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/api/analytics/PushNotificationAnalyticEvents.kt b/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/api/analytics/PushNotificationAnalyticEvents.kt index c82a9e2171..8ca2361b04 100644 --- a/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/api/analytics/PushNotificationAnalyticEvents.kt +++ b/features/push-notifications/api/src/main/java/com/tangem/features/pushnotifications/api/analytics/PushNotificationAnalyticEvents.kt @@ -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 } \ No newline at end of file diff --git a/features/push-notifications/impl/build.gradle.kts b/features/push-notifications/impl/build.gradle.kts index c102276ebc..a4312bcafc 100644 --- a/features/push-notifications/impl/build.gradle.kts +++ b/features/push-notifications/impl/build.gradle.kts @@ -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) } \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt index 4c25edc1aa..ee42ff3fe0 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsComponent.kt @@ -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, diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsFeatureToggles.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsFeatureToggles.kt new file mode 100644 index 0000000000..ac118aa569 --- /dev/null +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/DefaultPushNotificationsFeatureToggles.kt @@ -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, + ) +} \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/di/PushNotificationsModule.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/di/PushNotificationsModule.kt index 06d4376447..278d4274a8 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/di/PushNotificationsModule.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/di/PushNotificationsModule.kt @@ -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 } \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/DoubleAskVariant.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/DoubleAskVariant.kt new file mode 100644 index 0000000000..4eeda17454 --- /dev/null +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/DoubleAskVariant.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCase.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCase.kt new file mode 100644 index 0000000000..de1bdc445f --- /dev/null +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCase.kt @@ -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" + } +} \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsClickIntents.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsClickIntents.kt index ad7b69bc71..79cee2fbd6 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsClickIntents.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsClickIntents.kt @@ -8,4 +8,10 @@ internal interface PushNotificationsClickIntents { fun onAllowPermission() fun onDenyPermission() + + fun onDoubleAskEnableClick() + + fun onDoubleAskSkipClick() + + fun onDoubleAskDismiss() } \ No newline at end of file diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt index 62084b0caf..6acc5eca76 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt @@ -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 = _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() { diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt index 9a53b58aea..b8b9deaccc 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/presentation/ui/PushNotificationsScreen.kt @@ -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 = {}, + ) + } } \ No newline at end of file diff --git a/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCaseTest.kt b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCaseTest.kt new file mode 100644 index 0000000000..3f446feca3 --- /dev/null +++ b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCaseTest.kt @@ -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" + } +} \ No newline at end of file diff --git a/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt new file mode 100644 index 0000000000..afea4868f1 --- /dev/null +++ b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt @@ -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 { + 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 { 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 { 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 { 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 { 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, + ) + } +} \ No newline at end of file diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt index c15761c0d9..07e7b23df7 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt @@ -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), ), ), ;