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

View file

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

View file

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

View file

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