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

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