Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-28 16:22:08 +03:00
parent fb08f7c4e1
commit 4860b1fd8b
10 changed files with 425 additions and 63 deletions

View file

@ -1,5 +1,6 @@
package com.tangem.tap.di.domain
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase
@ -7,6 +8,8 @@ import com.tangem.domain.pushnotificationpreferences.PreloadWalletPushNotificati
import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase
import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificationPreferenceUseCase
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
import com.tangem.domain.wallets.usecase.ApplyPushNotificationFirstActivationUseCase
import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -64,4 +67,18 @@ internal object PushNotificationPreferencesDomainModule {
): MarkPushNotificationFirstActivationDoneUseCase {
return MarkPushNotificationFirstActivationDoneUseCase(repository = repository)
}
@Provides
@Singleton
fun providesApplyPushNotificationFirstActivationUseCase(
setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase,
repository: WalletPushNotificationPreferencesRepository,
notificationsRepository: NotificationsRepository,
): ApplyPushNotificationFirstActivationUseCase {
return ApplyPushNotificationFirstActivationUseCase(
setNotificationsEnabledUseCase = setNotificationsEnabledUseCase,
preferencesRepository = repository,
notificationsRepository = notificationsRepository,
)
}
}

View file

@ -41,6 +41,8 @@ dependencies {
api(projects.domain.walletManager)
api(projects.domain.core)
implementation(projects.domain.legacy)
implementation(projects.domain.notifications)
implementation(projects.domain.pushNotificationPreferences)
// endregion
// region Domain models

View file

@ -0,0 +1,55 @@
package com.tangem.domain.wallets.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.first
/**
* First-activation rule: on the first push permission grant, enables all three categories for a wallet.
* on the backend yet) is retried by the next trigger wallet screen or notification settings screen.
*/
class ApplyPushNotificationFirstActivationUseCase(
private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase,
private val preferencesRepository: WalletPushNotificationPreferencesRepository,
private val notificationsRepository: NotificationsRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<Throwable, Unit> {
return applyRule(userWalletId).onLeft {
TangemLogger.e("Push first activation failed for $userWalletId", it)
}
}
private suspend fun applyRule(userWalletId: UserWalletId): Either<Throwable, Unit> {
if (preferencesRepository.isFirstActivationDone(userWalletId)) return Unit.right()
// Wallets auto-enabled by the legacy flow are adopted as activated to not force-enable extra categories.
if (userWalletId.stringValue in notificationsRepository.getWalletAutomaticallyEnabledList()) {
preferencesRepository.markFirstActivationDone(userWalletId)
return Unit.right()
}
// The wallet may not be created on the backend yet (onboarding) — probe before any side effects.
val probe = Either.catch { preferencesRepository.observePreferences(userWalletId).first() }
if (probe is Either.Left) return probe.value.left()
// Tokens (address re-subscription) first, then preferences.
val tokensResult = setNotificationsEnabledUseCase(userWalletId, isEnabled = true)
if (tokensResult is Either.Left) return tokensResult
return preferencesRepository.setAllPreferences(
userWalletId = userWalletId,
transactionAlerts = true,
offersUpdates = true,
priceAlerts = true,
)
.onRight { preferencesRepository.markFirstActivationDone(userWalletId) }
.onLeft { setNotificationsEnabledUseCase(userWalletId, isEnabled = false) }
}
}

View file

@ -0,0 +1,150 @@
package com.tangem.domain.wallets.usecase
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class ApplyPushNotificationFirstActivationUseCaseTest {
private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase = mockk()
private val preferencesRepository: WalletPushNotificationPreferencesRepository = mockk()
private val notificationsRepository: NotificationsRepository = mockk()
private val useCase = ApplyPushNotificationFirstActivationUseCase(
setNotificationsEnabledUseCase = setNotificationsEnabledUseCase,
preferencesRepository = preferencesRepository,
notificationsRepository = notificationsRepository,
)
private val userWalletId = UserWalletId("0A0B0C0D")
@BeforeEach
fun resetMocks() {
clearMocks(setNotificationsEnabledUseCase, preferencesRepository, notificationsRepository)
coEvery { preferencesRepository.isFirstActivationDone(userWalletId) } returns false
coEvery { preferencesRepository.markFirstActivationDone(userWalletId) } just runs
coEvery { notificationsRepository.getWalletAutomaticallyEnabledList() } returns emptyList()
coEvery { preferencesRepository.observePreferences(userWalletId) } returns flowOf(allFalse())
coEvery { setNotificationsEnabledUseCase(userWalletId, any()) } returns Either.Right(Unit)
coEvery {
preferencesRepository.setAllPreferences(userWalletId, any(), any(), any())
} returns Either.Right(Unit)
}
@Test
fun `GIVEN activation already done WHEN invoke THEN returns Right without any writes`() = runTest {
// Arrange
coEvery { preferencesRepository.isFirstActivationDone(userWalletId) } returns true
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { setNotificationsEnabledUseCase(any(), any()) }
coVerify(exactly = 0) { preferencesRepository.setAllPreferences(any(), any(), any(), any()) }
coVerify(exactly = 0) { preferencesRepository.markFirstActivationDone(any()) }
}
@Test
fun `GIVEN wallet enabled by legacy flow WHEN invoke THEN flag adopted without enabling categories`() = runTest {
// Arrange
coEvery { notificationsRepository.getWalletAutomaticallyEnabledList() } returns
listOf(userWalletId.stringValue)
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { preferencesRepository.markFirstActivationDone(userWalletId) }
coVerify(exactly = 0) { setNotificationsEnabledUseCase(any(), any()) }
coVerify(exactly = 0) { preferencesRepository.setAllPreferences(any(), any(), any(), any()) }
}
@Test
fun `GIVEN preferences unavailable WHEN invoke THEN returns Left without side effects`() = runTest {
// Arrange: the wallet is not created on the backend yet (e.g. right after onboarding).
coEvery { preferencesRepository.observePreferences(userWalletId) } returns
flow { throw RuntimeException("404") }
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.isLeft()).isTrue()
coVerify(exactly = 0) { setNotificationsEnabledUseCase(any(), any()) }
coVerify(exactly = 0) { preferencesRepository.setAllPreferences(any(), any(), any(), any()) }
coVerify(exactly = 0) { preferencesRepository.markFirstActivationDone(any()) }
}
@Test
fun `GIVEN token subscription fails WHEN invoke THEN returns Left and preferences not written`() = runTest {
// Arrange
coEvery { setNotificationsEnabledUseCase(userWalletId, isEnabled = true) } returns
Either.Left(RuntimeException("net"))
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.isLeft()).isTrue()
coVerify(exactly = 0) { preferencesRepository.setAllPreferences(any(), any(), any(), any()) }
coVerify(exactly = 0) { preferencesRepository.markFirstActivationDone(any()) }
}
@Test
fun `GIVEN preferences write fails WHEN invoke THEN tokens undone and flag not marked`() = runTest {
// Arrange
coEvery { preferencesRepository.setAllPreferences(userWalletId, true, true, true) } returns
Either.Left(RuntimeException("net"))
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.isLeft()).isTrue()
coVerifyOrder {
setNotificationsEnabledUseCase(userWalletId, isEnabled = true)
preferencesRepository.setAllPreferences(userWalletId, true, true, true)
setNotificationsEnabledUseCase(userWalletId, isEnabled = false)
}
coVerify(exactly = 0) { preferencesRepository.markFirstActivationDone(any()) }
}
@Test
fun `GIVEN whole chain succeeds WHEN invoke THEN all three enabled and flag marked`() = runTest {
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.isRight()).isTrue()
coVerifyOrder {
setNotificationsEnabledUseCase(userWalletId, isEnabled = true)
preferencesRepository.setAllPreferences(userWalletId, true, true, true)
preferencesRepository.markFirstActivationDone(userWalletId)
}
}
private fun allFalse() = WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = false),
offersUpdates = PushNotificationPreference(isEnabled = false),
priceAlerts = PushNotificationPreference(isEnabled = false),
)
}

View file

@ -29,6 +29,7 @@ dependencies {
/* Project - Domain */
api(projects.domain.pushNotificationPreferences)
implementation(projects.domain.models)
implementation(projects.domain.notifications)
implementation(projects.domain.wallets)
/* AndroidX */

View file

@ -15,6 +15,7 @@ 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.models.wallet.UserWalletId
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase
@ -23,6 +24,7 @@ import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificatio
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.domain.wallets.usecase.ApplyPushNotificationFirstActivationUseCase
import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent
@ -50,6 +52,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -67,6 +70,8 @@ internal class PushNotificationSettingsModel @Inject constructor(
private val setNotificationsEnabled: SetNotificationsEnabledUseCase,
private val isFirstActivationDone: IsPushNotificationFirstActivationDoneUseCase,
private val markFirstActivationDone: MarkPushNotificationFirstActivationDoneUseCase,
private val applyFirstActivation: ApplyPushNotificationFirstActivationUseCase,
private val notificationsRepository: NotificationsRepository,
) : Model() {
private val params: PushNotificationSettingsComponent.Params = paramsContainer.require()
@ -76,6 +81,7 @@ internal class PushNotificationSettingsModel @Inject constructor(
private val osNotificationsEnabled = MutableStateFlow(systemNotificationsStateProvider.areNotificationsEnabled())
private var pendingPermissionToggle: ToggleSpec? = null
private val wasAutoActivationAttempted = AtomicBoolean(false)
private val preferencesJobHolder = JobHolder()
private val cachedPrefs: WalletPushNotificationPreferences?
@ -122,6 +128,7 @@ internal class PushNotificationSettingsModel @Inject constructor(
fun onResume() {
osNotificationsEnabled.value = systemNotificationsStateProvider.areNotificationsEnabled()
if (cachedPrefs != null) autoApplyFirstActivationIfNeeded()
}
fun onPermissionResult(isGranted: Boolean) {
@ -156,11 +163,34 @@ internal class PushNotificationSettingsModel @Inject constructor(
// 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) }
.onEach { value ->
loadState.value = LoadState.Content(value)
autoApplyFirstActivationIfNeeded()
}
.launchIn(modelScope)
.saveIn(preferencesJobHolder)
}
/**
* Silently re-applies the first-activation rule once preferences are loaded: the grant-time attempt
*/
private fun autoApplyFirstActivationIfNeeded() {
if (!wasAutoActivationAttempted.compareAndSet(false, true)) return
modelScope.launch(dispatchers.io) {
val areGatesPassed = osNotificationsEnabled.value &&
notificationsRepository.isUserAllowToSubscribeOnPushNotifications()
if (areGatesPassed) {
// Deliberately not retried on failure within this screen instance: a retry fired by the
// cache echo of a manual toggle write would force-enable all three against the user's choice.
applyFirstActivation(userWalletId)
} else {
// A gate miss is not an attempt — onResume may retry after the OS state changes.
wasAutoActivationAttempted.set(false)
}
}
}
private fun buildContent(
prefs: WalletPushNotificationPreferences,
osEnabled: Boolean,

View file

@ -9,6 +9,7 @@ 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.models.wallet.UserWalletId
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase
@ -17,6 +18,7 @@ import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificatio
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.domain.wallets.usecase.ApplyPushNotificationFirstActivationUseCase
import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase
import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent
import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM
@ -45,6 +47,8 @@ class PushNotificationSettingsModelTest {
private val setNotificationsEnabled: SetNotificationsEnabledUseCase = mockk()
private val isFirstActivationDone: IsPushNotificationFirstActivationDoneUseCase = mockk()
private val markFirstActivationDone: MarkPushNotificationFirstActivationDoneUseCase = mockk(relaxed = true)
private val applyFirstActivation: ApplyPushNotificationFirstActivationUseCase = mockk()
private val notificationsRepository: NotificationsRepository = mockk()
private val systemNotificationsStateProvider: SystemNotificationsStateProvider = mockk()
private val settingsManager: SettingsManager = mockk(relaxed = true)
private val messageSender: UiMessageSender = mockk(relaxed = true)
@ -53,6 +57,8 @@ class PushNotificationSettingsModelTest {
private fun model(
osEnabled: Boolean = true,
firstActivationDone: Boolean = true,
consentGiven: Boolean = false,
activationResult: Either<Throwable, Unit> = Either.Right(Unit),
preferencesFlow: MutableSharedFlow<WalletPushNotificationPreferences> = MutableSharedFlow(replay = 1),
): PushNotificationSettingsModel {
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns osEnabled
@ -60,6 +66,8 @@ class PushNotificationSettingsModelTest {
coEvery { isFirstActivationDone(userWalletId) } returns firstActivationDone
coEvery { setNotificationsEnabled(any(), any()) } returns Either.Right(Unit)
coEvery { setAllPreferences(any(), any(), any(), any()) } returns Either.Right(Unit)
coEvery { notificationsRepository.isUserAllowToSubscribeOnPushNotifications() } returns consentGiven
coEvery { applyFirstActivation(any()) } returns activationResult
return PushNotificationSettingsModel(
paramsContainer = MutableParamsContainer(PushNotificationSettingsComponent.Params(userWalletId)),
dispatchers = TestingCoroutineDispatcherProvider(),
@ -73,6 +81,8 @@ class PushNotificationSettingsModelTest {
setNotificationsEnabled = setNotificationsEnabled,
isFirstActivationDone = isFirstActivationDone,
markFirstActivationDone = markFirstActivationDone,
applyFirstActivation = applyFirstActivation,
notificationsRepository = notificationsRepository,
)
}
@ -95,6 +105,8 @@ class PushNotificationSettingsModelTest {
coEvery { isFirstActivationDone(userWalletId) } returns true
coEvery { setNotificationsEnabled(any(), any()) } returns Either.Right(Unit)
coEvery { setAllPreferences(any(), any(), any(), any()) } returns Either.Right(Unit)
coEvery { notificationsRepository.isUserAllowToSubscribeOnPushNotifications() } returns false
coEvery { applyFirstActivation(any()) } returns Either.Right(Unit)
val model = PushNotificationSettingsModel(
paramsContainer = MutableParamsContainer(PushNotificationSettingsComponent.Params(userWalletId)),
@ -109,6 +121,8 @@ class PushNotificationSettingsModelTest {
setNotificationsEnabled = setNotificationsEnabled,
isFirstActivationDone = isFirstActivationDone,
markFirstActivationDone = markFirstActivationDone,
applyFirstActivation = applyFirstActivation,
notificationsRepository = notificationsRepository,
)
advanceUntilIdle()
@ -469,6 +483,102 @@ class PushNotificationSettingsModelTest {
coVerify(exactly = 0) { setAllPreferences(any(), any(), any(), any()) }
}
@Test
fun `GIVEN consent and OS enabled WHEN preferences loaded THEN first activation reapplied`() = runTest {
// Arrange
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
// Act
model(osEnabled = true, consentGiven = true, preferencesFlow = flow)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { applyFirstActivation(userWalletId) }
}
@Test
fun `GIVEN preferences emitted twice WHEN loaded THEN first activation attempted once`() = runTest {
// Arrange
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
// Act
model(osEnabled = true, consentGiven = true, preferencesFlow = flow)
advanceUntilIdle()
flow.tryEmit(anyOn())
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { applyFirstActivation(userWalletId) }
}
@Test
fun `GIVEN no consent WHEN preferences loaded THEN first activation not reapplied`() = runTest {
// Arrange
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
// Act
model(osEnabled = true, consentGiven = false, preferencesFlow = flow)
advanceUntilIdle()
// Assert
coVerify(exactly = 0) { applyFirstActivation(any()) }
}
@Test
fun `GIVEN OS disabled WHEN preferences loaded THEN first activation not reapplied`() = runTest {
// Arrange
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
// Act
model(osEnabled = false, consentGiven = true, preferencesFlow = flow)
advanceUntilIdle()
// Assert
coVerify(exactly = 0) { applyFirstActivation(any()) }
}
@Test
fun `GIVEN activation failed WHEN preferences emitted again THEN not retried`() = runTest {
// Arrange
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
// Act
model(
osEnabled = true,
consentGiven = true,
activationResult = Either.Left(RuntimeException("net")),
preferencesFlow = flow,
)
advanceUntilIdle()
flow.tryEmit(anyOn())
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { applyFirstActivation(userWalletId) }
}
@Test
fun `GIVEN OS enabled after pause WHEN onResume THEN first activation reapplied`() = runTest {
// Arrange
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
val model = model(osEnabled = false, consentGiven = true, preferencesFlow = flow)
advanceUntilIdle()
// Act: the user enabled notifications in the OS settings and returned to the screen.
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true
model.onResume()
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { applyFirstActivation(userWalletId) }
}
private fun allFalse() = WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = false),
offersUpdates = PushNotificationPreference(isEnabled = false),

View file

@ -5,18 +5,15 @@ 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.common.wallets.UserWalletsListRepository
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase
import com.tangem.domain.settings.NeverRequestPermissionUseCase
import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase
import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase
import com.tangem.domain.wallets.usecase.ApplyPushNotificationFirstActivationUseCase
import com.tangem.features.pushnotifications.api.PushNotificationsParams
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
@ -42,10 +39,8 @@ 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 setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase,
private val isPushNotificationFirstActivationDone: IsPushNotificationFirstActivationDoneUseCase,
private val applyPushNotificationFirstActivation: ApplyPushNotificationFirstActivationUseCase,
private val markPushNotificationFirstActivationDone: MarkPushNotificationFirstActivationDoneUseCase,
private val getPushNotificationsDoubleAskVariantUseCase: GetPushNotificationsDoubleAskVariantUseCase,
) : Model(), PushNotificationsClickIntents {
@ -162,20 +157,7 @@ internal class PushNotificationsModel @Inject constructor(
/** On the first grant, enable all three categories for every not-yet-activated wallet (guarded once per wallet). */
private suspend fun applyFirstActivationRule() {
userWalletsListRepository.userWalletsSync().forEach { wallet ->
if (isPushNotificationFirstActivationDone(wallet.walletId)) return@forEach
// Tokens (address re-subscription) first, then preferences; mark only on success, undo tokens on failure.
val tokensResult = setNotificationsEnabledUseCase(wallet.walletId, isEnabled = true)
if (tokensResult is Either.Left) return@forEach
setAllWalletPushNotificationPreferences(
userWalletId = wallet.walletId,
transactionAlerts = true,
offersUpdates = true,
priceAlerts = true,
)
.onRight { markPushNotificationFirstActivationDone(wallet.walletId) }
.onLeft { setNotificationsEnabledUseCase(wallet.walletId, isEnabled = false) }
applyPushNotificationFirstActivation(wallet.walletId)
}
}

View file

@ -11,12 +11,10 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase
import com.tangem.domain.settings.NeverRequestPermissionUseCase
import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase
import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase
import com.tangem.domain.wallets.usecase.ApplyPushNotificationFirstActivationUseCase
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks
import com.tangem.features.pushnotifications.api.PushNotificationsParams
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
@ -47,12 +45,8 @@ internal class PushNotificationsModelTest {
private val analyticHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val notificationsRepository: NotificationsRepository = mockk(relaxed = true)
private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles = mockk(relaxed = true)
private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase =
mockk(relaxed = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true)
private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase = mockk(relaxed = true)
private val isPushNotificationFirstActivationDone: IsPushNotificationFirstActivationDoneUseCase =
mockk(relaxed = true)
private val applyPushNotificationFirstActivation: ApplyPushNotificationFirstActivationUseCase = mockk()
private val markPushNotificationFirstActivationDone: MarkPushNotificationFirstActivationDoneUseCase =
mockk(relaxed = true)
private val getDoubleAskVariantUseCase: GetPushNotificationsDoubleAskVariantUseCase = mockk()
@ -174,34 +168,43 @@ internal class PushNotificationsModelTest {
}
@Test
fun `GIVEN settings enabled WHEN onAllowPermission THEN fresh wallets get all-three and done wallets skipped`() =
fun `GIVEN settings enabled WHEN onAllowPermission THEN first activation applied for every wallet`() = runTest {
every { pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled } returns true
val w1 = UserWalletId("aa")
val w2 = UserWalletId("bb")
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(wallet(w1), wallet(w2))
coEvery { applyPushNotificationFirstActivation(any()) } returns Either.Right(Unit)
val model = createModel(testScope = this)
advanceUntilIdle()
model.onAllowPermission()
advanceUntilIdle()
coVerifyOrder {
applyPushNotificationFirstActivation(w1)
applyPushNotificationFirstActivation(w2)
}
}
@Test
fun `GIVEN activation fails for first wallet WHEN onAllowPermission THEN second wallet still attempted`() =
runTest {
every { pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled } returns true
val fresh = UserWalletId("aa")
val done = UserWalletId("bb")
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(wallet(fresh), wallet(done))
coEvery { isPushNotificationFirstActivationDone(fresh) } returns false
coEvery { isPushNotificationFirstActivationDone(done) } returns true
coEvery { setNotificationsEnabledUseCase(any(), any()) } returns Either.Right(Unit)
coEvery {
setAllWalletPushNotificationPreferences(any(), any(), any(), any())
} returns Either.Right(Unit)
val w1 = UserWalletId("aa")
val w2 = UserWalletId("bb")
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(wallet(w1), wallet(w2))
coEvery { applyPushNotificationFirstActivation(w1) } returns Either.Left(RuntimeException("404"))
coEvery { applyPushNotificationFirstActivation(w2) } returns Either.Right(Unit)
val model = createModel(testScope = this)
advanceUntilIdle()
model.onAllowPermission()
advanceUntilIdle()
// Fresh wallet: tokens (re-subscription) before preferences, then flag marked.
coVerifyOrder {
setNotificationsEnabledUseCase(fresh, isEnabled = true)
setAllWalletPushNotificationPreferences(fresh, true, true, true)
applyPushNotificationFirstActivation(w1)
applyPushNotificationFirstActivation(w2)
}
coVerify(exactly = 1) { markPushNotificationFirstActivationDone(fresh) }
// Already-activated wallet: skipped entirely.
coVerify(exactly = 0) { setAllWalletPushNotificationPreferences(done, any(), any(), any()) }
coVerify(exactly = 0) { setNotificationsEnabledUseCase(done, any()) }
coVerify(exactly = 0) { markPushNotificationFirstActivationDone(done) }
}
@Test
@ -218,28 +221,23 @@ internal class PushNotificationsModelTest {
coVerify(exactly = 1) { markPushNotificationFirstActivationDone(w1) }
coVerify(exactly = 1) { markPushNotificationFirstActivationDone(w2) }
// Deny only fixes the flag — it must not enable any category or re-subscribe tokens.
coVerify(exactly = 0) { setAllWalletPushNotificationPreferences(any(), any(), any(), any()) }
coVerify(exactly = 0) { setNotificationsEnabledUseCase(any(), any()) }
// Deny only fixes the flag — it must not enable any category.
coVerify(exactly = 0) { applyPushNotificationFirstActivation(any()) }
}
@Test
fun `GIVEN settings disabled WHEN onAllowPermission THEN no first-activation writes`() = runTest {
every { pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled } returns false
// Non-empty wallet list + not-done + successful stubs so that, if the toggle gate were removed,
// applyFirstActivationRule WOULD iterate and write — making the exactly=0 assertions actually protect the gate.
// Non-empty wallet list + successful stubs, so the exactly=0 assertions actually protect the toggle gate.
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(wallet(UserWalletId("aa")))
coEvery { isPushNotificationFirstActivationDone(any()) } returns false
coEvery { setNotificationsEnabledUseCase(any(), any()) } returns Either.Right(Unit)
coEvery { setAllWalletPushNotificationPreferences(any(), any(), any(), any()) } returns Either.Right(Unit)
coEvery { applyPushNotificationFirstActivation(any()) } returns Either.Right(Unit)
val model = createModel(testScope = this)
advanceUntilIdle()
model.onAllowPermission()
advanceUntilIdle()
coVerify(exactly = 0) { setAllWalletPushNotificationPreferences(any(), any(), any(), any()) }
coVerify(exactly = 0) { setNotificationsEnabledUseCase(any(), any()) }
coVerify(exactly = 0) { applyPushNotificationFirstActivation(any()) }
coVerify(exactly = 0) { markPushNotificationFirstActivationDone(any()) }
}
@ -267,10 +265,8 @@ internal class PushNotificationsModelTest {
analyticHandler = analyticHandler,
notificationsRepository = notificationsRepository,
pushNotificationSettingsFeatureToggles = pushNotificationSettingsFeatureToggles,
setAllWalletPushNotificationPreferences = setAllWalletPushNotificationPreferences,
userWalletsListRepository = userWalletsListRepository,
setNotificationsEnabledUseCase = setNotificationsEnabledUseCase,
isPushNotificationFirstActivationDone = isPushNotificationFirstActivationDone,
applyPushNotificationFirstActivation = applyPushNotificationFirstActivation,
markPushNotificationFirstActivationDone = markPushNotificationFirstActivationDone,
getPushNotificationsDoubleAskVariantUseCase = getDoubleAskVariantUseCase,
)

View file

@ -12,6 +12,7 @@ import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
@ -107,6 +108,8 @@ internal class WalletModel @Inject constructor(
private val notificationsRepository: NotificationsRepository,
private val getWalletsListForEnablingUseCase: GetWalletsForAutomaticallyPushEnablingUseCase,
private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase,
private val applyPushNotificationFirstActivationUseCase: ApplyPushNotificationFirstActivationUseCase,
private val systemNotificationsStateProvider: SystemNotificationsStateProvider,
private val getIsHuaweiDeviceWithoutGoogleServicesUseCase: GetIsHuaweiDeviceWithoutGoogleServicesUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
private val yieldSupplyApyUpdateUseCase: YieldSupplyApyUpdateUseCase,
@ -887,8 +890,10 @@ internal class WalletModel @Inject constructor(
}
private fun enableNotificationsIfNeeded() {
// New first-activation owns auto-enable when the feature is on; skip the legacy path.
if (pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled) return
if (pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled) {
applyPushFirstActivationIfNeeded()
return
}
modelScope.launch {
val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications()
if (isUserAllowToEnableNotifications) {
@ -907,6 +912,20 @@ internal class WalletModel @Inject constructor(
}
}
/**
* on the backend (onboarding). See [ApplyPushNotificationFirstActivationUseCase].
*/
private fun applyPushFirstActivationIfNeeded() {
modelScope.launch {
if (!systemNotificationsStateProvider.areNotificationsEnabled()) return@launch
if (!notificationsRepository.isUserAllowToSubscribeOnPushNotifications()) return@launch
userWalletsListRepository.userWalletsSync().forEach { wallet ->
applyPushNotificationFirstActivationUseCase(wallet.walletId)
}
}
}
inner class AskBiometryModelCallbacks : AskBiometryComponent.ModelCallbacks {
override fun onAllowed() {
analyticsEventsHandler.send(MainScreenAnalyticsEvent.EnableBiometrics(AnalyticsParam.OnOffState.On))