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 01859104f3..5737f2f7fb 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,6 @@ package com.tangem.tap.di.domain +import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase @@ -7,6 +8,8 @@ import com.tangem.domain.pushnotificationpreferences.PreloadWalletPushNotificati import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificationPreferenceUseCase import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository +import com.tangem.domain.wallets.usecase.ApplyPushNotificationFirstActivationUseCase +import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -64,4 +67,18 @@ internal object PushNotificationPreferencesDomainModule { ): MarkPushNotificationFirstActivationDoneUseCase { return MarkPushNotificationFirstActivationDoneUseCase(repository = repository) } + + @Provides + @Singleton + fun providesApplyPushNotificationFirstActivationUseCase( + setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase, + repository: WalletPushNotificationPreferencesRepository, + notificationsRepository: NotificationsRepository, + ): ApplyPushNotificationFirstActivationUseCase { + return ApplyPushNotificationFirstActivationUseCase( + setNotificationsEnabledUseCase = setNotificationsEnabledUseCase, + preferencesRepository = repository, + notificationsRepository = notificationsRepository, + ) + } } \ No newline at end of file diff --git a/domain/wallets/build.gradle.kts b/domain/wallets/build.gradle.kts index 839fe3ad59..a63f820473 100644 --- a/domain/wallets/build.gradle.kts +++ b/domain/wallets/build.gradle.kts @@ -41,6 +41,8 @@ dependencies { api(projects.domain.walletManager) api(projects.domain.core) implementation(projects.domain.legacy) + implementation(projects.domain.notifications) + implementation(projects.domain.pushNotificationPreferences) // endregion // region Domain models diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ApplyPushNotificationFirstActivationUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ApplyPushNotificationFirstActivationUseCase.kt new file mode 100644 index 0000000000..1867b8ff61 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/ApplyPushNotificationFirstActivationUseCase.kt @@ -0,0 +1,55 @@ +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.notifications.repository.NotificationsRepository +import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.flow.first + +/** + * First-activation rule: on the first push permission grant, enables all three categories for a wallet. + + * on the backend yet) is retried by the next trigger — wallet screen or notification settings screen. + */ +class ApplyPushNotificationFirstActivationUseCase( + private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase, + private val preferencesRepository: WalletPushNotificationPreferencesRepository, + private val notificationsRepository: NotificationsRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId): Either { + return applyRule(userWalletId).onLeft { + TangemLogger.e("Push first activation failed for $userWalletId", it) + } + } + + private suspend fun applyRule(userWalletId: UserWalletId): Either { + if (preferencesRepository.isFirstActivationDone(userWalletId)) return Unit.right() + + // Wallets auto-enabled by the legacy flow are adopted as activated to not force-enable extra categories. + if (userWalletId.stringValue in notificationsRepository.getWalletAutomaticallyEnabledList()) { + preferencesRepository.markFirstActivationDone(userWalletId) + return Unit.right() + } + + // The wallet may not be created on the backend yet (onboarding) — probe before any side effects. + val probe = Either.catch { preferencesRepository.observePreferences(userWalletId).first() } + if (probe is Either.Left) return probe.value.left() + + // Tokens (address re-subscription) first, then preferences. + val tokensResult = setNotificationsEnabledUseCase(userWalletId, isEnabled = true) + if (tokensResult is Either.Left) return tokensResult + + return preferencesRepository.setAllPreferences( + userWalletId = userWalletId, + transactionAlerts = true, + offersUpdates = true, + priceAlerts = true, + ) + .onRight { preferencesRepository.markFirstActivationDone(userWalletId) } + .onLeft { setNotificationsEnabledUseCase(userWalletId, isEnabled = false) } + } +} \ No newline at end of file diff --git a/domain/wallets/src/test/java/com/tangem/domain/wallets/usecase/ApplyPushNotificationFirstActivationUseCaseTest.kt b/domain/wallets/src/test/java/com/tangem/domain/wallets/usecase/ApplyPushNotificationFirstActivationUseCaseTest.kt new file mode 100644 index 0000000000..083f757063 --- /dev/null +++ b/domain/wallets/src/test/java/com/tangem/domain/wallets/usecase/ApplyPushNotificationFirstActivationUseCaseTest.kt @@ -0,0 +1,150 @@ +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.notifications.repository.NotificationsRepository +import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference +import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences +import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ApplyPushNotificationFirstActivationUseCaseTest { + + private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase = mockk() + private val preferencesRepository: WalletPushNotificationPreferencesRepository = mockk() + private val notificationsRepository: NotificationsRepository = mockk() + + private val useCase = ApplyPushNotificationFirstActivationUseCase( + setNotificationsEnabledUseCase = setNotificationsEnabledUseCase, + preferencesRepository = preferencesRepository, + notificationsRepository = notificationsRepository, + ) + + private val userWalletId = UserWalletId("0A0B0C0D") + + @BeforeEach + fun resetMocks() { + clearMocks(setNotificationsEnabledUseCase, preferencesRepository, notificationsRepository) + coEvery { preferencesRepository.isFirstActivationDone(userWalletId) } returns false + coEvery { preferencesRepository.markFirstActivationDone(userWalletId) } just runs + coEvery { notificationsRepository.getWalletAutomaticallyEnabledList() } returns emptyList() + coEvery { preferencesRepository.observePreferences(userWalletId) } returns flowOf(allFalse()) + coEvery { setNotificationsEnabledUseCase(userWalletId, any()) } returns Either.Right(Unit) + coEvery { + preferencesRepository.setAllPreferences(userWalletId, any(), any(), any()) + } returns Either.Right(Unit) + } + + @Test + fun `GIVEN activation already done WHEN invoke THEN returns Right without any writes`() = runTest { + // Arrange + coEvery { preferencesRepository.isFirstActivationDone(userWalletId) } returns true + + // Act + val result = useCase(userWalletId) + + // Assert + assertThat(result.isRight()).isTrue() + coVerify(exactly = 0) { setNotificationsEnabledUseCase(any(), any()) } + coVerify(exactly = 0) { preferencesRepository.setAllPreferences(any(), any(), any(), any()) } + coVerify(exactly = 0) { preferencesRepository.markFirstActivationDone(any()) } + } + + @Test + fun `GIVEN wallet enabled by legacy flow WHEN invoke THEN flag adopted without enabling categories`() = runTest { + // Arrange + coEvery { notificationsRepository.getWalletAutomaticallyEnabledList() } returns + listOf(userWalletId.stringValue) + + // Act + val result = useCase(userWalletId) + + // Assert + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { preferencesRepository.markFirstActivationDone(userWalletId) } + coVerify(exactly = 0) { setNotificationsEnabledUseCase(any(), any()) } + coVerify(exactly = 0) { preferencesRepository.setAllPreferences(any(), any(), any(), any()) } + } + + @Test + fun `GIVEN preferences unavailable WHEN invoke THEN returns Left without side effects`() = runTest { + // Arrange: the wallet is not created on the backend yet (e.g. right after onboarding). + coEvery { preferencesRepository.observePreferences(userWalletId) } returns + flow { throw RuntimeException("404") } + + // Act + val result = useCase(userWalletId) + + // Assert + assertThat(result.isLeft()).isTrue() + coVerify(exactly = 0) { setNotificationsEnabledUseCase(any(), any()) } + coVerify(exactly = 0) { preferencesRepository.setAllPreferences(any(), any(), any(), any()) } + coVerify(exactly = 0) { preferencesRepository.markFirstActivationDone(any()) } + } + + @Test + fun `GIVEN token subscription fails WHEN invoke THEN returns Left and preferences not written`() = runTest { + // Arrange + coEvery { setNotificationsEnabledUseCase(userWalletId, isEnabled = true) } returns + Either.Left(RuntimeException("net")) + + // Act + val result = useCase(userWalletId) + + // Assert + assertThat(result.isLeft()).isTrue() + coVerify(exactly = 0) { preferencesRepository.setAllPreferences(any(), any(), any(), any()) } + coVerify(exactly = 0) { preferencesRepository.markFirstActivationDone(any()) } + } + + @Test + fun `GIVEN preferences write fails WHEN invoke THEN tokens undone and flag not marked`() = runTest { + // Arrange + coEvery { preferencesRepository.setAllPreferences(userWalletId, true, true, true) } returns + Either.Left(RuntimeException("net")) + + // Act + val result = useCase(userWalletId) + + // Assert + assertThat(result.isLeft()).isTrue() + coVerifyOrder { + setNotificationsEnabledUseCase(userWalletId, isEnabled = true) + preferencesRepository.setAllPreferences(userWalletId, true, true, true) + setNotificationsEnabledUseCase(userWalletId, isEnabled = false) + } + coVerify(exactly = 0) { preferencesRepository.markFirstActivationDone(any()) } + } + + @Test + fun `GIVEN whole chain succeeds WHEN invoke THEN all three enabled and flag marked`() = runTest { + // Act + val result = useCase(userWalletId) + + // Assert + assertThat(result.isRight()).isTrue() + coVerifyOrder { + setNotificationsEnabledUseCase(userWalletId, isEnabled = true) + preferencesRepository.setAllPreferences(userWalletId, true, true, true) + preferencesRepository.markFirstActivationDone(userWalletId) + } + } + + private fun allFalse() = WalletPushNotificationPreferences( + transactionAlerts = PushNotificationPreference(isEnabled = false), + offersUpdates = PushNotificationPreference(isEnabled = false), + priceAlerts = PushNotificationPreference(isEnabled = false), + ) +} \ No newline at end of file diff --git a/features/push-notification-settings/impl/build.gradle.kts b/features/push-notification-settings/impl/build.gradle.kts index 2962bc3e13..b3ac0ded38 100644 --- a/features/push-notification-settings/impl/build.gradle.kts +++ b/features/push-notification-settings/impl/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { /* Project - Domain */ api(projects.domain.pushNotificationPreferences) implementation(projects.domain.models) + implementation(projects.domain.notifications) implementation(projects.domain.wallets) /* AndroidX */ 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 31f6c13f32..d536a67047 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 @@ -15,6 +15,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase @@ -23,6 +24,7 @@ import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificatio import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences +import com.tangem.domain.wallets.usecase.ApplyPushNotificationFirstActivationUseCase import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent @@ -50,6 +52,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") @@ -67,6 +70,8 @@ internal class PushNotificationSettingsModel @Inject constructor( private val setNotificationsEnabled: SetNotificationsEnabledUseCase, private val isFirstActivationDone: IsPushNotificationFirstActivationDoneUseCase, private val markFirstActivationDone: MarkPushNotificationFirstActivationDoneUseCase, + private val applyFirstActivation: ApplyPushNotificationFirstActivationUseCase, + private val notificationsRepository: NotificationsRepository, ) : Model() { private val params: PushNotificationSettingsComponent.Params = paramsContainer.require() @@ -76,6 +81,7 @@ internal class PushNotificationSettingsModel @Inject constructor( private val osNotificationsEnabled = MutableStateFlow(systemNotificationsStateProvider.areNotificationsEnabled()) private var pendingPermissionToggle: ToggleSpec? = null + private val wasAutoActivationAttempted = AtomicBoolean(false) private val preferencesJobHolder = JobHolder() private val cachedPrefs: WalletPushNotificationPreferences? @@ -122,6 +128,7 @@ internal class PushNotificationSettingsModel @Inject constructor( fun onResume() { osNotificationsEnabled.value = systemNotificationsStateProvider.areNotificationsEnabled() + if (cachedPrefs != null) autoApplyFirstActivationIfNeeded() } fun onPermissionResult(isGranted: Boolean) { @@ -156,11 +163,34 @@ internal class PushNotificationSettingsModel @Inject constructor( // Fall to Failed only when nothing is cached yet; otherwise keep showing the last value. if (loadState.value !is LoadState.Content) loadState.value = LoadState.Failed } - .onEach { value -> loadState.value = LoadState.Content(value) } + .onEach { value -> + loadState.value = LoadState.Content(value) + autoApplyFirstActivationIfNeeded() + } .launchIn(modelScope) .saveIn(preferencesJobHolder) } + /** + * Silently re-applies the first-activation rule once preferences are loaded: the grant-time attempt + + */ + private fun autoApplyFirstActivationIfNeeded() { + if (!wasAutoActivationAttempted.compareAndSet(false, true)) return + modelScope.launch(dispatchers.io) { + val areGatesPassed = osNotificationsEnabled.value && + notificationsRepository.isUserAllowToSubscribeOnPushNotifications() + if (areGatesPassed) { + // Deliberately not retried on failure within this screen instance: a retry fired by the + // cache echo of a manual toggle write would force-enable all three against the user's choice. + applyFirstActivation(userWalletId) + } else { + // A gate miss is not an attempt — onResume may retry after the OS state changes. + wasAutoActivationAttempted.set(false) + } + } + } + private fun buildContent( prefs: WalletPushNotificationPreferences, osEnabled: Boolean, 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 e3243d7bfd..01a6668b35 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 @@ -9,6 +9,7 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider import com.tangem.core.navigation.settings.SettingsManager import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase @@ -17,6 +18,7 @@ import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificatio import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences +import com.tangem.domain.wallets.usecase.ApplyPushNotificationFirstActivationUseCase import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM @@ -45,6 +47,8 @@ class PushNotificationSettingsModelTest { private val setNotificationsEnabled: SetNotificationsEnabledUseCase = mockk() private val isFirstActivationDone: IsPushNotificationFirstActivationDoneUseCase = mockk() private val markFirstActivationDone: MarkPushNotificationFirstActivationDoneUseCase = mockk(relaxed = true) + private val applyFirstActivation: ApplyPushNotificationFirstActivationUseCase = mockk() + private val notificationsRepository: NotificationsRepository = mockk() private val systemNotificationsStateProvider: SystemNotificationsStateProvider = mockk() private val settingsManager: SettingsManager = mockk(relaxed = true) private val messageSender: UiMessageSender = mockk(relaxed = true) @@ -53,6 +57,8 @@ class PushNotificationSettingsModelTest { private fun model( osEnabled: Boolean = true, firstActivationDone: Boolean = true, + consentGiven: Boolean = false, + activationResult: Either = Either.Right(Unit), preferencesFlow: MutableSharedFlow = MutableSharedFlow(replay = 1), ): PushNotificationSettingsModel { every { systemNotificationsStateProvider.areNotificationsEnabled() } returns osEnabled @@ -60,6 +66,8 @@ class PushNotificationSettingsModelTest { coEvery { isFirstActivationDone(userWalletId) } returns firstActivationDone coEvery { setNotificationsEnabled(any(), any()) } returns Either.Right(Unit) coEvery { setAllPreferences(any(), any(), any(), any()) } returns Either.Right(Unit) + coEvery { notificationsRepository.isUserAllowToSubscribeOnPushNotifications() } returns consentGiven + coEvery { applyFirstActivation(any()) } returns activationResult return PushNotificationSettingsModel( paramsContainer = MutableParamsContainer(PushNotificationSettingsComponent.Params(userWalletId)), dispatchers = TestingCoroutineDispatcherProvider(), @@ -73,6 +81,8 @@ class PushNotificationSettingsModelTest { setNotificationsEnabled = setNotificationsEnabled, isFirstActivationDone = isFirstActivationDone, markFirstActivationDone = markFirstActivationDone, + applyFirstActivation = applyFirstActivation, + notificationsRepository = notificationsRepository, ) } @@ -95,6 +105,8 @@ class PushNotificationSettingsModelTest { coEvery { isFirstActivationDone(userWalletId) } returns true coEvery { setNotificationsEnabled(any(), any()) } returns Either.Right(Unit) coEvery { setAllPreferences(any(), any(), any(), any()) } returns Either.Right(Unit) + coEvery { notificationsRepository.isUserAllowToSubscribeOnPushNotifications() } returns false + coEvery { applyFirstActivation(any()) } returns Either.Right(Unit) val model = PushNotificationSettingsModel( paramsContainer = MutableParamsContainer(PushNotificationSettingsComponent.Params(userWalletId)), @@ -109,6 +121,8 @@ class PushNotificationSettingsModelTest { setNotificationsEnabled = setNotificationsEnabled, isFirstActivationDone = isFirstActivationDone, markFirstActivationDone = markFirstActivationDone, + applyFirstActivation = applyFirstActivation, + notificationsRepository = notificationsRepository, ) advanceUntilIdle() @@ -469,6 +483,102 @@ class PushNotificationSettingsModelTest { coVerify(exactly = 0) { setAllPreferences(any(), any(), any(), any()) } } + @Test + fun `GIVEN consent and OS enabled WHEN preferences loaded THEN first activation reapplied`() = runTest { + // Arrange + val flow = MutableSharedFlow(replay = 1) + flow.tryEmit(allFalse()) + + // Act + model(osEnabled = true, consentGiven = true, preferencesFlow = flow) + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { applyFirstActivation(userWalletId) } + } + + @Test + fun `GIVEN preferences emitted twice WHEN loaded THEN first activation attempted once`() = runTest { + // Arrange + val flow = MutableSharedFlow(replay = 1) + flow.tryEmit(allFalse()) + + // Act + model(osEnabled = true, consentGiven = true, preferencesFlow = flow) + advanceUntilIdle() + flow.tryEmit(anyOn()) + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { applyFirstActivation(userWalletId) } + } + + @Test + fun `GIVEN no consent WHEN preferences loaded THEN first activation not reapplied`() = runTest { + // Arrange + val flow = MutableSharedFlow(replay = 1) + flow.tryEmit(allFalse()) + + // Act + model(osEnabled = true, consentGiven = false, preferencesFlow = flow) + advanceUntilIdle() + + // Assert + coVerify(exactly = 0) { applyFirstActivation(any()) } + } + + @Test + fun `GIVEN OS disabled WHEN preferences loaded THEN first activation not reapplied`() = runTest { + // Arrange + val flow = MutableSharedFlow(replay = 1) + flow.tryEmit(allFalse()) + + // Act + model(osEnabled = false, consentGiven = true, preferencesFlow = flow) + advanceUntilIdle() + + // Assert + coVerify(exactly = 0) { applyFirstActivation(any()) } + } + + @Test + fun `GIVEN activation failed WHEN preferences emitted again THEN not retried`() = runTest { + // Arrange + val flow = MutableSharedFlow(replay = 1) + flow.tryEmit(allFalse()) + + // Act + model( + osEnabled = true, + consentGiven = true, + activationResult = Either.Left(RuntimeException("net")), + preferencesFlow = flow, + ) + advanceUntilIdle() + flow.tryEmit(anyOn()) + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { applyFirstActivation(userWalletId) } + } + + @Test + fun `GIVEN OS enabled after pause WHEN onResume THEN first activation reapplied`() = runTest { + // Arrange + val flow = MutableSharedFlow(replay = 1) + flow.tryEmit(allFalse()) + val model = model(osEnabled = false, consentGiven = true, preferencesFlow = flow) + advanceUntilIdle() + + // Act: the user enabled notifications in the OS settings and returned to the screen. + every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true + model.onResume() + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { applyFirstActivation(userWalletId) } + } + private fun allFalse() = WalletPushNotificationPreferences( transactionAlerts = PushNotificationPreference(isEnabled = false), offersUpdates = PushNotificationPreference(isEnabled = false), 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 772853f7ad..f8b8fb519a 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 @@ -5,18 +5,15 @@ import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam -import arrow.core.Either import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.notifications.repository.NotificationsRepository -import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase -import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase import com.tangem.domain.settings.NeverRequestPermissionUseCase import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase -import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase +import com.tangem.domain.wallets.usecase.ApplyPushNotificationFirstActivationUseCase import com.tangem.features.pushnotifications.api.PushNotificationsParams import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION @@ -42,10 +39,8 @@ internal class PushNotificationsModel @Inject constructor( private val analyticHandler: AnalyticsEventHandler, private val notificationsRepository: NotificationsRepository, private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles, - private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase, private val userWalletsListRepository: UserWalletsListRepository, - private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase, - private val isPushNotificationFirstActivationDone: IsPushNotificationFirstActivationDoneUseCase, + private val applyPushNotificationFirstActivation: ApplyPushNotificationFirstActivationUseCase, private val markPushNotificationFirstActivationDone: MarkPushNotificationFirstActivationDoneUseCase, private val getPushNotificationsDoubleAskVariantUseCase: GetPushNotificationsDoubleAskVariantUseCase, ) : Model(), PushNotificationsClickIntents { @@ -162,20 +157,7 @@ internal class PushNotificationsModel @Inject constructor( /** On the first grant, enable all three categories for every not-yet-activated wallet (guarded once per wallet). */ private suspend fun applyFirstActivationRule() { userWalletsListRepository.userWalletsSync().forEach { wallet -> - if (isPushNotificationFirstActivationDone(wallet.walletId)) return@forEach - - // Tokens (address re-subscription) first, then preferences; mark only on success, undo tokens on failure. - val tokensResult = setNotificationsEnabledUseCase(wallet.walletId, isEnabled = true) - if (tokensResult is Either.Left) return@forEach - - setAllWalletPushNotificationPreferences( - userWalletId = wallet.walletId, - transactionAlerts = true, - offersUpdates = true, - priceAlerts = true, - ) - .onRight { markPushNotificationFirstActivationDone(wallet.walletId) } - .onLeft { setNotificationsEnabledUseCase(wallet.walletId, isEnabled = false) } + applyPushNotificationFirstActivation(wallet.walletId) } } 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 e914148bd1..f28ba109de 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 @@ -11,12 +11,10 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.notifications.repository.NotificationsRepository -import com.tangem.domain.pushnotificationpreferences.IsPushNotificationFirstActivationDoneUseCase import com.tangem.domain.pushnotificationpreferences.MarkPushNotificationFirstActivationDoneUseCase -import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase import com.tangem.domain.settings.NeverRequestPermissionUseCase import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase -import com.tangem.domain.wallets.usecase.SetNotificationsEnabledUseCase +import com.tangem.domain.wallets.usecase.ApplyPushNotificationFirstActivationUseCase import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks import com.tangem.features.pushnotifications.api.PushNotificationsParams import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents @@ -47,12 +45,8 @@ internal class PushNotificationsModelTest { private val analyticHandler: AnalyticsEventHandler = mockk(relaxed = true) private val notificationsRepository: NotificationsRepository = mockk(relaxed = true) private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles = mockk(relaxed = true) - private val setAllWalletPushNotificationPreferences: SetAllWalletPushNotificationPreferencesUseCase = - mockk(relaxed = true) private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true) - private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase = mockk(relaxed = true) - private val isPushNotificationFirstActivationDone: IsPushNotificationFirstActivationDoneUseCase = - mockk(relaxed = true) + private val applyPushNotificationFirstActivation: ApplyPushNotificationFirstActivationUseCase = mockk() private val markPushNotificationFirstActivationDone: MarkPushNotificationFirstActivationDoneUseCase = mockk(relaxed = true) private val getDoubleAskVariantUseCase: GetPushNotificationsDoubleAskVariantUseCase = mockk() @@ -174,34 +168,43 @@ internal class PushNotificationsModelTest { } @Test - fun `GIVEN settings enabled WHEN onAllowPermission THEN fresh wallets get all-three and done wallets skipped`() = + fun `GIVEN settings enabled WHEN onAllowPermission THEN first activation applied for every wallet`() = runTest { + every { pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled } returns true + val w1 = UserWalletId("aa") + val w2 = UserWalletId("bb") + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(wallet(w1), wallet(w2)) + coEvery { applyPushNotificationFirstActivation(any()) } returns Either.Right(Unit) + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAllowPermission() + advanceUntilIdle() + + coVerifyOrder { + applyPushNotificationFirstActivation(w1) + applyPushNotificationFirstActivation(w2) + } + } + + @Test + fun `GIVEN activation fails for first wallet WHEN onAllowPermission THEN second wallet still attempted`() = runTest { every { pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled } returns true - val fresh = UserWalletId("aa") - val done = UserWalletId("bb") - coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(wallet(fresh), wallet(done)) - coEvery { isPushNotificationFirstActivationDone(fresh) } returns false - coEvery { isPushNotificationFirstActivationDone(done) } returns true - coEvery { setNotificationsEnabledUseCase(any(), any()) } returns Either.Right(Unit) - coEvery { - setAllWalletPushNotificationPreferences(any(), any(), any(), any()) - } returns Either.Right(Unit) + val w1 = UserWalletId("aa") + val w2 = UserWalletId("bb") + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(wallet(w1), wallet(w2)) + coEvery { applyPushNotificationFirstActivation(w1) } returns Either.Left(RuntimeException("404")) + coEvery { applyPushNotificationFirstActivation(w2) } returns Either.Right(Unit) val model = createModel(testScope = this) advanceUntilIdle() model.onAllowPermission() advanceUntilIdle() - // Fresh wallet: tokens (re-subscription) before preferences, then flag marked. coVerifyOrder { - setNotificationsEnabledUseCase(fresh, isEnabled = true) - setAllWalletPushNotificationPreferences(fresh, true, true, true) + applyPushNotificationFirstActivation(w1) + applyPushNotificationFirstActivation(w2) } - coVerify(exactly = 1) { markPushNotificationFirstActivationDone(fresh) } - // Already-activated wallet: skipped entirely. - coVerify(exactly = 0) { setAllWalletPushNotificationPreferences(done, any(), any(), any()) } - coVerify(exactly = 0) { setNotificationsEnabledUseCase(done, any()) } - coVerify(exactly = 0) { markPushNotificationFirstActivationDone(done) } } @Test @@ -218,28 +221,23 @@ internal class PushNotificationsModelTest { coVerify(exactly = 1) { markPushNotificationFirstActivationDone(w1) } coVerify(exactly = 1) { markPushNotificationFirstActivationDone(w2) } - // Deny only fixes the flag — it must not enable any category or re-subscribe tokens. - coVerify(exactly = 0) { setAllWalletPushNotificationPreferences(any(), any(), any(), any()) } - coVerify(exactly = 0) { setNotificationsEnabledUseCase(any(), any()) } + // Deny only fixes the flag — it must not enable any category. + coVerify(exactly = 0) { applyPushNotificationFirstActivation(any()) } } @Test fun `GIVEN settings disabled WHEN onAllowPermission THEN no first-activation writes`() = runTest { every { pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled } returns false - // Non-empty wallet list + not-done + successful stubs so that, if the toggle gate were removed, - // applyFirstActivationRule WOULD iterate and write — making the exactly=0 assertions actually protect the gate. + // Non-empty wallet list + successful stubs, so the exactly=0 assertions actually protect the toggle gate. coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(wallet(UserWalletId("aa"))) - coEvery { isPushNotificationFirstActivationDone(any()) } returns false - coEvery { setNotificationsEnabledUseCase(any(), any()) } returns Either.Right(Unit) - coEvery { setAllWalletPushNotificationPreferences(any(), any(), any(), any()) } returns Either.Right(Unit) + coEvery { applyPushNotificationFirstActivation(any()) } returns Either.Right(Unit) val model = createModel(testScope = this) advanceUntilIdle() model.onAllowPermission() advanceUntilIdle() - coVerify(exactly = 0) { setAllWalletPushNotificationPreferences(any(), any(), any(), any()) } - coVerify(exactly = 0) { setNotificationsEnabledUseCase(any(), any()) } + coVerify(exactly = 0) { applyPushNotificationFirstActivation(any()) } coVerify(exactly = 0) { markPushNotificationFirstActivationDone(any()) } } @@ -267,10 +265,8 @@ internal class PushNotificationsModelTest { analyticHandler = analyticHandler, notificationsRepository = notificationsRepository, pushNotificationSettingsFeatureToggles = pushNotificationSettingsFeatureToggles, - setAllWalletPushNotificationPreferences = setAllWalletPushNotificationPreferences, userWalletsListRepository = userWalletsListRepository, - setNotificationsEnabledUseCase = setNotificationsEnabledUseCase, - isPushNotificationFirstActivationDone = isPushNotificationFirstActivationDone, + applyPushNotificationFirstActivation = applyPushNotificationFirstActivation, markPushNotificationFirstActivationDone = markPushNotificationFirstActivationDone, getPushNotificationsDoubleAskVariantUseCase = getDoubleAskVariantUseCase, ) 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 be97d76ac4..e5b01cf808 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 @@ -12,6 +12,7 @@ import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase @@ -107,6 +108,8 @@ internal class WalletModel @Inject constructor( private val notificationsRepository: NotificationsRepository, private val getWalletsListForEnablingUseCase: GetWalletsForAutomaticallyPushEnablingUseCase, private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase, + private val applyPushNotificationFirstActivationUseCase: ApplyPushNotificationFirstActivationUseCase, + private val systemNotificationsStateProvider: SystemNotificationsStateProvider, private val getIsHuaweiDeviceWithoutGoogleServicesUseCase: GetIsHuaweiDeviceWithoutGoogleServicesUseCase, private val userWalletsListRepository: UserWalletsListRepository, private val yieldSupplyApyUpdateUseCase: YieldSupplyApyUpdateUseCase, @@ -887,8 +890,10 @@ internal class WalletModel @Inject constructor( } private fun enableNotificationsIfNeeded() { - // New first-activation owns auto-enable when the feature is on; skip the legacy path. - if (pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled) return + if (pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled) { + applyPushFirstActivationIfNeeded() + return + } modelScope.launch { val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications() if (isUserAllowToEnableNotifications) { @@ -907,6 +912,20 @@ internal class WalletModel @Inject constructor( } } + /** + + * on the backend (onboarding). See [ApplyPushNotificationFirstActivationUseCase]. + */ + private fun applyPushFirstActivationIfNeeded() { + modelScope.launch { + if (!systemNotificationsStateProvider.areNotificationsEnabled()) return@launch + if (!notificationsRepository.isUserAllowToSubscribeOnPushNotifications()) return@launch + userWalletsListRepository.userWalletsSync().forEach { wallet -> + applyPushNotificationFirstActivationUseCase(wallet.walletId) + } + } + } + inner class AskBiometryModelCallbacks : AskBiometryComponent.ModelCallbacks { override fun onAllowed() { analyticsEventsHandler.send(MainScreenAnalyticsEvent.EnableBiometrics(AnalyticsParam.OnOffState.On))