diff --git a/app/src/main/java/com/tangem/tap/di/domain/PushNotificationPreferencesDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/PushNotificationPreferencesDomainModule.kt index 3ab23fe831..01859104f3 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/PushNotificationPreferencesDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/PushNotificationPreferencesDomainModule.kt @@ -1,5 +1,7 @@ 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.PreloadWalletPushNotificationPreferencesUseCase import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase @@ -46,4 +48,20 @@ internal object PushNotificationPreferencesDomainModule { ): SetAllWalletPushNotificationPreferencesUseCase { 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) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 245dbdbafd..559e62a0a2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -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 { intPreferencesKey(name = "tronNetworkFeeNotificationShowCount") } diff --git a/data/push-notification-preferences/build.gradle.kts b/data/push-notification-preferences/build.gradle.kts index 324ee965fe..ea45d72061 100644 --- a/data/push-notification-preferences/build.gradle.kts +++ b/data/push-notification-preferences/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { // endregion // region Tests + testImplementation(projects.test.core) testImplementation(deps.test.coroutine) testImplementation(deps.test.junit5) testImplementation(deps.test.mockk) diff --git a/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepository.kt b/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepository.kt index 7f8be9280c..e2b1ee4cc5 100644 --- a/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepository.kt +++ b/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepository.kt @@ -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.models.PushNotificationPreferencesBody 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.pushnotificationpreferences.models.PushNotificationCategory import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences @@ -28,11 +31,23 @@ import kotlinx.coroutines.withContext internal class DefaultWalletPushNotificationPreferencesRepository( private val tangemTechApi: TangemTechApi, private val cache: RuntimeSharedStore>, + private val appPreferencesStore: AppPreferencesStore, private val dispatchers: CoroutineDispatcherProvider, ) : WalletPushNotificationPreferencesRepository { private val walletMutexes = ConcurrentHashMap() + 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) { if (isCached(userWalletId)) return mutexFor(userWalletId).withLock { diff --git a/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/di/PushNotificationPreferencesModule.kt b/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/di/PushNotificationPreferencesModule.kt index bcdafa677d..65ac9ce737 100644 --- a/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/di/PushNotificationPreferencesModule.kt +++ b/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/di/PushNotificationPreferencesModule.kt @@ -3,6 +3,7 @@ package com.tangem.data.pushnotificationpreferences.di import com.tangem.data.pushnotificationpreferences.DefaultWalletPushNotificationPreferencesRepository import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -19,10 +20,12 @@ internal object PushNotificationPreferencesModule { @Provides fun providesWalletPushNotificationPreferencesRepository( tangemTechApi: TangemTechApi, + appPreferencesStore: AppPreferencesStore, dispatchers: CoroutineDispatcherProvider, ): WalletPushNotificationPreferencesRepository = DefaultWalletPushNotificationPreferencesRepository( tangemTechApi = tangemTechApi, cache = RuntimeSharedStore(), + appPreferencesStore = appPreferencesStore, dispatchers = dispatchers, ) } \ No newline at end of file diff --git a/data/push-notification-preferences/src/test/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepositoryTest.kt b/data/push-notification-preferences/src/test/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepositoryTest.kt index 20ec904473..60382fb811 100644 --- a/data/push-notification-preferences/src/test/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepositoryTest.kt +++ b/data/push-notification-preferences/src/test/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepositoryTest.kt @@ -1,17 +1,21 @@ package com.tangem.data.pushnotificationpreferences +import androidx.datastore.preferences.core.emptyPreferences import app.cash.turbine.test import arrow.core.Either 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.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesBody import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse 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.pushnotificationpreferences.models.PushNotificationCategory import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences +import com.tangem.test.core.datastore.MockStateDataStore import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.coEvery import io.mockk.coVerify @@ -30,9 +34,17 @@ class DefaultWalletPushNotificationPreferencesRepositoryTest { private val userWalletId = UserWalletId(stringValue = "0011223344556677") private val otherWalletId = UserWalletId(stringValue = "ffeeddccbbaa9988") + // Real in-memory store so the persisted first-activation flag (a Set merge) is genuinely exercised. + private val appPreferencesStore = AppPreferencesStore( + moshi = Moshi.Builder().build(), + dispatchers = TestingCoroutineDispatcherProvider(), + preferencesDataStore = MockStateDataStore(default = emptyPreferences()), + ) + private val repository = DefaultWalletPushNotificationPreferencesRepository( tangemTechApi = tangemTechApi, cache = RuntimeSharedStore(), + appPreferencesStore = appPreferencesStore, 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) { coEvery { tangemTechApi.getPushNotificationPreferences(id.stringValue) } returns ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price)) diff --git a/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/IsPushNotificationFirstActivationDoneUseCase.kt b/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/IsPushNotificationFirstActivationDoneUseCase.kt new file mode 100644 index 0000000000..0bc5af23a5 --- /dev/null +++ b/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/IsPushNotificationFirstActivationDoneUseCase.kt @@ -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) +} \ No newline at end of file diff --git a/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/MarkPushNotificationFirstActivationDoneUseCase.kt b/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/MarkPushNotificationFirstActivationDoneUseCase.kt new file mode 100644 index 0000000000..1feee682c9 --- /dev/null +++ b/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/MarkPushNotificationFirstActivationDoneUseCase.kt @@ -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) +} \ No newline at end of file diff --git a/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/repository/WalletPushNotificationPreferencesRepository.kt b/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/repository/WalletPushNotificationPreferencesRepository.kt index f238674bbf..048c678692 100644 --- a/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/repository/WalletPushNotificationPreferencesRepository.kt +++ b/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/repository/WalletPushNotificationPreferencesRepository.kt @@ -6,12 +6,18 @@ import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCate import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences 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 { /** Warms up the cache for [userWalletId]. No-op if already cached. */ 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 /** Updates a single [category]; full-replace PUT under the hood. On failure cache is untouched. */ diff --git a/features/push-notification-settings/impl/build.gradle.kts b/features/push-notification-settings/impl/build.gradle.kts index fa3339e9bc..2962bc3e13 100644 --- a/features/push-notification-settings/impl/build.gradle.kts +++ b/features/push-notification-settings/impl/build.gradle.kts @@ -27,9 +27,9 @@ dependencies { implementation(projects.core.ui) /* Project - Domain */ - api(projects.domain.account) api(projects.domain.pushNotificationPreferences) implementation(projects.domain.models) + implementation(projects.domain.wallets) /* AndroidX */ implementation(deps.androidx.activity) diff --git a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModel.kt b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModel.kt index 5e4e88cda0..31f6c13f32 100644 --- a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModel.kt +++ b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModel.kt @@ -1,5 +1,6 @@ package com.tangem.features.pushnotificationsettings.impl.model +import arrow.core.Either import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate 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.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction -import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase +import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase +import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificationPreferenceUseCase import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences +import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent 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.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder -import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.coroutines.saveIn -import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.channels.Channel @@ -61,7 +63,10 @@ internal class PushNotificationSettingsModel @Inject constructor( private val updatePreference: UpdateWalletPushNotificationPreferenceUseCase, private val systemNotificationsStateProvider: SystemNotificationsStateProvider, 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() { private val params: PushNotificationSettingsComponent.Params = paramsContainer.require() @@ -123,12 +128,24 @@ internal class PushNotificationSettingsModel @Inject constructor( val tapped = pendingPermissionToggle pendingPermissionToggle = null 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)) - if (isGranted && tapped != null) { - applyOptimisticToggle(tapped, newValue = true) - } else if (!isGranted) { + + if (!isGranted || !isNotificationsEnabled) { + markFirstActivationDone(userWalletId) 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) { - // TODO [REDACTED_TASK_KEY] figure out and maybe swap /tokens and /preferences further calls - updatePreference(userWalletId, spec.category, newValue) - .onRight { - if (spec.category == PushNotificationCategory.TransactionAlerts) { - // Best-effort token sync after the preference write already succeeded: - // log a failure but don't surface it to the user or revert the toggle. - runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } - .onFailure { error -> - TangemLogger.e( - messageString = "Failed to sync tokens after enabling " + - "transaction alerts for $userWalletId", - throwable = error, - ) - } - } - } - .onLeft { revertOptimistic(spec, newValue) } + if (spec.category == PushNotificationCategory.TransactionAlerts) { + writeTransactionAlerts(spec, newValue) + } else { + updatePreference(userWalletId, spec.category, newValue).onLeft { revertOptimistic(spec, newValue) } + } + } + + /** Tokens (address re-subscription) first, then preferences; on failure revert and undo the token subscription. */ + private suspend fun writeTransactionAlerts(spec: ToggleSpec, newValue: Boolean) { + val tokensResult = setNotificationsEnabled(userWalletId, isEnabled = newValue) + if (tokensResult is Either.Left) { + revertOptimistic(spec, newValue) + return + } + updatePreference(userWalletId, spec.category, newValue).onLeft { + setNotificationsEnabled(userWalletId, isEnabled = !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) { @@ -257,9 +305,18 @@ internal class PushNotificationSettingsModel @Inject constructor( 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( PushNotificationAnalyticEvents.NotificationSettingsErrorShown( - toggleType = spec.id.analyticsValue, + toggleType = toggleType, errorType = ERROR_TYPE_WRITE_FAILED, ), ) diff --git a/features/push-notification-settings/impl/src/test/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModelTest.kt b/features/push-notification-settings/impl/src/test/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModelTest.kt index a747c404c4..e3243d7bfd 100644 --- a/features/push-notification-settings/impl/src/test/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModelTest.kt +++ b/features/push-notification-settings/impl/src/test/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModelTest.kt @@ -8,20 +8,23 @@ import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider import com.tangem.core.navigation.settings.SettingsManager -import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase +import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase +import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificationPreferenceUseCase import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences +import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent -import com.tangem.features.pushnotificationsettings.impl.entity.AllowPushNotificationsBannerUM import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM import com.tangem.features.pushnotificationsettings.impl.entity.ToggleId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -31,25 +34,32 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") class PushNotificationSettingsModelTest { private val userWalletId = UserWalletId("0011223344556677") private val observePreferences: ObserveWalletPushNotificationPreferencesUseCase = 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 settingsManager: SettingsManager = mockk(relaxed = true) - private val accountsCRUDRepository: AccountsCRUDRepository = mockk(relaxed = true) private val messageSender: UiMessageSender = mockk(relaxed = true) private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) private fun model( osEnabled: Boolean = true, + firstActivationDone: Boolean = true, preferencesFlow: MutableSharedFlow = MutableSharedFlow(replay = 1), ): PushNotificationSettingsModel { every { systemNotificationsStateProvider.areNotificationsEnabled() } returns osEnabled 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( paramsContainer = MutableParamsContainer(PushNotificationSettingsComponent.Params(userWalletId)), dispatchers = TestingCoroutineDispatcherProvider(), @@ -59,7 +69,10 @@ class PushNotificationSettingsModelTest { updatePreference = updatePreference, systemNotificationsStateProvider = systemNotificationsStateProvider, 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 { every { observePreferences(userWalletId) } returns flow { throw IllegalStateException("boom") } 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( paramsContainer = MutableParamsContainer(PushNotificationSettingsComponent.Params(userWalletId)), @@ -89,7 +105,10 @@ class PushNotificationSettingsModelTest { updatePreference = updatePreference, systemNotificationsStateProvider = systemNotificationsStateProvider, settingsManager = settingsManager, - accountsCRUDRepository = accountsCRUDRepository, + setAllPreferences = setAllPreferences, + setNotificationsEnabled = setNotificationsEnabled, + isFirstActivationDone = isFirstActivationDone, + markFirstActivationDone = markFirstActivationDone, ) advanceUntilIdle() @@ -197,6 +216,28 @@ class PushNotificationSettingsModelTest { 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(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 fun `GIVEN OS disabled WHEN toggle ON THEN permission request is triggered`() = runTest { val flow = MutableSharedFlow(replay = 1) @@ -237,52 +278,195 @@ class PushNotificationSettingsModelTest { } @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(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(replay = 1) flow.tryEmit(allFalse()) coEvery { updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true) } returns Either.Right(Unit) - - val model = model(osEnabled = false, preferencesFlow = flow) + val model = model(osEnabled = false, firstActivationDone = true, preferencesFlow = flow) advanceUntilIdle() - val offers = (model.uiState.value as PushNotificationSettingsUM.Content) + (model.uiState.value as PushNotificationSettingsUM.Content) .toggles.first { it.id == ToggleId.OffersUpdates } - offers.onCheckedChange(true) + .onCheckedChange(true) advanceUntilIdle() - // OS prompt fires; user taps Allow. every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true model.onPermissionResult(isGranted = true) advanceUntilIdle() - coVerify(exactly = 1) { - updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true) - } - coVerify(exactly = 0) { - updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, any()) - } - coVerify(exactly = 0) { - updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, any()) - } + coVerify(exactly = 1) { 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) { setAllPreferences(any(), any(), any(), any()) } + coVerify(exactly = 0) { markFirstActivationDone(userWalletId) } } @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(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(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(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(replay = 1) 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() - val offers = (model.uiState.value as PushNotificationSettingsUM.Content) + (model.uiState.value as PushNotificationSettingsUM.Content) .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(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(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() model.onPermissionResult(isGranted = false) advanceUntilIdle() coVerify(exactly = 1) { messageSender.send(any()) } + coVerify(exactly = 1) { markFirstActivationDone(userWalletId) } coVerify(exactly = 0) { updatePreference(any(), any(), any()) } + coVerify(exactly = 0) { setAllPreferences(any(), any(), any(), any()) } } private fun allFalse() = WalletPushNotificationPreferences( diff --git a/features/push-notifications/impl/build.gradle.kts b/features/push-notifications/impl/build.gradle.kts index a4312bcafc..c7b1ddb51e 100644 --- a/features/push-notifications/impl/build.gradle.kts +++ b/features/push-notifications/impl/build.gradle.kts @@ -45,8 +45,8 @@ dependencies { implementation(projects.domain.notifications) implementation(projects.domain.pushNotificationPreferences) implementation(projects.domain.common) - implementation(projects.domain.account) implementation(projects.domain.models) + implementation(projects.domain.wallets) /** Feature modules */ implementation(projects.features.pushNotifications.api) diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt index aee804ce16..772853f7ad 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt @@ -9,12 +9,14 @@ import arrow.core.Either import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.notifications.repository.NotificationsRepository +import com.tangem.domain.pushnotificationpreferences.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.features.pushnotifications.api.PushNotificationsParams import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents 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.pushnotificationsettings.PushNotificationSettingsFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.runSuspendCatching import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -43,7 +44,9 @@ internal class PushNotificationsModel @Inject constructor( private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles, private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase, private val userWalletsListRepository: UserWalletsListRepository, - private val accountsCRUDRepository: AccountsCRUDRepository, + private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase, + private val isPushNotificationFirstActivationDone: IsPushNotificationFirstActivationDoneUseCase, + private val markPushNotificationFirstActivationDone: MarkPushNotificationFirstActivationDoneUseCase, private val getPushNotificationsDoubleAskVariantUseCase: GetPushNotificationsDoubleAskVariantUseCase, ) : Model(), PushNotificationsClickIntents { @@ -145,6 +148,10 @@ internal class PushNotificationsModel @Inject constructor( modelScope.launch { neverRequestPermissionUseCase(PUSH_PERMISSION) neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION) + if (isPushNotificationSettingsEnabled) { + // Flag is fixed on DENY too, so a later activation is selective. + markFirstActivationDoneForAllWallets() + } params.modelCallbacks.onDenySystemPermission() if (!params.isBottomSheet) { params.nextRoute?.let { appRouter.push(it) } @@ -152,21 +159,29 @@ internal class PushNotificationsModel @Inject constructor( } } - // TODO [REDACTED_JIRA] evaluate per-wallet "first-activation done" - // tracking (iOS keeps a [walletId] array in UserDefaults). Today the bulk-enable fires every - // time onAllowPermission is called under the feature toggle, but Soft Ask itself is gated by - // the existing `shouldShowPushPermission_*` flag so in practice it runs once per install. + /** 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 -> - 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, transactionAlerts = true, offersUpdates = true, priceAlerts = true, ) - if (result is Either.Right) { - runSuspendCatching { accountsCRUDRepository.syncTokens(wallet.walletId) } - } + .onRight { markPushNotificationFirstActivationDone(wallet.walletId) } + .onLeft { setNotificationsEnabledUseCase(wallet.walletId, isEnabled = false) } + } + } + + private suspend fun markFirstActivationDoneForAllWallets() { + userWalletsListRepository.userWalletsSync().forEach { wallet -> + markPushNotificationFirstActivationDone(wallet.walletId) } } } \ No newline at end of file diff --git a/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt index d35162df1f..e914148bd1 100644 --- a/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt +++ b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt @@ -1,17 +1,22 @@ package com.tangem.features.pushnotifications.impl.model +import arrow.core.Either import com.google.common.truth.Truth.assertThat import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.MutableParamsContainer 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.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.features.pushnotifications.api.PushNotificationsModelCallbacks import com.tangem.features.pushnotifications.api.PushNotificationsParams 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 io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -43,7 +50,11 @@ internal class PushNotificationsModelTest { private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase = 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 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( testScope: TestScope, isBottomSheet: Boolean = false, @@ -186,7 +269,9 @@ internal class PushNotificationsModelTest { pushNotificationSettingsFeatureToggles = pushNotificationSettingsFeatureToggles, setAllWalletPushNotificationPreferences = setAllWalletPushNotificationPreferences, userWalletsListRepository = userWalletsListRepository, - accountsCRUDRepository = accountsCRUDRepository, + setNotificationsEnabledUseCase = setNotificationsEnabledUseCase, + isPushNotificationFirstActivationDone = isPushNotificationFirstActivationDone, + markPushNotificationFirstActivationDone = markPushNotificationFirstActivationDone, getPushNotificationsDoubleAskVariantUseCase = getDoubleAskVariantUseCase, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 5d62057745..661eef9160 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -894,6 +894,8 @@ 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 modelScope.launch { val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications() if (isUserAllowToEnableNotifications) { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 5325ece800..7d5540b348 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -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.utils.WalletEventSender import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents +import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.async @@ -120,6 +121,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( private val notificationsRepository: NotificationsRepository, private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase, private val getWalletsListForEnablingUseCase: GetWalletsForAutomaticallyPushEnablingUseCase, + private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles, private val uiMessageSender: UiMessageSender, private val reviewManager: ReviewManager, private val closeHotWalletUpgradeBannerUseCase: CloseHotWalletUpgradeBannerUseCase, @@ -373,6 +375,8 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( } 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 { UserWalletId(it) }