Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-08 19:28:17 +03:00
parent 8af068b74b
commit fc319f2b70
17 changed files with 518 additions and 68 deletions

View file

@ -1,5 +1,7 @@
package com.tangem.tap.di.domain package com.tangem.tap.di.domain
import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase
import com.tangem.domain.pushnotificationpreferences.PreloadWalletPushNotificationPreferencesUseCase import com.tangem.domain.pushnotificationpreferences.PreloadWalletPushNotificationPreferencesUseCase
import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase
@ -46,4 +48,20 @@ internal object PushNotificationPreferencesDomainModule {
): SetAllWalletPushNotificationPreferencesUseCase { ): SetAllWalletPushNotificationPreferencesUseCase {
return SetAllWalletPushNotificationPreferencesUseCase(repository = repository) return SetAllWalletPushNotificationPreferencesUseCase(repository = repository)
} }
@Provides
@Singleton
fun providesIsPushNotificationFirstActivationDoneUseCase(
repository: WalletPushNotificationPreferencesRepository,
): IsPushNotificationFirstActivationDoneUseCase {
return IsPushNotificationFirstActivationDoneUseCase(repository = repository)
}
@Provides
@Singleton
fun providesMarkPushNotificationFirstActivationDoneUseCase(
repository: WalletPushNotificationPreferencesRepository,
): MarkPushNotificationFirstActivationDoneUseCase {
return MarkPushNotificationFirstActivationDoneUseCase(repository = repository)
}
} }

View file

@ -163,6 +163,10 @@ object PreferencesKeys {
) )
} }
val PUSH_NOTIFICATION_FIRST_ACTIVATION_DONE_WALLET_IDS_KEY by lazy {
stringSetPreferencesKey(name = "pushNotificationFirstActivationDoneWalletIds")
}
val TRON_NETWORK_FEE_NOTIFICATION_SHOW_COUNT_KEY by lazy { val TRON_NETWORK_FEE_NOTIFICATION_SHOW_COUNT_KEY by lazy {
intPreferencesKey(name = "tronNetworkFeeNotificationShowCount") intPreferencesKey(name = "tronNetworkFeeNotificationShowCount")
} }

View file

@ -36,6 +36,7 @@ dependencies {
// endregion // endregion
// region Tests // region Tests
testImplementation(projects.test.core)
testImplementation(deps.test.coroutine) testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5) testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk) testImplementation(deps.test.mockk)

View file

@ -6,6 +6,9 @@ import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesBody import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesBody
import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
@ -28,11 +31,23 @@ import kotlinx.coroutines.withContext
internal class DefaultWalletPushNotificationPreferencesRepository( internal class DefaultWalletPushNotificationPreferencesRepository(
private val tangemTechApi: TangemTechApi, private val tangemTechApi: TangemTechApi,
private val cache: RuntimeSharedStore<Map<String, WalletPushNotificationPreferences>>, private val cache: RuntimeSharedStore<Map<String, WalletPushNotificationPreferences>>,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : WalletPushNotificationPreferencesRepository { ) : WalletPushNotificationPreferencesRepository {
private val walletMutexes = ConcurrentHashMap<String, Mutex>() private val walletMutexes = ConcurrentHashMap<String, Mutex>()
override suspend fun isFirstActivationDone(userWalletId: UserWalletId): Boolean =
appPreferencesStore.getSyncOrNull(PreferencesKeys.PUSH_NOTIFICATION_FIRST_ACTIVATION_DONE_WALLET_IDS_KEY)
?.contains(userWalletId.stringValue) == true
override suspend fun markFirstActivationDone(userWalletId: UserWalletId) {
appPreferencesStore.editData { preferences ->
val key = PreferencesKeys.PUSH_NOTIFICATION_FIRST_ACTIVATION_DONE_WALLET_IDS_KEY
preferences[key] = preferences.getOrDefault(key, emptySet()) + userWalletId.stringValue
}
}
override suspend fun preload(userWalletId: UserWalletId) { override suspend fun preload(userWalletId: UserWalletId) {
if (isCached(userWalletId)) return if (isCached(userWalletId)) return
mutexFor(userWalletId).withLock { mutexFor(userWalletId).withLock {

View file

@ -3,6 +3,7 @@ package com.tangem.data.pushnotificationpreferences.di
import com.tangem.data.pushnotificationpreferences.DefaultWalletPushNotificationPreferencesRepository import com.tangem.data.pushnotificationpreferences.DefaultWalletPushNotificationPreferencesRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module import dagger.Module
@ -19,10 +20,12 @@ internal object PushNotificationPreferencesModule {
@Provides @Provides
fun providesWalletPushNotificationPreferencesRepository( fun providesWalletPushNotificationPreferencesRepository(
tangemTechApi: TangemTechApi, tangemTechApi: TangemTechApi,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider, dispatchers: CoroutineDispatcherProvider,
): WalletPushNotificationPreferencesRepository = DefaultWalletPushNotificationPreferencesRepository( ): WalletPushNotificationPreferencesRepository = DefaultWalletPushNotificationPreferencesRepository(
tangemTechApi = tangemTechApi, tangemTechApi = tangemTechApi,
cache = RuntimeSharedStore(), cache = RuntimeSharedStore(),
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers, dispatchers = dispatchers,
) )
} }

View file

@ -1,17 +1,21 @@
package com.tangem.data.pushnotificationpreferences package com.tangem.data.pushnotificationpreferences
import androidx.datastore.preferences.core.emptyPreferences
import app.cash.turbine.test import app.cash.turbine.test
import arrow.core.Either import arrow.core.Either
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesBody import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesBody
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
@ -30,9 +34,17 @@ class DefaultWalletPushNotificationPreferencesRepositoryTest {
private val userWalletId = UserWalletId(stringValue = "0011223344556677") private val userWalletId = UserWalletId(stringValue = "0011223344556677")
private val otherWalletId = UserWalletId(stringValue = "ffeeddccbbaa9988") private val otherWalletId = UserWalletId(stringValue = "ffeeddccbbaa9988")
// Real in-memory store so the persisted first-activation flag (a Set<String> merge) is genuinely exercised.
private val appPreferencesStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = MockStateDataStore(default = emptyPreferences()),
)
private val repository = DefaultWalletPushNotificationPreferencesRepository( private val repository = DefaultWalletPushNotificationPreferencesRepository(
tangemTechApi = tangemTechApi, tangemTechApi = tangemTechApi,
cache = RuntimeSharedStore(), cache = RuntimeSharedStore(),
appPreferencesStore = appPreferencesStore,
dispatchers = TestingCoroutineDispatcherProvider(), dispatchers = TestingCoroutineDispatcherProvider(),
) )
@ -202,6 +214,28 @@ class DefaultWalletPushNotificationPreferencesRepositoryTest {
} }
} }
@Test
fun `GIVEN wallet never activated WHEN isFirstActivationDone THEN false`() = runTest {
assertThat(repository.isFirstActivationDone(userWalletId)).isFalse()
}
@Test
fun `GIVEN wallet marked WHEN isFirstActivationDone THEN true and persisted`() = runTest {
repository.markFirstActivationDone(userWalletId)
assertThat(repository.isFirstActivationDone(userWalletId)).isTrue()
}
@Test
fun `GIVEN one wallet marked WHEN another wallet marked THEN both stay done`() = runTest {
// Guards the additive-merge backbone: a regression to a single-id overwrite would drop the first wallet.
repository.markFirstActivationDone(userWalletId)
repository.markFirstActivationDone(otherWalletId)
assertThat(repository.isFirstActivationDone(userWalletId)).isTrue()
assertThat(repository.isFirstActivationDone(otherWalletId)).isTrue()
}
private fun stubGet(id: UserWalletId, transaction: Boolean, offers: Boolean, price: Boolean) { private fun stubGet(id: UserWalletId, transaction: Boolean, offers: Boolean, price: Boolean) {
coEvery { tangemTechApi.getPushNotificationPreferences(id.stringValue) } returns coEvery { tangemTechApi.getPushNotificationPreferences(id.stringValue) } returns
ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price)) ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price))

View file

@ -0,0 +1,11 @@
package com.tangem.domain.pushnotificationpreferences
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
class IsPushNotificationFirstActivationDoneUseCase(
private val repository: WalletPushNotificationPreferencesRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Boolean = repository.isFirstActivationDone(userWalletId)
}

View file

@ -0,0 +1,11 @@
package com.tangem.domain.pushnotificationpreferences
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
class MarkPushNotificationFirstActivationDoneUseCase(
private val repository: WalletPushNotificationPreferencesRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId) = repository.markFirstActivationDone(userWalletId)
}

View file

@ -6,12 +6,18 @@ import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCate
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/** Per-wallet push notification preferences. In-memory cache, not persisted. */ /** Per-wallet push notification preferences. Values are cached in-memory; the first-activation flag is persisted. */
interface WalletPushNotificationPreferencesRepository { interface WalletPushNotificationPreferencesRepository {
/** Warms up the cache for [userWalletId]. No-op if already cached. */ /** Warms up the cache for [userWalletId]. No-op if already cached. */
suspend fun preload(userWalletId: UserWalletId) suspend fun preload(userWalletId: UserWalletId)
/** Whether the first push-permission activation has already run for [userWalletId]. Persisted. */
suspend fun isFirstActivationDone(userWalletId: UserWalletId): Boolean
/** Marks the first push-permission activation as done for [userWalletId]. */
suspend fun markFirstActivationDone(userWalletId: UserWalletId)
fun observePreferences(userWalletId: UserWalletId): Flow<WalletPushNotificationPreferences> fun observePreferences(userWalletId: UserWalletId): Flow<WalletPushNotificationPreferences>
/** Updates a single [category]; full-replace PUT under the hood. On failure cache is untouched. */ /** Updates a single [category]; full-replace PUT under the hood. On failure cache is untouched. */

View file

@ -27,9 +27,9 @@ dependencies {
implementation(projects.core.ui) implementation(projects.core.ui)
/* Project - Domain */ /* Project - Domain */
api(projects.domain.account)
api(projects.domain.pushNotificationPreferences) api(projects.domain.pushNotificationPreferences)
implementation(projects.domain.models) implementation(projects.domain.models)
implementation(projects.domain.wallets)
/* AndroidX */ /* AndroidX */
implementation(deps.androidx.activity) implementation(deps.androidx.activity)

View file

@ -1,5 +1,6 @@
package com.tangem.features.pushnotificationsettings.impl.model package com.tangem.features.pushnotificationsettings.impl.model
import arrow.core.Either
import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -13,13 +14,16 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction 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.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase
import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase
import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificationPreferenceUseCase import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificationPreferenceUseCase
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent
import com.tangem.features.pushnotificationsettings.impl.R import com.tangem.features.pushnotificationsettings.impl.R
@ -30,9 +34,7 @@ import com.tangem.features.pushnotificationsettings.impl.entity.ToggleId
import com.tangem.features.pushnotificationsettings.impl.entity.ToggleUM import com.tangem.features.pushnotificationsettings.impl.entity.ToggleUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.coroutines.saveIn import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@ -61,7 +63,10 @@ internal class PushNotificationSettingsModel @Inject constructor(
private val updatePreference: UpdateWalletPushNotificationPreferenceUseCase, private val updatePreference: UpdateWalletPushNotificationPreferenceUseCase,
private val systemNotificationsStateProvider: SystemNotificationsStateProvider, private val systemNotificationsStateProvider: SystemNotificationsStateProvider,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val accountsCRUDRepository: AccountsCRUDRepository, private val setAllPreferences: SetAllWalletPushNotificationPreferencesUseCase,
private val setNotificationsEnabled: SetNotificationsEnabledUseCase,
private val isFirstActivationDone: IsPushNotificationFirstActivationDoneUseCase,
private val markFirstActivationDone: MarkPushNotificationFirstActivationDoneUseCase,
) : Model() { ) : Model() {
private val params: PushNotificationSettingsComponent.Params = paramsContainer.require() private val params: PushNotificationSettingsComponent.Params = paramsContainer.require()
@ -123,12 +128,24 @@ internal class PushNotificationSettingsModel @Inject constructor(
val tapped = pendingPermissionToggle val tapped = pendingPermissionToggle
pendingPermissionToggle = null pendingPermissionToggle = null
modelScope.launch { modelScope.launch {
osNotificationsEnabled.value = systemNotificationsStateProvider.areNotificationsEnabled() // Enable all three only on a real grant that actually turned notifications on: an
// already-granted-but-OS-disabled wallet returns isGranted=true instantly, so also require
// areNotificationsEnabled() before triggering the rule; a deny routes the user to settings.
val isNotificationsEnabled = systemNotificationsStateProvider.areNotificationsEnabled()
osNotificationsEnabled.value = isNotificationsEnabled
analyticsEventHandler.send(PushNotificationAnalyticEvents.PermissionStatus(isAllowed = isGranted)) analyticsEventHandler.send(PushNotificationAnalyticEvents.PermissionStatus(isAllowed = isGranted))
if (isGranted && tapped != null) {
applyOptimisticToggle(tapped, newValue = true) if (!isGranted || !isNotificationsEnabled) {
} else if (!isGranted) { markFirstActivationDone(userWalletId)
showEnableNotificationsDialog() showEnableNotificationsDialog()
return@launch
}
if (isFirstActivationDone(userWalletId)) {
tapped?.let { applyOptimisticToggle(it, newValue = true) }
} else if (enableAllCategories(initiatingToggle = tapped)) {
// Fix the flag only after a successful enable, so a transient failure can retry.
markFirstActivationDone(userWalletId)
} }
} }
} }
@ -228,23 +245,54 @@ internal class PushNotificationSettingsModel @Inject constructor(
} }
private suspend fun writeToggle(spec: ToggleSpec, newValue: Boolean) { private suspend fun writeToggle(spec: ToggleSpec, newValue: Boolean) {
// TODO [REDACTED_TASK_KEY] figure out and maybe swap /tokens and /preferences further calls if (spec.category == PushNotificationCategory.TransactionAlerts) {
updatePreference(userWalletId, spec.category, newValue) writeTransactionAlerts(spec, newValue)
.onRight { } else {
if (spec.category == PushNotificationCategory.TransactionAlerts) { updatePreference(userWalletId, spec.category, newValue).onLeft { revertOptimistic(spec, newValue) }
// 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 -> /** Tokens (address re-subscription) first, then preferences; on failure revert and undo the token subscription. */
TangemLogger.e( private suspend fun writeTransactionAlerts(spec: ToggleSpec, newValue: Boolean) {
messageString = "Failed to sync tokens after enabling " + val tokensResult = setNotificationsEnabled(userWalletId, isEnabled = newValue)
"transaction alerts for $userWalletId", if (tokensResult is Either.Left) {
throwable = error, revertOptimistic(spec, newValue)
) return
} }
} updatePreference(userWalletId, spec.category, newValue).onLeft {
} setNotificationsEnabled(userWalletId, isEnabled = !newValue)
.onLeft { revertOptimistic(spec, newValue) } revertOptimistic(spec, newValue)
}
}
/** Enables all three categories at once (first-activation): tokens first, then preferences. True on full success. */
private suspend fun enableAllCategories(initiatingToggle: ToggleSpec?): Boolean {
val previous = cachedPrefs ?: return false
loadState.value = LoadState.Content(
previous.copy(
transactionAlerts = previous.transactionAlerts.copy(isEnabled = true),
offersUpdates = previous.offersUpdates.copy(isEnabled = true),
priceAlerts = previous.priceAlerts.copy(isEnabled = true),
),
)
val tokensResult = setNotificationsEnabled(userWalletId, isEnabled = true)
if (tokensResult is Either.Left) {
revertAll(previous, initiatingToggle)
return false
}
return setAllPreferences(
userWalletId = userWalletId,
transactionAlerts = true,
offersUpdates = true,
priceAlerts = true,
).fold(
ifLeft = {
setNotificationsEnabled(userWalletId, isEnabled = false)
revertAll(previous, initiatingToggle)
false
},
ifRight = { true },
)
} }
private fun revertOptimistic(spec: ToggleSpec, newValue: Boolean) { private fun revertOptimistic(spec: ToggleSpec, newValue: Boolean) {
@ -257,9 +305,18 @@ internal class PushNotificationSettingsModel @Inject constructor(
state state
} }
} }
showWriteErrorMessage(toggleType = spec.id.analyticsValue)
}
private fun revertAll(previous: WalletPushNotificationPreferences, initiatingToggle: ToggleSpec?) {
loadState.update { state -> if (state is LoadState.Content) LoadState.Content(previous) else state }
showWriteErrorMessage(toggleType = (initiatingToggle?.id ?: ToggleId.TransactionAlerts).analyticsValue)
}
private fun showWriteErrorMessage(toggleType: String) {
analyticsEventHandler.send( analyticsEventHandler.send(
PushNotificationAnalyticEvents.NotificationSettingsErrorShown( PushNotificationAnalyticEvents.NotificationSettingsErrorShown(
toggleType = spec.id.analyticsValue, toggleType = toggleType,
errorType = ERROR_TYPE_WRITE_FAILED, errorType = ERROR_TYPE_WRITE_FAILED,
), ),
) )

View file

@ -8,20 +8,23 @@ import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider
import com.tangem.core.navigation.settings.SettingsManager 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.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase
import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase
import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase
import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificationPreferenceUseCase import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificationPreferenceUseCase
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase
import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent 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.PushNotificationSettingsUM
import com.tangem.features.pushnotificationsettings.impl.entity.ToggleId import com.tangem.features.pushnotificationsettings.impl.entity.ToggleId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
@ -31,25 +34,32 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@Suppress("LongParameterList") @Suppress("LongParameterList", "LargeClass")
class PushNotificationSettingsModelTest { class PushNotificationSettingsModelTest {
private val userWalletId = UserWalletId("0011223344556677") private val userWalletId = UserWalletId("0011223344556677")
private val observePreferences: ObserveWalletPushNotificationPreferencesUseCase = mockk() private val observePreferences: ObserveWalletPushNotificationPreferencesUseCase = mockk()
private val updatePreference: UpdateWalletPushNotificationPreferenceUseCase = mockk() private val updatePreference: UpdateWalletPushNotificationPreferenceUseCase = mockk()
private val setAllPreferences: SetAllWalletPushNotificationPreferencesUseCase = mockk()
private val setNotificationsEnabled: SetNotificationsEnabledUseCase = mockk()
private val isFirstActivationDone: IsPushNotificationFirstActivationDoneUseCase = mockk()
private val markFirstActivationDone: MarkPushNotificationFirstActivationDoneUseCase = mockk(relaxed = true)
private val systemNotificationsStateProvider: SystemNotificationsStateProvider = mockk() private val systemNotificationsStateProvider: SystemNotificationsStateProvider = mockk()
private val settingsManager: SettingsManager = mockk(relaxed = true) private val settingsManager: SettingsManager = mockk(relaxed = true)
private val accountsCRUDRepository: AccountsCRUDRepository = mockk(relaxed = true)
private val messageSender: UiMessageSender = mockk(relaxed = true) private val messageSender: UiMessageSender = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private fun model( private fun model(
osEnabled: Boolean = true, osEnabled: Boolean = true,
firstActivationDone: Boolean = true,
preferencesFlow: MutableSharedFlow<WalletPushNotificationPreferences> = MutableSharedFlow(replay = 1), preferencesFlow: MutableSharedFlow<WalletPushNotificationPreferences> = MutableSharedFlow(replay = 1),
): PushNotificationSettingsModel { ): PushNotificationSettingsModel {
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns osEnabled every { systemNotificationsStateProvider.areNotificationsEnabled() } returns osEnabled
every { observePreferences(userWalletId) } returns preferencesFlow every { observePreferences(userWalletId) } returns preferencesFlow
coEvery { isFirstActivationDone(userWalletId) } returns firstActivationDone
coEvery { setNotificationsEnabled(any(), any()) } returns Either.Right(Unit)
coEvery { setAllPreferences(any(), any(), any(), any()) } returns Either.Right(Unit)
return PushNotificationSettingsModel( return PushNotificationSettingsModel(
paramsContainer = MutableParamsContainer(PushNotificationSettingsComponent.Params(userWalletId)), paramsContainer = MutableParamsContainer(PushNotificationSettingsComponent.Params(userWalletId)),
dispatchers = TestingCoroutineDispatcherProvider(), dispatchers = TestingCoroutineDispatcherProvider(),
@ -59,7 +69,10 @@ class PushNotificationSettingsModelTest {
updatePreference = updatePreference, updatePreference = updatePreference,
systemNotificationsStateProvider = systemNotificationsStateProvider, systemNotificationsStateProvider = systemNotificationsStateProvider,
settingsManager = settingsManager, settingsManager = settingsManager,
accountsCRUDRepository = accountsCRUDRepository, setAllPreferences = setAllPreferences,
setNotificationsEnabled = setNotificationsEnabled,
isFirstActivationDone = isFirstActivationDone,
markFirstActivationDone = markFirstActivationDone,
) )
} }
@ -79,6 +92,9 @@ class PushNotificationSettingsModelTest {
fun `GIVEN observe throws WHEN model created THEN ui state becomes Error`() = runTest { fun `GIVEN observe throws WHEN model created THEN ui state becomes Error`() = runTest {
every { observePreferences(userWalletId) } returns flow { throw IllegalStateException("boom") } every { observePreferences(userWalletId) } returns flow { throw IllegalStateException("boom") }
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true
coEvery { isFirstActivationDone(userWalletId) } returns true
coEvery { setNotificationsEnabled(any(), any()) } returns Either.Right(Unit)
coEvery { setAllPreferences(any(), any(), any(), any()) } returns Either.Right(Unit)
val model = PushNotificationSettingsModel( val model = PushNotificationSettingsModel(
paramsContainer = MutableParamsContainer(PushNotificationSettingsComponent.Params(userWalletId)), paramsContainer = MutableParamsContainer(PushNotificationSettingsComponent.Params(userWalletId)),
@ -89,7 +105,10 @@ class PushNotificationSettingsModelTest {
updatePreference = updatePreference, updatePreference = updatePreference,
systemNotificationsStateProvider = systemNotificationsStateProvider, systemNotificationsStateProvider = systemNotificationsStateProvider,
settingsManager = settingsManager, settingsManager = settingsManager,
accountsCRUDRepository = accountsCRUDRepository, setAllPreferences = setAllPreferences,
setNotificationsEnabled = setNotificationsEnabled,
isFirstActivationDone = isFirstActivationDone,
markFirstActivationDone = markFirstActivationDone,
) )
advanceUntilIdle() advanceUntilIdle()
@ -197,6 +216,28 @@ class PushNotificationSettingsModelTest {
assertThat(toggles.first { it.id == ToggleId.OffersUpdates }.isOn).isTrue() assertThat(toggles.first { it.id == ToggleId.OffersUpdates }.isOn).isTrue()
} }
@Test
fun `GIVEN transaction alerts on WHEN written THEN tokens synced before preference`() = runTest {
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
coEvery {
updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, true)
} returns Either.Right(Unit)
val model = model(osEnabled = true, firstActivationDone = true, preferencesFlow = flow)
advanceUntilIdle()
(model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.TransactionAlerts }
.onCheckedChange(true)
advanceUntilIdle()
// Order matters: token re-subscription (which carries addresses) precedes the preferences write (spec §13.2).
coVerifyOrder {
setNotificationsEnabled(userWalletId, isEnabled = true)
updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, true)
}
}
@Test @Test
fun `GIVEN OS disabled WHEN toggle ON THEN permission request is triggered`() = runTest { fun `GIVEN OS disabled WHEN toggle ON THEN permission request is triggered`() = runTest {
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1) val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
@ -237,52 +278,195 @@ class PushNotificationSettingsModelTest {
} }
@Test @Test
fun `WHEN Allow on a single tapped toggle THEN only that toggle is enabled`() = runTest { fun `GIVEN first activation not done WHEN Allow THEN all three categories enabled`() = runTest {
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
val model = model(osEnabled = false, firstActivationDone = false, preferencesFlow = flow)
advanceUntilIdle()
// Tap a single toggle in not_determined state -> OS prompt fires.
(model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
.onCheckedChange(true)
advanceUntilIdle()
// User taps Allow.
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true
model.onPermissionResult(isGranted = true)
advanceUntilIdle()
// First-activation rule: all three enabled at once (not just the tapped one), token re-subscription, flag fixed.
coVerify(exactly = 1) { setAllPreferences(userWalletId, true, true, true) }
coVerify(exactly = 1) { setNotificationsEnabled(userWalletId, isEnabled = true) }
coVerify(exactly = 1) { markFirstActivationDone(userWalletId) }
coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
}
@Test
fun `GIVEN first activation done WHEN Allow THEN only tapped toggle enabled`() = runTest {
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1) val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse()) flow.tryEmit(allFalse())
coEvery { coEvery {
updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true) updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true)
} returns Either.Right(Unit) } returns Either.Right(Unit)
val model = model(osEnabled = false, firstActivationDone = true, preferencesFlow = flow)
val model = model(osEnabled = false, preferencesFlow = flow)
advanceUntilIdle() advanceUntilIdle()
val offers = (model.uiState.value as PushNotificationSettingsUM.Content) (model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates } .toggles.first { it.id == ToggleId.OffersUpdates }
offers.onCheckedChange(true) .onCheckedChange(true)
advanceUntilIdle() advanceUntilIdle()
// OS prompt fires; user taps Allow.
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true
model.onPermissionResult(isGranted = true) model.onPermissionResult(isGranted = true)
advanceUntilIdle() advanceUntilIdle()
coVerify(exactly = 1) { coVerify(exactly = 1) { updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true) }
updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true) // Selective: exactly one preference write (the tapped one), no bulk enable, and the flag is already set.
} coVerify(exactly = 1) { updatePreference(any(), any(), any()) }
coVerify(exactly = 0) { coVerify(exactly = 0) { setAllPreferences(any(), any(), any(), any()) }
updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, any()) coVerify(exactly = 0) { markFirstActivationDone(userWalletId) }
}
coVerify(exactly = 0) {
updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, any())
}
} }
@Test @Test
fun `WHEN Deny THEN Enable Notifications dialog is shown and no PUT`() = runTest { fun `GIVEN permission granted but notifications disabled WHEN result THEN no all-three and settings dialog`() =
runTest {
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
val model = model(osEnabled = false, firstActivationDone = false, preferencesFlow = flow)
advanceUntilIdle()
(model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
.onCheckedChange(true)
advanceUntilIdle()
// POST_NOTIFICATIONS already granted (launcher returns true) but notifications stay OFF at OS level.
model.onPermissionResult(isGranted = true)
advanceUntilIdle()
coVerify(exactly = 0) { setAllPreferences(any(), any(), any(), any()) }
coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
coVerify(exactly = 1) { messageSender.send(any()) }
coVerify(exactly = 1) { markFirstActivationDone(userWalletId) }
}
@Test
fun `GIVEN transaction alerts on AND tokens write fails WHEN written THEN preference not written and reverted`() =
runTest {
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
val model = model(osEnabled = true, firstActivationDone = true, preferencesFlow = flow)
// Registered AFTER model() so this specific stub wins over model()'s any()-> Right default.
coEvery {
setNotificationsEnabled(userWalletId, isEnabled = true)
} returns Either.Left(RuntimeException("net"))
advanceUntilIdle()
(model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.TransactionAlerts }
.onCheckedChange(true)
advanceUntilIdle()
coVerify(exactly = 0) { updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, any()) }
coVerify(atLeast = 1) { messageSender.send(any()) }
val tx = (model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.TransactionAlerts }
assertThat(tx.isOn).isFalse()
}
@Test
fun `GIVEN transaction alerts on AND preference write fails WHEN written THEN token subscription undone`() =
runTest {
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
coEvery {
updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, true)
} returns Either.Left(RuntimeException("net"))
val model = model(osEnabled = true, firstActivationDone = true, preferencesFlow = flow)
advanceUntilIdle()
(model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.TransactionAlerts }
.onCheckedChange(true)
advanceUntilIdle()
// tokens on -> preferences fail -> compensating tokens off; toggle reverted.
coVerifyOrder {
setNotificationsEnabled(userWalletId, isEnabled = true)
updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, true)
setNotificationsEnabled(userWalletId, isEnabled = false)
}
val tx = (model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.TransactionAlerts }
assertThat(tx.isOn).isFalse()
}
@Test
fun `GIVEN first activation AND tokens write fails WHEN Allow THEN reverted and flag not marked`() = runTest {
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1) val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse()) flow.tryEmit(allFalse())
val model = model(osEnabled = false, preferencesFlow = flow) val model = model(osEnabled = false, firstActivationDone = false, preferencesFlow = flow)
// Registered AFTER model() so this specific stub wins over model()'s any()-> Right default.
coEvery { setNotificationsEnabled(userWalletId, isEnabled = true) } returns Either.Left(RuntimeException("net"))
advanceUntilIdle() advanceUntilIdle()
val offers = (model.uiState.value as PushNotificationSettingsUM.Content) (model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates } .toggles.first { it.id == ToggleId.OffersUpdates }
offers.onCheckedChange(true) .onCheckedChange(true)
advanceUntilIdle()
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true
model.onPermissionResult(isGranted = true)
advanceUntilIdle()
coVerify(exactly = 0) { setAllPreferences(any(), any(), any(), any()) }
coVerify(exactly = 0) { markFirstActivationDone(userWalletId) }
coVerify(atLeast = 1) { messageSender.send(any()) }
}
@Test
fun `GIVEN first activation AND preferences write fails WHEN Allow THEN tokens undone and flag not marked`() =
runTest {
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
val model = model(osEnabled = false, firstActivationDone = false, preferencesFlow = flow)
// Registered AFTER model() so this specific stub wins over model()'s any()-> Right default.
coEvery {
setAllPreferences(userWalletId, true, true, true)
} returns Either.Left(RuntimeException("net"))
advanceUntilIdle()
(model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
.onCheckedChange(true)
advanceUntilIdle()
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true
model.onPermissionResult(isGranted = true)
advanceUntilIdle()
coVerifyOrder {
setNotificationsEnabled(userWalletId, isEnabled = true)
setAllPreferences(userWalletId, true, true, true)
setNotificationsEnabled(userWalletId, isEnabled = false)
}
coVerify(exactly = 0) { markFirstActivationDone(userWalletId) }
}
@Test
fun `WHEN Deny THEN dialog shown AND flag marked AND no writes`() = runTest {
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
val model = model(osEnabled = false, firstActivationDone = false, preferencesFlow = flow)
advanceUntilIdle()
(model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
.onCheckedChange(true)
advanceUntilIdle() advanceUntilIdle()
model.onPermissionResult(isGranted = false) model.onPermissionResult(isGranted = false)
advanceUntilIdle() advanceUntilIdle()
coVerify(exactly = 1) { messageSender.send(any()) } coVerify(exactly = 1) { messageSender.send(any()) }
coVerify(exactly = 1) { markFirstActivationDone(userWalletId) }
coVerify(exactly = 0) { updatePreference(any(), any(), any()) } coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
coVerify(exactly = 0) { setAllPreferences(any(), any(), any(), any()) }
} }
private fun allFalse() = WalletPushNotificationPreferences( private fun allFalse() = WalletPushNotificationPreferences(

View file

@ -45,8 +45,8 @@ dependencies {
implementation(projects.domain.notifications) implementation(projects.domain.notifications)
implementation(projects.domain.pushNotificationPreferences) implementation(projects.domain.pushNotificationPreferences)
implementation(projects.domain.common) implementation(projects.domain.common)
implementation(projects.domain.account)
implementation(projects.domain.models) implementation(projects.domain.models)
implementation(projects.domain.wallets)
/** Feature modules */ /** Feature modules */
implementation(projects.features.pushNotifications.api) implementation(projects.features.pushNotifications.api)

View file

@ -9,12 +9,14 @@ import arrow.core.Either
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer 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.common.wallets.UserWalletsListRepository
import com.tangem.domain.notifications.repository.NotificationsRepository 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.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase
import com.tangem.domain.settings.NeverRequestPermissionUseCase import com.tangem.domain.settings.NeverRequestPermissionUseCase
import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase
import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase
import com.tangem.features.pushnotifications.api.PushNotificationsParams import com.tangem.features.pushnotifications.api.PushNotificationsParams
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
@ -22,7 +24,6 @@ import com.tangem.features.pushnotifications.impl.domain.GetPushNotificationsDou
import com.tangem.features.pushnotifications.impl.domain.DoubleAskVariant import com.tangem.features.pushnotifications.impl.domain.DoubleAskVariant
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -43,7 +44,9 @@ internal class PushNotificationsModel @Inject constructor(
private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles, private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles,
private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase, private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase,
private val userWalletsListRepository: UserWalletsListRepository, private val userWalletsListRepository: UserWalletsListRepository,
private val accountsCRUDRepository: AccountsCRUDRepository, private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase,
private val isPushNotificationFirstActivationDone: IsPushNotificationFirstActivationDoneUseCase,
private val markPushNotificationFirstActivationDone: MarkPushNotificationFirstActivationDoneUseCase,
private val getPushNotificationsDoubleAskVariantUseCase: GetPushNotificationsDoubleAskVariantUseCase, private val getPushNotificationsDoubleAskVariantUseCase: GetPushNotificationsDoubleAskVariantUseCase,
) : Model(), PushNotificationsClickIntents { ) : Model(), PushNotificationsClickIntents {
@ -145,6 +148,10 @@ internal class PushNotificationsModel @Inject constructor(
modelScope.launch { modelScope.launch {
neverRequestPermissionUseCase(PUSH_PERMISSION) neverRequestPermissionUseCase(PUSH_PERMISSION)
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION) neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
if (isPushNotificationSettingsEnabled) {
// Flag is fixed on DENY too, so a later activation is selective.
markFirstActivationDoneForAllWallets()
}
params.modelCallbacks.onDenySystemPermission() params.modelCallbacks.onDenySystemPermission()
if (!params.isBottomSheet) { if (!params.isBottomSheet) {
params.nextRoute?.let { appRouter.push(it) } params.nextRoute?.let { appRouter.push(it) }
@ -152,21 +159,29 @@ internal class PushNotificationsModel @Inject constructor(
} }
} }
// TODO [REDACTED_JIRA] evaluate per-wallet "first-activation done" /** On the first grant, enable all three categories for every not-yet-activated wallet (guarded once per wallet). */
// 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() { private suspend fun applyFirstActivationRule() {
userWalletsListRepository.userWalletsSync().forEach { wallet -> userWalletsListRepository.userWalletsSync().forEach { wallet ->
val result = setAllWalletPushNotificationPreferences( 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, userWalletId = wallet.walletId,
transactionAlerts = true, transactionAlerts = true,
offersUpdates = true, offersUpdates = true,
priceAlerts = true, priceAlerts = true,
) )
if (result is Either.Right) { .onRight { markPushNotificationFirstActivationDone(wallet.walletId) }
runSuspendCatching { accountsCRUDRepository.syncTokens(wallet.walletId) } .onLeft { setNotificationsEnabledUseCase(wallet.walletId, isEnabled = false) }
} }
}
private suspend fun markFirstActivationDoneForAllWallets() {
userWalletsListRepository.userWalletsSync().forEach { wallet ->
markPushNotificationFirstActivationDone(wallet.walletId)
} }
} }
} }

View file

@ -1,17 +1,22 @@
package com.tangem.features.pushnotifications.impl.model package com.tangem.features.pushnotifications.impl.model
import arrow.core.Either
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer 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.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.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.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase
import com.tangem.domain.settings.NeverRequestPermissionUseCase import com.tangem.domain.settings.NeverRequestPermissionUseCase
import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase
import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks
import com.tangem.features.pushnotifications.api.PushNotificationsParams import com.tangem.features.pushnotifications.api.PushNotificationsParams
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
@ -21,6 +26,8 @@ import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeat
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -43,7 +50,11 @@ internal class PushNotificationsModelTest {
private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase = private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase =
mockk(relaxed = true) mockk(relaxed = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true) private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true)
private val accountsCRUDRepository: AccountsCRUDRepository = mockk(relaxed = true) private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase = mockk(relaxed = true)
private val isPushNotificationFirstActivationDone: IsPushNotificationFirstActivationDoneUseCase =
mockk(relaxed = true)
private val markPushNotificationFirstActivationDone: MarkPushNotificationFirstActivationDoneUseCase =
mockk(relaxed = true)
private val getDoubleAskVariantUseCase: GetPushNotificationsDoubleAskVariantUseCase = mockk() private val getDoubleAskVariantUseCase: GetPushNotificationsDoubleAskVariantUseCase = mockk()
private val modelCallbacks: PushNotificationsModelCallbacks = mockk(relaxed = true) private val modelCallbacks: PushNotificationsModelCallbacks = mockk(relaxed = true)
@ -162,6 +173,78 @@ internal class PushNotificationsModelTest {
} }
} }
@Test
fun `GIVEN settings enabled WHEN onAllowPermission THEN fresh wallets get all-three and done wallets skipped`() =
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 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)
}
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
fun `GIVEN settings enabled WHEN onDenyPermission THEN first-activation flag marked for all wallets`() = runTest {
every { pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled } returns true
val w1 = UserWalletId("aa")
val w2 = UserWalletId("bb")
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(wallet(w1), wallet(w2))
val model = createModel(testScope = this)
advanceUntilIdle()
model.onDenyPermission()
advanceUntilIdle()
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()) }
}
@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.
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)
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) { markPushNotificationFirstActivationDone(any()) }
}
private fun wallet(id: UserWalletId): UserWallet = mockk { every { walletId } returns id }
private fun createModel( private fun createModel(
testScope: TestScope, testScope: TestScope,
isBottomSheet: Boolean = false, isBottomSheet: Boolean = false,
@ -186,7 +269,9 @@ internal class PushNotificationsModelTest {
pushNotificationSettingsFeatureToggles = pushNotificationSettingsFeatureToggles, pushNotificationSettingsFeatureToggles = pushNotificationSettingsFeatureToggles,
setAllWalletPushNotificationPreferences = setAllWalletPushNotificationPreferences, setAllWalletPushNotificationPreferences = setAllWalletPushNotificationPreferences,
userWalletsListRepository = userWalletsListRepository, userWalletsListRepository = userWalletsListRepository,
accountsCRUDRepository = accountsCRUDRepository, setNotificationsEnabledUseCase = setNotificationsEnabledUseCase,
isPushNotificationFirstActivationDone = isPushNotificationFirstActivationDone,
markPushNotificationFirstActivationDone = markPushNotificationFirstActivationDone,
getPushNotificationsDoubleAskVariantUseCase = getDoubleAskVariantUseCase, getPushNotificationsDoubleAskVariantUseCase = getDoubleAskVariantUseCase,
) )
} }

View file

@ -894,6 +894,8 @@ internal class WalletModel @Inject constructor(
} }
private fun enableNotificationsIfNeeded() { private fun enableNotificationsIfNeeded() {
// New first-activation owns auto-enable when the feature is on; skip the legacy path.
if (pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled) return
modelScope.launch { modelScope.launch {
val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications() val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications()
if (isUserAllowToEnableNotifications) { if (isUserAllowToEnableNotifications) {

View file

@ -43,6 +43,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent
import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -120,6 +121,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
private val notificationsRepository: NotificationsRepository, private val notificationsRepository: NotificationsRepository,
private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase, private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase,
private val getWalletsListForEnablingUseCase: GetWalletsForAutomaticallyPushEnablingUseCase, private val getWalletsListForEnablingUseCase: GetWalletsForAutomaticallyPushEnablingUseCase,
private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles,
private val uiMessageSender: UiMessageSender, private val uiMessageSender: UiMessageSender,
private val reviewManager: ReviewManager, private val reviewManager: ReviewManager,
private val closeHotWalletUpgradeBannerUseCase: CloseHotWalletUpgradeBannerUseCase, private val closeHotWalletUpgradeBannerUseCase: CloseHotWalletUpgradeBannerUseCase,
@ -373,6 +375,8 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
} }
private suspend fun enableNotificationsIfNeeded() { private suspend fun enableNotificationsIfNeeded() {
// New first-activation owns auto-enable when the feature is on; skip the legacy path.
if (pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled) return
val alreadyEnabledWallets = notificationsRepository.getWalletAutomaticallyEnabledList().map { val alreadyEnabledWallets = notificationsRepository.getWalletAutomaticallyEnabledList().map {
UserWalletId(it) UserWalletId(it)
} }