Updated on 2026-08-14

This commit is contained in:
Tangem 2025-06-03 12:25:53 +03:00
commit 7c629b68d1
1179 changed files with 24718 additions and 16703 deletions

View file

@ -26,6 +26,7 @@ dependencies {
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.notifications.models)
// endregion
// region Tangem libraries
@ -37,4 +38,11 @@ dependencies {
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// end
// region Tests
testImplementation(deps.test.junit)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
// end
}

View file

@ -1,45 +1,88 @@
package com.tangem.domain.wallets.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.models.UserWallet.Cold
import kotlinx.serialization.Serializable
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
/**
* Represents user's wallet which stored in app persistence
*
* @property name User wallet name
* @property walletId User wallet [UserWalletId]
* @property cardsInWallet List of cards IDs assigned with this user's wallet. The list will be empty if the wallet
* has been backed up on another device.
* @property isMultiCurrency Indicates whether this user wallet can work with more than one currency
* @property scanResponse [ScanResponse] of primary user's wallet card.
*/
@JsonClass(generateAdapter = true)
data class UserWallet(
@Json(name = "name")
val name: String,
@Json(name = "walletId")
val walletId: UserWalletId,
@Json(name = "cardsInWallet")
val cardsInWallet: Set<String>,
@Json(name = "isMultiCurrency")
val isMultiCurrency: Boolean,
@Json(name = "hasBackupError")
val hasBackupError: Boolean,
@Json(name = "scanResponse")
val scanResponse: ScanResponse, // TODO: Replace with [com.tangem.domain.models.scan.CardDTO]
) {
@Serializable
sealed interface UserWallet {
/** ID of user's wallet primary card */
val cardId: String get() = scanResponse.card.cardId
val name: String
val walletId: UserWalletId
/** Indicates if the user's wallet primary card has access code */
val hasAccessCode: Boolean get() = scanResponse.card.isAccessCodeSet
/**
* Represents user's wallet backed by tangem card.
*
* @property name User wallet name
* @property walletId User wallet [UserWalletId]
* @property cardsInWallet List of cards IDs assigned with this user's wallet. The list will be empty if the wallet
* has been backed up on another device.
* @property isMultiCurrency Indicates whether this user wallet can work with more than one currency
* @property scanResponse [ScanResponse] of primary user's wallet card.
*/
@Serializable
data class Cold(
override val name: String,
override val walletId: UserWalletId,
val cardsInWallet: Set<String>,
val isMultiCurrency: Boolean,
val hasBackupError: Boolean,
val scanResponse: ScanResponse, // TODO: Replace with [com.tangem.domain.models.scan.CardDTO]
) : UserWallet {
/** Indicates if this primary card has no currency wallets */
val isLocked: Boolean get() = scanResponse.card.wallets.isEmpty()
/** ID of user's wallet primary card */
val cardId: String get() = scanResponse.card.cardId
/** Indicated if this primary card is imported */
val isImported: Boolean get() = scanResponse.card.wallets.any(CardDTO.Wallet::isImported)
}
/** Indicates if the user's wallet primary card has access code */
val hasAccessCode: Boolean get() = scanResponse.card.isAccessCodeSet
/** Indicates if this primary card has no currency wallets */
val isLocked: Boolean get() = scanResponse.card.wallets.isEmpty()
/** Indicated if this primary card is imported */
val isImported: Boolean get() = scanResponse.card.wallets.any(CardDTO.Wallet::isImported)
}
@Serializable
data class Hot(
override val name: String,
override val walletId: UserWalletId,
val isLocked: Boolean,
) : UserWallet
}
@OptIn(ExperimentalContracts::class)
fun UserWallet.requireColdWallet(): Cold {
contract {
returns() implies (this@requireColdWallet is Cold)
}
return this as? Cold
?: error("This user wallet is not a cold wallet")
}
fun UserWallet.copy(name: String = this.name, walletId: UserWalletId = this.walletId): UserWallet = when (this) {
is UserWallet.Cold -> this.copy(name = name, walletId = walletId)
is UserWallet.Hot -> this.copy(name = name, walletId = walletId)
}
val UserWallet.isMultiCurrency
get() = when (this) {
is UserWallet.Cold -> isMultiCurrency
is UserWallet.Hot -> true
}
val UserWallet.isLocked
get() = when (this) {
is UserWallet.Cold -> isLocked
is UserWallet.Hot -> isLocked
}

View file

@ -0,0 +1,7 @@
package com.tangem.domain.wallets.models
class UserWalletRemoteInfo(
val walletId: UserWalletId,
val name: String,
val isNotificationsEnabled: Boolean,
)

View file

@ -9,7 +9,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
class UserWalletBuilder @AssistedInject constructor(
class ColdUserWalletBuilder @AssistedInject constructor(
@Assisted private val scanResponse: ScanResponse,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
) {
@ -36,12 +36,12 @@ class UserWalletBuilder @AssistedInject constructor(
this.hasBackupError = hasBackupError
}
fun build(): UserWallet? {
fun build(): UserWallet.Cold? {
return with(scanResponse) {
UserWalletIdBuilder.scanResponse(scanResponse)
.build()
?.let {
UserWallet(
UserWallet.Cold(
walletId = it,
name = generateWalletNameUseCase(
productType = productType,
@ -60,6 +60,6 @@ class UserWalletBuilder @AssistedInject constructor(
@AssistedFactory
interface Factory {
fun create(scanResponse: ScanResponse): UserWalletBuilder
fun create(scanResponse: ScanResponse): ColdUserWalletBuilder
}
}

View file

@ -0,0 +1,57 @@
package com.tangem.domain.wallets.delegate
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.common.CompletionResult
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UpdateWalletError
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.UserWalletRemoteInfo
import com.tangem.domain.wallets.models.copy
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
class DefaultUserWalletsSyncDelegate(
private val userWalletsListManager: UserWalletsListManager,
private val dispatchers: CoroutineDispatcherProvider,
) : UserWalletsSyncDelegate {
override suspend fun syncWallet(userWalletId: UserWalletId, name: String): Either<UpdateWalletError, UserWallet> {
return renameUserWallet(userWalletId, name)
}
override suspend fun syncWallets(list: List<UserWalletRemoteInfo>): Either<UpdateWalletError, Unit> = either {
list.forEach { userWallet ->
renameUserWallet(userWallet.walletId, userWallet.name).bind()
}
}
// TODO remove dispatchers whnen UserWalletsListManager will be main safe
private suspend fun renameUserWallet(
userWalletId: UserWalletId,
name: String,
): Either<UpdateWalletError, UserWallet> = withContext(dispatchers.io) {
either {
val existingNames = userWalletsListManager.userWalletsSync
ensure(existingNames.none { it.name == name && it.walletId != userWalletId }) {
UpdateWalletError.NameAlreadyExists
}
val previousName = existingNames.firstOrNull { it.walletId == userWalletId }?.name.orEmpty()
if (previousName == name) {
raise(UpdateWalletError.NameAlreadyExists)
}
return@withContext when (
val result =
userWalletsListManager.update(userWalletId) { it.copy(name = name) }
) {
is CompletionResult.Failure -> raise(UpdateWalletError.DataError(result.error))
is CompletionResult.Success -> result.data
}
}
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.domain.wallets.delegate
import arrow.core.Either
import com.tangem.domain.wallets.models.UpdateWalletError
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.UserWalletRemoteInfo
interface UserWalletsSyncDelegate {
suspend fun syncWallet(userWalletId: UserWalletId, name: String): Either<UpdateWalletError, UserWallet>
suspend fun syncWallets(list: List<UserWalletRemoteInfo>): Either<UpdateWalletError, Unit>
}

View file

@ -1,9 +1,12 @@
package com.tangem.domain.wallets.repository
import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.UserWalletRemoteInfo
import kotlinx.coroutines.flow.Flow
@Suppress("TooManyFunctions")
interface WalletsRepository {
suspend fun shouldSaveUserWalletsSync(): Boolean
@ -38,9 +41,21 @@ interface WalletsRepository {
suspend fun disableNFT(userWalletId: UserWalletId)
@Throws
suspend fun isNotificationsEnabled(userWalletId: UserWalletId, force: Boolean): Boolean
fun notificationsEnabledStatus(userWalletId: UserWalletId): Flow<Boolean>
suspend fun isNotificationsEnabled(userWalletId: UserWalletId): Boolean
suspend fun setNotificationsEnabled(userWalletId: UserWalletId, isEnabled: Boolean)
@Throws
suspend fun setNotificationsEnabled(userWalletId: UserWalletId, isEnabled: Boolean)
suspend fun setWalletName(walletId: String, walletName: String)
@Throws
suspend fun getWalletInfo(walletId: String): UserWalletRemoteInfo
@Throws
suspend fun getWalletsInfo(applicationId: String, updateCache: Boolean = true): List<UserWalletRemoteInfo>
@Throws
suspend fun associateWallets(applicationId: String, wallets: List<UserWallet>)
}

View file

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

View file

@ -2,7 +2,7 @@ package com.tangem.domain.wallets.usecase
import arrow.core.raise.catch
import com.tangem.blockchain.common.address.AddressType
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId

View file

@ -8,10 +8,9 @@ class GetIsNotificationsEnabledUseCase(
private val walletsRepository: WalletsRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, force: Boolean): Either<Throwable, Boolean> = Either.catch {
suspend operator fun invoke(userWalletId: UserWalletId): Either<Throwable, Boolean> = Either.catch {
walletsRepository.isNotificationsEnabled(
userWalletId = userWalletId,
force = force,
)
}
}

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

@ -0,0 +1,13 @@
package com.tangem.domain.wallets.usecase
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.repository.WalletsRepository
import kotlinx.coroutines.flow.Flow
class GetWalletNotificationsEnabledUseCase(
private val walletsRepository: WalletsRepository,
) {
operator fun invoke(userWalletId: UserWalletId): Flow<Boolean> = walletsRepository
.notificationsEnabledStatus(userWalletId)
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.wallets.usecase
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@ -20,7 +21,7 @@ class IsNeedToBackupUseCase(private val userWalletsListManager: UserWalletsListM
if (wallet == null) {
false
} else {
wallet.scanResponse.card.backupStatus is CardDTO.BackupStatus.NoBackup
wallet is UserWallet.Cold && wallet.scanResponse.card.backupStatus is CardDTO.BackupStatus.NoBackup
}
}
}

View file

@ -2,38 +2,23 @@ package com.tangem.domain.wallets.usecase
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.common.CompletionResult
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.delegate.UserWalletsSyncDelegate
import com.tangem.domain.wallets.models.UpdateWalletError
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import com.tangem.domain.wallets.repository.WalletsRepository
/**
* Use case for rename user wallet
*
* @property userWalletsListManager user wallets list manager
*/
class RenameWalletUseCase(
private val userWalletsListManager: UserWalletsListManager,
private val dispatchers: CoroutineDispatcherProvider,
private val walletsRepository: WalletsRepository,
private val userWalletsSyncDelegate: UserWalletsSyncDelegate,
) {
suspend operator fun invoke(userWalletId: UserWalletId, name: String): Either<UpdateWalletError, UserWallet> =
withContext(dispatchers.io) {
either {
val existingNames = userWalletsListManager.userWalletsSync
ensure(existingNames.none { it.name == name && it.walletId != userWalletId }) {
UpdateWalletError.NameAlreadyExists
}
when (val result = userWalletsListManager.update(userWalletId) { it.copy(name = name) }) {
is CompletionResult.Failure -> raise(UpdateWalletError.DataError(result.error))
is CompletionResult.Success -> result.data
}
either {
runCatching {
walletsRepository.setWalletName(userWalletId.stringValue, name)
}
userWalletsSyncDelegate.syncWallet(userWalletId, name).bind()
}
}

View file

@ -1,11 +1,13 @@
package com.tangem.domain.wallets.usecase
import arrow.core.Either
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.repository.WalletsRepository
class SetNotificationsEnabledUseCase(
private val walletsRepository: WalletsRepository,
private val currenciesRepository: CurrenciesRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, isEnabled: Boolean): Either<Throwable, Unit> =
@ -14,5 +16,11 @@ class SetNotificationsEnabledUseCase(
userWalletId = userWalletId,
isEnabled = isEnabled,
)
currenciesRepository.syncTokens(userWalletId)
}.onLeft {
walletsRepository.setNotificationsEnabled(
userWalletId = userWalletId,
isEnabled = !isEnabled,
)
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.domain.wallets.usecase
import arrow.core.Either
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.domain.wallets.delegate.UserWalletsSyncDelegate
import com.tangem.domain.wallets.repository.WalletsRepository
class UpdateRemoteWalletsInfoUseCase(
private val walletsRepository: WalletsRepository,
private val userWalletsSyncDelegate: UserWalletsSyncDelegate,
) {
suspend operator fun invoke(applicationId: ApplicationId): Either<Throwable, Unit> = Either.catch {
val walletsInfo = walletsRepository.getWalletsInfo(applicationId.value)
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)
}
}