Updated on 2026-08-14
This commit is contained in:
parent
0f8b2ab26e
commit
1cf8a16fae
18 changed files with 922 additions and 22 deletions
|
|
@ -6,4 +6,17 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace = "com.tangem.features.pushnotificationsettings.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
/* Project - Domain */
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/* Project - Core */
|
||||
implementation(projects.core.decompose)
|
||||
implementation(projects.core.ui)
|
||||
|
||||
/* Compose */
|
||||
implementation(deps.compose.runtime)
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.tangem.features.pushnotificationsettings.component
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
interface PushNotificationSettingsComponent : ComposableContentComponent {
|
||||
|
||||
data class Params(val userWalletId: UserWalletId)
|
||||
|
||||
interface Factory : ComponentFactory<Params, PushNotificationSettingsComponent>
|
||||
}
|
||||
|
|
@ -11,16 +11,48 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
/** Api */
|
||||
|
||||
/* Project - API */
|
||||
implementation(projects.features.pushNotificationSettings.api)
|
||||
implementation(projects.features.pushNotifications.api)
|
||||
implementation(projects.features.walletSettings.api)
|
||||
|
||||
/** Core modules */
|
||||
/* Project - Core */
|
||||
implementation(projects.core.decompose)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.analytics)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Compose */
|
||||
/* Project - Domain */
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.pushNotificationPreferences)
|
||||
|
||||
/* AndroidX */
|
||||
implementation(deps.lifecycle.compose)
|
||||
|
||||
/* Compose */
|
||||
implementation(deps.compose.ui)
|
||||
implementation(deps.compose.foundation)
|
||||
implementation(deps.compose.material3)
|
||||
implementation(deps.compose.runtime)
|
||||
implementation(deps.compose.shimmer)
|
||||
implementation(deps.decompose.ext.compose)
|
||||
|
||||
/** DI */
|
||||
/* DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
/* Other */
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
|
||||
/* Tests */
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.turbine)
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.features.pushnotificationsettings.impl.di
|
||||
|
||||
import com.tangem.core.decompose.di.ModelComponent
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.features.pushnotificationsettings.impl.model.PushNotificationSettingsModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.multibindings.ClassKey
|
||||
import dagger.multibindings.IntoMap
|
||||
|
||||
@Module
|
||||
@InstallIn(ModelComponent::class)
|
||||
internal interface PushNotificationSettingsModelModule {
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(PushNotificationSettingsModel::class)
|
||||
fun bindPushNotificationSettingsModel(model: PushNotificationSettingsModel): Model
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.tangem.features.pushnotificationsettings.impl.entity
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
internal data class AllowPushNotificationsBannerUM(
|
||||
val onOpenSettingsClick: () -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.features.pushnotificationsettings.impl.entity
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal object NetworksAvailableForNotificationBSConfig
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.features.pushnotificationsettings.impl.entity
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.event.StateEvent
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
|
||||
@Immutable
|
||||
internal sealed interface PushNotificationSettingsUM {
|
||||
|
||||
data object Loading : PushNotificationSettingsUM
|
||||
|
||||
data class Content(
|
||||
val banner: AllowPushNotificationsBannerUM?,
|
||||
val toggles: PersistentList<ToggleUM>,
|
||||
val requestPermissionEvent: StateEvent<Unit>,
|
||||
val onMoreInfoClick: () -> Unit,
|
||||
) : PushNotificationSettingsUM
|
||||
|
||||
data class Error(
|
||||
val onRetryClick: () -> Unit,
|
||||
) : PushNotificationSettingsUM
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.features.pushnotificationsettings.impl.entity
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
@Immutable
|
||||
internal data class ToggleUM(
|
||||
val id: ToggleId,
|
||||
@StringRes val titleRes: Int,
|
||||
val subtitle: TextReference,
|
||||
val isOn: Boolean,
|
||||
val onCheckedChange: (Boolean) -> Unit,
|
||||
)
|
||||
|
||||
internal enum class ToggleId {
|
||||
TransactionAlerts,
|
||||
OffersUpdates,
|
||||
PriceAlerts,
|
||||
}
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
package com.tangem.features.pushnotificationsettings.impl.model
|
||||
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider
|
||||
import com.tangem.core.navigation.settings.SettingsManager
|
||||
import com.tangem.core.ui.event.StateEvent
|
||||
import com.tangem.core.ui.event.consumedEvent
|
||||
import com.tangem.core.ui.event.triggeredEvent
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.message.DialogMessage
|
||||
import com.tangem.core.ui.message.EventMessageAction
|
||||
import com.tangem.domain.account.repository.AccountsCRUDRepository
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase
|
||||
import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificationPreferenceUseCase
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
|
||||
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
|
||||
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
|
||||
import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent
|
||||
import com.tangem.features.pushnotificationsettings.impl.R
|
||||
import com.tangem.features.pushnotificationsettings.impl.entity.AllowPushNotificationsBannerUM
|
||||
import com.tangem.features.pushnotificationsettings.impl.entity.NetworksAvailableForNotificationBSConfig
|
||||
import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM
|
||||
import com.tangem.features.pushnotificationsettings.impl.entity.ToggleId
|
||||
import com.tangem.features.pushnotificationsettings.impl.entity.ToggleUM
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.JobHolder
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import com.tangem.utils.coroutines.saveIn
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LongParameterList", "LargeClass")
|
||||
@ModelScoped
|
||||
internal class PushNotificationSettingsModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val messageSender: UiMessageSender,
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
private val observePreferences: ObserveWalletPushNotificationPreferencesUseCase,
|
||||
private val updatePreference: UpdateWalletPushNotificationPreferenceUseCase,
|
||||
private val systemNotificationsStateProvider: SystemNotificationsStateProvider,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val accountsCRUDRepository: AccountsCRUDRepository,
|
||||
) : Model() {
|
||||
|
||||
private val params: PushNotificationSettingsComponent.Params = paramsContainer.require()
|
||||
private val userWalletId: UserWalletId get() = params.userWalletId
|
||||
|
||||
private val loadState = MutableStateFlow<LoadState>(LoadState.Loading)
|
||||
private val osNotificationsEnabled = MutableStateFlow(systemNotificationsStateProvider.areNotificationsEnabled())
|
||||
private val pendingRequest = MutableStateFlow<StateEvent<Unit>>(consumedEvent())
|
||||
|
||||
private var pendingPermissionToggle: ToggleSpec? = null
|
||||
private val preferencesJobHolder = JobHolder()
|
||||
|
||||
private val cachedPrefs: WalletPushNotificationPreferences?
|
||||
get() = (loadState.value as? LoadState.Content)?.prefs
|
||||
|
||||
val uiState: StateFlow<PushNotificationSettingsUM> = combine(
|
||||
loadState,
|
||||
osNotificationsEnabled,
|
||||
pendingRequest,
|
||||
) { load, osEnabled, request ->
|
||||
when (load) {
|
||||
is LoadState.Failed -> PushNotificationSettingsUM.Error(onRetryClick = ::onRetry)
|
||||
is LoadState.Loading -> PushNotificationSettingsUM.Loading
|
||||
is LoadState.Content -> buildContent(prefs = load.prefs, osEnabled = osEnabled, request = request)
|
||||
}
|
||||
}.stateIn(
|
||||
scope = modelScope,
|
||||
// Eagerly: tests read uiState.value synchronously after advanceUntilIdle() with no live collector.
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = PushNotificationSettingsUM.Loading,
|
||||
)
|
||||
|
||||
val bottomSheetNavigation: SlotNavigation<NetworksAvailableForNotificationBSConfig> = SlotNavigation()
|
||||
|
||||
private val ToggleId.analyticsValue: String
|
||||
get() = when (this) {
|
||||
ToggleId.TransactionAlerts -> "transaction_alerts"
|
||||
ToggleId.OffersUpdates -> "offers_updates"
|
||||
ToggleId.PriceAlerts -> "price_alerts"
|
||||
}
|
||||
|
||||
init {
|
||||
analyticsEventHandler.send(
|
||||
PushNotificationAnalyticEvents.NotificationSettingsScreenOpened(
|
||||
isSystemPermissionEnabled = osNotificationsEnabled.value,
|
||||
),
|
||||
)
|
||||
subscribeOnPreferences()
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
osNotificationsEnabled.value = systemNotificationsStateProvider.areNotificationsEnabled()
|
||||
}
|
||||
|
||||
fun onPermissionResult(isGranted: Boolean) {
|
||||
pendingRequest.value = consumedEvent()
|
||||
val tapped = pendingPermissionToggle
|
||||
pendingPermissionToggle = null
|
||||
modelScope.launch {
|
||||
osNotificationsEnabled.value = systemNotificationsStateProvider.areNotificationsEnabled()
|
||||
analyticsEventHandler.send(PushNotificationAnalyticEvents.PermissionStatus(isAllowed = isGranted))
|
||||
if (isGranted && tapped != null) {
|
||||
applyOptimisticToggle(tapped, newValue = true)
|
||||
} else if (!isGranted) {
|
||||
showEnableNotificationsDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeOnPreferences() {
|
||||
observePreferences(userWalletId)
|
||||
.catch {
|
||||
// Fall to Failed only when nothing is cached yet; otherwise keep showing the last value.
|
||||
if (loadState.value !is LoadState.Content) loadState.value = LoadState.Failed
|
||||
}
|
||||
.onEach { value -> loadState.value = LoadState.Content(value) }
|
||||
.launchIn(modelScope)
|
||||
.saveIn(preferencesJobHolder)
|
||||
}
|
||||
|
||||
private fun buildContent(
|
||||
prefs: WalletPushNotificationPreferences,
|
||||
osEnabled: Boolean,
|
||||
request: StateEvent<Unit>,
|
||||
): PushNotificationSettingsUM.Content {
|
||||
return PushNotificationSettingsUM.Content(
|
||||
banner = buildBanner(prefs = prefs, osEnabled = osEnabled),
|
||||
toggles = buildToggles(prefs),
|
||||
requestPermissionEvent = request,
|
||||
onMoreInfoClick = ::onMoreInfoClick,
|
||||
)
|
||||
}
|
||||
|
||||
private fun onMoreInfoClick() {
|
||||
bottomSheetNavigation.activate(NetworksAvailableForNotificationBSConfig)
|
||||
}
|
||||
|
||||
private fun buildToggles(prefs: WalletPushNotificationPreferences): PersistentList<ToggleUM> {
|
||||
return TOGGLE_ORDER
|
||||
.asSequence()
|
||||
.map { id -> id.spec(prefs) }
|
||||
.filter { it.preference.isVisible }
|
||||
.map { spec ->
|
||||
ToggleUM(
|
||||
id = spec.id,
|
||||
titleRes = spec.titleRes,
|
||||
subtitle = spec.subtitle,
|
||||
isOn = spec.preference.isEnabled,
|
||||
onCheckedChange = { newValue -> onToggleTapped(spec, newValue) },
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
.toPersistentList()
|
||||
}
|
||||
|
||||
private fun buildBanner(
|
||||
prefs: WalletPushNotificationPreferences,
|
||||
osEnabled: Boolean,
|
||||
): AllowPushNotificationsBannerUM? {
|
||||
val isAnyOn = prefs.transactionAlerts.isEnabled ||
|
||||
prefs.offersUpdates.isEnabled ||
|
||||
prefs.priceAlerts.isEnabled
|
||||
if (osEnabled || !isAnyOn) return null
|
||||
return AllowPushNotificationsBannerUM(onOpenSettingsClick = ::onBannerCtaClick)
|
||||
}
|
||||
|
||||
private fun requestPermission(tapped: ToggleSpec? = null) {
|
||||
pendingPermissionToggle = tapped
|
||||
pendingRequest.value = triggeredEvent(data = Unit, onConsume = ::onPermissionEventConsumed)
|
||||
}
|
||||
|
||||
private fun onBannerCtaClick() {
|
||||
analyticsEventHandler.send(PushNotificationAnalyticEvents.BannerOpenSettingsTapped())
|
||||
// The banner only shows when OS notifications are disabled, which also covers the case
|
||||
// where POST_NOTIFICATIONS is already granted but notifications are off at the system level.
|
||||
// A permission request would be a no-op there, so send the user to the OS settings instead.
|
||||
settingsManager.openAppNotificationSettings()
|
||||
}
|
||||
|
||||
private fun onRetry() {
|
||||
loadState.value = LoadState.Loading
|
||||
subscribeOnPreferences()
|
||||
}
|
||||
|
||||
private fun onToggleTapped(spec: ToggleSpec, newValue: Boolean) {
|
||||
analyticsEventHandler.send(
|
||||
PushNotificationAnalyticEvents.ToggleClicked(toggleType = spec.id.analyticsValue, isEnabled = newValue),
|
||||
)
|
||||
|
||||
if (newValue && !osNotificationsEnabled.value) {
|
||||
requestPermission(tapped = spec)
|
||||
return
|
||||
}
|
||||
|
||||
applyOptimisticToggle(spec, newValue)
|
||||
}
|
||||
|
||||
private fun onPermissionEventConsumed() {
|
||||
pendingRequest.value = consumedEvent()
|
||||
}
|
||||
|
||||
private fun applyOptimisticToggle(spec: ToggleSpec, newValue: Boolean) {
|
||||
val current = cachedPrefs ?: return
|
||||
loadState.value = LoadState.Content(current.withCategory(spec.category, newValue))
|
||||
|
||||
// Writes are intentionally not serialized here: serializing repository writes is the data
|
||||
// layer's responsibility, not the model's. A failed write reverts only its own category.
|
||||
modelScope.launch { writeToggle(spec, newValue) }
|
||||
}
|
||||
|
||||
private suspend fun writeToggle(spec: ToggleSpec, newValue: Boolean) {
|
||||
// TODO [REDACTED_TASK_KEY] figure out and maybe swap /tokens and /preferences further calls
|
||||
updatePreference(userWalletId, spec.category, newValue)
|
||||
.onRight {
|
||||
if (spec.category == PushNotificationCategory.TransactionAlerts) {
|
||||
// Best-effort token sync after the preference write already succeeded:
|
||||
// log a failure but don't surface it to the user or revert the toggle.
|
||||
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
|
||||
.onFailure { error ->
|
||||
TangemLogger.e(
|
||||
messageString = "Failed to sync tokens after enabling " +
|
||||
"transaction alerts for $userWalletId",
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onLeft { revertOptimistic(spec, newValue) }
|
||||
}
|
||||
|
||||
private fun revertOptimistic(spec: ToggleSpec, newValue: Boolean) {
|
||||
// Revert only the failed category on top of the current state, so a concurrent toggle's
|
||||
// optimistic value isn't clobbered by a stale full-snapshot replacement.
|
||||
loadState.update { state ->
|
||||
if (state is LoadState.Content) {
|
||||
LoadState.Content(state.prefs.withCategory(spec.category, !newValue))
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
analyticsEventHandler.send(
|
||||
PushNotificationAnalyticEvents.NotificationSettingsErrorShown(
|
||||
toggleType = spec.id.analyticsValue,
|
||||
errorType = ERROR_TYPE_WRITE_FAILED,
|
||||
),
|
||||
)
|
||||
messageSender.send(
|
||||
DialogMessage(
|
||||
title = resourceReference(R.string.common_something_went_wrong),
|
||||
message = resourceReference(R.string.common_try_again_later),
|
||||
firstAction = EventMessageAction(
|
||||
title = resourceReference(R.string.common_ok),
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun showEnableNotificationsDialog() {
|
||||
messageSender.send(
|
||||
DialogMessage(
|
||||
title = resourceReference(R.string.push_notifications_permission_alert_title),
|
||||
message = resourceReference(R.string.push_notifications_permission_alert_description),
|
||||
firstAction = EventMessageAction(
|
||||
title = resourceReference(R.string.push_notifications_permission_alert_positive_button),
|
||||
onClick = { settingsManager.openAppNotificationSettings() },
|
||||
),
|
||||
secondAction = EventMessageAction(
|
||||
title = resourceReference(R.string.push_notifications_permission_alert_negative_button),
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ToggleId.spec(prefs: WalletPushNotificationPreferences): ToggleSpec = when (this) {
|
||||
ToggleId.TransactionAlerts -> ToggleSpec(
|
||||
id = this,
|
||||
titleRes = R.string.push_notification_settings_transaction_alerts_title,
|
||||
subtitle = resourceReference(R.string.push_notification_settings_transaction_alerts_subtitle),
|
||||
preference = prefs.transactionAlerts,
|
||||
category = PushNotificationCategory.TransactionAlerts,
|
||||
)
|
||||
ToggleId.OffersUpdates -> ToggleSpec(
|
||||
id = this,
|
||||
titleRes = R.string.push_notification_settings_offers_updates_title,
|
||||
subtitle = resourceReference(R.string.push_notification_settings_offers_updates_subtitle),
|
||||
preference = prefs.offersUpdates,
|
||||
category = PushNotificationCategory.OffersUpdates,
|
||||
)
|
||||
ToggleId.PriceAlerts -> ToggleSpec(
|
||||
id = this,
|
||||
titleRes = R.string.push_notification_settings_price_alerts_title,
|
||||
subtitle = resourceReference(R.string.push_notification_settings_price_alerts_subtitle),
|
||||
preference = prefs.priceAlerts,
|
||||
category = PushNotificationCategory.PriceAlerts,
|
||||
)
|
||||
}
|
||||
|
||||
private data class ToggleSpec(
|
||||
val id: ToggleId,
|
||||
val titleRes: Int,
|
||||
val subtitle: TextReference,
|
||||
val preference: PushNotificationPreference,
|
||||
val category: PushNotificationCategory,
|
||||
)
|
||||
|
||||
private sealed interface LoadState {
|
||||
data object Loading : LoadState
|
||||
data object Failed : LoadState
|
||||
data class Content(val prefs: WalletPushNotificationPreferences) : LoadState
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ERROR_TYPE_WRITE_FAILED = "Write Failed"
|
||||
val TOGGLE_ORDER = listOf(
|
||||
ToggleId.TransactionAlerts,
|
||||
ToggleId.OffersUpdates,
|
||||
ToggleId.PriceAlerts,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
package com.tangem.features.pushnotificationsettings.impl.model
|
||||
|
||||
import app.cash.turbine.test
|
||||
import arrow.core.Either
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider
|
||||
import com.tangem.core.navigation.settings.SettingsManager
|
||||
import com.tangem.domain.account.repository.AccountsCRUDRepository
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase
|
||||
import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificationPreferenceUseCase
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
|
||||
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
|
||||
import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent
|
||||
import com.tangem.features.pushnotificationsettings.impl.entity.AllowPushNotificationsBannerUM
|
||||
import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM
|
||||
import com.tangem.features.pushnotificationsettings.impl.entity.ToggleId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
class PushNotificationSettingsModelTest {
|
||||
|
||||
private val userWalletId = UserWalletId("0011223344556677")
|
||||
|
||||
private val observePreferences: ObserveWalletPushNotificationPreferencesUseCase = mockk()
|
||||
private val updatePreference: UpdateWalletPushNotificationPreferenceUseCase = mockk()
|
||||
private val systemNotificationsStateProvider: SystemNotificationsStateProvider = mockk()
|
||||
private val settingsManager: SettingsManager = mockk(relaxed = true)
|
||||
private val accountsCRUDRepository: AccountsCRUDRepository = mockk(relaxed = true)
|
||||
private val messageSender: UiMessageSender = mockk(relaxed = true)
|
||||
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
|
||||
private fun model(
|
||||
osEnabled: Boolean = true,
|
||||
preferencesFlow: MutableSharedFlow<WalletPushNotificationPreferences> = MutableSharedFlow(replay = 1),
|
||||
): PushNotificationSettingsModel {
|
||||
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns osEnabled
|
||||
every { observePreferences(userWalletId) } returns preferencesFlow
|
||||
return PushNotificationSettingsModel(
|
||||
paramsContainer = MutableParamsContainer(PushNotificationSettingsComponent.Params(userWalletId)),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
messageSender = messageSender,
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
observePreferences = observePreferences,
|
||||
updatePreference = updatePreference,
|
||||
systemNotificationsStateProvider = systemNotificationsStateProvider,
|
||||
settingsManager = settingsManager,
|
||||
accountsCRUDRepository = accountsCRUDRepository,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cache populated WHEN model created THEN ui state becomes Content`() = runTest {
|
||||
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
|
||||
flow.tryEmit(allFalse())
|
||||
val model = model(preferencesFlow = flow)
|
||||
advanceUntilIdle()
|
||||
|
||||
model.uiState.test {
|
||||
assertThat(awaitItem()).isInstanceOf(PushNotificationSettingsUM.Content::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN observe throws WHEN model created THEN ui state becomes Error`() = runTest {
|
||||
every { observePreferences(userWalletId) } returns flow { throw IllegalStateException("boom") }
|
||||
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true
|
||||
|
||||
val model = PushNotificationSettingsModel(
|
||||
paramsContainer = MutableParamsContainer(PushNotificationSettingsComponent.Params(userWalletId)),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
messageSender = messageSender,
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
observePreferences = observePreferences,
|
||||
updatePreference = updatePreference,
|
||||
systemNotificationsStateProvider = systemNotificationsStateProvider,
|
||||
settingsManager = settingsManager,
|
||||
accountsCRUDRepository = accountsCRUDRepository,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
model.uiState.test {
|
||||
assertThat(awaitItem()).isInstanceOf(PushNotificationSettingsUM.Error::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN OS enabled AND any toggle on WHEN built THEN banner is Hidden`() = runTest {
|
||||
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
|
||||
flow.tryEmit(anyOn())
|
||||
val model = model(osEnabled = true, preferencesFlow = flow)
|
||||
advanceUntilIdle()
|
||||
|
||||
val content = model.uiState.value as PushNotificationSettingsUM.Content
|
||||
assertThat(content.banner).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN OS disabled AND any toggle on WHEN built THEN banner is Visible`() = runTest {
|
||||
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
|
||||
flow.tryEmit(anyOn())
|
||||
val model = model(osEnabled = false, preferencesFlow = flow)
|
||||
advanceUntilIdle()
|
||||
|
||||
val content = model.uiState.value as PushNotificationSettingsUM.Content
|
||||
assertThat(content.banner).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN OS disabled AND no toggle on WHEN built THEN banner is Hidden`() = runTest {
|
||||
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
|
||||
flow.tryEmit(allFalse())
|
||||
val model = model(osEnabled = false, preferencesFlow = flow)
|
||||
advanceUntilIdle()
|
||||
|
||||
val content = model.uiState.value as PushNotificationSettingsUM.Content
|
||||
assertThat(content.banner).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN OS enabled WHEN toggle flipped on THEN repository is updated`() = runTest {
|
||||
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
|
||||
flow.tryEmit(allFalse())
|
||||
coEvery {
|
||||
updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true)
|
||||
} returns Either.Right(Unit)
|
||||
val model = model(osEnabled = true, preferencesFlow = flow)
|
||||
advanceUntilIdle()
|
||||
|
||||
val content = model.uiState.value as PushNotificationSettingsUM.Content
|
||||
val offers = content.toggles.first { it.id == ToggleId.OffersUpdates }
|
||||
offers.onCheckedChange(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify {
|
||||
updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN repository write fails WHEN toggle flipped THEN message is sent and toggle is reverted`() = runTest {
|
||||
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
|
||||
flow.tryEmit(allFalse())
|
||||
coEvery {
|
||||
updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true)
|
||||
} returns Either.Left(RuntimeException("network"))
|
||||
val model = model(osEnabled = true, preferencesFlow = flow)
|
||||
advanceUntilIdle()
|
||||
|
||||
val offers = (model.uiState.value as PushNotificationSettingsUM.Content)
|
||||
.toggles.first { it.id == ToggleId.OffersUpdates }
|
||||
offers.onCheckedChange(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(atLeast = 1) { messageSender.send(any()) }
|
||||
val current = (model.uiState.value as PushNotificationSettingsUM.Content)
|
||||
.toggles.first { it.id == ToggleId.OffersUpdates }
|
||||
assertThat(current.isOn).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN two toggles flipped WHEN one write fails THEN only the failed toggle reverts`() = runTest {
|
||||
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
|
||||
flow.tryEmit(allFalse())
|
||||
coEvery {
|
||||
updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, true)
|
||||
} returns Either.Left(RuntimeException("network"))
|
||||
coEvery {
|
||||
updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true)
|
||||
} returns Either.Right(Unit)
|
||||
val model = model(osEnabled = true, preferencesFlow = flow)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Flip both toggles optimistically before either write resolves.
|
||||
val initial = model.uiState.value as PushNotificationSettingsUM.Content
|
||||
initial.toggles.first { it.id == ToggleId.TransactionAlerts }.onCheckedChange(true)
|
||||
initial.toggles.first { it.id == ToggleId.OffersUpdates }.onCheckedChange(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
// The failed TransactionAlerts write reverts only itself; OffersUpdates keeps its value.
|
||||
val toggles = (model.uiState.value as PushNotificationSettingsUM.Content).toggles
|
||||
assertThat(toggles.first { it.id == ToggleId.TransactionAlerts }.isOn).isFalse()
|
||||
assertThat(toggles.first { it.id == ToggleId.OffersUpdates }.isOn).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN OS disabled WHEN toggle ON THEN permission request is triggered`() = runTest {
|
||||
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
|
||||
flow.tryEmit(allFalse())
|
||||
val model = model(osEnabled = false, preferencesFlow = flow)
|
||||
advanceUntilIdle()
|
||||
|
||||
val offers = (model.uiState.value as PushNotificationSettingsUM.Content)
|
||||
.toggles.first { it.id == ToggleId.OffersUpdates }
|
||||
offers.onCheckedChange(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
val content = model.uiState.value as PushNotificationSettingsUM.Content
|
||||
assertThat(content.requestPermissionEvent.javaClass.simpleName).isEqualTo("Triggered")
|
||||
coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN banner CTA tapped THEN OS notification settings are opened`() = runTest {
|
||||
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
|
||||
flow.tryEmit(anyOn())
|
||||
val model = model(osEnabled = false, preferencesFlow = flow)
|
||||
advanceUntilIdle()
|
||||
|
||||
val banner = requireNotNull(
|
||||
(model.uiState.value as PushNotificationSettingsUM.Content).banner,
|
||||
)
|
||||
banner.onOpenSettingsClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(exactly = 1) { settingsManager.openAppNotificationSettings() }
|
||||
val refreshed = model.uiState.value as PushNotificationSettingsUM.Content
|
||||
assertThat(refreshed.requestPermissionEvent.javaClass.simpleName).isEqualTo("Consumed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN Allow on a single tapped toggle THEN only that toggle is enabled`() = runTest {
|
||||
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
|
||||
flow.tryEmit(allFalse())
|
||||
coEvery {
|
||||
updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true)
|
||||
} returns Either.Right(Unit)
|
||||
|
||||
val model = model(osEnabled = false, preferencesFlow = flow)
|
||||
advanceUntilIdle()
|
||||
|
||||
val offers = (model.uiState.value as PushNotificationSettingsUM.Content)
|
||||
.toggles.first { it.id == ToggleId.OffersUpdates }
|
||||
offers.onCheckedChange(true)
|
||||
advanceUntilIdle()
|
||||
// OS prompt fires; user taps Allow.
|
||||
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true
|
||||
model.onPermissionResult(isGranted = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true)
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, any())
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN Deny THEN Enable Notifications dialog is shown and no PUT`() = runTest {
|
||||
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
|
||||
flow.tryEmit(allFalse())
|
||||
val model = model(osEnabled = false, preferencesFlow = flow)
|
||||
advanceUntilIdle()
|
||||
|
||||
val offers = (model.uiState.value as PushNotificationSettingsUM.Content)
|
||||
.toggles.first { it.id == ToggleId.OffersUpdates }
|
||||
offers.onCheckedChange(true)
|
||||
advanceUntilIdle()
|
||||
model.onPermissionResult(isGranted = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { messageSender.send(any()) }
|
||||
coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
|
||||
}
|
||||
|
||||
private fun allFalse() = WalletPushNotificationPreferences(
|
||||
transactionAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
|
||||
offersUpdates = PushNotificationPreference(isEnabled = false, isVisible = true),
|
||||
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
|
||||
)
|
||||
|
||||
private fun anyOn() = WalletPushNotificationPreferences(
|
||||
transactionAlerts = PushNotificationPreference(isEnabled = true, isVisible = true),
|
||||
offersUpdates = PushNotificationPreference(isEnabled = false, isVisible = true),
|
||||
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
|
||||
)
|
||||
}
|
||||
|
|
@ -70,4 +70,39 @@ sealed class PushNotificationAnalyticEvents(
|
|||
AnalyticsParam.STATE to if (isEnabled) "On" else "Off",
|
||||
),
|
||||
)
|
||||
|
||||
data class NotificationSettingsScreenOpened(
|
||||
val isSystemPermissionEnabled: Boolean,
|
||||
) : PushNotificationAnalyticEvents(
|
||||
event = "Notification Settings Screen Opened",
|
||||
params = mapOf(
|
||||
AnalyticsParam.STATE to isSystemPermissionEnabled.toString(),
|
||||
),
|
||||
)
|
||||
|
||||
data class ToggleClicked(
|
||||
val toggleType: String,
|
||||
val isEnabled: Boolean,
|
||||
) : PushNotificationAnalyticEvents(
|
||||
event = "Toggle Clicked",
|
||||
params = mapOf(
|
||||
"Toggle Type" to toggleType,
|
||||
AnalyticsParam.STATE to if (isEnabled) "On" else "Off",
|
||||
),
|
||||
)
|
||||
|
||||
class BannerOpenSettingsTapped : PushNotificationAnalyticEvents(
|
||||
event = "Banner - Open Settings Tapped",
|
||||
)
|
||||
|
||||
data class NotificationSettingsErrorShown(
|
||||
val toggleType: String,
|
||||
val errorType: String,
|
||||
) : PushNotificationAnalyticEvents(
|
||||
event = "Notification Settings Error Shown",
|
||||
params = mapOf(
|
||||
"Toggle Type" to toggleType,
|
||||
AnalyticsParam.ERROR_TYPE to errorType,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -41,6 +41,10 @@ dependencies {
|
|||
/** Domain module */
|
||||
implementation(projects.domain.settings)
|
||||
implementation(projects.domain.notifications)
|
||||
implementation(projects.domain.pushNotificationPreferences)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/** Feature modules */
|
||||
implementation(projects.features.pushNotifications.api)
|
||||
|
|
|
|||
|
|
@ -5,10 +5,14 @@ import com.tangem.common.routing.AppRoute
|
|||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import arrow.core.Either
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
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.PushNotificationsParams
|
||||
|
|
@ -16,6 +20,7 @@ import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnaly
|
|||
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
|
||||
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -31,6 +36,9 @@ internal class PushNotificationsModel @Inject constructor(
|
|||
private val analyticHandler: AnalyticsEventHandler,
|
||||
private val notificationsRepository: NotificationsRepository,
|
||||
private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles,
|
||||
private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val accountsCRUDRepository: AccountsCRUDRepository,
|
||||
) : Model(), PushNotificationsClickIntents {
|
||||
|
||||
val params: PushNotificationsParams = paramsContainer.require()
|
||||
|
|
@ -75,6 +83,9 @@ internal class PushNotificationsModel @Inject constructor(
|
|||
modelScope.launch {
|
||||
neverRequestPermissionUseCase(PUSH_PERMISSION)
|
||||
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
|
||||
if (isPushNotificationSettingsEnabled) {
|
||||
applyFirstActivationRule()
|
||||
}
|
||||
params.modelCallbacks.onAllowSystemPermission()
|
||||
if (!params.isBottomSheet) {
|
||||
params.nextRoute?.let { appRouter.push(it) }
|
||||
|
|
@ -95,4 +106,22 @@ internal class PushNotificationsModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO [REDACTED_JIRA] evaluate per-wallet "first-activation done"
|
||||
// tracking (iOS keeps a [walletId] array in UserDefaults). Today the bulk-enable fires every
|
||||
// time onAllowPermission is called under the feature toggle, but Soft Ask itself is gated by
|
||||
// the existing `shouldShowPushPermission_*` flag so in practice it runs once per install.
|
||||
private suspend fun applyFirstActivationRule() {
|
||||
userWalletsListRepository.userWalletsSync().forEach { wallet ->
|
||||
val result = setAllWalletPushNotificationPreferences(
|
||||
userWalletId = wallet.walletId,
|
||||
transactionAlerts = true,
|
||||
offersUpdates = true,
|
||||
priceAlerts = true,
|
||||
)
|
||||
if (result is Either.Right) {
|
||||
runSuspendCatching { accountsCRUDRepository.syncTokens(wallet.walletId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue