Updated on 2026-08-14

This commit is contained in:
Tangem 2025-05-19 14:10:44 +05:00
parent 03dce384bd
commit c4603c4cbd
13 changed files with 171 additions and 39 deletions

View file

@ -111,6 +111,7 @@ dependencies {
implementation(projects.domain.networks)
implementation(projects.domain.quotes)
implementation(projects.domain.notifications)
implementation(projects.domain.notifications.models)
implementation(projects.domain.notifications.toggles)
implementation(projects.common)

View file

@ -3,8 +3,9 @@ package com.tangem.tap.data
import com.google.firebase.messaging.FirebaseMessaging
import com.tangem.utils.notifications.PushNotificationsTokenProvider
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
internal class FirebasePushNotificationsTokenProvider : PushNotificationsTokenProvider {
internal class FirebasePushNotificationsTokenProvider @Inject constructor() : PushNotificationsTokenProvider {
override suspend fun getToken(): String {
return FirebaseMessaging.getInstance().token.await()
}

View file

@ -31,12 +31,10 @@ internal object NotificationsDomainModule {
@Singleton
fun providesSendPushTokenUseCase(
notificationsRepository: NotificationsRepository,
getApplicationIdUseCase: GetApplicationIdUseCase,
pushNotificationsTokenProvider: PushNotificationsTokenProvider,
): SendPushTokenUseCase {
return SendPushTokenUseCase(
notificationsRepository = notificationsRepository,
getApplicationIdUseCase = getApplicationIdUseCase,
pushNotificationsTokenProvider = pushNotificationsTokenProvider,
)
}

View file

@ -225,4 +225,24 @@ internal object WalletsDomainModule {
userWalletsSyncDelegate = userWalletsSyncDelegate,
)
}
@Provides
@Singleton
fun providesGetSavedWalletChangesIdUseCase(
userWalletsListManager: UserWalletsListManager,
): GetSavedWalletChangesUseCase {
return GetSavedWalletChangesUseCase(
userWalletsListManager = userWalletsListManager,
)
}
@Provides
@Singleton
fun providesAssociateWalletsWithApplicationIdUseCase(
walletsRepository: WalletsRepository,
): AssociateWalletsWithApplicationIdUseCase {
return AssociateWalletsWithApplicationIdUseCase(
walletsRepository = walletsRepository,
)
}
}

View file

@ -11,7 +11,6 @@ import com.tangem.core.analytics.models.event.TechAnalyticsEvent
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.core.ui.BuildConfig
import com.tangem.core.ui.R
import com.tangem.core.ui.coil.ImagePreloader
import com.tangem.core.ui.extensions.resourceReference
@ -24,6 +23,10 @@ import com.tangem.domain.balancehiding.BalanceHidingSettings
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.balancehiding.ListenToFlipsUseCase
import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase
import com.tangem.domain.notifications.GetApplicationIdUseCase
import com.tangem.domain.notifications.SendPushTokenUseCase
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles
import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.domain.onramp.FetchHotCryptoUseCase
import com.tangem.domain.promo.GetStoryContentUseCase
@ -33,6 +36,9 @@ import com.tangem.domain.settings.IncrementAppLaunchCounterUseCase
import com.tangem.domain.settings.usercountry.FetchUserCountryUseCase
import com.tangem.domain.staking.FetchStakingTokensUseCase
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.usecase.AssociateWalletsWithApplicationIdUseCase
import com.tangem.domain.wallets.usecase.GetSavedWalletChangesUseCase
import com.tangem.domain.wallets.usecase.UpdateRemoteWalletsInfoUseCase
import com.tangem.feature.swap.analytics.StoriesEvents
import com.tangem.features.onramp.deeplink.OnrampDeepLink
import com.tangem.tap.common.extensions.setContext
@ -40,6 +46,7 @@ import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupDialog
import com.tangem.tap.store
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.wallet.BuildConfig
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
@ -71,6 +78,12 @@ internal class MainViewModel @Inject constructor(
private val onboardingRepository: OnboardingRepository,
private val deepLinksRegistry: DeepLinksRegistry,
private val onrampDeepLinkFactory: OnrampDeepLink.Factory,
private val notificationsToggles: NotificationsFeatureToggles,
private val getApplicationIdUseCase: GetApplicationIdUseCase,
private val subscribeOnWalletsUseCase: GetSavedWalletChangesUseCase,
private val associateWalletsWithApplicationIdUseCase: AssociateWalletsWithApplicationIdUseCase,
private val updateRemoteWalletsInfoUseCase: UpdateRemoteWalletsInfoUseCase,
private val sendPushTokenUseCase: SendPushTokenUseCase,
private val apiConfigsManager: ApiConfigsManager,
routingFeatureToggle: RoutingFeatureToggle,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
@ -96,6 +109,8 @@ internal class MainViewModel @Inject constructor(
launch { fetchAppCurrenciesUseCase() }
launch { fetchStakingTokens() }
launch { initPushNotifications() }
}
viewModelScope.launch { incrementAppLaunchCounterUseCase() }
@ -370,4 +385,20 @@ internal class MainViewModel @Inject constructor(
private fun initializeDeepLinks() {
deepLinksRegistry.register(onrampDeepLinkFactory.create(viewModelScope))
}
private suspend fun initPushNotifications() {
if (notificationsToggles.isNotificationsEnabled) {
getApplicationIdUseCase().onRight { applicationId ->
sendPushTokenUseCase(applicationId = applicationId)
associateWalletsWithApplicationId(applicationId = applicationId)
updateRemoteWalletsInfoUseCase(applicationId = applicationId)
}.onLeft { Timber.e(it.toString()) }
}
}
private fun associateWalletsWithApplicationId(applicationId: ApplicationId) {
subscribeOnWalletsUseCase().onEach { wallets ->
associateWalletsWithApplicationIdUseCase(applicationId, wallets)
}.launchIn(viewModelScope)
}
}

View file

@ -154,7 +154,7 @@ interface TangemTechApi {
suspend fun updatePushTokenForApplicationId(
@Path("application_id") applicationId: String,
@Body body: NotificationApplicationCreateBody,
): ApiResponse<String>
): ApiResponse<Unit>
@PATCH("user-wallets/wallets/{wallet_id}/notify")
suspend fun setNotificationsEnabled(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse<Unit>

View file

@ -121,7 +121,7 @@ class DefaultNotificationsRepositoryTest {
timezone = null,
),
)
} returns ApiResponse.Success(appId.value)
} returns ApiResponse.Success(Unit)
// WHEN
repository.sendPushToken(appId, pushToken)

View file

@ -1,18 +1,16 @@
package com.tangem.domain.notifications
import arrow.core.Either
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.utils.notifications.PushNotificationsTokenProvider
class SendPushTokenUseCase(
private val notificationsRepository: NotificationsRepository,
private val getApplicationIdUseCase: GetApplicationIdUseCase,
private val pushNotificationsTokenProvider: PushNotificationsTokenProvider,
) {
suspend operator fun invoke(): Either<Throwable, Unit> = Either.catch {
val applicationId = getApplicationIdUseCase().getOrNull()
?: error("Application ID not found")
suspend operator fun invoke(applicationId: ApplicationId): Either<Throwable, Unit> = Either.catch {
val token = pushNotificationsTokenProvider.getToken()
notificationsRepository.sendPushToken(applicationId, token)
}

View file

@ -15,18 +15,15 @@ import org.junit.Test
class SendPushTokenUseCaseTest {
private lateinit var notificationsRepository: NotificationsRepository
private lateinit var getApplicationIdUseCase: GetApplicationIdUseCase
private lateinit var pushNotificationsTokenProvider: PushNotificationsTokenProvider
private lateinit var sendPushTokenUseCase: SendPushTokenUseCase
@Before
fun setup() {
notificationsRepository = mockk()
getApplicationIdUseCase = mockk()
pushNotificationsTokenProvider = mockk()
sendPushTokenUseCase = SendPushTokenUseCase(
notificationsRepository = notificationsRepository,
getApplicationIdUseCase = getApplicationIdUseCase,
pushNotificationsTokenProvider = pushNotificationsTokenProvider,
)
}
@ -36,42 +33,28 @@ class SendPushTokenUseCaseTest {
// GIVEN
val applicationId = ApplicationId("test-app-id")
val token = "test-token"
coEvery { getApplicationIdUseCase() } returns Either.Right(applicationId)
coEvery { pushNotificationsTokenProvider.getToken() } returns token
coEvery { notificationsRepository.sendPushToken(applicationId, token) } returns Unit
// WHEN
val result = sendPushTokenUseCase()
val result = sendPushTokenUseCase(applicationId)
// THEN
assertThat(result).isEqualTo(Either.Right(Unit))
coVerify(exactly = 1) { notificationsRepository.sendPushToken(applicationId, token) }
}
@Test
fun `GIVEN application ID is not found WHEN invoke THEN throws error`() = runTest {
// GIVEN
coEvery { getApplicationIdUseCase() } returns Either.Left(Throwable("Application ID not found"))
// WHEN & THEN
val result = sendPushTokenUseCase()
assertThat(result.isLeft()).isTrue()
assertThat(result.fold({ it.message }, { null })).isEqualTo("Application ID not found")
coVerify(exactly = 0) { notificationsRepository.sendPushToken(any(), any()) }
}
@Test
fun `GIVEN repository throws error WHEN invoke THEN returns error`() = runTest {
// GIVEN
val applicationId = ApplicationId("test-app-id")
val token = "test-token"
val expectedError = RuntimeException("Network error")
coEvery { getApplicationIdUseCase() } returns Either.Right(applicationId)
coEvery { pushNotificationsTokenProvider.getToken() } returns token
coEvery { notificationsRepository.sendPushToken(applicationId, token) } throws expectedError
// WHEN
val result = sendPushTokenUseCase()
val result = sendPushTokenUseCase(applicationId)
// THEN
assertThat(result).isEqualTo(Either.Left(expectedError))

View file

@ -2,16 +2,15 @@ package com.tangem.domain.wallets.usecase
import arrow.core.Either
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.repository.WalletsRepository
class AssociateWalletsWithApplicationIdUseCase(
private val userWalletsListManager: UserWalletsListManager,
private val walletsRepository: WalletsRepository,
) {
suspend operator fun invoke(applicationId: ApplicationId): Either<Throwable, Unit> = Either.catch {
val wallets = userWalletsListManager.userWalletsSync
walletsRepository.associateWallets(applicationId.value, wallets)
}
suspend operator fun invoke(applicationId: ApplicationId, wallets: List<UserWallet>): Either<Throwable, Unit> =
Either.catch {
walletsRepository.associateWallets(applicationId.value, wallets)
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.domain.wallets.usecase
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.domain.wallets.legacy.isLockedSync
import com.tangem.domain.wallets.models.UserWallet
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
class GetSavedWalletChangesUseCase(
private val userWalletsListManager: UserWalletsListManager,
) {
operator fun invoke(): Flow<List<UserWallet>> {
return userWalletsListManager.userWallets
.filter {
userWalletsListManager.asLockable() ?: return@filter false
return@filter if (userWalletsListManager.isLockedSync.not()) {
it.isNotEmpty()
} else {
false
}
}
.distinctUntilChanged()
}
}

View file

@ -1,10 +1,8 @@
package com.tangem.domain.wallets.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.domain.wallets.delegate.UserWalletsSyncDelegate
import com.tangem.domain.wallets.models.UpdateWalletError
import com.tangem.domain.wallets.repository.WalletsRepository
class UpdateRemoteWalletsInfoUseCase(
@ -12,8 +10,8 @@ class UpdateRemoteWalletsInfoUseCase(
private val userWalletsSyncDelegate: UserWalletsSyncDelegate,
) {
suspend operator fun invoke(applicationId: ApplicationId): Either<UpdateWalletError, Unit> = either {
suspend operator fun invoke(applicationId: ApplicationId): Either<Throwable, Unit> = Either.catch {
val walletsInfo = walletsRepository.getWalletsInfo(applicationId.value)
userWalletsSyncDelegate.syncWallets(walletsInfo).bind()
userWalletsSyncDelegate.syncWallets(walletsInfo)
}
}

View file

@ -0,0 +1,76 @@
package com.tangem.domain.wallets.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.domain.wallets.legacy.isLockedSync
import com.tangem.domain.wallets.models.UserWallet
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
class GetSavedWalletChangesUseCaseTest {
private lateinit var useCase: GetSavedWalletChangesUseCase
private lateinit var userWalletsListManager: UserWalletsListManager
@Before
fun setup() {
userWalletsListManager = mockk()
useCase = GetSavedWalletChangesUseCase(userWalletsListManager)
mockkStatic("com.tangem.domain.wallets.legacy.UserWalletsListManagerExtensionsKt")
}
@Test
fun `GIVEN manager is not lockable WHEN invoke THEN return empty list`() = runTest {
// GIVEN
every { userWalletsListManager.isLockable } returns false
every { userWalletsListManager.userWallets } returns flowOf(emptyList())
every { userWalletsListManager.isLockedSync } returns false
every { userWalletsListManager.asLockable() } returns null
// WHEN
val result = useCase().firstOrNull()
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN manager is locked WHEN invoke THEN return empty list`() = runTest {
// GIVEN
val mockLockable = mockk<UserWalletsListManager.Lockable>()
every { userWalletsListManager.isLockable } returns true
every { userWalletsListManager.userWallets } returns flowOf(emptyList())
every { userWalletsListManager.isLockedSync } returns true
every { userWalletsListManager.asLockable() } returns mockLockable
// WHEN
val result = useCase().firstOrNull()
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN manager is not locked and has wallets WHEN invoke THEN return wallets list`() = runTest {
// GIVEN
val mockLockable = mockk<UserWalletsListManager.Lockable>()
val wallets = listOf(mockk<UserWallet>(), mockk<UserWallet>())
every { userWalletsListManager.isLockable } returns true
every { userWalletsListManager.userWallets } returns flowOf(wallets)
every { userWalletsListManager.isLockedSync } returns false
every { userWalletsListManager.asLockable() } returns mockLockable
// WHEN
val result = useCase().firstOrNull()
// THEN
assertThat(result).isEqualTo(wallets)
}
}