Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-28 11:16:20 +04:00
parent 41164b1baf
commit 54e612cf30
9 changed files with 200 additions and 18 deletions

View file

@ -83,4 +83,14 @@ internal object NotificationsDomainModule {
): GetNetworksAvailableForNotificationsUseCase {
return GetNetworksAvailableForNotificationsUseCase(pushNotificationsRepository = pushNotificationsRepository)
}
@Provides
@Singleton
fun provideClearApplicationIdUseCase(
pushNotificationsRepository: PushNotificationsRepository,
): ClearApplicationIdUseCase {
return ClearApplicationIdUseCase(
pushNotificationsRepository = pushNotificationsRepository,
)
}
}

View file

@ -21,9 +21,11 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.balancehiding.ListenToFlipsUseCase
import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.notifications.ClearApplicationIdUseCase
import com.tangem.domain.notifications.GetApplicationIdUseCase
import com.tangem.domain.notifications.SendPushTokenUseCase
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.domain.notifications.models.NotificationsError
import com.tangem.domain.onramp.FetchHotCryptoUseCase
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.promo.models.StoryContentIds
@ -72,6 +74,7 @@ internal class MainViewModel @Inject constructor(
private val getApplicationIdUseCase: GetApplicationIdUseCase,
private val subscribeOnWalletsUseCase: GetSavedWalletsCountUseCase,
private val associateWalletsWithApplicationIdUseCase: AssociateWalletsWithApplicationIdUseCase,
private val clearApplicationIdUseCase: ClearApplicationIdUseCase,
private val updateRemoteWalletsInfoUseCase: UpdateRemoteWalletsInfoUseCase,
private val sendPushTokenUseCase: SendPushTokenUseCase,
private val apiConfigsManager: ApiConfigsManager,
@ -104,7 +107,7 @@ internal class MainViewModel @Inject constructor(
launch { fetchStakingOptions() }
launch { initPushNotifications() }
launch(dispatchers.default) { initPushNotifications() }
}
viewModelScope.launch { incrementAppLaunchCounterUseCase() }
@ -398,13 +401,27 @@ internal class MainViewModel @Inject constructor(
private suspend fun initPushNotifications() {
getApplicationIdUseCase()
.onRight { applicationId ->
sendPushTokenUseCase(applicationId = applicationId)
associateWalletsWithApplicationId(applicationId = applicationId)
updateRemoteWalletsInfoUseCase(applicationId = applicationId)
sendPushTokenUseCase(applicationId = applicationId).onLeft { error ->
if (error is NotificationsError.ApplicationIdNotFound) {
clearApplicationIdUseCase()
getApplicationIdUseCase().onRight { newApplicationId ->
associateAndUpdateWallets(applicationId = newApplicationId)
}
} else {
associateAndUpdateWallets(applicationId = applicationId)
}
}.onRight {
associateAndUpdateWallets(applicationId = applicationId)
}
}
.onLeft(Timber::e)
}
private suspend fun associateAndUpdateWallets(applicationId: ApplicationId) {
associateWalletsWithApplicationId(applicationId = applicationId)
updateRemoteWalletsInfoUseCase(applicationId = applicationId)
}
private fun associateWalletsWithApplicationId(applicationId: ApplicationId) {
subscribeOnWalletsUseCase()
.onEach { wallets ->

View file

@ -1,6 +1,12 @@
package com.tangem.data.notifications
import androidx.datastore.preferences.core.edit
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.data.notifications.converters.NotificationsEligibleNetworkConverter
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.NotificationApplicationCreateBody
@ -9,6 +15,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.*
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.domain.notifications.models.NotificationsError
import com.tangem.domain.notifications.repository.PushNotificationsRepository
import com.tangem.domain.notifications.models.NotificationsEligibleNetwork
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -47,9 +54,15 @@ internal class DefaultPushNotificationsRepository @Inject constructor(
?.let(::ApplicationId)
}
override suspend fun sendPushToken(appId: ApplicationId, pushToken: String) {
override suspend fun clearApplicationId() {
appPreferencesStore.edit { mutablePreferences ->
mutablePreferences.remove(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY)
}
}
override suspend fun sendPushToken(appId: ApplicationId, pushToken: String): Either<NotificationsError, Unit> =
withContext(dispatchers.io) {
tangemTechApi.updatePushTokenForApplicationId(
val response = tangemTechApi.updatePushTokenForApplicationId(
appId.value,
NotificationApplicationCreateBody(
pushToken = pushToken,
@ -58,7 +71,19 @@ internal class DefaultPushNotificationsRepository @Inject constructor(
timezone = appInfoProvider.timezone,
version = appInfoProvider.appVersion,
),
).getOrThrow()
)
when (response) {
is ApiResponse.Success -> Unit.right()
is ApiResponse.Error -> {
val cause = response.cause
if (cause is ApiResponseError.HttpException &&
cause.code == ApiResponseError.HttpException.Code.NOT_FOUND
) {
NotificationsError.ApplicationIdNotFound.left()
} else {
NotificationsError.DataError(cause.message ?: "Unknown error").left()
}
}
}
}

View file

@ -10,9 +10,12 @@ import com.tangem.datasource.local.preferences.PreferencesKeys
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.data.notifications.converters.NotificationsEligibleNetworkConverter
import arrow.core.Either
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.models.*
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.domain.notifications.models.NotificationsError
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
@ -107,7 +110,20 @@ class DefaultPushNotificationsRepositoryTest {
}
@Test
fun `GIVEN application id and push token WHEN sendPushToken THEN updates push token`() = runTest {
fun `GIVEN stored application id WHEN clearApplicationId THEN removes it from preferences`() = runTest {
// GIVEN
val preferences = mockk<Preferences>(relaxed = true)
coEvery { preferencesDataStore.updateData(any()) } returns preferences
// WHEN
repository.clearApplicationId()
// THEN
coVerify { preferencesDataStore.updateData(any()) }
}
@Test
fun `GIVEN application id and push token WHEN sendPushToken succeeds THEN returns Right Unit`() = runTest {
// GIVEN
val appId = ApplicationId("test-app-id")
val pushToken = "test-push-token"
@ -130,9 +146,10 @@ class DefaultPushNotificationsRepositoryTest {
} returns ApiResponse.Success(Unit)
// WHEN
repository.sendPushToken(appId, pushToken)
val result = repository.sendPushToken(appId, pushToken)
// THEN
assertThat(result).isEqualTo(Either.Right(Unit))
coVerify {
tangemTechApi.updatePushTokenForApplicationId(
appId.value,
@ -147,6 +164,59 @@ class DefaultPushNotificationsRepositoryTest {
}
}
@Test
fun `GIVEN application id not found WHEN sendPushToken THEN returns Left ApplicationIdNotFound`() = runTest {
// GIVEN
val appId = ApplicationId("test-app-id")
val pushToken = "test-push-token"
coEvery { appInfoProvider.osVersion } returns "11"
coEvery { appInfoProvider.language } returns "en"
coEvery { appInfoProvider.appVersion } returns "5.21.1"
coEvery { appInfoProvider.timezone } returns "UTC"
coEvery {
tangemTechApi.updatePushTokenForApplicationId(any(), any())
} returns (ApiResponse.Error(
ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.NOT_FOUND,
message = "Not found",
errorBody = null,
),
) as ApiResponse<Unit>)
// WHEN
val result = repository.sendPushToken(appId, pushToken)
// THEN
assertThat(result).isEqualTo(Either.Left(NotificationsError.ApplicationIdNotFound))
}
@Test
fun `GIVEN api error WHEN sendPushToken THEN returns Left DataError`() = runTest {
// GIVEN
val appId = ApplicationId("test-app-id")
val pushToken = "test-push-token"
val errorMessage = "Server error"
coEvery { appInfoProvider.osVersion } returns "11"
coEvery { appInfoProvider.language } returns "en"
coEvery { appInfoProvider.appVersion } returns "5.21.1"
coEvery { appInfoProvider.timezone } returns "UTC"
coEvery {
tangemTechApi.updatePushTokenForApplicationId(any(), any())
} returns (ApiResponse.Error(
ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.BAD_REQUEST,
message = errorMessage,
errorBody = null,
),
) as ApiResponse<Unit>)
// WHEN
val result = repository.sendPushToken(appId, pushToken)
// THEN
assertThat(result).isEqualTo(Either.Left(NotificationsError.DataError(errorMessage)))
}
@Test
fun `GIVEN eligible networks WHEN getEligibleNetworks THEN returns converted networks`() = runTest {
// GIVEN

View file

@ -0,0 +1,8 @@
package com.tangem.domain.notifications.models
sealed class NotificationsError {
data class DataError(val message: String) : NotificationsError()
data object ApplicationIdNotFound : NotificationsError()
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.notifications
import com.tangem.domain.notifications.repository.PushNotificationsRepository
class ClearApplicationIdUseCase(
private val pushNotificationsRepository: PushNotificationsRepository,
) {
suspend operator fun invoke() {
pushNotificationsRepository.clearApplicationId()
}
}

View file

@ -1,7 +1,9 @@
package com.tangem.domain.notifications
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.domain.notifications.models.NotificationsError
import com.tangem.domain.notifications.repository.PushNotificationsRepository
import com.tangem.utils.notifications.PushNotificationsTokenProvider
@ -10,8 +12,8 @@ class SendPushTokenUseCase(
private val pushNotificationsTokenProvider: PushNotificationsTokenProvider,
) {
suspend operator fun invoke(applicationId: ApplicationId): Either<Throwable, Unit> = Either.catch {
suspend operator fun invoke(applicationId: ApplicationId): Either<NotificationsError, Unit> = either {
val token = pushNotificationsTokenProvider.getToken()
pushNotificationsRepository.sendPushToken(applicationId, token)
pushNotificationsRepository.sendPushToken(applicationId, token).bind()
}
}

View file

@ -1,7 +1,9 @@
package com.tangem.domain.notifications.repository
import arrow.core.Either
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.domain.notifications.models.NotificationsEligibleNetwork
import com.tangem.domain.notifications.models.NotificationsError
interface PushNotificationsRepository {
@ -12,8 +14,9 @@ interface PushNotificationsRepository {
suspend fun getApplicationId(): ApplicationId?
@Throws
suspend fun sendPushToken(appId: ApplicationId, pushToken: String)
suspend fun clearApplicationId()
suspend fun sendPushToken(appId: ApplicationId, pushToken: String): Either<NotificationsError, Unit>
@Throws
suspend fun getEligibleNetworks(): List<NotificationsEligibleNetwork>

View file

@ -3,6 +3,7 @@ package com.tangem.domain.notifications
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.domain.notifications.models.NotificationsError
import com.tangem.domain.notifications.repository.PushNotificationsRepository
import com.tangem.utils.notifications.PushNotificationsTokenProvider
import io.mockk.coEvery
@ -34,7 +35,7 @@ class SendPushTokenUseCaseTest {
val applicationId = ApplicationId("test-app-id")
val token = "test-token"
coEvery { pushNotificationsTokenProvider.getToken() } returns token
coEvery { pushNotificationsRepository.sendPushToken(applicationId, token) } returns Unit
coEvery { pushNotificationsRepository.sendPushToken(applicationId, token) } returns Either.Right(Unit)
// WHEN
val result = sendPushTokenUseCase(applicationId)
@ -45,13 +46,13 @@ class SendPushTokenUseCaseTest {
}
@Test
fun `GIVEN repository throws error WHEN invoke THEN returns error`() = runTest {
fun `GIVEN repository returns DataError WHEN invoke THEN returns DataError`() = runTest {
// GIVEN
val applicationId = ApplicationId("test-app-id")
val token = "test-token"
val expectedError = RuntimeException("Network error")
val expectedError = NotificationsError.DataError("Network error")
coEvery { pushNotificationsTokenProvider.getToken() } returns token
coEvery { pushNotificationsRepository.sendPushToken(applicationId, token) } throws expectedError
coEvery { pushNotificationsRepository.sendPushToken(applicationId, token) } returns Either.Left(expectedError)
// WHEN
val result = sendPushTokenUseCase(applicationId)
@ -60,4 +61,38 @@ class SendPushTokenUseCaseTest {
assertThat(result).isEqualTo(Either.Left(expectedError))
coVerify(exactly = 1) { pushNotificationsRepository.sendPushToken(applicationId, token) }
}
@Test
fun `GIVEN repository returns ApplicationIdNotFound WHEN invoke THEN returns ApplicationIdNotFound`() = runTest {
// GIVEN
val applicationId = ApplicationId("test-app-id")
val token = "test-token"
coEvery { pushNotificationsTokenProvider.getToken() } returns token
coEvery {
pushNotificationsRepository.sendPushToken(applicationId, token)
} returns Either.Left(NotificationsError.ApplicationIdNotFound)
// WHEN
val result = sendPushTokenUseCase(applicationId)
// THEN
assertThat(result).isEqualTo(Either.Left(NotificationsError.ApplicationIdNotFound))
coVerify(exactly = 1) { pushNotificationsRepository.sendPushToken(applicationId, token) }
}
@Test
fun `GIVEN empty token WHEN invoke THEN sends empty token to repository`() = runTest {
// GIVEN
val applicationId = ApplicationId("test-app-id")
val emptyToken = ""
coEvery { pushNotificationsTokenProvider.getToken() } returns emptyToken
coEvery { pushNotificationsRepository.sendPushToken(applicationId, emptyToken) } returns Either.Right(Unit)
// WHEN
val result = sendPushTokenUseCase(applicationId)
// THEN
assertThat(result).isEqualTo(Either.Right(Unit))
coVerify(exactly = 1) { pushNotificationsRepository.sendPushToken(applicationId, emptyToken) }
}
}