Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-08 12:38:54 +03:00
parent 0f8b2ab26e
commit 1cf8a16fae
18 changed files with 922 additions and 22 deletions

View file

@ -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)
}

View file

@ -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>
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.pushnotificationsettings.impl.entity
import androidx.compose.runtime.Immutable
@Immutable
internal data class AllowPushNotificationsBannerUM(
val onOpenSettingsClick: () -> Unit,
)

View file

@ -0,0 +1,6 @@
package com.tangem.features.pushnotificationsettings.impl.entity
import kotlinx.serialization.Serializable
@Serializable
internal object NetworksAvailableForNotificationBSConfig

View file

@ -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
}

View file

@ -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,
}

View file

@ -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,
)
}
}

View file

@ -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),
)
}