Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 13:39:30 +03:00
commit 92849d43c2
1407 changed files with 64092 additions and 11990 deletions

View file

@ -2,6 +2,7 @@ plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.ksp)
id("configuration")
}
@ -10,41 +11,21 @@ android {
}
dependencies {
implementation(projects.features.virtualAccounts.details.api) // VIRTUAL_ACCOUNTS_ENABLED
// region Project - Common
implementation(projects.common.ui) // It's needed for getting AccountName.DefaultMain value
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Project - Core
implementation(projects.core.datasource)
implementation(projects.core.configToggles)
implementation(projects.core.res)
api(projects.core.utils)
// endregion
// region Project - Domain
api(projects.domain.account)
api(projects.domain.card)
api(projects.domain.common)
api(projects.domain.models)
api(projects.domain.tokens)
api(projects.domain.wallets)
api(projects.domain.visa)
// endregion
// region Project - Data
implementation(projects.data.common)
// endregion
// region Project - Libs
implementation(projects.libs.crypto)
implementation(projects.libs.blockchainSdk)
// region Other libraries
api(deps.moshi)
ksp(deps.moshi.kotlin.codegen)
implementation(deps.androidx.annotation)
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
// endregion
// region Tangem dependencies
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
implementation(tangemDeps.hot.core)
// endregion
@ -53,20 +34,44 @@ dependencies {
kapt(deps.hilt.kapt)
// endregion
// region AndroidX libraries
implementation(deps.androidx.datastore)
// region Project - Core
api(projects.core.configToggles)
api(projects.core.datasource)
api(projects.core.utils)
implementation(projects.core.res)
// endregion
// region Other Dependencies
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
// region Project - Domain
api(projects.domain.account)
api(projects.domain.common)
api(projects.domain.core)
api(projects.domain.models)
api(projects.domain.tokens)
implementation(projects.domain.card)
runtimeOnly(projects.domain.visa)
runtimeOnly(projects.domain.wallets)
// endregion
// region Project - Data
api(projects.data.common)
// endregion
// region Project - Features
api(projects.features.virtualAccounts.details.api) // VIRTUAL_ACCOUNTS_ENABLED
// endregion
// region Project - Common
implementation(projects.common.ui) // It's needed for getting AccountName.DefaultMain value
// endregion
// region Project - Libs
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
// endregion
// region Test
testImplementation(deps.test.turbine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testImplementation(deps.test.turbine)
// endregion
}

View file

@ -0,0 +1,49 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
android {
namespace = "com.tangem.data.addressbook"
}
dependencies {
// region Kotlin
api(deps.kotlin.coroutines)
implementation(deps.kotlin.serialization)
// endregion
// region SDK
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
implementation(deps.jodatime)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Project - Core
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Project - Data
api(projects.data.common)
// endregion
// region Project - Domain
api(projects.domain.addressBook)
api(projects.domain.common)
api(projects.domain.models)
// endregion
// region Testing
testImplementation(projects.test.core)
// endregion
}

View file

@ -0,0 +1,254 @@
package com.tangem.data.addressbook
import arrow.core.Either
import arrow.core.flatMap
import arrow.core.left
import arrow.core.right
import com.tangem.data.addressbook.store.AddressBookBlobStore
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.datasource.api.addressbook.AddressBookApi
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
import com.tangem.domain.addressbook.crypto.AddressBookCipher
import com.tangem.domain.addressbook.error.AddressBookSyncError
import com.tangem.domain.addressbook.model.AddressBook
import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.joda.time.DateTime
@Suppress("LongParameterList")
internal class DefaultAddressBookRepository(
private val blobStore: AddressBookBlobStore,
private val cipher: AddressBookCipher,
private val addressBookApi: AddressBookApi,
private val eTagsStore: ETagsStore,
private val userWalletsListRepository: UserWalletsListRepository,
private val timestampProvider: IsoTimestampProvider,
private val dispatchers: CoroutineDispatcherProvider,
) : AddressBookRepository {
private val writeMutex = Mutex()
override fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>> {
return getContactsForWallet(userWalletId)
.onStart { syncAddressBooks() }
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun getAllContacts(): Flow<List<Contact>> {
return userWalletsListRepository.userWallets
.filterNotNull()
.flatMapLatest { wallets ->
val walletsById = wallets.associateBy { it.walletId.stringValue }
val ids = wallets.mapTo(mutableSetOf()) { it.walletId }
blobStore.getBlobs(ids).map { blobs ->
blobs.flatMap { blob ->
walletsById[blob.walletId]?.let { userWallet ->
decryptContacts(blob, userWallet)
}.orEmpty()
}
}
}
.onStart { syncAddressBooks() }
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
private fun getContactsForWallet(userWalletId: UserWalletId): Flow<List<Contact>> {
return blobStore.getBlob(userWalletId).map { blob ->
val userWallet = blob?.let { findUserWallet(it.walletId) } ?: return@map emptyList()
decryptContacts(blob, userWallet)
}
}
override suspend fun getContactsSync(userWalletId: UserWalletId): List<Contact> {
return withContext(dispatchers.io) {
val blob = blobStore.getBlobSync(userWalletId) ?: return@withContext emptyList()
val userWallet = findUserWallet(blob.walletId) ?: return@withContext emptyList()
decryptContacts(blob, userWallet)
}
}
override suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? =
withContext(dispatchers.default) {
val blob = blobStore.getBlobSync(userWalletId) ?: return@withContext null
val userWallet = findUserWallet(blob.walletId) ?: return@withContext null
decryptContacts(blob, userWallet).find { it.name.value == name }
}
override suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit> =
withContext(dispatchers.default) {
writeMutex.withLock {
val userWallet = findUserWallet(contact.walletId.stringValue)
?: return@withLock AddressBookSyncError.Unknown.left()
val current = currentContacts(contact.walletId, userWallet)
val merged = current.filterNot { it.id == contact.id } + contact
persist(userWallet, AddressBook(contacts = merged))
}
}
override suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit> =
withContext(dispatchers.default) {
writeMutex.withLock {
userWalletsListRepository.userWalletsSync().forEach { userWallet ->
val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach
val addressBook = cipher.decrypt(blob, userWallet).getOrNull() ?: return@forEach
if (addressBook.contacts.none { it.id == id }) return@forEach
val remaining = addressBook.contacts.filterNot { it.id == id }
return@withLock persist(userWallet, addressBook.copy(contacts = remaining))
}
// No wallet held the contact — nothing to push, treat as success.
Unit.right()
}
}
override suspend fun syncAddressBooks(): Either<AddressBookSyncError, Unit> = withContext(dispatchers.default) {
val wallets = userWalletsListRepository.userWalletsSync()
// The backend rejects more than MAX_SYNC_WALLETS per request, so sync in chunks and stop on the
// first failed chunk.
wallets.chunked(MAX_SYNC_WALLETS)
.fold(initial = Unit.right() as Either<AddressBookSyncError, Unit>) { acc, chunk ->
acc.flatMap { syncWalletsChunk(chunk) }
}
}
private suspend fun syncWalletsChunk(wallets: List<UserWallet>): Either<AddressBookSyncError, Unit> {
val request = SyncAddressBooksRequest(
wallets = wallets.map { wallet ->
SyncAddressBooksRequest.Wallet(
walletId = wallet.walletId.stringValue,
etag = eTagsStore.getSyncOrNull(wallet.walletId, ETagsStore.Key.AddressBook),
)
},
)
return safeApiCall(
call = {
val response = withContext(dispatchers.io) { addressBookApi.syncAddressBooks(request).bind() }
// Only wallets whose etag changed are returned; the rest keep their local copy.
response.items.forEach { item ->
val userWalletId = UserWalletId(stringValue = item.walletId)
blobStore.storeBlob(item.toBlob())
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, item.etag)
}
Unit.right()
},
onError = { error ->
TangemLogger.e(messageString = "Failed to sync address books: $error")
error.toSyncError().left()
},
)
}
private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List<Contact> {
return cipher.decrypt(blob, userWallet).getOrNull()?.contacts.orEmpty()
}
private suspend fun currentContacts(userWalletId: UserWalletId, userWallet: UserWallet): List<Contact> {
val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList()
return decryptContacts(blob, userWallet)
}
/**
* Encrypts [addressBook], pushes it to the backend, and persists it locally **only** on success.
* On any failure (encryption, network, etag conflict, ) nothing is written locally.
*/
private suspend fun persist(userWallet: UserWallet, addressBook: AddressBook): Either<AddressBookSyncError, Unit> {
val updatedAt = DateTime.parse(timestampProvider.now())
return cipher.encrypt(addressBook, userWallet, updatedAt)
.mapLeft { error ->
TangemLogger.e(
messageString = "Failed to encrypt address book for wallet ${userWallet.walletId}: $error",
)
AddressBookSyncError.Unknown
}
.flatMap { blob -> pushBlob(userWallet.walletId, blob) }
}
private suspend fun pushBlob(
userWalletId: UserWalletId,
blob: AddressBookBlob,
): Either<AddressBookSyncError, Unit> {
// Absent etag means the book has not been created on the backend yet → omit If-Match to create it.
val eTag = eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.AddressBook)
return safeApiCall(
call = {
val response = withContext(dispatchers.io) {
addressBookApi.updateAddressBook(
walletId = blob.walletId,
eTag = eTag,
body = UpdateAddressBookRequest(
version = blob.version,
nonce = blob.nonce,
ciphertext = blob.ciphertext,
authTag = blob.authTag,
),
).bind()
}
blobStore.storeBlob(blob)
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, response.etag)
Unit.right()
},
onError = { error ->
TangemLogger.e(messageString = "Failed to push address book for wallet $userWalletId: $error")
val syncError = error.toSyncError()
// A 412 means the local etag is stale relative to the backend. Refresh the local blob + etag so the
// next save attempt (user re-taps Save) starts from the current backend state. We do NOT re-push here
// on purpose — the write stays single-shot; the conflict is still surfaced so the UI can prompt.
if (syncError is AddressBookSyncError.Conflict) {
syncAddressBooks()
}
syncError.left()
},
)
}
private fun SyncAddressBooksResponse.Item.toBlob(): AddressBookBlob = AddressBookBlob(
version = version,
walletId = walletId,
updatedAt = updatedAt,
nonce = nonce,
ciphertext = ciphertext,
authTag = authTag,
)
private fun ApiResponseError.toSyncError(): AddressBookSyncError = when (this) {
is ApiResponseError.HttpException -> when (code) {
Code.PRECONDITION_FAILED -> AddressBookSyncError.Conflict
Code.NOT_FOUND -> AddressBookSyncError.NotFound
Code.UNAUTHORIZED -> AddressBookSyncError.Unauthorized
Code.BAD_REQUEST -> AddressBookSyncError.BadRequest
else -> AddressBookSyncError.Unknown
}
is ApiResponseError.NetworkException,
is ApiResponseError.TimeoutException,
-> AddressBookSyncError.Network
is ApiResponseError.UnknownException -> AddressBookSyncError.Unknown
}
private suspend fun findUserWallet(walletId: String): UserWallet? =
userWalletsListRepository.userWalletsSync().find { it.walletId.stringValue == walletId }
private companion object {
const val MAX_SYNC_WALLETS = 20
}
}

View file

@ -0,0 +1,75 @@
package com.tangem.data.addressbook.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.tangem.data.addressbook.DefaultAddressBookRepository
import com.tangem.data.addressbook.store.AddressBookBlobStore
import com.tangem.data.addressbook.store.DefaultAddressBookBlobStore
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.datasource.api.addressbook.AddressBookApi
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
import com.tangem.domain.addressbook.crypto.AddressBookCipher
import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object AddressBookDataModule {
@Provides
@Singleton
fun provideAddressBookBlobStore(
@ApplicationContext context: Context,
appScope: AppCoroutineScope,
): AddressBookBlobStore {
return DefaultAddressBookBlobStore(
dataStore = DataStoreFactory.create(
serializer = KotlinxDataStoreSerializer(
defaultValue = emptyMap(),
serializer = MapSerializer(
keySerializer = String.serializer(),
valueSerializer = AddressBookBlob.serializer(),
),
),
produceFile = { context.dataStoreFile(fileName = "address_book_blobs") },
scope = appScope,
),
)
}
@Provides
@Singleton
@Suppress("LongParameterList")
fun provideAddressBookRepository(
blobStore: AddressBookBlobStore,
cipher: AddressBookCipher,
addressBookApi: AddressBookApi,
eTagsStore: ETagsStore,
userWalletsListRepository: UserWalletsListRepository,
timestampProvider: IsoTimestampProvider,
dispatchers: CoroutineDispatcherProvider,
): AddressBookRepository {
return DefaultAddressBookRepository(
blobStore = blobStore,
cipher = cipher,
addressBookApi = addressBookApi,
eTagsStore = eTagsStore,
userWalletsListRepository = userWalletsListRepository,
timestampProvider = timestampProvider,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.addressbook.store
import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
interface AddressBookBlobStore {
fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?>
fun getBlobs(userWalletIds: Set<UserWalletId>): Flow<List<AddressBookBlob>>
suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob?
suspend fun storeBlob(blob: AddressBookBlob)
suspend fun deleteBlob(userWalletId: UserWalletId)
}

View file

@ -0,0 +1,43 @@
package com.tangem.data.addressbook.store
import androidx.datastore.core.DataStore
import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
internal typealias AddressBookBlobs = Map<String, AddressBookBlob>
internal class DefaultAddressBookBlobStore(
private val dataStore: DataStore<AddressBookBlobs>,
) : AddressBookBlobStore {
override fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?> {
return dataStore.data
.map { it[userWalletId.stringValue] }
.distinctUntilChanged()
}
override fun getBlobs(userWalletIds: Set<UserWalletId>): Flow<List<AddressBookBlob>> {
val ids = userWalletIds.mapTo(mutableSetOf()) { it.stringValue }
return dataStore.data
.map { stored -> stored.filterKeys { it in ids }.values.toList() }
.distinctUntilChanged()
}
override suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? {
return getStoredBlobs()[userWalletId.stringValue]
}
override suspend fun storeBlob(blob: AddressBookBlob) {
dataStore.updateData { stored -> stored + (blob.walletId to blob) }
}
override suspend fun deleteBlob(userWalletId: UserWalletId) {
dataStore.updateData { stored -> stored - userWalletId.stringValue }
}
private suspend fun getStoredBlobs(): AddressBookBlobs = dataStore.data.first()
}

View file

@ -0,0 +1,442 @@
package com.tangem.data.addressbook
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.data.addressbook.store.AddressBookBlobStore
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.datasource.api.addressbook.AddressBookApi
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookResponse
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.domain.addressbook.crypto.AddressBookCipher
import com.tangem.domain.addressbook.error.AddressBookCryptoError
import com.tangem.domain.addressbook.error.AddressBookSyncError
import com.tangem.domain.addressbook.model.AddressBook
import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultAddressBookRepositoryTest {
private val blobStore: AddressBookBlobStore = mockk()
private val cipher: AddressBookCipher = mockk()
private val addressBookApi: AddressBookApi = mockk()
private val eTagsStore: ETagsStore = mockk(relaxed = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val timestampProvider: IsoTimestampProvider = mockk()
private val userWallet: UserWallet = mockk {
every { walletId } returns UserWalletId(WALLET_A)
}
private val repository = DefaultAddressBookRepository(
blobStore = blobStore,
cipher = cipher,
addressBookApi = addressBookApi,
eTagsStore = eTagsStore,
userWalletsListRepository = userWalletsListRepository,
timestampProvider = timestampProvider,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun setup() {
clearMocks(blobStore, cipher, addressBookApi, eTagsStore, userWalletsListRepository, timestampProvider)
every { timestampProvider.now() } returns TIMESTAMP
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
coEvery { addressBookApi.syncAddressBooks(any()) } returns
ApiResponse.Success(SyncAddressBooksResponse(items = emptyList()))
}
@Test
fun `GIVEN decryptable blob WHEN getContacts THEN emits decrypted contacts`() = runTest {
// Arrange
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob()
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
// Act
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).containsExactly(contact)
}
@Test
fun `GIVEN blob WHEN getContacts THEN syncs before reading contacts`() = runTest {
// Arrange
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob()
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
// Act
repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
coVerifyOrder {
addressBookApi.syncAddressBooks(any())
cipher.decrypt(blob, userWallet)
}
}
@Test
fun `GIVEN multiple wallets WHEN getAllContacts THEN emits contacts from all wallets`() = runTest {
// Arrange
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob()
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob))
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
// Act
val result = repository.getAllContacts().first()
// Assert
assertThat(result).containsExactly(contact)
}
@Test
fun `GIVEN blob WHEN getAllContacts THEN syncs before reading contacts`() = runTest {
// Arrange
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob()
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob))
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
// Act
repository.getAllContacts().first()
// Assert
coVerifyOrder {
addressBookApi.syncAddressBooks(any())
cipher.decrypt(blob, userWallet)
}
}
@Test
fun `GIVEN no blob WHEN getContacts THEN emits empty`() = runTest {
// Arrange
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(null)
// Act
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN decryption fails WHEN getContacts THEN emits empty`() = runTest {
// Arrange
val blob = createBlob()
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
every { cipher.decrypt(blob, userWallet) } returns AddressBookCryptoError.DecryptionFailed.left()
// Act
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN backend accepts WHEN saveContact THEN pushes merged book and stores blob and etag`() = runTest {
// Arrange
val existing = createContact(id = "c1", name = "Alice")
val added = createContact(id = "c2", name = "Bob")
val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(listOf(existing)).right()
val bookSlot = slot<AddressBook>()
val newBlob = createBlob()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
coEvery { blobStore.storeBlob(newBlob) } returns Unit
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
// Act
val result = repository.saveContact(added)
// Assert
assertThat(result).isEqualTo(Unit.right())
assertThat(bookSlot.captured.contacts).containsExactly(existing, added)
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) }
}
@Test
fun `GIVEN no stored etag WHEN saveContact THEN PUT is sent without If-Match`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
val newBlob = createBlob()
every { cipher.encrypt(any(), userWallet, any()) } returns newBlob.right()
coEvery { blobStore.storeBlob(any()) } returns Unit
coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns null
coEvery { addressBookApi.updateAddressBook(WALLET_A, null, any()) } returns successPutResponse()
// Act
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
assertThat(result).isEqualTo(Unit.right())
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, null, any()) }
}
@Test
fun `GIVEN stored etag WHEN saveContact THEN PUT carries it in If-Match`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { blobStore.storeBlob(any()) } returns Unit
coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns ETAG_OLD
coEvery { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) } returns successPutResponse()
// Act
repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) }
}
@Test
fun `GIVEN etag conflict WHEN saveContact THEN returns Conflict and does not store locally`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns
errorResponse(ApiResponseError.HttpException.Code.PRECONDITION_FAILED)
// Act
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.Conflict.left())
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
coVerify(exactly = 0) { eTagsStore.store(any(), any(), any()) }
}
@Test
fun `GIVEN etag conflict WHEN saveContact THEN re-syncs once without retrying the write`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns
errorResponse(ApiResponseError.HttpException.Code.PRECONDITION_FAILED)
// Act
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.Conflict.left())
coVerify(exactly = 1) { addressBookApi.syncAddressBooks(any()) }
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, any(), any()) }
}
@Test
fun `GIVEN network error WHEN saveContact THEN does not re-sync`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns networkErrorResponse()
// Act
repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
coVerify(exactly = 0) { addressBookApi.syncAddressBooks(any()) }
}
@Test
fun `GIVEN no network WHEN saveContact THEN returns Network and does not store locally`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns networkErrorResponse()
// Act
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.Network.left())
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
}
@Test
fun `GIVEN existing contact id WHEN saveContact THEN replaces it`() = runTest {
// Arrange
val original = createContact(id = "c1", name = "Alice")
val updated = createContact(id = "c1", name = "Alice Updated")
val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(listOf(original)).right()
val bookSlot = slot<AddressBook>()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right()
coEvery { blobStore.storeBlob(any()) } returns Unit
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
// Act
repository.saveContact(updated)
// Assert
assertThat(bookSlot.captured.contacts).containsExactly(updated)
}
@Test
fun `GIVEN contact in wallet WHEN deleteContact THEN pushes and re-stores book without it`() = runTest {
// Arrange
val kept = createContact(id = "c1", name = "Alice")
val removed = createContact(id = "c2", name = "Bob")
val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(listOf(kept, removed)).right()
val bookSlot = slot<AddressBook>()
val newBlob = createBlob()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
coEvery { blobStore.storeBlob(newBlob) } returns Unit
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
// Act
val result = repository.deleteContact(ContactId("c2"))
// Assert
assertThat(result).isEqualTo(Unit.right())
assertThat(bookSlot.captured.contacts).containsExactly(kept)
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
}
@Test
fun `GIVEN backend returns changed item WHEN syncAddressBooks THEN stores blob and etag for it`() = runTest {
// Arrange
val walletB: UserWallet = mockk { every { walletId } returns UserWalletId(WALLET_B) }
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet, walletB)
val requestSlot = slot<SyncAddressBooksRequest>()
// Only wallet A changed; wallet B is omitted (matching etag) → must keep its local copy.
coEvery { addressBookApi.syncAddressBooks(capture(requestSlot)) } returns
ApiResponse.Success(SyncAddressBooksResponse(items = listOf(syncItem(WALLET_A))))
val blobSlot = slot<AddressBookBlob>()
coEvery { blobStore.storeBlob(capture(blobSlot)) } returns Unit
// Act
val result = repository.syncAddressBooks()
// Assert
assertThat(result).isEqualTo(Unit.right())
assertThat(requestSlot.captured.wallets.map { it.walletId }).containsExactly(WALLET_A, WALLET_B)
assertThat(blobSlot.captured.walletId).isEqualTo(WALLET_A)
coVerify(exactly = 1) { blobStore.storeBlob(any()) }
coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) }
coVerify(exactly = 0) { blobStore.storeBlob(match { it.walletId == WALLET_B }) }
}
@Test
fun `GIVEN unauthorized WHEN syncAddressBooks THEN returns Unauthorized and stores nothing`() = runTest {
// Arrange
coEvery { addressBookApi.syncAddressBooks(any()) } returns
errorResponse(ApiResponseError.HttpException.Code.UNAUTHORIZED)
// Act
val result = repository.syncAddressBooks()
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.Unauthorized.left())
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
}
@Test
fun `GIVEN matching name WHEN getContact THEN returns it`() = runTest {
// Arrange
val alice = createContact(id = "c1", name = "Alice")
val bob = createContact(id = "c2", name = "Bob")
val blob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns blob
every { cipher.decrypt(blob, userWallet) } returns
AddressBook(listOf(alice, bob)).right()
// Act
val result = repository.getContact(UserWalletId(WALLET_A), name = "Bob")
// Assert
assertThat(result).isEqualTo(bob)
}
private fun successPutResponse(etag: String = ETAG_NEW): ApiResponse<UpdateAddressBookResponse> =
ApiResponse.Success(
data = UpdateAddressBookResponse(walletId = WALLET_A, etag = etag, updatedAt = TIMESTAMP),
)
@Suppress("UNCHECKED_CAST")
private fun <T : Any> errorResponse(code: ApiResponseError.HttpException.Code): ApiResponse<T> =
ApiResponse.Error(
cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null),
) as ApiResponse<T>
@Suppress("UNCHECKED_CAST")
private fun <T : Any> networkErrorResponse(): ApiResponse<T> =
ApiResponse.Error(cause = ApiResponseError.NetworkException()) as ApiResponse<T>
private fun syncItem(walletId: String): SyncAddressBooksResponse.Item = SyncAddressBooksResponse.Item(
walletId = walletId,
etag = ETAG_NEW,
version = AddressBookBlob.CURRENT_VERSION,
updatedAt = TIMESTAMP,
nonce = "00112233445566778899aabb",
ciphertext = "deadbeef",
authTag = "cafebabecafebabecafebabecafebabe",
)
private fun createContact(id: String, name: String, iconColor: String = "KekColor"): Contact = Contact(
id = ContactId(id),
walletId = UserWalletId(WALLET_A),
name = ContactName(name).getOrNull()!!,
icon = "",
iconColor = iconColor,
createdAt = TIMESTAMP,
updatedAt = TIMESTAMP,
addresses = emptyList(),
)
private fun createBlob(): AddressBookBlob = AddressBookBlob(
walletId = WALLET_A,
updatedAt = TIMESTAMP,
nonce = "00112233445566778899aabb",
ciphertext = "deadbeef",
authTag = "cafebabecafebabecafebabecafebabe",
)
private companion object {
const val WALLET_A = "0a0a0a"
const val WALLET_B = "0b0b0b"
const val TIMESTAMP = "2026-05-22T09:00:00.000Z"
const val ETAG_OLD = "etag-old"
const val ETAG_NEW = "etag-new"
}
}

View file

@ -0,0 +1,96 @@
package com.tangem.data.addressbook.store
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.datastore.MockStateDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultAddressBookBlobStoreTest {
private lateinit var store: DefaultAddressBookBlobStore
@BeforeEach
fun setup() {
store = DefaultAddressBookBlobStore(
dataStore = MockStateDataStore(default = emptyMap()),
)
}
@Test
fun `GIVEN blob WHEN storeBlob THEN getBlob emits it`() = runTest {
// Arrange
val blob = createBlob(walletId = WALLET_A)
// Act
store.storeBlob(blob)
// Assert
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isEqualTo(blob)
assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isEqualTo(blob)
}
@Test
fun `GIVEN blobs for two wallets WHEN getBlob walletA THEN only walletA blob emitted`() = runTest {
// Arrange
val blobA = createBlob(walletId = WALLET_A)
val blobB = createBlob(walletId = WALLET_B)
store.storeBlob(blobA)
store.storeBlob(blobB)
// Act
val result = store.getBlob(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isEqualTo(blobA)
}
@Test
fun `GIVEN blobs for two wallets WHEN getBlobs THEN only requested wallets returned`() = runTest {
// Arrange
val blobA = createBlob(walletId = WALLET_A)
val blobB = createBlob(walletId = WALLET_B)
store.storeBlob(blobA)
store.storeBlob(blobB)
// Act
val result = store.getBlobs(setOf(UserWalletId(WALLET_A), UserWalletId(WALLET_B))).first()
val onlyA = store.getBlobs(setOf(UserWalletId(WALLET_A))).first()
// Assert
assertThat(result).containsExactly(blobA, blobB)
assertThat(onlyA).containsExactly(blobA)
}
@Test
fun `GIVEN stored blob WHEN deleteBlob THEN getBlob emits null`() = runTest {
// Arrange
val blob = createBlob(walletId = WALLET_A)
store.storeBlob(blob)
// Act
store.deleteBlob(UserWalletId(WALLET_A))
// Assert
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isNull()
assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isNull()
}
private fun createBlob(walletId: String): AddressBookBlob = AddressBookBlob(
walletId = walletId,
updatedAt = "2026-05-22T09:00:00.000Z",
nonce = "00112233445566778899aabb",
ciphertext = "deadbeef",
authTag = "cafebabecafebabecafebabecafebabe",
)
private companion object {
const val WALLET_A = "0a0a0a"
const val WALLET_B = "0b0b0b"
}
}

View file

@ -11,26 +11,27 @@ android {
dependencies {
/** Project - Domain */
implementation(projects.domain.analytics)
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** Project - Analytics */
implementation(projects.core.analytics.models)
implementation(projects.core.utils)
/** Project - Data */
implementation(projects.core.datasource)
implementation(projects.data.common)
/** AndroidX */
// region Other libraries
implementation(deps.androidx.datastore)
implementation(deps.moshi)
// endregion
/** DI */
// region DI
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// endregion
/** Other */
implementation(deps.kotlin.coroutines)
// region Project - Core
api(projects.core.datasource)
implementation(projects.core.utils)
// endregion
// region Project - Domain
api(projects.domain.analytics)
implementation(projects.domain.models)
// endregion
}

View file

@ -11,26 +11,31 @@ android {
dependencies {
/** Project - Domain */
implementation(projects.domain.core)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** Project - Data */
implementation(projects.core.datasource)
implementation(projects.data.common)
// region Other libraries
implementation(deps.arrow.core)
implementation(deps.jodatime)
// endregion
/** Project - Utils */
implementation(projects.core.utils)
/** AndroidX */
implementation(deps.androidx.datastore)
/** DI */
// region DI
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// endregion
/** Other */
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)
// region Project - Core
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Project - Data
api(projects.data.common)
// endregion
// region Project - Domain
api(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
// endregion
}

View file

@ -11,23 +11,27 @@ android {
dependencies {
/** Project - Domain */
implementation(projects.domain.appTheme)
implementation(projects.domain.appTheme.models)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** Project - Data */
implementation(projects.core.datasource)
// region Other libraries
implementation(deps.androidx.datastore)
implementation(deps.moshi)
// endregion
/** Project - Utils */
implementation(projects.core.utils)
/** DI */
// region DI
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// endregion
/** Other */
implementation(deps.kotlin.coroutines)
// region Project - Core
api(projects.core.datasource)
implementation(projects.core.utils)
// endregion
/** Local storages */
implementation(deps.androidx.datastore)
// region Project - Domain
api(projects.domain.appTheme)
implementation(projects.domain.appTheme.models)
// endregion
}

1
data/app-update/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,27 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.data.appupdate"
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.domain.appUpdate)
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
}

View file

@ -0,0 +1,64 @@
package com.tangem.data.appupdate
import arrow.core.Either
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.ApplicationVersionsResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.domain.appupdate.model.AppVersionInfo
import com.tangem.domain.appupdate.model.OptionalUpdateShown
import com.tangem.domain.appupdate.repository.AppUpdateRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.withContext
internal class DefaultAppUpdateRepository(
private val tangemTechApi: TangemTechApi,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
private val currentTimeMillis: () -> Long = System::currentTimeMillis,
) : AppUpdateRepository {
override suspend fun getCachedAppVersionInfo(): AppVersionInfo? = appPreferencesStore
.getObjectSyncOrNull<ApplicationVersionsResponse>(PreferencesKeys.CACHED_APP_VERSIONS_KEY)
?.toDomain()
override suspend fun getCachedAppVersionTimestamp(): Long? =
appPreferencesStore.getSyncOrNull(PreferencesKeys.CACHED_APP_VERSIONS_AT_KEY)
override suspend fun refreshAppVersionInfo(): Either<Throwable, AppVersionInfo> = withContext(dispatchers.io) {
Either.catch {
val response = tangemTechApi.getApplicationVersions().getOrThrow()
appPreferencesStore.storeObject(PreferencesKeys.CACHED_APP_VERSIONS_KEY, response)
appPreferencesStore.store(PreferencesKeys.CACHED_APP_VERSIONS_AT_KEY, currentTimeMillis())
response.toDomain()
}.onLeft { error -> TangemLogger.e("Unable to fetch application versions", error) }
}
override suspend fun getOptionalUpdateShown(): OptionalUpdateShown? {
val version = appPreferencesStore.getSyncOrNull(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_VERSION_KEY)
?: return null
val shownAtMillis = appPreferencesStore.getSyncOrNull(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_AT_KEY)
?: return null
return OptionalUpdateShown(version = version, shownAtMillis = shownAtMillis)
}
override suspend fun setOptionalUpdateShown(shown: OptionalUpdateShown) {
appPreferencesStore.store(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_VERSION_KEY, shown.version)
appPreferencesStore.store(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_AT_KEY, shown.shownAtMillis)
}
private fun ApplicationVersionsResponse.toDomain() = AppVersionInfo(
minSupportedVersion = minSupportedVersion,
minSupportedOSVersion = minSupportedOSVersion,
criticalVersion = criticalVersion,
criticalOSVersion = criticalOSVersion,
latestVersion = latestVersion,
)
}

View file

@ -0,0 +1,41 @@
package com.tangem.data.appupdate.di
import com.tangem.data.appupdate.DefaultAppUpdateRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.appupdate.repository.AppUpdateRepository
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.info.AppInfoProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object AppUpdateDataModule {
@Provides
@Singleton
fun provideAppUpdateRepository(
tangemTechApi: TangemTechApi,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): AppUpdateRepository = DefaultAppUpdateRepository(
tangemTechApi = tangemTechApi,
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
)
@Provides
@Singleton
fun provideGetAppUpdateStateUseCase(
repository: AppUpdateRepository,
appInfoProvider: AppInfoProvider,
): GetAppUpdateStateUseCase = GetAppUpdateStateUseCase(
repository = repository,
appInfoProvider = appInfoProvider,
)
}

View file

@ -11,10 +11,17 @@ android {
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.domain.appsflyer)
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Core modules
api(projects.core.datasource)
// endregion
// region Domain
api(projects.domain.appsflyer)
// endregion
}

View file

@ -11,27 +11,45 @@ android {
}
dependencies {
api(projects.domain.assetsdiscovery)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.models)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets)
implementation(projects.data.common)
implementation(projects.data.walletManager)
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.tangemSdkApi)
implementation(projects.core.datasource)
implementation(projects.core.utils)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Other libraries
api(deps.moshi)
implementation(deps.androidx.datastore)
// endregion
// region Tangem SDK
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
// endregion
implementation(deps.androidx.datastore)
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
// region Core modules
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Data
api(projects.data.common)
implementation(projects.data.walletManager)
// endregion
// region Domain
api(projects.domain.assetsdiscovery)
api(projects.domain.common)
api(projects.domain.models)
implementation(projects.domain.wallets)
runtimeOnly(projects.domain.tokens)
// endregion
// region Libs
api(projects.libs.blockchainSdk)
// endregion
}

View file

@ -11,18 +11,29 @@ android {
}
dependencies {
implementation(deps.androidx.datastore)
implementation(deps.androidx.appCompat)
/** DI */
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region Other libraries
api(deps.lifecycle.runtime.ktx)
implementation(deps.androidx.datastore)
implementation(deps.moshi)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
implementation(deps.kotlin.coroutines)
// region Core modules
api(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.core.datasource)
// endregion
implementation(projects.domain.balanceHiding)
// region Domain
api(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
// endregion
}

View file

@ -10,35 +10,43 @@ android {
}
dependencies {
/* Project - Domain */
implementation(projects.data.common)
implementation(projects.domain.models)
implementation(projects.domain.blockaid)
implementation(projects.domain.blockaid.models)
/* Project - Data */
implementation(projects.core.datasource)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/* Project - Core */
implementation(projects.core.utils)
implementation(projects.libs.blockchainSdk)
// region Other libraries
api(deps.arrow.core)
// endregion
/* DI */
// region Tangem libraries
api(tangemDeps.blockchain)
// endregion
// region DI
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// endregion
/* Tangem libraries */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
// region Core modules
api(projects.core.datasource)
api(projects.core.utils)
// endregion
/* Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
// region Domain
api(projects.domain.blockaid)
api(projects.domain.blockaid.models)
api(projects.domain.models)
// endregion
/* Tests */
// region Libs
implementation(projects.libs.blockchainSdk)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.turbine)
testImplementation(deps.test.truth)
// endregion
}

View file

@ -11,27 +11,40 @@ android {
}
dependencies {
implementation(deps.androidx.datastore)
implementation(tangemDeps.blockchain) {
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Other libraries
api(deps.androidx.fragment)
implementation(deps.androidx.datastore)
implementation(deps.moshi)
// endregion
// region Tangem SDK
api(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
api(tangemDeps.card.core)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
implementation(tangemDeps.card.android)
implementation(tangemDeps.card.core)
implementation(projects.core.datasource)
// region Core modules
api(projects.core.datasource)
implementation(projects.core.utils)
// endregion
implementation(projects.libs.blockchainSdk)
implementation(projects.domain.card)
implementation(projects.domain.models)
// region Domain
api(projects.domain.card)
api(projects.domain.models)
// endregion
// region Tests
testImplementation(projects.test.core)
// end
// endregion
}

View file

@ -9,41 +9,56 @@ android {
namespace = "com.tangem.data.common"
}
dependencies {
/* Core */
implementation(projects.core.datasource)
implementation(projects.core.utils)
/* Domain */
implementation(projects.domain.account)
implementation(projects.domain.demo)
implementation(projects.domain.legacy)
implementation(projects.domain.card)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.express.models)
implementation(projects.domain.networks)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
/* Libs - SDK */
implementation(tangemDeps.blockchain)
// region Other libraries
api(deps.arrow.core)
api(deps.jodatime)
implementation(deps.androidx.annotation)
implementation(deps.androidx.datastore)
// endregion
// region Libs - SDK
api(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
implementation(projects.libs.crypto)
implementation(projects.libs.blockchainSdk)
// endregion
/* DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/* Libs - Other */
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)
// region Core
api(projects.core.datasource)
api(projects.core.utils)
// endregion
/* Test */
// region Domain
api(projects.domain.common)
api(projects.domain.walletManager)
api(projects.domain.wallets)
implementation(projects.domain.card)
implementation(projects.domain.core)
runtimeOnly(projects.domain.account)
// endregion
// region Domain models
api(projects.domain.demo.models)
api(projects.domain.express.models)
api(projects.domain.models)
implementation(projects.domain.wallets.models)
// endregion
// region Libs
api(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
// endregion
// region Test
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testImplementation(deps.moshi)
// endregion
}

View file

@ -37,5 +37,6 @@ interface ETagsStore {
enum class Key {
WalletAccounts,
UserTokens,
AddressBook,
}
}

View file

@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.FeePaidCurrency
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.card.common.extensions.canHandleToken
@ -217,173 +218,6 @@ class NetworkFactory @Inject constructor(
}
}
@Suppress("LongMethod")
private fun Blockchain.getSupportedTransactionExtras(): Network.TransactionExtrasType {
return when (this) {
Blockchain.XRP -> Network.TransactionExtrasType.DESTINATION_TAG
Blockchain.Binance,
Blockchain.TON,
Blockchain.Cosmos,
Blockchain.Gonka,
Blockchain.TerraV1,
Blockchain.TerraV2,
Blockchain.Stellar,
Blockchain.Hedera,
Blockchain.Algorand,
Blockchain.Sei,
Blockchain.InternetComputer,
Blockchain.Casper,
-> Network.TransactionExtrasType.MEMO
// region Other blockchains
Blockchain.Unknown,
Blockchain.Alephium,
Blockchain.AlephiumTestnet,
Blockchain.Arbitrum,
Blockchain.ArbitrumTestnet,
Blockchain.Avalanche,
Blockchain.AvalancheTestnet,
Blockchain.BinanceTestnet,
Blockchain.BSC,
Blockchain.BSCTestnet,
Blockchain.Bitcoin,
Blockchain.BitcoinTestnet,
Blockchain.BitcoinCash,
Blockchain.BitcoinCashTestnet,
Blockchain.Cardano,
Blockchain.CosmosTestnet,
Blockchain.Dogecoin,
Blockchain.Ducatus,
Blockchain.Ethereum,
Blockchain.EthereumTestnet,
Blockchain.EthereumClassic,
Blockchain.EthereumClassicTestnet,
Blockchain.Fantom,
Blockchain.FantomTestnet,
Blockchain.Litecoin,
Blockchain.Near,
Blockchain.NearTestnet,
Blockchain.Polkadot,
Blockchain.PolkadotTestnet,
Blockchain.Kava,
Blockchain.KavaTestnet,
Blockchain.Kusama,
Blockchain.Polygon,
Blockchain.PolygonTestnet,
Blockchain.RSK,
Blockchain.SeiTestnet,
Blockchain.StellarTestnet,
Blockchain.Solana,
Blockchain.SolanaTestnet,
Blockchain.Tezos,
Blockchain.Tron,
Blockchain.TronTestnet,
Blockchain.Gnosis,
Blockchain.Dash,
Blockchain.Optimism,
Blockchain.OptimismTestnet,
Blockchain.Dischain,
Blockchain.EthereumPow,
Blockchain.EthereumPowTestnet,
Blockchain.Kaspa,
Blockchain.KaspaTestnet,
Blockchain.Telos,
Blockchain.TelosTestnet,
Blockchain.TONTestnet,
Blockchain.Ravencoin,
Blockchain.Clore,
Blockchain.RavencoinTestnet,
Blockchain.Cronos,
Blockchain.AlephZero,
Blockchain.AlephZeroTestnet,
Blockchain.OctaSpace,
Blockchain.OctaSpaceTestnet,
Blockchain.Chia,
Blockchain.ChiaTestnet,
Blockchain.Decimal,
Blockchain.DecimalTestnet,
Blockchain.XDC,
Blockchain.XDCTestnet,
Blockchain.VeChain,
Blockchain.VeChainTestnet,
Blockchain.Aptos,
Blockchain.AptosTestnet,
Blockchain.Playa3ull,
Blockchain.Shibarium,
Blockchain.ShibariumTestnet,
Blockchain.AlgorandTestnet,
Blockchain.HederaTestnet,
Blockchain.Aurora,
Blockchain.AuroraTestnet,
Blockchain.Areon,
Blockchain.AreonTestnet,
Blockchain.PulseChain,
Blockchain.PulseChainTestnet,
Blockchain.ZkSyncEra,
Blockchain.ZkSyncEraTestnet,
Blockchain.Nexa,
Blockchain.NexaTestnet,
Blockchain.Moonbeam,
Blockchain.MoonbeamTestnet,
Blockchain.Manta,
Blockchain.MantaTestnet,
Blockchain.PolygonZkEVM,
Blockchain.PolygonZkEVMTestnet,
Blockchain.Radiant,
Blockchain.Fact0rn,
Blockchain.Base,
Blockchain.BaseTestnet,
Blockchain.Moonriver,
Blockchain.MoonriverTestnet,
Blockchain.Mantle,
Blockchain.MantleTestnet,
Blockchain.Flare,
Blockchain.FlareTestnet,
Blockchain.Taraxa,
Blockchain.TaraxaTestnet,
Blockchain.Koinos,
Blockchain.KoinosTestnet,
Blockchain.Joystream,
Blockchain.Bittensor,
Blockchain.Filecoin,
Blockchain.Blast,
Blockchain.BlastTestnet,
Blockchain.Cyber,
Blockchain.CyberTestnet,
Blockchain.Sui,
Blockchain.SuiTestnet,
Blockchain.EnergyWebChain,
Blockchain.EnergyWebChainTestnet,
Blockchain.EnergyWebX,
Blockchain.EnergyWebXTestnet,
Blockchain.CasperTestnet,
Blockchain.Core,
Blockchain.CoreTestnet,
Blockchain.Xodex,
Blockchain.Canxium,
Blockchain.Chiliz,
Blockchain.ChilizTestnet,
Blockchain.VanarChain,
Blockchain.VanarChainTestnet,
Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet,
Blockchain.Bitrock, Blockchain.BitrockTestnet,
Blockchain.Sonic, Blockchain.SonicTestnet,
Blockchain.ApeChain, Blockchain.ApeChainTestnet,
Blockchain.Scroll, Blockchain.ScrollTestnet,
Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet,
Blockchain.Pepecoin, Blockchain.PepecoinTestnet,
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet,
Blockchain.Quai, Blockchain.QuaiTestnet,
Blockchain.Linea, Blockchain.LineaTestnet,
Blockchain.ArbitrumNova,
Blockchain.Plasma, Blockchain.PlasmaTestnet,
Blockchain.Adi, Blockchain.AdiTestnet,
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet,
Blockchain.Monad, Blockchain.MonadTestnet,
-> Network.TransactionExtrasType.NONE
// endregion
}
}
private fun Blockchain.getNameResolvingType(): Network.NameResolvingType {
return when (this) {
Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.NameResolvingType.ENS

View file

@ -0,0 +1,15 @@
package com.tangem.data.common.txhistory
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
/**
* Persists express (exchange/onramp) transactions into the local tx-history database and fetches any missing token
* metadata for the referenced assets.
*/
interface ExpressHistoryRepository {
suspend fun storeExchanges(items: List<ExchangeItemResponse>)
suspend fun storeOnramps(items: List<OnrampItemResponse>)
}

View file

@ -387,6 +387,7 @@ class NetworkFactoryTest {
Blockchain.CosmosTestnet,
Blockchain.Dogecoin,
Blockchain.Ducatus,
Blockchain.Gonka,
Blockchain.Ethereum, Blockchain.EthereumTestnet,
Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet,
Blockchain.Fantom, Blockchain.FantomTestnet,

View file

@ -9,28 +9,17 @@ android {
namespace = "com.tangem.data.dynamicaddresses"
}
dependencies {
// region Project - Core
implementation(projects.core.configToggles)
implementation(projects.core.datasource)
implementation(projects.core.utils)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Project - Data
implementation(projects.data.common)
// endregion
// region Project - Domain
implementation(projects.domain.account)
implementation(projects.domain.common)
implementation(projects.domain.dynamicAddresses)
implementation(projects.domain.dynamicAddresses.models)
implementation(projects.domain.models)
implementation(projects.domain.walletManager)
// region Other libraries
implementation(deps.arrow.core)
// endregion
// region Project - Libs
implementation(tangemDeps.blockchain) { exclude(module = "joda-time") }
implementation(tangemDeps.card.core)
// endregion
// region DI
@ -38,7 +27,29 @@ dependencies {
kapt(deps.hilt.kapt)
// endregion
// region Project - Core
api(projects.core.configToggles)
api(projects.core.utils)
implementation(projects.core.datasource)
// endregion
// region Project - Data
api(projects.data.common)
// endregion
// region Project - Domain
api(projects.domain.account)
api(projects.domain.common)
api(projects.domain.dynamicAddresses)
api(projects.domain.models)
api(projects.domain.walletManager)
implementation(projects.domain.dynamicAddresses.models)
// endregion
// region Testing
testImplementation(projects.test.core)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
// endregion
}

View file

@ -10,23 +10,14 @@ android {
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
api(projects.core.utils)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Project - Data
implementation(projects.data.common)
// endregion
// region Project - Domain
implementation(projects.domain.earn)
implementation(projects.domain.common)
implementation(projects.domain.account.status)
// endregion
// region Project - Libs
implementation(projects.libs.blockchainSdk)
// region Other libraries
implementation(deps.arrow.core)
implementation(tangemDeps.blockchain)
// endregion
// region DI
@ -34,10 +25,24 @@ dependencies {
kapt(deps.hilt.kapt)
// endregion
// region Other libraries
implementation(deps.androidx.datastore)
implementation(deps.moshi.kotlin)
implementation(tangemDeps.blockchain)
// region Project - Core
api(projects.core.datasource)
api(projects.core.utils)
implementation(projects.core.pagination)
// endregion
// region Project - Data
implementation(projects.data.common)
// endregion
// region Project - Domain
api(projects.domain.common)
api(projects.domain.earn)
implementation(projects.domain.models)
runtimeOnly(projects.domain.account.status)
// endregion
// region Project - Libs
api(projects.libs.blockchainSdk)
// endregion
}

View file

@ -3,7 +3,6 @@ plugins {
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.ksp)
id("configuration")
}
@ -13,26 +12,35 @@ android {
dependencies {
/** Core */
implementation(projects.core.datasource)
implementation(projects.core.utils)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** Data */
implementation(projects.data.common)
// region Other libraries
api(deps.moshi)
implementation(deps.arrow.core)
// endregion
/** Domain */
implementation(projects.domain.common)
implementation(projects.domain.express.models)
implementation(projects.domain.express)
implementation(projects.domain.wallets.models)
implementation(projects.domain.txhistory)
api(projects.domain.models)
/** Other */
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
/** DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Core
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Data
implementation(projects.data.common)
// endregion
// region Domain
api(projects.domain.common)
api(projects.domain.express)
api(projects.domain.express.models)
api(projects.domain.txhistory)
implementation(projects.domain.core)
implementation(projects.domain.models)
// endregion
}

View file

@ -10,8 +10,9 @@ android {
}
dependencies {
// region AndroidX libraries
implementation(deps.androidx.datastore)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region DI
@ -24,27 +25,23 @@ dependencies {
implementation(tangemDeps.card.core)
// endregion
// Other libraries
// endregion
// region Core modules
implementation(projects.core.configToggles)
implementation(projects.core.datasource)
implementation(projects.core.navigation)
implementation(projects.core.utils)
api(projects.core.configToggles)
api(projects.core.datasource)
api(projects.core.navigation)
api(projects.core.utils)
// endregion
// Feature modules
implementation(projects.features.hotWallet.api)
implementation(projects.domain.feedback)
implementation(projects.domain.feedback.models)
implementation(projects.domain.legacy)
// region Domain
api(projects.domain.common)
api(projects.domain.feedback)
implementation(projects.domain.card)
implementation(projects.domain.feedback.models)
implementation(projects.domain.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
// endregion
// region Libs
implementation(projects.libs.blockchainSdk)
// endregion
}

View file

@ -9,5 +9,5 @@ internal class DefaultFeedbackFeatureToggles(
) : FeedbackFeatureToggles {
override val isUsedeskEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.USEDESK_ENABLED)
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.TWI_485_USEDESK_ENABLED)
}

View file

@ -11,17 +11,29 @@ android {
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.domain.hotWallet)
implementation(projects.domain.models)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Other libraries
implementation(deps.androidx.annotation)
implementation(deps.androidx.datastore)
implementation(deps.moshi)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
implementation(deps.androidx.datastore)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
// region Core modules
api(projects.core.datasource)
implementation(projects.core.utils)
// endregion
// region Domain
api(projects.domain.hotWallet)
implementation(projects.domain.models)
// endregion
}

View file

@ -4,7 +4,6 @@ plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.ksp)
id("configuration")
}
@ -13,44 +12,52 @@ android {
}
dependencies {
/** Libs */
implementation(projects.libs.crypto)
/** Project - Domain */
implementation(projects.domain.account)
implementation(projects.domain.demo)
implementation(projects.domain.models)
implementation(projects.domain.manageTokens)
implementation(projects.domain.card)
implementation(projects.domain.wallets)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.legacy)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** Project - Data */
implementation(projects.core.datasource)
implementation(projects.data.common)
implementation(projects.data.tokens)
// region Other libraries
implementation(deps.arrow.core)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
/** Project - Utils */
implementation(projects.core.utils)
implementation(projects.core.pagination)
implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk)
/** Tangem SDKs */
// region Tangem SDKs
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
// endregion
/** AndroidX */
implementation(deps.androidx.datastore)
/** DI */
// region DI
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// endregion
/** Other */
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// region Project - Core
api(projects.core.datasource)
api(projects.core.utils)
implementation(projects.core.pagination)
// endregion
// region Project - Data
api(projects.data.common)
runtimeOnly(projects.data.tokens)
// endregion
// region Project - Domain
api(projects.domain.common)
api(projects.domain.manageTokens)
implementation(projects.domain.card)
implementation(projects.domain.models)
implementation(projects.domain.wallets)
runtimeOnly(projects.domain.account)
// endregion
// region Project - Domain models
implementation(projects.domain.manageTokens.models)
// endregion
// region Project - Libs
api(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
// endregion
}

View file

@ -24,6 +24,8 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.config.curvesConfig
import com.tangem.lib.crypto.derivation.supportsDerivationPath
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
@ -217,6 +219,20 @@ internal class DefaultCustomTokensRepository(
}
}
override suspend fun isDerivationPathSupported(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): Boolean = withContext(dispatchers.io) {
val rawPath = derivationPath.value ?: return@withContext true
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
val blockchain = networkId.toBlockchain()
val curve = userWallet.curvesConfig.primaryCurve(blockchain) ?: return@withContext false
val path = runCatching { DerivationPath(rawPath) }.getOrNull() ?: return@withContext false
curve.supportsDerivationPath(path)
}
override suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> = withContext(dispatchers.io) {
when (val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)) {
is UserWallet.Hot -> {

View file

@ -5,7 +5,6 @@ plugins {
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.ksp)
id("configuration")
}
@ -14,39 +13,48 @@ android {
}
dependencies {
/** Libs */
implementation(projects.libs.crypto)
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.core.pagination)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.configToggles)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
implementation(projects.domain.legacy)
implementation(projects.domain.markets)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.tokens)
implementation(projects.data.common)
implementation(projects.libs.blockchainSdk)
// region Other libraries
implementation(deps.arrow.core)
implementation(deps.jodatime)
implementation(tangemDeps.blockchain)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Others dependencies
implementation(deps.androidx.datastore)
implementation(deps.kotlin.coroutines)
implementation(deps.jodatime)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(tangemDeps.blockchain)
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// region Core modules
api(projects.core.analytics)
api(projects.core.analytics.models)
api(projects.core.datasource)
api(projects.core.utils)
implementation(projects.core.pagination)
// endregion
// region Data
api(projects.data.common)
// endregion
// region Domain
api(projects.domain.common)
api(projects.domain.markets)
implementation(projects.domain.models)
runtimeOnly(projects.domain.tokens)
// endregion
// region Domain models
implementation(projects.domain.markets.models)
// endregion
// region Libs
api(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
// endregion
}

View file

@ -9,27 +9,18 @@ android {
namespace = "com.tangem.data.networks"
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
implementation(projects.core.utils)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Project - Data
implementation(projects.data.common)
implementation(projects.data.dynamicAddresses)
// region Other libraries
api(deps.moshi)
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
// endregion
// region Project - Domain
implementation(projects.domain.card)
implementation(projects.domain.common)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.networks)
implementation(projects.domain.walletManager)
// endregion
// region Project - Libs
implementation(projects.libs.blockchainSdk)
// region Project - Libs (SDK)
implementation(tangemDeps.blockchain) { exclude(module = "joda-time") }
// endregion
@ -38,15 +29,32 @@ dependencies {
kapt(deps.hilt.kapt)
// endregion
// region Other libraries
implementation(deps.androidx.datastore)
implementation(deps.moshi)
// region Project - Core
api(projects.core.utils)
implementation(projects.core.datasource)
// endregion
// region Project - Data
api(projects.data.common)
api(projects.data.dynamicAddresses)
// endregion
// region Project - Domain
api(projects.domain.common)
api(projects.domain.core)
api(projects.domain.models)
api(projects.domain.networks)
api(projects.domain.walletManager)
runtimeOnly(projects.domain.card)
// endregion
// region Project - Libs
api(projects.libs.blockchainSdk)
// endregion
// region Tests
testImplementation(tangemDeps.blockchain)
testImplementation(tangemDeps.card.core)
testImplementation(projects.common.test)
testImplementation(projects.domain.card)
testImplementation(projects.test.core)
// endregion
}

View file

@ -67,7 +67,7 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId,
network = network,
networkCurrencies = networksCurrencies[network].orEmpty().toSet(),
networkCurrencies = networksCurrencies[network].orEmpty().toSet() + params.extraTokens,
xpub = xpubByNetwork[network],
)
}

View file

@ -21,6 +21,7 @@ internal class DefaultSingleNetworkStatusFetcher @Inject constructor(
params = MultiNetworkStatusFetcher.Params(
userWalletId = params.userWalletId,
networks = setOf(params.network),
extraTokens = params.extraTokens,
),
)
}

View file

@ -9,21 +9,13 @@ android {
namespace = "com.tangem.data.news"
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
api(projects.core.utils)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Project - Data
implementation(projects.data.common)
// endregion
// region Project - Domain
implementation(projects.domain.news)
// endregion
// region Project - Libs
implementation(projects.libs.blockchainSdk)
// region Other libraries
implementation(deps.arrow.core)
// endregion
// region DI
@ -31,16 +23,19 @@ dependencies {
kapt(deps.hilt.kapt)
// endregion
// region Other libraries
implementation(deps.androidx.datastore)
implementation(deps.moshi.kotlin)
// region Project - Core
api(projects.core.datasource)
api(projects.core.utils)
implementation(projects.core.pagination)
// endregion
// region Project - Domain
api(projects.domain.news)
implementation(projects.domain.models)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
// endregion
}

View file

@ -4,55 +4,60 @@ plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.ksp)
id("configuration")
}
android {
namespace = "com.tangem.data.nft"
}
dependencies {
/** Project - Data */
implementation(projects.core.datasource)
implementation(projects.data.common)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** Project - Domain */
// region Other libraries
implementation(deps.arrow.core)
implementation(deps.arrow.fx)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
// region Libs - Tangem
implementation(tangemDeps.blockchain)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Project - Core
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Project - Data
api(projects.data.common)
// endregion
// region Project - Domain
api(projects.domain.common)
api(projects.domain.walletManager)
implementation(projects.domain.card)
implementation(projects.domain.common)
implementation(projects.domain.models)
implementation(projects.domain.nft)
implementation(projects.domain.nft.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets.models)
// endregion
/** Project - Utils */
implementation(projects.core.utils)
implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk)
// region Project - Libs
api(projects.libs.blockchainSdk)
// endregion
/** Feature Api modules */
implementation(projects.features.nft.api)
/** Libs - Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
implementation(deps.arrow.fx)
implementation(deps.jodatime)
implementation(deps.androidx.paging.runtime)
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
/** Libs - Tangem */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
testImplementation(projects.test.core)
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(projects.common.test)
// endregion
}

View file

@ -10,13 +10,19 @@ android {
}
dependencies {
// region sdk
implementation(tangemDeps.blockchain)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region AndroidX libraries
// region Other libraries
implementation(deps.androidx.datastore)
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
implementation(deps.moshi)
// endregion
// region SDK
implementation(tangemDeps.blockchain)
// endregion
// region DI
@ -24,29 +30,23 @@ dependencies {
kapt(deps.hilt.kapt)
// endregion
// region Arrow
implementation(deps.arrow.core)
// endregion
// region Core modules
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.core.ui)
api(projects.core.datasource)
api(projects.core.utils)
implementation(projects.common.ui)
implementation(projects.libs.blockchainSdk)
// endregion
// region Domain modules
api(projects.domain.notifications)
implementation(projects.domain.models)
implementation(projects.domain.notifications.models)
implementation(projects.domain.notifications)
// endregion
// region tests
testImplementation(deps.test.junit5)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.moshi)
testImplementation(deps.moshi.kotlin)
testImplementation(deps.test.truth)
// endregion
}

View file

@ -11,8 +11,13 @@ android {
dependencies {
// region AndroidX libraries
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Other libraries
implementation(deps.androidx.datastore)
implementation(deps.moshi)
// endregion
// region DI
@ -21,11 +26,12 @@ dependencies {
// endregion
// region Core modules
implementation(projects.core.datasource)
api(projects.core.datasource)
implementation(projects.core.utils)
// endregion
// region Domain modules
implementation(projects.domain.onboarding)
api(projects.domain.onboarding)
implementation(projects.domain.models)
// endregion
}

View file

@ -14,52 +14,61 @@ android {
}
dependencies {
/** Core modules */
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.core.analytics)
/** Common modules */
implementation(projects.data.common)
/** Domain modules */
implementation(projects.domain.account)
implementation(projects.domain.onramp)
implementation(projects.domain.legacy)
implementation(projects.domain.card)
implementation(projects.domain.walletManager)
implementation(projects.domain.appTheme.models)
implementation(projects.domain.models)
implementation(projects.domain.express.models)
implementation(projects.domain.txhistory)
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// region Kotlin
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.serialization)
// endregion
// region Others dependencies
// region Other libraries
api(deps.moshi)
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.immutable.collections)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
implementation(deps.kotlin.serialization)
// endregion
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain) {
// region Tangem SDK
api(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
implementation(tangemDeps.card.core)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Core modules
api(projects.core.analytics)
api(projects.core.datasource)
api(projects.core.utils)
implementation(projects.core.analytics.models)
// endregion
// region Data modules
api(projects.data.common)
// endregion
// region Domain modules
api(projects.domain.common)
api(projects.domain.models)
api(projects.domain.onramp)
api(projects.domain.txhistory)
api(projects.domain.walletManager)
implementation(projects.domain.card)
runtimeOnly(projects.domain.account)
// endregion
// region Domain models
implementation(projects.domain.express.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.tokens.models)
// endregion
// region Libs
api(projects.libs.blockchainSdk)
// endregion
}

View file

@ -4,6 +4,7 @@ import android.net.Uri
import com.squareup.moshi.Moshi
import com.tangem.blockchain.extensions.toBigDecimalOrDefault
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.txhistory.ExpressHistoryRepository
import com.tangem.data.onramp.converters.CountryConverter
import com.tangem.data.onramp.converters.CurrencyConverter
import com.tangem.data.onramp.converters.PaymentMethodConverter
@ -76,6 +77,7 @@ internal class DefaultOnrampRepository(
private val walletManagersFacade: WalletManagersFacade,
private val dataSignatureVerifier: DataSignatureVerifier,
private val expressHistoryDao: ExpressHistoryDao,
private val expressHistoryRepository: ExpressHistoryRepository,
private val txHistoryFeatureToggles: TxHistoryFeatureToggles,
moshi: Moshi,
) : OnrampRepository {
@ -115,7 +117,7 @@ internal class DefaultOnrampRepository(
override suspend fun fetchCountries(userWallet: UserWallet): List<OnrampCountry> = withContext(dispatchers.io) {
if (!countriesStore.getSyncOrNull(COUNTRIES_KEY).isNullOrEmpty()) return@withContext emptyList()
val result = onrampApi.getCountries(
val response = onrampApi.getCountries(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
@ -123,8 +125,12 @@ internal class DefaultOnrampRepository(
),
)
.getOrThrow()
.map(countryConverter::convert)
if (txHistoryFeatureToggles.isNewTxHistoryEnabled) {
expressHistoryDao.upsertCountries(response.map { it.toEntity() })
}
val result = response.map(countryConverter::convert)
countriesStore.store(COUNTRIES_KEY, result)
result
@ -168,7 +174,7 @@ internal class DefaultOnrampRepository(
.getOrThrow()
if (txHistoryFeatureToggles.isNewTxHistoryEnabled) {
expressHistoryDao.upsertOnramps(listOf(response.toEntity(ownerAddress = response.payoutAddress)))
expressHistoryRepository.storeOnramps(items = listOf(response))
}
statusConverter.convert(response)

View file

@ -4,6 +4,7 @@ import com.squareup.moshi.Moshi
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.txhistory.ExpressHistoryRepository
import com.tangem.data.onramp.DefaultHotCryptoRepository
import com.tangem.data.onramp.DefaultOnrampErrorResolver
import com.tangem.data.onramp.DefaultOnrampRepository
@ -59,6 +60,7 @@ internal object OnrampDataModule {
dataSignatureVerifier: DataSignatureVerifier,
onrampCurrentCountryByIPStore: OnrampCurrentCountryByIPStore,
expressHistoryDao: ExpressHistoryDao,
expressHistoryRepository: ExpressHistoryRepository,
txHistoryFeatureToggles: TxHistoryFeatureToggles,
@NetworkMoshi moshi: Moshi,
): OnrampRepository {
@ -76,6 +78,7 @@ internal object OnrampDataModule {
walletManagersFacade = walletManagersFacade,
dataSignatureVerifier = dataSignatureVerifier,
expressHistoryDao = expressHistoryDao,
expressHistoryRepository = expressHistoryRepository,
txHistoryFeatureToggles = txHistoryFeatureToggles,
moshi = moshi,
)

View file

@ -1,48 +1,9 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.ksp)
id("configuration")
}
android {
namespace = "com.tangem.data.payment"
}
dependencies {
/** Project - Data */
implementation(projects.core.error)
implementation(projects.core.error.ext)
implementation(projects.data.common)
implementation(projects.data.wallets)
/** Project - Domain */
implementation(projects.domain.payment)
implementation(projects.domain.payment.models)
implementation(projects.domain.wallets)
implementation(projects.domain.models)
implementation(projects.domain.common)
/** Project - Utils */
implementation(projects.core.utils)
implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk)
/** Libs - Tangem */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
implementation(tangemDeps.card.android)
implementation(tangemDeps.hot.core)
implementation(projects.libs.tangemSdkApi)
/** Libs - Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}
}

View file

@ -11,29 +11,35 @@ android {
}
dependencies {
/** Domain */
implementation(projects.domain.pushNotificationPreferences)
implementation(projects.domain.models)
/** Core */
implementation(projects.core.datasource)
implementation(projects.core.utils)
/** Other */
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** DI */
// region Other libraries
implementation(deps.arrow.core)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Tests */
testImplementation(deps.test.junit5)
// region Core
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Domain
api(projects.domain.pushNotificationPreferences)
implementation(projects.domain.models)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.turbine)
testImplementation(deps.moshi)
testImplementation(deps.moshi.kotlin)
// endregion
}

View file

@ -1,54 +1,49 @@
package com.tangem.data.pushnotificationpreferences
import arrow.core.Either
import com.tangem.data.pushnotificationpreferences.converters.PushNotificationPreferencesConverter
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.getObjectMapSync
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.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* In-memory cache implementation of [WalletPushNotificationPreferencesRepository].
*
* Mock-mode (current): defaults are computed locally and writes are kept in-memory only.
* Real-mode (when Variant C BE is ready): replace TODO blocks with [TangemTechApi] calls.
*
* Defaults for existing users (until BE migration runs): TX read from
* [PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY] (default true), Offers&Updates = true, Price Alerts = false,
* isVisible = true for all three.
* Preferences are cached in-memory (non-persistent); writes are full-replace PUTs and the server echo
* is cached as the source of truth.
*/
internal class DefaultWalletPushNotificationPreferencesRepository(
private val appPreferencesStore: AppPreferencesStore,
@Suppress("unused") private val tangemTechApi: TangemTechApi,
private val tangemTechApi: TangemTechApi,
private val cache: RuntimeSharedStore<Map<String, WalletPushNotificationPreferences>>,
private val dispatchers: CoroutineDispatcherProvider,
) : WalletPushNotificationPreferencesRepository {
private val walletMutexes = ConcurrentHashMap<String, Mutex>()
override suspend fun preload(userWalletId: UserWalletId) {
if (cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true) return
val preferences = withContext(dispatchers.io) {
// TODO: uncomment when api is ready
// val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow()
// PushNotificationPreferencesConverter.convert(response)
loadDefaults(userWalletId)
}
cache.update(default = emptyMap()) { current ->
if (current.containsKey(userWalletId.stringValue)) {
current
} else {
current + (userWalletId.stringValue to preferences)
if (isCached(userWalletId)) return
mutexFor(userWalletId).withLock {
if (isCached(userWalletId)) return
val preferences = fetch(userWalletId)
cache.update(default = emptyMap()) { current ->
if (current.containsKey(userWalletId.stringValue)) {
current
} else {
current + (userWalletId.stringValue to preferences)
}
}
}
}
@ -64,9 +59,11 @@ internal class DefaultWalletPushNotificationPreferencesRepository(
category: PushNotificationCategory,
isEnabled: Boolean,
): Either<Throwable, Unit> = Either.catch {
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
val updated = current.withCategory(category, isEnabled)
putAndCommit(userWalletId, updated)
mutexFor(userWalletId).withLock {
val current = currentOrFetch(userWalletId)
val updated = current.withCategory(category, isEnabled)
putAndCommit(userWalletId, updated)
}
}
override suspend fun setAllPreferences(
@ -75,39 +72,45 @@ internal class DefaultWalletPushNotificationPreferencesRepository(
offersUpdates: Boolean,
priceAlerts: Boolean,
): Either<Throwable, Unit> = Either.catch {
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
val updated = current.copy(
transactionAlerts = current.transactionAlerts.copy(isEnabled = transactionAlerts),
offersUpdates = current.offersUpdates.copy(isEnabled = offersUpdates),
priceAlerts = current.priceAlerts.copy(isEnabled = priceAlerts),
)
putAndCommit(userWalletId, updated)
mutexFor(userWalletId).withLock {
val current = currentOrFetch(userWalletId)
val updated = current.copy(
transactionAlerts = current.transactionAlerts.copy(isEnabled = transactionAlerts),
offersUpdates = current.offersUpdates.copy(isEnabled = offersUpdates),
priceAlerts = current.priceAlerts.copy(isEnabled = priceAlerts),
)
putAndCommit(userWalletId, updated)
}
}
// Cache, or a freshly fetched server snapshot, so a full-replace PUT never carries fabricated defaults.
private suspend fun currentOrFetch(userWalletId: UserWalletId): WalletPushNotificationPreferences =
cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: fetch(userWalletId)
private suspend fun fetch(userWalletId: UserWalletId): WalletPushNotificationPreferences =
withContext(dispatchers.io) {
val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow()
PushNotificationPreferencesConverter.convert(response)
}
private suspend fun isCached(userWalletId: UserWalletId): Boolean =
cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true
private fun mutexFor(userWalletId: UserWalletId): Mutex =
walletMutexes.computeIfAbsent(userWalletId.stringValue) { Mutex() }
private suspend fun putAndCommit(userWalletId: UserWalletId, updated: WalletPushNotificationPreferences) {
withContext(dispatchers.io) {
// TODO: uncomment when api is ready
// tangemTechApi.updatePushNotificationPreferences(
// walletId = userWalletId.stringValue,
// body = PushNotificationPreferencesBody(
// areTransactionAlertsEnabled = updated.transactionAlerts.isEnabled,
// areOffersUpdatesEnabled = updated.offersUpdates.isEnabled,
// arePriceAlertsEnabled = updated.priceAlerts.isEnabled,
// ),
// ).getOrThrow()
val applied = withContext(dispatchers.io) {
val response = tangemTechApi.updatePushNotificationPreferences(
walletId = userWalletId.stringValue,
body = PushNotificationPreferencesBody(
areTransactionEventsEnabled = updated.transactionAlerts.isEnabled,
areOfferUpdatesEnabled = updated.offersUpdates.isEnabled,
arePriceAlertsEnabled = updated.priceAlerts.isEnabled,
),
).getOrThrow()
PushNotificationPreferencesConverter.convert(response)
}
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to updated) }
}
// TODO remove when api is ready, use api methods to load real settings
private suspend fun loadDefaults(userWalletId: UserWalletId): WalletPushNotificationPreferences {
val areTransactionAlertsEnabled = appPreferencesStore
.getObjectMapSync<Boolean>(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY)[userWalletId.stringValue] !=
false
return WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = areTransactionAlertsEnabled, isVisible = true),
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
)
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to applied) }
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.data.pushnotificationpreferences.converters
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferenceState
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
@ -11,11 +10,8 @@ internal object PushNotificationPreferencesConverter :
override fun convert(value: PushNotificationPreferencesResponse): WalletPushNotificationPreferences =
WalletPushNotificationPreferences(
transactionAlerts = value.transactionAlerts.toDomain(),
offersUpdates = value.offersUpdates.toDomain(),
priceAlerts = value.priceAlerts.toDomain(),
transactionAlerts = PushNotificationPreference(isEnabled = value.areTransactionEventsEnabled),
offersUpdates = PushNotificationPreference(isEnabled = value.areOfferUpdatesEnabled),
priceAlerts = PushNotificationPreference(isEnabled = value.arePriceAlertsEnabled),
)
private fun PushNotificationPreferenceState.toDomain(): PushNotificationPreference =
PushNotificationPreference(isEnabled = isEnabled, isVisible = isVisible)
}

View file

@ -3,7 +3,6 @@ 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,11 +18,9 @@ internal object PushNotificationPreferencesModule {
@Singleton
@Provides
fun providesWalletPushNotificationPreferencesRepository(
appPreferencesStore: AppPreferencesStore,
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): WalletPushNotificationPreferencesRepository = DefaultWalletPushNotificationPreferencesRepository(
appPreferencesStore = appPreferencesStore,
tangemTechApi = tangemTechApi,
cache = RuntimeSharedStore(),
dispatchers = dispatchers,

View file

@ -1,141 +1,220 @@
package com.tangem.data.pushnotificationpreferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
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.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class DefaultWalletPushNotificationPreferencesRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()
private val preferencesDataStore: DataStore<Preferences> = mockk()
private val appPreferencesStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = preferencesDataStore,
)
private val userWalletId = UserWalletId(stringValue = "0011223344556677")
private val otherWalletId = UserWalletId(stringValue = "ffeeddccbbaa9988")
private val repository = DefaultWalletPushNotificationPreferencesRepository(
appPreferencesStore = appPreferencesStore,
tangemTechApi = tangemTechApi,
cache = RuntimeSharedStore(),
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `GIVEN no prior state WHEN preload THEN cache contains defaults`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
fun `GIVEN server returns prefs WHEN preload THEN cache holds converted server state`() = runTest {
// Arrange
stubGet(userWalletId, transaction = true, offers = true, price = false)
// Act
repository.preload(userWalletId)
// Assert
repository.observePreferences(userWalletId).test {
assertThat(awaitItem()).isEqualTo(defaults(transactionAlertsEnabled = true))
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = false))
}
coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) }
}
@Test
fun `GIVEN preload already done WHEN preload called again THEN no-op`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
fun `GIVEN already preloaded WHEN preload called again THEN no second GET`() = runTest {
// Arrange
stubGet(userWalletId, transaction = true, offers = true, price = false)
// Act
repository.preload(userWalletId)
repository.preload(userWalletId)
// Assert
coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) }
}
@Test
fun `GIVEN cache miss WHEN updatePreference THEN fetches baseline AND sends full-replace PUT AND caches echo`() =
runTest {
// Arrange
stubGet(userWalletId, transaction = true, offers = true, price = false)
stubPut(userWalletId, transaction = true, offers = true, price = true)
// Act
val result = repository.updatePreference(
userWalletId = userWalletId,
category = PushNotificationCategory.PriceAlerts,
isEnabled = true,
)
// Assert
assertThat(result).isInstanceOf(Either.Right::class.java)
// The full-replace body changes only the tapped category on top of the server baseline.
coVerify(exactly = 1) {
tangemTechApi.updatePushNotificationPreferences(
userWalletId.stringValue,
PushNotificationPreferencesBody(
areTransactionEventsEnabled = true,
areOfferUpdatesEnabled = true,
arePriceAlertsEnabled = true,
),
)
}
repository.observePreferences(userWalletId).test {
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = true))
}
}
@Test
fun `GIVEN preloaded state WHEN updatePreference THEN only the tapped category changes in the PUT body`() = runTest {
// Arrange
stubGet(userWalletId, transaction = true, offers = true, price = false)
stubPut(userWalletId, transaction = true, offers = false, price = false)
// Act
repository.preload(userWalletId)
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
// Assert
coVerify(exactly = 1) {
tangemTechApi.updatePushNotificationPreferences(
userWalletId.stringValue,
PushNotificationPreferencesBody(
areTransactionEventsEnabled = true,
areOfferUpdatesEnabled = false,
arePriceAlertsEnabled = false,
),
)
}
}
@Test
fun `GIVEN write fails WHEN updatePreference THEN returns Left`() = runTest {
// Arrange
stubGet(userWalletId, transaction = true, offers = true, price = false)
repository.preload(userWalletId)
coEvery { tangemTechApi.updatePushNotificationPreferences(any(), any()) } throws IllegalStateException("boom")
// Act
val result = repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
// Assert
assertThat(result).isInstanceOf(Either.Left::class.java)
}
@Test
fun `GIVEN different wallets WHEN observed THEN each keeps its own server state`() = runTest {
// Arrange
stubGet(userWalletId, transaction = false, offers = false, price = false)
stubGet(otherWalletId, transaction = true, offers = true, price = true)
// Assert
repository.observePreferences(userWalletId).test {
assertThat(awaitItem()).isEqualTo(prefs(transaction = false, offers = false, price = false))
}
repository.observePreferences(otherWalletId).test {
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = true))
}
}
@Test
fun `GIVEN concurrent collectors WHEN preload races THEN a single GET is issued`() = runTest {
// Arrange
val gate = CompletableDeferred<Unit>()
coEvery { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) } coAnswers {
gate.await()
ApiResponse.Success(PushNotificationPreferencesResponse(true, true, false))
}
// Act
launch { repository.preload(userWalletId) }
runCurrent()
launch { repository.preload(userWalletId) }
runCurrent()
gate.complete(Unit)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) }
}
@Test
fun `GIVEN concurrent writes WHEN updatePreference races THEN serialized so no update is lost`() = runTest {
// Arrange
stubGet(userWalletId, transaction = true, offers = true, price = false)
val gate = CompletableDeferred<Unit>()
coEvery { tangemTechApi.updatePushNotificationPreferences(eq(userWalletId.stringValue), any()) } coAnswers {
val body = arg<PushNotificationPreferencesBody>(1)
gate.await()
ApiResponse.Success(
PushNotificationPreferencesResponse(
body.areTransactionEventsEnabled,
body.areOfferUpdatesEnabled,
body.arePriceAlertsEnabled,
),
)
}
repository.preload(userWalletId)
// Act
launch { repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false) }
runCurrent()
launch { repository.updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true) }
runCurrent()
gate.complete(Unit)
advanceUntilIdle()
// Assert
coVerify(exactly = 2) { tangemTechApi.updatePushNotificationPreferences(eq(userWalletId.stringValue), any()) }
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isFalse()
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = false, price = true))
}
}
@Test
fun `GIVEN cache miss WHEN updatePreference THEN loads defaults and applies update`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
val result = repository.updatePreference(
userWalletId = userWalletId,
category = PushNotificationCategory.PriceAlerts,
isEnabled = true,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.priceAlerts.isEnabled).isTrue()
assertThat(item.offersUpdates.isEnabled).isTrue()
assertThat(item.transactionAlerts.isEnabled).isTrue()
}
private fun stubGet(id: UserWalletId, transaction: Boolean, offers: Boolean, price: Boolean) {
coEvery { tangemTechApi.getPushNotificationPreferences(id.stringValue) } returns
ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price))
}
@Test
fun `GIVEN preloaded state WHEN updatePreference for each category THEN updates only that category`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.preload(userWalletId)
repository.updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, isEnabled = false)
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
repository.updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.transactionAlerts.isEnabled).isFalse()
assertThat(item.offersUpdates.isEnabled).isFalse()
assertThat(item.priceAlerts.isEnabled).isTrue()
}
private fun stubPut(id: UserWalletId, transaction: Boolean, offers: Boolean, price: Boolean) {
coEvery { tangemTechApi.updatePushNotificationPreferences(eq(id.stringValue), any()) } returns
ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price))
}
@Test
fun `GIVEN no subscription yet WHEN observePreferences subscribed THEN triggers preload and emits defaults`() =
runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item).isEqualTo(defaults(transactionAlertsEnabled = true))
}
}
@Test
fun `GIVEN updates for different wallets WHEN observed independently THEN each wallet has its own state`() =
runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
repository.updatePreference(otherWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isFalse()
assertThat(item.priceAlerts.isEnabled).isFalse()
}
repository.observePreferences(otherWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isTrue()
assertThat(item.priceAlerts.isEnabled).isTrue()
}
}
private fun defaults(transactionAlertsEnabled: Boolean) = WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = transactionAlertsEnabled, isVisible = true),
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
private fun prefs(transaction: Boolean, offers: Boolean, price: Boolean) = WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = transaction),
offersUpdates = PushNotificationPreference(isEnabled = offers),
priceAlerts = PushNotificationPreference(isEnabled = price),
)
}

View file

@ -12,25 +12,37 @@ android {
dependencies {
/** Domain */
implementation(projects.domain.models)
implementation(projects.domain.qrScanning)
implementation(projects.domain.qrScanning.models)
implementation(projects.domain.tokens.models)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
implementation(projects.core.ui)
implementation(projects.libs.blockchainSdk)
/** SdK */
// region SDK
implementation(tangemDeps.blockchain)
// endregion
/** DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Tests */
testImplementation(deps.test.junit5)
// region Core modules
implementation(projects.core.ui)
// endregion
// region Domain
api(projects.domain.qrScanning)
implementation(projects.domain.models)
implementation(projects.domain.qrScanning.models)
// endregion
// region Libs
implementation(projects.libs.blockchainSdk)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
// endregion
}

View file

@ -9,22 +9,20 @@ android {
namespace = "com.tangem.data.quotes"
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
api(projects.core.utils)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Project - Data
implementation(projects.data.common)
// region Other libraries
api(deps.moshi)
implementation(deps.androidx.annotation)
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
// endregion
// region Project - Domain
api(projects.domain.models)
api(projects.domain.quotes)
// endregion
// region Project - Libs
implementation(projects.libs.blockchainSdk)
// region Tangem SDKs
implementation(tangemDeps.blockchain)
// endregion
// region DI
@ -32,13 +30,23 @@ dependencies {
kapt(deps.hilt.kapt)
// endregion
// region Tangem SDKs
implementation(tangemDeps.blockchain)
// region Project - Core
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Other libraries
implementation(deps.androidx.datastore)
implementation(deps.moshi.kotlin)
// region Project - Data
api(projects.data.common)
// endregion
// region Project - Domain
api(projects.domain.core)
api(projects.domain.quotes)
implementation(projects.domain.models)
// endregion
// region Project - Libs
implementation(projects.libs.blockchainSdk)
// endregion
// region Tests

View file

@ -15,6 +15,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
@ -79,4 +80,12 @@ internal object QuotesDataModule {
fun provideGetCurrencyUSDQuoteUseCase(quotesRepository: QuotesRepository): GetCurrencyUSDQuoteUseCase {
return GetCurrencyUSDQuoteUseCase(quotesRepository)
}
@Singleton
@Provides
fun provideIsHighNetworkFeeUseCase(
getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase,
): IsHighNetworkFeeUseCase {
return IsHighNetworkFeeUseCase(getCurrencyUSDQuoteUseCase)
}
}

View file

@ -2,6 +2,7 @@ plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.ksp)
id("configuration")
}
@ -10,22 +11,15 @@ android {
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
api(projects.core.utils)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Project - Data
implementation(projects.data.common)
// endregion
// region Project - Domain
implementation(projects.domain.search)
implementation(projects.domain.common)
implementation(projects.domain.account.status)
implementation(projects.domain.markets.models)
implementation(projects.domain.wallets)
implementation(projects.domain.appCurrency)
// region Other libraries
api(deps.androidx.datastore)
api(deps.moshi)
ksp(deps.moshi.kotlin.codegen)
// endregion
// region DI
@ -33,8 +27,15 @@ dependencies {
kapt(deps.hilt.kapt)
// endregion
// region Other libraries
implementation(deps.androidx.datastore)
implementation(deps.moshi.kotlin)
// region Project - Core
api(projects.core.utils)
implementation(projects.core.datasource)
// endregion
// region Project - Domain
api(projects.domain.account.status)
api(projects.domain.common)
api(projects.domain.search)
implementation(projects.domain.models)
// endregion
}

View file

@ -5,38 +5,40 @@ plugins {
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.ksp)
id("configuration")
}
android {
namespace = "com.tangem.data.settings"
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.settings)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Others dependencies
implementation(deps.androidx.core)
implementation(deps.androidx.datastore)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Core modules
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Domain
api(projects.domain.settings)
// endregion
// region Test
testImplementation(projects.test.core)
// endregion
// region Others dependencies
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
}

View file

@ -0,0 +1,50 @@
package com.tangem.data.settings
import androidx.datastore.preferences.core.longPreferencesKey
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.domain.settings.UsedeskTokenTtlManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
/**
* Development implementation of [UsedeskTokenTtlManager].
*
* Reads and writes the Usedesk chat token TTL from [AppPreferencesStore],
* allowing testers to override it via the Tester Menu.
* The preference [kotlinx.coroutines.flow.Flow] is converted to a [StateFlow] on construction,
* so [getTokenTtlMillisSync] can be called from non-suspending contexts.
* Defaults to [UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS] when no value is stored.
*/
internal class DevUsedeskTokenTtlManager(
private val appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
) : UsedeskTokenTtlManager {
private val ttlMillisState: StateFlow<Long> =
appPreferencesStore
.get(key = USEDESK_TOKEN_TTL_MILLIS_KEY, default = UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
.stateIn(
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
started = SharingStarted.Eagerly,
initialValue = UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS,
)
override fun getTokenTtlMillis(): StateFlow<Long> = ttlMillisState
override fun getTokenTtlMillisSync(): Long = ttlMillisState.value
override suspend fun setTokenTtlMillis(millis: Long) {
appPreferencesStore.editData { preferences ->
preferences[USEDESK_TOKEN_TTL_MILLIS_KEY] = millis
}
}
private companion object {
val USEDESK_TOKEN_TTL_MILLIS_KEY = longPreferencesKey(name = "usedeskTokenTtlMillis")
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.data.settings
import com.tangem.domain.settings.UsedeskTokenTtlManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Production implementation of [UsedeskTokenTtlManager].
*
* The Usedesk chat token TTL is fixed to [UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS] in production
* a tester override must never leak into release builds.
* [setTokenTtlMillis] is a no-op since the TTL cannot be changed at runtime.
*/
internal class ProdUsedeskTokenTtlManager : UsedeskTokenTtlManager {
private val state: StateFlow<Long> = MutableStateFlow(UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
override fun getTokenTtlMillis(): StateFlow<Long> = state
override fun getTokenTtlMillisSync(): Long = UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS
override suspend fun setTokenTtlMillis(millis: Long) = Unit
}

View file

@ -6,11 +6,14 @@ import com.tangem.data.settings.DefaultAppRatingRepository
import com.tangem.data.settings.DefaultPermissionRepository
import com.tangem.data.settings.DefaultSettingsRepository
import com.tangem.data.settings.DevHotWalletRestrictionManager
import com.tangem.data.settings.DevUsedeskTokenTtlManager
import com.tangem.data.settings.ProdHotWalletRestrictionManager
import com.tangem.data.settings.ProdUsedeskTokenTtlManager
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.settings.HotWalletRestrictionManager
import com.tangem.domain.settings.UsedeskTokenTtlManager
import com.tangem.domain.settings.repositories.AppRatingRepository
import com.tangem.domain.settings.repositories.PermissionRepository
import com.tangem.domain.settings.repositories.SettingsRepository
@ -72,4 +75,17 @@ internal object SettingsDataModule {
ProdHotWalletRestrictionManager()
}
}
@Provides
@Singleton
fun provideUsedeskTokenTtlManager(
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): UsedeskTokenTtlManager {
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
} else {
ProdUsedeskTokenTtlManager()
}
}
}

View file

@ -0,0 +1,104 @@
package com.tangem.data.settings
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.domain.settings.UsedeskTokenTtlManager
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DevUsedeskTokenTtlManagerTest {
private val appPreferencesStore = mockk<AppPreferencesStore>(relaxed = true)
private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider()
@BeforeEach
fun setup() {
mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
}
@AfterEach
fun tearDown() {
unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
}
@Test
fun `GIVEN stored ttl WHEN getTokenTtlMillis THEN emits stored value`() = runTest {
// Arrange
val storedTtl = 15L * 60 * 1000
every {
appPreferencesStore.get(key = any<Preferences.Key<Long>>(), default = DEFAULT)
} returns flowOf(storedTtl)
val manager = DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
// Act
val emitted = getEmittedValues(manager.getTokenTtlMillis())
// Assert
assertThat(emitted).containsExactly(storedTtl)
}
@Test
fun `GIVEN no stored ttl WHEN getTokenTtlMillisSync THEN returns default`() = runTest {
// Arrange
every {
appPreferencesStore.get(key = any<Preferences.Key<Long>>(), default = DEFAULT)
} returns flowOf(DEFAULT)
val manager = DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
// Act & Assert
assertThat(manager.getTokenTtlMillisSync()).isEqualTo(DEFAULT)
}
@Test
fun `GIVEN stored ttl WHEN getTokenTtlMillisSync THEN returns cached value`() = runTest {
// Arrange
val storedTtl = 60L * 60 * 1000
every {
appPreferencesStore.get(key = any<Preferences.Key<Long>>(), default = DEFAULT)
} returns flowOf(storedTtl)
val manager = DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
// Act & Assert
assertThat(manager.getTokenTtlMillisSync()).isEqualTo(storedTtl)
}
@Test
fun `WHEN setTokenTtlMillis THEN opens editData transaction`() = runTest {
// Arrange
every {
appPreferencesStore.get(key = any<Preferences.Key<Long>>(), default = DEFAULT)
} returns flowOf(DEFAULT)
coEvery { appPreferencesStore.editData(any()) } returns mockk(relaxed = true)
val manager = DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
// Act
manager.setTokenTtlMillis(millis = 15L * 60 * 1000)
// Assert
coVerify { appPreferencesStore.editData(any()) }
}
private companion object {
val DEFAULT = UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.data.settings
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.settings.UsedeskTokenTtlManager
import com.tangem.test.core.getEmittedValues
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ProdUsedeskTokenTtlManagerTest {
private val manager = ProdUsedeskTokenTtlManager()
@Test
fun `WHEN getTokenTtlMillis THEN always emits default`() = runTest {
val emitted = getEmittedValues(manager.getTokenTtlMillis())
assertThat(emitted).containsExactly(UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
}
@Test
fun `WHEN getTokenTtlMillisSync THEN returns default`() = runTest {
assertThat(manager.getTokenTtlMillisSync()).isEqualTo(UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
}
@Test
fun `GIVEN override attempt WHEN setTokenTtlMillis THEN sync still returns default`() = runTest {
manager.setTokenTtlMillis(millis = 15L * 60 * 1000)
assertThat(manager.getTokenTtlMillisSync()).isEqualTo(UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
}
}

View file

@ -5,66 +5,76 @@ plugins {
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.ksp)
id("configuration")
}
android {
namespace = "com.tangem.data.staking"
}
dependencies {
/** Core modules */
implementation(projects.core.datasource)
implementation(projects.core.utils)
/** Common modules */
implementation(projects.data.common)
/** Domain modules */
implementation(projects.core.configToggles)
implementation(projects.domain.tokens.models)
implementation(projects.domain.staking)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.legacy)
implementation(projects.domain.walletManager)
implementation(projects.domain.card)
implementation(projects.domain.models)
/** Feature Api modules */
implementation(projects.features.staking.api)
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// region Kotlin
api(deps.kotlin.coroutines)
implementation(deps.kotlin.datetime)
// endregion
// region Others dependencies
implementation(deps.androidx.datastore)
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.immutable.collections)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
// region Other libraries
api(deps.androidx.datastore)
api(deps.moshi)
implementation(deps.arrow.core)
implementation(deps.firebase.crashlytics)
ksp(deps.moshi.kotlin.codegen)
implementation(deps.jodatime)
implementation(deps.kotlin.immutable.collections)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
implementation(tangemDeps.card.core)
// region Tangem SDK
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
implementation(tangemDeps.card.core)
// endregion
testImplementation(tangemDeps.card.core)
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Core modules
api(projects.core.analytics)
api(projects.core.configToggles)
api(projects.core.datasource)
api(projects.core.utils)
implementation(projects.core.analytics.models)
// endregion
// region Data
implementation(projects.data.common)
// endregion
// region Domain
api(projects.domain.common)
api(projects.domain.core)
api(projects.domain.models)
api(projects.domain.staking)
api(projects.domain.walletManager)
api(projects.domain.wallets)
implementation(projects.domain.card)
// endregion
// region Domain models
api(projects.domain.staking.models)
implementation(projects.domain.wallets.models)
// endregion
// region Libs
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
// endregion
// region Tests
testImplementation(projects.common.test)
testImplementation(projects.test.core)
}
// endregion
}

View file

@ -15,7 +15,7 @@ internal class DefaultStakingFeatureToggles(
}
private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) {
is StakingIntegrationID.P2PEthPool -> FeatureToggles.STAKING_ETH_ENABLED
is StakingIntegrationID.P2PEthPool -> null
is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle()
}

View file

@ -1,6 +1,5 @@
package com.tangem.data.staking.toggles
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.staking.model.StakingIntegrationID
import com.google.common.truth.Truth.assertThat
@ -24,21 +23,10 @@ internal class DefaultStakingFeatureTogglesTest {
}
@Test
fun `P2PEthPool returns true when STAKING_ETH_ENABLED is enabled`() {
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns true
fun `P2PEthPool integration is always enabled`() {
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isTrue()
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
}
@Test
fun `P2PEthPool returns false when STAKING_ETH_ENABLED is disabled`() {
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns false
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isFalse()
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) }
}
@Test

View file

@ -11,17 +11,27 @@ android {
}
dependencies {
implementation(deps.androidx.datastore)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Other libraries
implementation(deps.androidx.datastore)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
implementation(projects.domain.stories)
// region Core modules
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Domain
api(projects.domain.stories)
implementation(projects.domain.stories.models)
api(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(projects.features.referral.domain)
implementation(projects.core.datasource)
implementation(projects.core.utils)
// endregion
}

View file

@ -11,56 +11,68 @@ android {
namespace = "com.tangem.data.swap"
}
dependencies {
/** Core */
implementation(projects.core.datasource)
implementation(projects.core.utils)
/** Data */
implementation(projects.data.common)
implementation(projects.data.express)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** Domain */
implementation(projects.domain.express.models)
implementation(projects.domain.express)
implementation(projects.domain.swap.models)
implementation(projects.domain.swap)
implementation(projects.domain.wallets.models)
implementation(projects.domain.wallets)
implementation(projects.domain.tokens.models)
implementation(projects.domain.tokens)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.quotes)
implementation(projects.domain.networks)
implementation(projects.domain.staking.models)
implementation(projects.domain.staking)
implementation(projects.domain.account)
// region Other libraries
api(deps.moshi)
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
implementation(deps.jodatime)
ksp(deps.moshi.kotlin.codegen)
// endregion
/** Tangem SDK */
// region Tangem SDK
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
// endregion
/** Core */
implementation(projects.core.configToggles)
/** Libs */
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
/** Other */
implementation(deps.androidx.datastore)
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
/** DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Test */
testImplementation(tangemDeps.card.core)
// region Core
api(projects.core.configToggles)
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Data
api(projects.data.common)
implementation(projects.data.express)
// endregion
// region Domain
api(projects.domain.account)
api(projects.domain.express)
api(projects.domain.models)
api(projects.domain.quotes)
api(projects.domain.swap)
implementation(projects.domain.core)
implementation(projects.domain.tokens)
runtimeOnly(projects.domain.staking)
runtimeOnly(projects.domain.wallets)
// endregion
// region Domain models
api(projects.domain.swap.models)
implementation(projects.domain.express.models)
// endregion
// region Libs
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
// endregion
// region Test
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
// endregion
}

View file

@ -4,58 +4,29 @@ plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.ksp)
id("configuration")
}
android {
namespace = "com.tangem.data.tokens"
}
dependencies {
// region Project - Data
implementation(projects.data.common)
implementation(projects.data.networks)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Project - Domain
implementation(projects.domain.account)
implementation(projects.domain.card)
implementation(projects.domain.common)
implementation(projects.domain.core)
implementation(projects.domain.demo)
implementation(projects.domain.express)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.staking)
implementation(projects.domain.staking.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.walletManager)
implementation(projects.domain.transaction)
implementation(projects.domain.wallets.models)
// endregion
// region Project - Utils
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.libs.blockchainSdk)
implementation(projects.common)
// endregion
// region Project - Features API
implementation(projects.features.send.api)
// endregion
// region Tangem SDKs
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
// endregion
// region AndroidX
// region Other libraries
implementation(deps.androidx.datastore)
implementation(deps.androidx.paging.runtime)
implementation(deps.arrow.atomic)
implementation(deps.arrow.core)
implementation(deps.moshi)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
// region Tangem SDK
implementation(tangemDeps.blockchain)
// endregion
// region DI
@ -63,13 +34,39 @@ dependencies {
kapt(deps.hilt.kapt)
// endregion
// region Other
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi.kotlin)
implementation(deps.retrofit) // For HttpException
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// region Core modules
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Data
api(projects.data.common)
runtimeOnly(projects.data.networks)
// endregion
// region Domain
api(projects.domain.common)
api(projects.domain.tokens)
api(projects.domain.transaction)
api(projects.domain.walletManager)
implementation(projects.domain.core)
implementation(projects.domain.models)
runtimeOnly(projects.domain.account)
runtimeOnly(projects.domain.card)
runtimeOnly(projects.domain.staking)
// endregion
// region Domain models
implementation(projects.domain.tokens.models)
implementation(projects.domain.walletManager.models)
// endregion
// region Common
implementation(projects.common)
// endregion
// region Libs
api(projects.libs.blockchainSdk)
// endregion
// region Tests

View file

@ -11,41 +11,55 @@ android {
}
dependencies {
/** Tangem SDKs */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** Core */
implementation(projects.core.datasource)
implementation(projects.core.utils)
// region Other libraries
implementation(deps.androidx.core.ktx)
// endregion
/** Common */
implementation(projects.data.common)
// region Tangem SDKs
api(tangemDeps.blockchain)
// endregion
/** Domain */
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
implementation(projects.domain.legacy)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.transaction.models)
implementation(projects.domain.transaction)
implementation(projects.domain.demo)
/** Api */
implementation(projects.features.send.api)
/** DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Other */
// region Core
api(projects.core.configToggles)
api(projects.core.datasource)
api(projects.core.utils)
// endregion
/** tests */
testImplementation(projects.common.test)
testImplementation(deps.test.junit5)
// region Common
api(projects.data.common)
// endregion
// region Domain
api(projects.domain.models)
api(projects.domain.transaction)
api(projects.domain.walletManager)
implementation(projects.domain.demo)
// endregion
// region Domain models
api(projects.domain.wallets.models)
implementation(projects.domain.demo.models)
implementation(projects.domain.transaction.models)
// endregion
// region Libs
api(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
// endregion
// region tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
// endregion
}

View file

@ -3,17 +3,23 @@ package com.tangem.data.transaction
import com.tangem.blockchain.common.Token
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.transaction.convertes.GaslessBatchTransactionRequestBuilder
import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter
import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder
import com.tangem.data.transaction.convertes.GaslessTxDataToGaslessRequestConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
@ -23,17 +29,19 @@ import java.math.BigInteger
class DefaultGaslessTransactionRepository(
private val gaslessTxServiceApi: GaslessTxServiceApi,
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2,
private val isGaslessV2Enabled: Boolean,
private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
) : GaslessTransactionRepository {
private val supportedTokensState = MutableStateFlow<Map<Network.ID, Set<CryptoCurrency>>>(hashMapOf())
private val allFeeRecipientAddress = mutableSetOf<String>()
private val allAddressesMutex = Mutex()
private val receiverAddressMutex = Mutex()
private var feeReceiverAddress: String? = null
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder()
private val requestConverter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = isGaslessV2Enabled)
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder(requestConverter)
private val gaslessBatchTransactionRequestBuilder = GaslessBatchTransactionRequestBuilder(requestConverter)
private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter()
override suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency> {
@ -109,7 +117,11 @@ class DefaultGaslessTransactionRepository(
eip7702Auth = eip7702Auth,
)
val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow()
val response = if (isGaslessV2Enabled) {
gaslessTxServiceApiV2.signGaslessTransaction(transactionRequest)
} else {
gaslessTxServiceApi.signGaslessTransaction(transactionRequest)
}.getOrThrow()
if (!response.isSuccess) {
error("Gasless service returned unsuccessful response")
@ -119,6 +131,31 @@ class DefaultGaslessTransactionRepository(
signedTransactionResultConverter.convert(response.result)
}
override suspend fun signGaslessBatchTransaction(
gaslessBatchTransactionData: GaslessBatchTransactionData,
signature: String,
userAddress: String,
network: Network,
eip7702Auth: Eip7702Authorization?,
): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) {
val blockchain = network.toBlockchain()
val transactionRequest = gaslessBatchTransactionRequestBuilder.build(
gaslessBatchTransaction = gaslessBatchTransactionData,
signature = signature,
userAddress = userAddress,
chainId = blockchain.getChainId() ?: error("ChainId is null for blockchain: $blockchain"),
eip7702Auth = eip7702Auth,
)
val response = gaslessTxServiceApiV2.signGaslessBatchTransaction(transactionRequest).getOrThrow()
if (!response.isSuccess) {
error("Gasless service returned unsuccessful response")
}
signedTransactionResultConverter.convert(response.result)
}
override fun getBaseGasForTransaction(): BigInteger {
return BASE_GAS_FOR_TRANSACTION
}
@ -129,21 +166,20 @@ class DefaultGaslessTransactionRepository(
}
override suspend fun getGaslessFeeAddresses(): Set<String> {
return allAddressesMutex.withLock {
allFeeRecipientAddress.ifEmpty {
val allFeeAddresses = getAllFeeRecipientAddresses()
allFeeRecipientAddress.addAll(allFeeAddresses)
allFeeRecipientAddress
}
}
}
private suspend fun getAllFeeRecipientAddresses(): Set<String> {
// TODO Replace with other backend call to get all fee recipient addresses when available
return setOf(getTokenFeeReceiverAddress())
val backendAddress = runSuspendCatching { getTokenFeeReceiverAddress() }
.onFailure { TangemLogger.e("Failed to load gasless fee recipient; serving hardcoded addresses", it) }
.getOrNull()
return KNOWN_FEE_COLLECTION_ADDRESSES + setOfNotNull(backendAddress)
}
private companion object {
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("60000")
val KNOWN_FEE_COLLECTION_ADDRESSES = setOf(
"0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c",
"0xAf722F46145fbb106379d506ED3a5B96f110c8E5",
)
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
import com.tangem.domain.transaction.models.GaslessTransactionData
import java.math.BigInteger
@ -53,6 +54,16 @@ class MockedGaslessTransactionRepository(
txHash = "0x000",
)
override suspend fun signGaslessBatchTransaction(
gaslessBatchTransactionData: GaslessBatchTransactionData,
signature: String,
userAddress: String,
network: Network,
eip7702Auth: Eip7702Authorization?,
): GaslessSignedTransactionResult = GaslessSignedTransactionResult(
txHash = "0x000",
)
override fun getBaseGasForTransaction(): BigInteger {
return BASE_GAS_FOR_TRANSACTION
}

View file

@ -0,0 +1,23 @@
package com.tangem.data.transaction.convertes
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.utils.converter.Converter
/**
* Converts domain [Eip7702Authorization] to its DTO representation.
* Shared by both single-transaction and batch-transaction request builders.
*/
class Eip7702AuthorizationConverter : Converter<Eip7702Authorization, Eip7702AuthorizationDTO> {
override fun convert(value: Eip7702Authorization): Eip7702AuthorizationDTO {
return Eip7702AuthorizationDTO(
chainId = value.chainId,
address = value.address,
nonce = value.nonce.toString(),
yParity = value.yParity,
r = value.r,
s = value.s,
)
}
}

View file

@ -0,0 +1,49 @@
package com.tangem.data.transaction.convertes
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionDataDTO
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionRequest
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
/**
* Builder for creating complete [GaslessBatchTransactionRequest] from domain model.
* Combines batch transaction data with signature and user information.
*
* Reuses [GaslessTxDataToGaslessRequestConverter] for transaction and fee conversion
* to avoid duplicating mapping logic.
*/
class GaslessBatchTransactionRequestBuilder(
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
) {
/**
* Creates complete gasless batch transaction request.
*
* @param gaslessBatchTransaction domain model of batch transaction
* @param signature transaction signature in hex format (with 0x prefix)
* @param userAddress user's Ethereum address
* @param chainId blockchain network chain ID
* @param eip7702Auth optional EIP-7702 authorization for account abstraction
* @return complete request ready for API submission
*/
fun build(
gaslessBatchTransaction: GaslessBatchTransactionData,
signature: String,
userAddress: String,
chainId: Int,
eip7702Auth: Eip7702Authorization? = null,
): GaslessBatchTransactionRequest {
return GaslessBatchTransactionRequest(
gaslessTransaction = GaslessBatchTransactionDataDTO(
transactions = gaslessBatchTransaction.transactions.map { converter.convertTransaction(it) },
fee = converter.convertFee(gaslessBatchTransaction.fee),
nonce = gaslessBatchTransaction.nonce.toString(),
),
signature = signature,
userAddress = userAddress,
chainId = chainId,
eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
)
}
}

View file

@ -3,7 +3,6 @@ package com.tangem.data.transaction.convertes
import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
/**
* Builder for creating complete GaslessTransactionRequest from domain model.
@ -11,6 +10,7 @@ import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
*/
class GaslessTransactionRequestBuilder(
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
) {
/**
@ -35,21 +35,7 @@ class GaslessTransactionRequestBuilder(
signature = signature,
userAddress = userAddress,
chainId = chainId,
eip7702Auth = eip7702Auth?.toDTO(),
)
}
/**
* Converts domain Eip7702Authorization to DTO.
*/
private fun Eip7702Authorization.toDTO(): Eip7702AuthorizationDTO {
return Eip7702AuthorizationDTO(
chainId = chainId,
address = address,
nonce = nonce.toString(),
yParity = yParity,
r = r,
s = s,
eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
)
}
}

View file

@ -13,8 +13,13 @@ import com.tangem.datasource.api.gasless.models.GaslessTransactionData as Gasles
* Note: This converter only handles the transaction data conversion.
* Additional fields (signature, userAddress, chainId) must be added separately
* to create complete GaslessTransactionRequest.
*
* @param shouldIncludeGasLimit when true (v2), serializes the per-call `gasLimit`; when false (v1), omits it so the
* request matches the legacy v1 service. Must stay in sync with the EIP-712 message that was signed.
*/
class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
class GaslessTxDataToGaslessRequestConverter(
private val shouldIncludeGasLimit: Boolean = true,
) : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO {
return GaslessTransactionDataDTO(
@ -24,15 +29,16 @@ class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData,
)
}
private fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
internal fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
return TransactionData(
to = transaction.to,
value = transaction.value.toString(),
gasLimit = transaction.gasLimit.toString().takeIf { shouldIncludeGasLimit },
data = transaction.data.toHexString().formatHex(),
)
}
private fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
internal fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
return FeeData(
feeToken = fee.feeToken,
maxTokenFee = fee.maxTokenFee.toString(),

View file

@ -4,7 +4,10 @@ import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.transaction.*
import com.tangem.data.transaction.error.DefaultFeeErrorResolver
import com.tangem.blockchainsdk.BlockchainSDKFactory
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.demo.models.DemoConfig
@ -84,10 +87,17 @@ internal object TransactionDataModule {
fun provideGaslessTransactionRepository(
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
gaslessTxServiceApi: GaslessTxServiceApi,
gaslessTxServiceApiV2: GaslessTxServiceApiV2,
featureTogglesManager: FeatureTogglesManager,
coroutineDispatcherProvider: CoroutineDispatcherProvider,
): GaslessTransactionRepository {
return DefaultGaslessTransactionRepository(
gaslessTxServiceApi = gaslessTxServiceApi,
gaslessTxServiceApiV2 = gaslessTxServiceApiV2,
// Single master toggle for the whole gasless v2 protocol (+ yield-withdraw batch).
isGaslessV2Enabled = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
),
coroutineDispatcherProvider = coroutineDispatcherProvider,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
)

View file

@ -0,0 +1,117 @@
package com.tangem.data.transaction
import com.google.common.truth.Truth.assertThat
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
import com.tangem.datasource.api.gasless.models.GaslessFeeRecipient
import com.tangem.datasource.api.gasless.models.GaslessServiceResponse
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class DefaultGaslessTransactionRepositoryTest {
private val gaslessTxServiceApi: GaslessTxServiceApi = mockk()
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2 = mockk()
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
@BeforeEach
fun resetMocks() {
clearMocks(gaslessTxServiceApi, gaslessTxServiceApiV2, responseCryptoCurrenciesFactory)
}
private fun createRepository() = DefaultGaslessTransactionRepository(
gaslessTxServiceApi = gaslessTxServiceApi,
gaslessTxServiceApiV2 = gaslessTxServiceApiV2,
isGaslessV2Enabled = true,
coroutineDispatcherProvider = TestingCoroutineDispatcherProvider(),
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
)
private fun stubFeeRecipientSuccess(address: String) {
coEvery { gaslessTxServiceApi.getFeeRecipient() } returns ApiResponse.Success(
data = GaslessServiceResponse(
result = GaslessFeeRecipient(address = address),
isSuccess = true,
timestamp = "2026-06-11T00:00:00.000Z",
),
)
}
private fun stubFeeRecipientFailure() {
@Suppress("UNCHECKED_CAST")
val error = ApiResponse.Error(cause = ApiResponseError.NetworkException())
as ApiResponse<GaslessServiceResponse<GaslessFeeRecipient>>
coEvery { gaslessTxServiceApi.getFeeRecipient() } returns error
}
@Test
fun `GIVEN backend returns recipient WHEN getGaslessFeeAddresses THEN hardcoded plus backend address`() = runTest {
// Arrange
stubFeeRecipientSuccess(BACKEND_ADDRESS)
val repository = createRepository()
// Act
val actual = repository.getGaslessFeeAddresses()
// Assert
assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS)
}
@Test
fun `GIVEN backend fails WHEN getGaslessFeeAddresses THEN hardcoded addresses only`() = runTest {
// Arrange
stubFeeRecipientFailure()
val repository = createRepository()
// Act
val actual = repository.getGaslessFeeAddresses()
// Assert
assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2)
}
@Test
fun `GIVEN backend fails then recovers WHEN called twice THEN second call includes backend address`() = runTest {
// Arrange
stubFeeRecipientFailure()
val repository = createRepository()
val firstResult = repository.getGaslessFeeAddresses()
stubFeeRecipientSuccess(BACKEND_ADDRESS)
// Act
val secondResult = repository.getGaslessFeeAddresses()
// Assert
assertThat(firstResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2)
assertThat(secondResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS)
}
@Test
fun `GIVEN backend succeeds WHEN called twice THEN fee recipient requested once`() = runTest {
// Arrange
stubFeeRecipientSuccess(BACKEND_ADDRESS)
val repository = createRepository()
// Act
repository.getGaslessFeeAddresses()
repository.getGaslessFeeAddresses()
// Assert
coVerify(exactly = 1) { gaslessTxServiceApi.getFeeRecipient() }
}
private companion object {
const val HARDCODED_ADDRESS_1 = "0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c"
const val HARDCODED_ADDRESS_2 = "0xAf722F46145fbb106379d506ED3a5B96f110c8E5"
const val BACKEND_ADDRESS = "0x1111111111111111111111111111111111111111"
}
}

View file

@ -0,0 +1,193 @@
package com.tangem.data.transaction.convertes
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.transaction.models.Eip7702Authorization
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessTransactionData
import org.junit.jupiter.api.Test
import java.math.BigInteger
class GaslessBatchTransactionRequestBuilderTest {
private val builder = GaslessBatchTransactionRequestBuilder()
// byteArrayOf(0x12, 0x34).toHexString() == "1234" (uppercase), .formatHex() prepends "0x" → "0x1234"
private val tx1Data = byteArrayOf(0x12, 0x34)
private val tx1DataHex = "0x1234"
// byteArrayOf(0xAB.toByte(), 0xCD.toByte()).toHexString() == "ABCD", .formatHex() → "0xABCD"
private val tx2Data = byteArrayOf(0xAB.toByte(), 0xCD.toByte())
private val tx2DataHex = "0xABCD"
private val tx1 = GaslessTransactionData.Transaction(
to = "0xContractA",
value = BigInteger("100"),
gasLimit = BigInteger("120000"),
data = tx1Data,
)
private val tx2 = GaslessTransactionData.Transaction(
to = "0xContractB",
value = BigInteger("0"),
gasLimit = BigInteger("150000"),
data = tx2Data,
)
private val fee = GaslessTransactionData.Fee(
feeToken = "0xFeeToken",
maxTokenFee = BigInteger("500"),
coinPriceInToken = BigInteger("200"),
feeTransferGasLimit = BigInteger("21000"),
baseGas = BigInteger("60000"),
feeReceiver = "0xFeeReceiver",
)
private val nonce = BigInteger("42")
private val batchData = GaslessBatchTransactionData(
transactions = listOf(tx1, tx2),
fee = fee,
nonce = nonce,
)
@Test
fun `build - transactions list has correct size and order`() {
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
)
assertThat(result.gaslessTransaction.transactions).hasSize(2)
assertThat(result.gaslessTransaction.transactions[0].to).isEqualTo("0xContractA")
assertThat(result.gaslessTransaction.transactions[1].to).isEqualTo("0xContractB")
}
@Test
fun `build - transaction data fields are encoded correctly`() {
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
)
val txDtoList = result.gaslessTransaction.transactions
// data bytes are hex-encoded with 0x prefix (uppercase)
assertThat(txDtoList[0].data).isEqualTo(tx1DataHex)
assertThat(txDtoList[1].data).isEqualTo(tx2DataHex)
// value is BigInteger.toString()
assertThat(txDtoList[0].value).isEqualTo("100")
assertThat(txDtoList[1].value).isEqualTo("0")
// v2: per-call gasLimit is BigInteger.toString()
assertThat(txDtoList[0].gasLimit).isEqualTo("120000")
assertThat(txDtoList[1].gasLimit).isEqualTo("150000")
}
@Test
fun `build - v1 converter omits per-call gasLimit`() {
// Arrange: a builder whose converter is in v1 mode (shouldIncludeGasLimit = false)
val v1Builder = GaslessBatchTransactionRequestBuilder(
converter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = false),
)
// Act
val result = v1Builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
)
// Assert: gasLimit is null so Moshi omits it, restoring the legacy v1 {to, value, data} shape
val txDtoList = result.gaslessTransaction.transactions
assertThat(txDtoList[0].gasLimit).isNull()
assertThat(txDtoList[1].gasLimit).isNull()
// other fields are unaffected
assertThat(txDtoList[0].value).isEqualTo("100")
assertThat(txDtoList[0].data).isEqualTo(tx1DataHex)
}
@Test
fun `build - fee fields are all toString of BigInteger inputs`() {
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
)
val feeDto = result.gaslessTransaction.fee
assertThat(feeDto.feeToken).isEqualTo("0xFeeToken")
assertThat(feeDto.maxTokenFee).isEqualTo("500")
assertThat(feeDto.coinPriceInToken).isEqualTo("200")
assertThat(feeDto.feeTransferGasLimit).isEqualTo("21000")
assertThat(feeDto.baseGas).isEqualTo("60000")
assertThat(feeDto.feeReceiver).isEqualTo("0xFeeReceiver")
}
@Test
fun `build - nonce is toString of BigInteger input`() {
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
)
assertThat(result.gaslessTransaction.nonce).isEqualTo("42")
}
@Test
fun `build - top-level signature, userAddress, chainId pass through`() {
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xDeadBeef",
userAddress = "0xAlice",
chainId = 137,
)
assertThat(result.signature).isEqualTo("0xDeadBeef")
assertThat(result.userAddress).isEqualTo("0xAlice")
assertThat(result.chainId).isEqualTo(137)
}
@Test
fun `build - eip7702Auth is null when not provided`() {
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
)
assertThat(result.eip7702Auth).isNull()
}
@Test
fun `build - eip7702Auth maps correctly when provided`() {
val auth = Eip7702Authorization(
chainId = 1,
address = "0xEntryPoint",
nonce = BigInteger("7"),
yParity = 0,
r = "0xRValue",
s = "0xSValue",
)
val result = builder.build(
gaslessBatchTransaction = batchData,
signature = "0xSig",
userAddress = "0xUser",
chainId = 1,
eip7702Auth = auth,
)
val authDto = result.eip7702Auth
assertThat(authDto).isNotNull()
assertThat(authDto!!.chainId).isEqualTo(1)
assertThat(authDto.address).isEqualTo("0xEntryPoint")
assertThat(authDto.nonce).isEqualTo("7")
assertThat(authDto.yParity).isEqualTo(0)
assertThat(authDto.r).isEqualTo("0xRValue")
assertThat(authDto.s).isEqualTo("0xSValue")
}
}

View file

@ -9,45 +9,66 @@ android {
namespace = "com.tangem.data.txhistory"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
implementation(projects.data.common)
implementation(projects.core.utils)
implementation(projects.core.datasource)
implementation(projects.core.pagination)
implementation(projects.core.analytics)
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
implementation(projects.domain.legacy)
implementation(projects.domain.common)
implementation(projects.domain.walletManager)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.express.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.wallets)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.libs.blockchainSdk)
implementation(deps.kotlin.coroutines)
// region Other libraries
implementation(deps.androidx.annotation)
implementation(deps.androidx.paging.runtime)
implementation(deps.arrow.core)
implementation(deps.jodatime)
implementation(tangemDeps.blockchain)
implementation(deps.room.runtime)
// endregion
// region Tangem SDK
implementation(tangemDeps.blockchain)
// endregion
// region DI
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// endregion
// region Core modules
api(projects.core.analytics)
api(projects.core.datasource)
api(projects.core.utils)
implementation(projects.core.pagination)
// endregion
// region Data
api(projects.data.common)
// endregion
// region Domain
api(projects.domain.account)
api(projects.domain.common)
api(projects.domain.express)
api(projects.domain.models)
api(projects.domain.onramp)
api(projects.domain.txhistory)
api(projects.domain.walletManager)
api(projects.domain.wallets)
implementation(projects.domain.account.status)
// endregion
// region Domain models
implementation(projects.domain.express.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory.models)
// endregion
// region Libs
api(projects.libs.blockchainSdk)
// endregion
// region Test
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testImplementation(projects.test.mock)
testRuntimeOnly(deps.test.junit5.engine)
// endregion
}

View file

@ -1,8 +1,10 @@
package com.tangem.data.txhistory.di
import com.tangem.data.common.txhistory.ExpressHistoryRepository
import com.tangem.data.txhistory.fetcher.DefaultAppTxHistoryFetcher
import com.tangem.data.txhistory.fetcher.DefaultTxHistoryFetcherUtils
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils
import com.tangem.data.txhistory.repository.DefaultExpressHistoryRepository
import com.tangem.data.txhistory.repository.DefaultTxHistoryRepository
import com.tangem.data.txhistory.repository.RefactoredTxHistoryRepository
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
@ -32,4 +34,8 @@ internal interface TxHistoryDataModule {
@Binds
fun provideTxHistoryFetcherUtils(default: DefaultTxHistoryFetcherUtils): TxHistoryFetcherUtils
@Binds
@Singleton
fun provideExpressHistoryRepository(default: DefaultExpressHistoryRepository): ExpressHistoryRepository
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.txhistory.fetcher.WalletTxHistoryFetcher
@ -22,6 +23,7 @@ import javax.inject.Inject
internal class DefaultAppTxHistoryFetcher @Inject constructor(
private val utils: TxHistoryFetcherUtils,
private val expressRepository: ExpressRepository,
private val onrampRepository: OnrampRepository,
private val getWalletsUseCase: GetWalletsUseCase,
private val selectedWalletUseCase: GetSelectedWalletUseCase,
private val walletTxHistoryFetcherFactory: DefaultWalletTxHistoryFetcher.Factory,
@ -30,9 +32,6 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val fetchers = ConcurrentHashMap<UserWalletId, WalletTxHistoryFetcher>()
/** Wallets whose express providers were already loaded — to load them at most once per wallet. */
private val providersLoadedWallets = mutableSetOf<UserWalletId>()
init {
defaultLaunchIn(buildFlow())
}
@ -53,11 +52,14 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
.stateIn(this)
walletsFlow.value.keys.createForNewWallets()
walletsFlow.value.values.firstOrNull()?.let { wallet ->
loadExpressProviders(wallet)
loadOnrampCountries(wallet)
}
selectedWalletUseCase.selectedFlow()
.filter { wallet -> wallet.isMultiCurrency }
// todo txhistory some init trigger?
.onEach { wallet -> loadExpressProviders(wallet) }
.launchIn(this)
walletsFlow
@ -79,13 +81,17 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
}
private fun ProducerScope<*>.loadExpressProviders(wallet: UserWallet) {
// Load once per wallet: `add` returns false if this walletId was already loaded.
if (!providersLoadedWallets.add(wallet.walletId)) return
flow { emit(expressRepository.getProviders(userWallet = wallet, filterProviderTypes = emptyList())) }
.retryThreeTimes()
.launchIn(this)
}
private fun ProducerScope<*>.loadOnrampCountries(wallet: UserWallet) {
flow { emit(onrampRepository.fetchCountries(userWallet = wallet)) }
.retryThreeTimes()
.launchIn(this)
}
private fun Flow<Set<UserWalletId>>.createForNewWallets() = onEach { ids -> ids.createForNewWallets() }
private fun Set<UserWalletId>.createForNewWallets() = this.forEach { walletId -> getOrPutFetcher(walletId) }

View file

@ -4,7 +4,7 @@ import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.cancelS
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.defaultLaunchIn
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.receiveTriggerInstance
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.retryThreeTimes
import com.tangem.data.txhistory.repository.ExpressHistoryRepository
import com.tangem.data.txhistory.repository.DefaultExpressHistoryRepository
import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
@ -31,7 +31,7 @@ internal class DefaultExpressTxHistoryFetcher @AssistedInject constructor(
@Assisted private val accountId: AccountId,
private val utils: TxHistoryFetcherUtils,
private val expressSyncStateDao: ExpressSyncStateDao,
private val expressHistoryRepository: ExpressHistoryRepository,
private val expressHistoryRepository: DefaultExpressHistoryRepository,
) : ExpressTxHistoryFetcher, TxHistoryFetcherUtils by utils {
private val userWalletId: UserWalletId get() = accountId.userWalletId

View file

@ -1,34 +1,46 @@
package com.tangem.data.txhistory.repository
import androidx.room.withTransaction
import com.tangem.data.common.txhistory.ExpressHistoryRepository
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntities
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntity
import com.tangem.data.txhistory.repository.factory.TokenInfoRepository
import com.tangem.data.txhistory.repository.factory.toAssetId
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
import com.tangem.datasource.api.express.models.response.ExpressPagination
import com.tangem.datasource.api.express.models.response.ExpressPaginationDelta
import com.tangem.datasource.api.express.models.response.*
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.local.converter.toEntity
import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.dao.HistoryIndexDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Fetches express (exchange & onramp) transaction history from the API and persists it into the local database.
*
* Fetches express (exchange & onramp) transaction history from the API, persists it into the local database, and
* fetches any missing token metadata for the referenced assets.
*/
internal class ExpressHistoryRepository @Inject constructor(
@Suppress("LongParameterList")
internal class DefaultExpressHistoryRepository @Inject constructor(
private val exchangeApi: TangemExpressApi,
private val onrampApi: OnrampApi,
private val expressHistoryDao: ExpressHistoryDao,
private val historyIndexDao: HistoryIndexDao,
private val expressSyncStateDao: ExpressSyncStateDao,
) {
private val tokenInfoRepository: TokenInfoRepository,
private val database: TxHistoryDatabase,
private val appScope: AppCoroutineScope,
) : ExpressHistoryRepository {
suspend fun fetchExchangeHistory(
fromAddress: String,
@ -44,7 +56,7 @@ internal class ExpressHistoryRepository @Inject constructor(
limit = limit,
).getOrThrow()
saveExchanges(ownerAddress = fromAddress, items = response.items)
storeExchanges(items = response.items)
persistHistoryState(
type = ExpressSyncStateEntity.Type.EXCHANGE,
address = fromAddress,
@ -68,7 +80,7 @@ internal class ExpressHistoryRepository @Inject constructor(
limit = limit,
).getOrThrow()
saveExchanges(ownerAddress = fromAddress, items = response.items)
storeExchanges(items = response.items)
persistDeltaState(
type = ExpressSyncStateEntity.Type.EXCHANGE,
address = fromAddress,
@ -91,7 +103,7 @@ internal class ExpressHistoryRepository @Inject constructor(
limit = limit,
).getOrThrow()
saveOnramps(ownerAddress = payoutAddress, items = response.items)
storeOnramps(items = response.items)
persistHistoryState(
type = ExpressSyncStateEntity.Type.ONRAMP,
address = payoutAddress,
@ -115,7 +127,7 @@ internal class ExpressHistoryRepository @Inject constructor(
limit = limit,
).getOrThrow()
saveOnramps(ownerAddress = payoutAddress, items = response.items)
storeOnramps(items = response.items)
persistDeltaState(
type = ExpressSyncStateEntity.Type.ONRAMP,
address = payoutAddress,
@ -124,16 +136,39 @@ internal class ExpressHistoryRepository @Inject constructor(
return response
}
override suspend fun storeExchanges(items: List<ExchangeItemResponse>) {
val entities = items.mapNotNull { it.toEntity() }
if (entities.isEmpty()) return
database.withTransaction {
expressHistoryDao.upsertExchanges(entities)
historyIndexDao.upsert(entities.flatMap { it.toHistoryIndexEntities() })
}
fetchMissingTokenInfo(
buildSet {
entities.forEach { entity ->
add(entity.from.toAssetId())
add(entity.to.toAssetId())
}
},
)
}
override suspend fun storeOnramps(items: List<OnrampItemResponse>) {
if (items.isEmpty()) return
val entities = items.map { it.toEntity() }
database.withTransaction {
expressHistoryDao.upsertOnramps(entities)
historyIndexDao.upsert(entities.map { it.toHistoryIndexEntity() })
}
fetchMissingTokenInfo(entities.mapTo(mutableSetOf()) { it.to.toAssetId() })
}
suspend fun syncState(type: ExpressSyncStateEntity.Type, address: String): ExpressSyncStateEntity? {
return expressSyncStateDao.observe(type = type.name, address = address).first()
}
private suspend fun saveExchanges(ownerAddress: String, items: List<ExchangeItemResponse>) {
expressHistoryDao.upsertExchanges(items.map { it.toEntity(ownerAddress) })
}
private suspend fun saveOnramps(ownerAddress: String, items: List<OnrampItemResponse>) {
expressHistoryDao.upsertOnramps(items.map { it.toEntity(ownerAddress) })
private fun fetchMissingTokenInfo(assetIds: Set<ExpressAsset.ID>) {
appScope.launch { tokenInfoRepository.fetchMissing(assetIds) }
}
private suspend fun persistHistoryState(

View file

@ -5,9 +5,16 @@ import com.tangem.data.common.converter.ExpressProviderConverter
import com.tangem.data.txhistory.repository.converter.ExpressStatusMapper
import com.tangem.data.txhistory.repository.converter.ExpressOnrampConverter
import com.tangem.data.txhistory.repository.converter.ExpressSwapConverter
import com.tangem.data.txhistory.repository.converter.OnrampCountryConverter
import com.tangem.data.txhistory.repository.factory.ExpressTransactionAssetFactory
import com.tangem.data.txhistory.repository.factory.toAssetId
import com.tangem.data.txhistory.repository.paging.TxHistoryPageBatchFetcher
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
@ -36,6 +43,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
private val walletManagersFacade: WalletManagersFacade,
private val txHistoryItemsStore: TxHistoryItemsStore,
private val expressHistoryDao: ExpressHistoryDao,
private val expressTransactionAssetFactory: ExpressTransactionAssetFactory,
private val cacheRegistry: CacheRegistry,
private val dispatchers: CoroutineDispatcherProvider,
) : TxHistoryRepositoryV2 {
@ -44,6 +52,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
private val expressProviderConverter = ExpressProviderConverter()
private val swapConverter = ExpressSwapConverter()
private val onrampConverter = ExpressOnrampConverter()
private val onrampCountryConverter = OnrampCountryConverter()
private val TxHistoryListConfig.storeKey get() = TxHistoryItemsStore.Key(userWalletId, currency)
override fun getExpressHistory(
@ -64,7 +73,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
val flow = combine(
flow = expressHistoryDao.observeOutgoingSwaps(
ownerAddress = address,
fromAddress = address,
network = rawNetwork,
contract = contract,
fromCreatedAtIso = fromCreatedAtIso,
@ -77,45 +86,87 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
activeStatuses = ExpressStatusMapper.activeExchangeStatuses,
).distinctUntilChanged(),
flow3 = expressHistoryDao.observeIncomingOnramps(
ownerAddress = address,
payoutAddress = address,
network = rawNetwork,
contract = contract,
fromCreatedAtIso = fromCreatedAtIso,
activeStatuses = ExpressStatusMapper.activeOnrampStatuses,
).distinctUntilChanged(),
flow4 = expressHistoryDao.getProvidersById().distinctUntilChanged(),
transform = { outgoingSwaps, incomingSwaps, onramps, providers ->
buildList<ExpressTx> {
fun String.expressProvider() = providers[this]?.let(expressProviderConverter::convert)
outgoingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
isOutgoing = true,
)
add(swapConverter.convert(input))
}
incomingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
isOutgoing = false,
)
add(swapConverter.convert(input))
}
onramps.forEach { entity ->
val input = ExpressOnrampConverter.Input(entity, entity.providerId.expressProvider())
add(onrampConverter.convert(input))
}
}
// An exchange row may satisfy both swap queries only in degenerate cases;
// keep the outgoing interpretation (added first).
.distinctBy { it.txId }
flow5 = expressHistoryDao.getCountriesByCode().distinctUntilChanged(),
transform = { outgoingSwaps, incomingSwaps, onramps, providers, countries ->
buildExpressHistory(
userWalletId = userWalletId,
sources = ExpressHistorySources(
outgoingSwaps = outgoingSwaps,
incomingSwaps = incomingSwaps,
onramps = onramps,
providers = providers,
countries = countries,
),
)
},
)
emitAll(flow)
}.flowOn(dispatchers.io)
/** The reactive express-history inputs gathered from the DB in a single [combine] tick. */
private data class ExpressHistorySources(
val outgoingSwaps: List<ExpressExchangeEntity>,
val incomingSwaps: List<ExpressExchangeEntity>,
val onramps: List<ExpressOnrampEntity>,
val providers: Map<String, ExpressProviderEntity>,
val countries: Map<String, OnrampCountryEntity>,
)
private suspend fun buildExpressHistory(
userWalletId: UserWalletId,
sources: ExpressHistorySources,
): List<ExpressTx> {
val currencies = expressTransactionAssetFactory.create(
userWalletId = userWalletId,
outgoingSwaps = sources.outgoingSwaps,
incomingSwaps = sources.incomingSwaps,
onramps = sources.onramps,
)
fun String.expressProvider() = sources.providers[this]?.let(expressProviderConverter::convert)
fun String.onrampCountry() = sources.countries[this]?.let(onrampCountryConverter::convert)
return buildList {
sources.outgoingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
isOutgoing = true,
fromCurrency = currencies[entity.from.toAssetId()],
toCurrency = currencies[entity.to.toAssetId()],
)
add(swapConverter.convert(input))
}
sources.incomingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
isOutgoing = false,
fromCurrency = currencies[entity.from.toAssetId()],
toCurrency = currencies[entity.to.toAssetId()],
)
add(swapConverter.convert(input))
}
sources.onramps.forEach { entity ->
val input = ExpressOnrampConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
toCurrency = currencies[entity.to.toAssetId()],
country = entity.countryCode.onrampCountry(),
)
add(onrampConverter.convert(input))
}
}
// An exchange row may satisfy both swap queries only in degenerate cases;
// keep the outgoing interpretation (added first).
.distinctBy { it.txId }
}
override fun getTxHistoryBatchFlow(batchSize: Int, context: TxHistoryListBatchingContext): TxHistoryListBatchFlow {
return BatchListSource(
fetchDispatcher = dispatchers.io,

View file

@ -9,6 +9,8 @@ import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.txhistory.model.ExpressTx
@ -26,7 +28,7 @@ import java.math.BigDecimal
internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, ExpressTx.Swap> {
override fun convert(value: Input): ExpressTx.Swap = ExpressTx.Swap(
tx = convertExchangeTransaction(value.entity, value.provider),
tx = convertExchangeTransaction(value),
isOutgoing = value.isOutgoing,
txInfo = null,
)
@ -35,6 +37,8 @@ internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, Expr
val entity: ExpressExchangeEntity,
val provider: ExpressProvider?,
val isOutgoing: Boolean,
val fromCurrency: CryptoCurrency? = null,
val toCurrency: CryptoCurrency? = null,
)
}
@ -49,49 +53,69 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
createdAtMillis = parseIsoMillis(entity.createdAt),
provider = value.provider,
payoutHash = entity.payoutHash,
payoutAddress = entity.payoutAddress,
fromFiat = Amount(
currencySymbol = entity.fromCurrencyCode,
value = entity.fromAmount.toBigDecimalOrZero(),
value = entity.fromAmount.toScaledBigDecimal(entity.fromPrecision),
decimals = entity.fromPrecision,
type = AmountType.FiatType(code = entity.fromCurrencyCode),
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
amount = (entity.to.actualAmount ?: entity.to.amount)?.toScaledBigDecimal(entity.to.decimals),
decimals = entity.to.decimals,
cryptoCurrency = value.toCurrency,
),
country = value.country,
externalTxUrl = entity.externalTxUrl,
),
txInfo = null,
)
}
data class Input(val entity: ExpressOnrampEntity, val provider: ExpressProvider?)
data class Input(
val entity: ExpressOnrampEntity,
val provider: ExpressProvider?,
val toCurrency: CryptoCurrency? = null,
val country: OnrampCountry? = null,
)
}
private fun convertExchangeTransaction(entity: ExpressExchangeEntity, provider: ExpressProvider?): ExchangeTransaction {
private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): ExchangeTransaction {
val entity = value.entity
return ExchangeTransaction(
txId = entity.txId,
status = ExpressExchangeStatus.fromRaw(entity.status),
createdAtMillis = parseIsoMillis(entity.createdAt),
provider = provider,
provider = value.provider,
payinHash = entity.payinHash,
payoutHash = entity.payoutHash,
fromAddress = entity.fromAddress,
payoutAddress = entity.payoutAddress,
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.from.network, contractAddress = entity.from.contractAddress),
amount = entity.from.amount.toBigDecimalOrZero(),
amount = entity.from.amount.toScaledBigDecimal(entity.from.decimals),
decimals = entity.from.decimals,
cryptoCurrency = value.fromCurrency,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
amount = (entity.to.actualAmount ?: entity.to.amount).toScaledBigDecimal(entity.to.decimals),
decimals = entity.to.decimals,
cryptoCurrency = value.toCurrency,
),
externalTxUrl = entity.externalTxUrl,
)
}
private fun parseIsoMillis(iso: String): Long = DateTime.parse(iso).millis
private fun String?.toBigDecimalOrZero(): BigDecimal = this?.toBigDecimalOrNull() ?: BigDecimal.ZERO
/**
* Parses a raw minimal-unit amount string from the express backend and scales it to the human-readable
* value promised by [ExpressTransactionAsset.amount] (and the onramp fiat [Amount.value]).
*/
private fun String.toScaledBigDecimal(decimals: Int): BigDecimal =
(this.toBigDecimalOrNull() ?: BigDecimal.ZERO).movePointLeft(decimals)
/**
* Active (non-terminal) RAW status values passed to the DAO `observe` queries so in-progress deals

View file

@ -0,0 +1,31 @@
package com.tangem.data.txhistory.repository.converter
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import org.joda.time.DateTime
/**
* Index rows for a swap: it shows on the from-token screen (outgoing, under `from_address`) and, when the payout lands
* on another owned token, on that screen too (incoming, under `payout_address`). The set de-duplicates the addresses so
* a swap whose from and payout addresses coincide is indexed once.
*/
internal fun ExpressExchangeEntity.toHistoryIndexEntities(): List<HistoryIndexEntity> {
val sortTimeMillis = DateTime.parse(createdAt).millis
return setOf(fromAddress, payoutAddress).map { address ->
HistoryIndexEntity(
type = HistoryIndexEntity.Type.EXCHANGE.value,
entityId = txId,
address = address,
sortTimeMillis = sortTimeMillis,
)
}
}
/** Onramp is always incoming: a single index row under the payout address. */
internal fun ExpressOnrampEntity.toHistoryIndexEntity(): HistoryIndexEntity = HistoryIndexEntity(
type = HistoryIndexEntity.Type.ONRAMP.value,
entityId = txId,
address = payoutAddress,
sortTimeMillis = DateTime.parse(createdAt).millis,
)

View file

@ -0,0 +1,29 @@
package com.tangem.data.txhistory.repository.converter
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.utils.converter.Converter
/** Maps a persisted [OnrampCountryEntity] into the domain [OnrampCountry]. */
internal class OnrampCountryConverter : Converter<OnrampCountryEntity, OnrampCountry> {
override fun convert(value: OnrampCountryEntity): OnrampCountry {
return OnrampCountry(
id = "${value.alpha3}-${value.name}",
name = value.name,
code = value.code,
image = value.image,
alpha3 = value.alpha3,
continent = value.continent,
defaultCurrency = OnrampCurrency(
name = value.defaultCurrency.name,
code = value.defaultCurrency.code,
image = value.defaultCurrency.image,
precision = value.defaultCurrency.precision,
unit = value.defaultCurrency.unit,
),
onrampAvailable = value.isOnrampAvailable,
)
}
}

View file

@ -0,0 +1,129 @@
package com.tangem.data.txhistory.repository.factory
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.first
import javax.inject.Inject
/**
* Resolves a [CryptoCurrency] for every express asset (network id + contract address) referenced by a batch of
* exchange/onramp entities.
*/
internal class ExpressTransactionAssetFactory @Inject constructor(
private val multiAccountListSupplier: MultiAccountListSupplier,
private val userWalletsListRepository: UserWalletsListRepository,
private val tokenInfoRepository: TokenInfoRepository,
excludedBlockchains: ExcludedBlockchains,
) {
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
/**
* Builds a `assetId -> resolved currency` map covering both legs of every swap and the to-leg of every onramp.
* Entries whose currency could not be resolved at all are omitted.
*/
suspend fun create(
userWalletId: UserWalletId,
outgoingSwaps: List<ExpressExchangeEntity>,
incomingSwaps: List<ExpressExchangeEntity>,
onramps: List<ExpressOnrampEntity>,
): Map<ExpressAsset.ID, CryptoCurrency> {
val assetIds = buildSet {
(outgoingSwaps + incomingSwaps).forEach { entity ->
add(entity.from.toAssetId())
add(entity.to.toAssetId())
}
onramps.forEach { entity -> add(entity.to.toAssetId()) }
}
if (assetIds.isEmpty()) return emptyMap()
val portfolioCurrencies = multiAccountListSupplier.invoke()
.first()
.flatMap { accountList -> accountList.flattenCurrencies() }
val userWallet = userWalletsListRepository.userWalletsSync()
.firstOrNull { it.walletId == userWalletId }
val cachedTokens = loadCachedTokens(assetIds)
return buildMap {
assetIds.forEach { id ->
val currency = portfolioCurrencies.findMatching(id) ?: resolveUnmatched(id, cachedTokens, userWallet)
if (currency != null) put(id, currency)
}
}
}
private fun resolveUnmatched(
id: ExpressAsset.ID,
cachedTokens: Map<String, TokenInfoEntity>,
userWallet: UserWallet?,
): CryptoCurrency? {
return if (id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE) {
createCoin(id, userWallet)
} else {
cachedTokens[cacheKey(id)]?.let { createToken(it, userWallet) }
}
}
private suspend fun loadCachedTokens(assetIds: Set<ExpressAsset.ID>): Map<String, TokenInfoEntity> =
tokenInfoRepository.getCached(assetIds).associateBy { cacheKey(it.networkId, it.contractAddress) }
private fun createToken(entity: TokenInfoEntity, userWallet: UserWallet?): CryptoCurrency.Token? {
userWallet ?: return null
return cryptoCurrencyFactory.createToken(
token = CryptoCurrencyFactory.Token(
name = entity.name,
symbol = entity.symbol,
contractAddress = entity.contractAddress,
decimals = entity.decimals,
id = entity.coinId,
),
networkId = entity.networkId,
extraDerivationPath = null,
userWallet = userWallet,
)
}
private fun cacheKey(id: ExpressAsset.ID): String = cacheKey(id.networkId, id.contractAddress)
private fun cacheKey(networkId: String, contractAddress: String): String =
"$networkId|${contractAddress.lowercase()}"
private fun List<CryptoCurrency>.findMatching(id: ExpressAsset.ID): CryptoCurrency? {
val isCoin = id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE
return firstOrNull { currency ->
currency.network.rawId == id.networkId &&
if (isCoin) {
currency is CryptoCurrency.Coin
} else {
currency is CryptoCurrency.Token &&
currency.contractAddress.equals(id.contractAddress, ignoreCase = true)
}
}
}
private fun createCoin(id: ExpressAsset.ID, userWallet: UserWallet?): CryptoCurrency.Coin? {
userWallet ?: return null
return cryptoCurrencyFactory.createCoin(
networkId = id.networkId,
extraDerivationPath = null,
userWallet = userWallet,
)
}
}
internal fun ExpressExchangeEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)
internal fun ExpressOnrampEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)

View file

@ -0,0 +1,134 @@
package com.tangem.data.txhistory.repository.factory
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
/**
* Owns the cached token metadata (from [TangemTechApi.getCoins]) so [ExpressTransactionAssetFactory] can resolve
* tokens that are not in any user portfolio. [fetchMissing] populates the cache; [getCached] reads it back.
*/
internal class TokenInfoRepository @Inject constructor(
private val tangemTechApi: TangemTechApi,
private val tokenInfoDao: TokenInfoDao,
private val multiAccountListSupplier: MultiAccountListSupplier,
private val dispatchers: CoroutineDispatcherProvider,
) {
/** Cached token infos for the token assets among [assetIds] (coins are skipped). */
suspend fun getCached(assetIds: Set<ExpressAsset.ID>): List<TokenInfoEntity> = withContext(dispatchers.io) {
val networkIds = mutableSetOf<String>()
val contracts = mutableSetOf<String>()
assetIds.forEach { id ->
if (id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE) return@forEach
networkIds += id.networkId
contracts += id.contractAddress
}
if (networkIds.isEmpty()) emptyList() else tokenInfoDao.getCached(networkIds, contracts)
}
/** Fetches and caches metadata for token assets that are stale/missing in the cache and not already owned. */
suspend fun fetchMissing(assetIds: Set<ExpressAsset.ID>) = withContext(dispatchers.io) {
// Coins resolve without the catalog — only tokens are looked up.
val tokenIds = mutableListOf<ExpressAsset.ID>()
val networkIds = mutableSetOf<String>()
val contracts = mutableSetOf<String>()
assetIds.forEach { id ->
if (id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE) return@forEach
tokenIds += id
networkIds += id.networkId
contracts += id.contractAddress
}
if (tokenIds.isEmpty()) return@withContext
val now = System.currentTimeMillis()
val freshKeys = tokenInfoDao.getCached(
networkIds = networkIds,
contractAddresses = contracts,
minUpdatedAt = now - CACHE_TTL_MILLIS,
).mapTo(hashSetOf()) { cacheKey(it.networkId, it.contractAddress) }
val notCached = tokenIds.filterNot { cacheKey(it.networkId, it.contractAddress) in freshKeys }
if (notCached.isEmpty()) return@withContext
// Tokens already present in any portfolio don't need a catalog fetch.
val portfolioKeys = portfolioTokenKeys()
val unresolved = notCached.filterNot { cacheKey(it.networkId, it.contractAddress) in portfolioKeys }
if (unresolved.isEmpty()) return@withContext
val entities = fetchTokenInfos(unresolved, now)
if (entities.isNotEmpty()) tokenInfoDao.upsert(entities)
}
/** Cache keys of every token owned across all wallets. */
private suspend fun portfolioTokenKeys(): Set<String> = buildSet {
multiAccountListSupplier.invoke().first().forEach { accountList ->
accountList.flattenCurrencies().forEach { currency ->
if (currency is CryptoCurrency.Token) {
add(cacheKey(currency.network.rawId, currency.contractAddress))
}
}
}
}
private suspend fun fetchTokenInfos(ids: List<ExpressAsset.ID>, timestamp: Long): List<TokenInfoEntity> {
val requestedPairs = mutableSetOf<String>()
val networkIds = mutableSetOf<String>()
val contracts = mutableSetOf<String>()
ids.forEach { id ->
requestedPairs += cacheKey(id.networkId, id.contractAddress)
networkIds += id.networkId
contracts += id.contractAddress
}
return try {
// getCoins returns a cross-product of the queried networks×contracts, so the response is filtered back
// to the exact requested pairs.
val coins = tangemTechApi.getCoins(
networkIds = networkIds.joinToString(separator = ","),
contractAddresses = contracts.joinToString(separator = ","),
active = true,
).getOrThrow().coins
coins.flatMap { coin ->
coin.networks.mapNotNull { network ->
val contract = network.contractAddress
val decimals = network.decimalCount?.toInt()
if (contract == null || decimals == null ||
cacheKey(network.networkId, contract) !in requestedPairs
) {
return@mapNotNull null
}
TokenInfoEntity(
networkId = network.networkId,
contractAddress = contract,
coinId = coin.id,
name = coin.name,
symbol = coin.symbol,
decimals = decimals,
updatedAt = timestamp,
)
}
}
} catch (e: Throwable) {
TangemLogger.w("Failed to fetch token info for ${ids.size} express assets", e)
emptyList()
}
}
private fun cacheKey(networkId: String, contractAddress: String): String =
"$networkId|${contractAddress.lowercase()}"
private companion object {
val CACHE_TTL_MILLIS: Long = TimeUnit.DAYS.toMillis(10)
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
@ -14,7 +15,6 @@ import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.job
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.BeforeEach
@ -29,14 +29,16 @@ internal class DefaultAppTxHistoryFetcherTest {
private val selectedWalletUseCase: GetSelectedWalletUseCase = mockk()
private val walletFetcherFactory: DefaultWalletTxHistoryFetcher.Factory = mockk()
private val expressRepository: ExpressRepository = mockk()
private val onrampRepository: OnrampRepository = mockk()
private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
@BeforeEach
fun setup() {
clearMocks(getWalletsUseCase, selectedWalletUseCase, walletFetcherFactory, expressRepository)
clearMocks(getWalletsUseCase, selectedWalletUseCase, walletFetcherFactory, expressRepository, onrampRepository)
every { selectedWalletUseCase.selectedFlow() } returns emptyFlow()
coEvery { expressRepository.getProviders(any(), any()) } returns emptyList()
coEvery { onrampRepository.fetchCountries(any()) } returns emptyList()
}
@Test
@ -147,15 +149,16 @@ internal class DefaultAppTxHistoryFetcherTest {
}
@Test
fun `loads express providers when selected wallet is multi-currency`() = runTest {
fun `loads express providers and onramp countries for the first wallet on init`() = runTest {
// Arrange
val utils = createUtils()
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns MutableStateFlow(linkedMapOf())
val wallet = mockk<UserWallet.Cold>(relaxed = true) {
every { isMultiCurrency } returns true
every { walletId } returns WALLET_ID_1
}
every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet)
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns
MutableStateFlow(linkedMapOf(WALLET_ID_1 to wallet))
every { walletFetcherFactory.create(WALLET_ID_1) } returns relaxedWalletFetcher()
// Act
createFetcher(utils)
@ -163,48 +166,40 @@ internal class DefaultAppTxHistoryFetcherTest {
// Assert
coVerify(exactly = 1) { expressRepository.getProviders(wallet, emptyList()) }
coVerify(exactly = 1) { onrampRepository.fetchCountries(wallet) }
}
@Test
fun `loads express providers only once per wallet`() = runTest {
fun `does not load express data when there are no wallets`() = runTest {
// Arrange
val utils = createUtils()
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns MutableStateFlow(linkedMapOf())
val wallet = mockk<UserWallet.Cold>(relaxed = true) {
every { isMultiCurrency } returns true
every { walletId } returns WALLET_ID_1
}
// Same wallet selected several times.
every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet, wallet, wallet)
// Act
createFetcher(utils)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { expressRepository.getProviders(wallet, emptyList()) }
coVerify(inverse = true) { expressRepository.getProviders(any(), any()) }
coVerify(inverse = true) { onrampRepository.fetchCountries(any()) }
}
@Test
fun `provider loading failure does not break the wallet pipeline`() = runTest {
// Arrange
val utils = createUtils()
val walletsFlow = MutableStateFlow(linkedMapOf<UserWalletId, UserWallet>())
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns walletsFlow
val wallet = mockk<UserWallet.Cold>(relaxed = true) {
every { isMultiCurrency } returns true
every { walletId } returns WALLET_ID_1
}
every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet)
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns
MutableStateFlow(linkedMapOf(WALLET_ID_1 to wallet))
coEvery { expressRepository.getProviders(any(), any()) } throws RuntimeException("boom")
val walletFetcher1 = relaxedWalletFetcher()
every { walletFetcherFactory.create(WALLET_ID_1) } returns walletFetcher1
val fetcher = createFetcher(utils)
advanceUntilIdle()
// Act — the provider error is swallowed, so the wallet pipeline must keep working.
walletsFlow.value = linkedMapOf(WALLET_ID_1 to mockk())
val fetcher = createFetcher(utils)
advanceUntilIdle()
// Assert
@ -220,6 +215,7 @@ internal class DefaultAppTxHistoryFetcherTest {
private fun createFetcher(utils: DefaultTxHistoryFetcherUtils) = DefaultAppTxHistoryFetcher(
utils = utils,
expressRepository = expressRepository,
onrampRepository = onrampRepository,
getWalletsUseCase = getWalletsUseCase,
selectedWalletUseCase = selectedWalletUseCase,
walletTxHistoryFetcherFactory = walletFetcherFactory,

View file

@ -3,7 +3,7 @@ package com.tangem.data.txhistory.fetcher
import com.google.common.truth.Truth.assertThat
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.txhistory.repository.ExpressHistoryRepository
import com.tangem.data.txhistory.repository.DefaultExpressHistoryRepository
import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
import com.tangem.datasource.api.express.models.response.ExpressPagination
@ -30,7 +30,7 @@ import org.junit.jupiter.api.TestInstance
internal class DefaultExpressTxHistoryFetcherTest {
private val expressSyncStateDao: ExpressSyncStateDao = mockk()
private val expressHistoryRepository: ExpressHistoryRepository = mockk()
private val expressHistoryRepository: DefaultExpressHistoryRepository = mockk()
private val coin: CryptoCurrency = MockCryptoCurrencyFactory().ethereum

View file

@ -1,7 +1,10 @@
package com.tangem.data.txhistory.repository
import androidx.room.withTransaction
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.converter.toEntity
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntities
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntity
import com.tangem.data.txhistory.repository.factory.TokenInfoRepository
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.express.TangemExpressApi
@ -14,40 +17,65 @@ import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.local.converter.toEntity
import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.dao.HistoryIndexDao
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.TestAppCoroutineScope
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ExpressHistoryRepositoryTest {
internal class DefaultExpressHistoryRepositoryTest {
private val exchangeApi: TangemExpressApi = mockk()
private val onrampApi: OnrampApi = mockk()
private val expressHistoryDao: ExpressHistoryDao = mockk(relaxUnitFun = true)
private val historyIndexDao: HistoryIndexDao = mockk(relaxUnitFun = true)
private val expressSyncStateDao: ExpressSyncStateDao = mockk(relaxUnitFun = true)
private val tokenInfoRepository: TokenInfoRepository = mockk(relaxUnitFun = true)
private val repository = ExpressHistoryRepository(
private val database: TxHistoryDatabase = mockk()
private val repository = DefaultExpressHistoryRepository(
exchangeApi = exchangeApi,
onrampApi = onrampApi,
expressHistoryDao = expressHistoryDao,
historyIndexDao = historyIndexDao,
expressSyncStateDao = expressSyncStateDao,
tokenInfoRepository = tokenInfoRepository,
database = database,
appScope = TestAppCoroutineScope(),
)
@BeforeEach
fun setup() {
clearMocks(exchangeApi, onrampApi, expressHistoryDao, expressSyncStateDao)
clearMocks(exchangeApi, onrampApi, expressHistoryDao, historyIndexDao, expressSyncStateDao, tokenInfoRepository)
// Run the withTransaction block inline so the DAO writes inside it actually happen and can be verified.
mockkStatic("androidx.room.RoomDatabaseKt")
val block = slot<suspend () -> Any?>()
coEvery { database.withTransaction(capture(block)) } coAnswers { block.captured.invoke() }
}
@AfterEach
fun tearDown() {
unmockkStatic("androidx.room.RoomDatabaseKt")
}
// region exchange history
@ -70,7 +98,7 @@ internal class ExpressHistoryRepositoryTest {
coVerify(exactly = 1) {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOfNotNull(item.toEntity())) }
}
@Test
@ -91,24 +119,6 @@ internal class ExpressHistoryRepositoryTest {
}
}
@Test
fun `GIVEN custom limit WHEN fetchExchangeHistory THEN forwards limit to api`() = runTest {
// GIVEN
val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response)
// WHEN
repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID, limit = 25)
// THEN
coVerify(exactly = 1) {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = 25)
}
}
@Test
fun `GIVEN api error WHEN fetchExchangeHistory THEN throws and does not persist`() = runTest {
// GIVEN
@ -144,7 +154,7 @@ internal class ExpressHistoryRepositoryTest {
coVerify(exactly = 1) {
exchangeApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOfNotNull(item.toEntity())) }
}
// endregion
@ -169,25 +179,7 @@ internal class ExpressHistoryRepositoryTest {
coVerify(exactly = 1) {
onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
}
@Test
fun `GIVEN no sync state WHEN fetchOnrampHistory THEN passes null cursor`() = runTest {
// GIVEN
val response = OnrampHistoryResponse(items = emptyList(), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, state = null)
coEvery {
onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = null, limit = any())
} returns ApiResponse.Success(response)
// WHEN
repository.fetchOnrampHistory(payoutAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN
coVerify(exactly = 1) {
onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = null, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity())) }
}
@Test
@ -208,7 +200,7 @@ internal class ExpressHistoryRepositoryTest {
coVerify(exactly = 1) {
onrampApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity())) }
}
@Test
@ -230,42 +222,70 @@ internal class ExpressHistoryRepositoryTest {
// endregion
// region syncState
// region store
@Test
fun `GIVEN stored sync state WHEN syncState THEN returns first emitted value`() = runTest {
fun `GIVEN exchanges WHEN storeExchanges THEN persists entities and fetches missing info for both legs`() = runTest {
// GIVEN
val state = syncState(afterCursor = AFTER_CURSOR, deltaCursor = DELTA_CURSOR)
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, state)
val item = createExchangeItem()
// WHEN
val result = repository.syncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS)
repository.storeExchanges(items = listOf(item))
// THEN
assertThat(result).isEqualTo(state)
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOfNotNull(item.toEntity())) }
coVerify(exactly = 1) { historyIndexDao.upsert(item.toEntity()!!.toHistoryIndexEntities()) }
coVerify(exactly = 1) {
tokenInfoRepository.fetchMissing(
setOf(
ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xfromContract"),
ExpressAsset.ID(networkId = "bitcoin", contractAddress = "0xtoContract"),
),
)
}
}
@Test
fun `GIVEN multiple items WHEN fetchExchangeHistory THEN maps every item with owner address`() = runTest {
fun `GIVEN onramps WHEN storeOnramps THEN persists entities and fetches missing info for to-leg`() = runTest {
// GIVEN
val items = listOf(
createExchangeItem(txId = "tx-1"),
createExchangeItem(txId = "tx-2"),
)
val response = ExchangeHistoryResponse(items = items, pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response)
val saved = slot<List<ExpressExchangeEntity>>()
coEvery { expressHistoryDao.upsertExchanges(capture(saved)) } returns Unit
val item = createOnrampItem()
// WHEN
repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID)
repository.storeOnramps(items = listOf(item))
// THEN
assertThat(saved.captured).isEqualTo(items.map { it.toEntity(ADDRESS) })
assertThat(saved.captured.map { it.ownerAddress }.toSet()).containsExactly(ADDRESS)
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity())) }
coVerify(exactly = 1) { historyIndexDao.upsert(listOf(item.toEntity().toHistoryIndexEntity())) }
coVerify(exactly = 1) {
tokenInfoRepository.fetchMissing(setOf(ExpressAsset.ID(networkId = "bitcoin", contractAddress = "0xtoContract")))
}
}
@Test
fun `GIVEN empty items WHEN store THEN does nothing`() = runTest {
// WHEN
repository.storeExchanges(items = emptyList())
repository.storeOnramps(items = emptyList())
// THEN
coVerify(exactly = 0) { expressHistoryDao.upsertExchanges(any()) }
coVerify(exactly = 0) { expressHistoryDao.upsertOnramps(any()) }
coVerify(exactly = 0) { historyIndexDao.upsert(any<List<HistoryIndexEntity>>()) }
coVerify(exactly = 0) { tokenInfoRepository.fetchMissing(any()) }
}
@Test
fun `GIVEN exchange with null fromAddress WHEN storeExchanges THEN it is skipped`() = runTest {
// GIVEN
val item = createExchangeItem().copy(fromAddress = null)
// WHEN
repository.storeExchanges(items = listOf(item))
// THEN
coVerify(exactly = 0) { expressHistoryDao.upsertExchanges(any()) }
coVerify(exactly = 0) { historyIndexDao.upsert(any<List<HistoryIndexEntity>>()) }
coVerify(exactly = 0) { tokenInfoRepository.fetchMissing(any()) }
}
// endregion
@ -313,8 +333,6 @@ internal class ExpressHistoryRepositoryTest {
refundNetwork = null,
refundContractAddress = null,
createdAt = "2026-06-01T00:00:00Z",
// todo txHistory uncomment
// updatedAt = "2026-06-01T00:05:00Z",
payTill = null,
averageDuration = null,
fromContractAddress = "0xfromContract",
@ -338,8 +356,6 @@ internal class ExpressHistoryRepositoryTest {
externalTxUrl = null,
payoutHash = "payout-hash",
createdAt = "2026-06-01T00:00:00Z",
// todo txHistory uncomment
// updatedAt = "2026-06-01T00:05:00Z",
fromCurrencyCode = "USD",
fromAmount = "100.0",
fromPrecision = 2,

View file

@ -31,8 +31,9 @@ internal class ExpressTxHistoryConverterTest {
assertThat(swap.txInfo).isNull()
assertThat(swap.tx.status).isEqualTo(ExpressExchangeStatus.Waiting)
assertThat(swap.createdAtMillis).isEqualTo(DateTime.parse(CREATED_AT).millis)
assertThat(swap.tx.fromAsset.amount).isEqualTo(BigDecimal("1.5"))
assertThat(swap.tx.toAsset.amount).isEqualTo(BigDecimal("0.001"))
// Raw backend amounts are scaled by decimals into the human-readable value the domain model promises.
assertThat(swap.tx.fromAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("1.5"))
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.001"))
}
@Test
@ -50,14 +51,14 @@ internal class ExpressTxHistoryConverterTest {
@Test
fun `GIVEN exchange entity with actual amount WHEN toOutgoingSwap THEN to-asset uses actual amount`() {
// Arrange
val entity = createExchangeEntity(toAmount = "0.001", toActualAmount = "0.00099")
// Arrange (raw minimal-unit amounts, to-asset decimals = 8)
val entity = createExchangeEntity(toAmount = "100000", toActualAmount = "99000")
// Act
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = true))
// Assert
assertThat(swap.tx.toAsset.amount).isEqualTo(BigDecimal("0.00099"))
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.00099"))
}
@Test
@ -73,21 +74,21 @@ internal class ExpressTxHistoryConverterTest {
assertThat(onramp.txInfo).isNull()
assertThat(onramp.tx.status).isEqualTo(ExpressOnrampStatus.Finished)
assertThat(onramp.tx.fromFiat.currencySymbol).isEqualTo("USD")
assertThat(onramp.tx.fromFiat.value).isEqualTo(BigDecimal("100.0"))
assertThat(onramp.tx.fromFiat.value).isEquivalentAccordingToCompareTo(BigDecimal("100"))
assertThat(onramp.tx.fromFiat.decimals).isEqualTo(2)
assertThat(onramp.tx.fromFiat.type).isEqualTo(AmountType.FiatType("USD"))
assertThat(onramp.tx.toAsset.amount).isEqualTo(BigDecimal("0.5"))
assertThat(onramp.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.5"))
}
private fun createExchangeEntity(
payinHash: String? = "payin",
payoutHash: String? = "payout",
status: String = "waiting",
toAmount: String = "0.001",
// Raw minimal-unit amount (to-asset decimals = 8) → 0.001
toAmount: String = "100000",
toActualAmount: String? = null,
) = ExpressExchangeEntity(
txId = "tx-1",
ownerAddress = "owner",
providerId = "provider",
fromAddress = "owner",
payinAddress = "payin-addr",
@ -111,7 +112,8 @@ internal class ExpressTxHistoryConverterTest {
contractAddress = "",
network = "ethereum",
decimals = 18,
amount = "1.5",
// Raw minimal-unit amount (decimals = 18) → 1.5
amount = "1500000000000000000",
actualAmount = null,
),
to = ExpressExchangeEntity.AssetEmbedded(
@ -128,7 +130,6 @@ internal class ExpressTxHistoryConverterTest {
status: String = "finished",
) = ExpressOnrampEntity(
txId = "onramp-1",
ownerAddress = "owner",
providerId = "provider",
payoutAddress = "owner",
status = status,
@ -139,13 +140,15 @@ internal class ExpressTxHistoryConverterTest {
createdAt = CREATED_AT,
updatedAt = CREATED_AT,
fromCurrencyCode = "USD",
fromAmount = "100.0",
// Raw minimal-unit fiat amount (precision = 2) → 100
fromAmount = "10000",
fromPrecision = 2,
to = ExpressOnrampEntity.AssetEmbedded(
contractAddress = "0xtoken",
network = "ethereum",
decimals = 18,
amount = "0.5",
// Raw minimal-unit amount (decimals = 18) → 0.5
amount = "500000000000000000",
actualAmount = null,
),
paymentMethod = "card",

View file

@ -0,0 +1,97 @@
package com.tangem.data.txhistory.repository.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import io.mockk.every
import io.mockk.mockk
import org.joda.time.DateTime
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Verifies the indexing rules that build [HistoryIndexEntity] rows: a swap is indexed under both its from- and
* into the sort time.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class HistoryIndexConverterTest {
@Test
fun `GIVEN swap with distinct from and payout WHEN indexed THEN one row per address`() {
// Arrange
val entity = exchange(txId = "tx1", from = "addrA", payout = "addrB", createdAt = CREATED_AT)
// Act
val rows = entity.toHistoryIndexEntities()
// Assert
assertThat(rows).containsExactly(
indexRow(HistoryIndexEntity.Type.EXCHANGE, "tx1", "addrA"),
indexRow(HistoryIndexEntity.Type.EXCHANGE, "tx1", "addrB"),
)
}
@Test
fun `GIVEN swap with equal from and payout WHEN indexed THEN de-duplicated to one row`() {
// Arrange
val entity = exchange(txId = "tx1", from = "same", payout = "same", createdAt = CREATED_AT)
// Act
val rows = entity.toHistoryIndexEntities()
// Assert
assertThat(rows).containsExactly(indexRow(HistoryIndexEntity.Type.EXCHANGE, "tx1", "same"))
}
@Test
fun `GIVEN onramp WHEN indexed THEN single row under payout address`() {
// Arrange
val entity = onramp(txId = "tx2", payout = "addrP", createdAt = CREATED_AT)
// Act
val row = entity.toHistoryIndexEntity()
// Assert
assertThat(row).isEqualTo(indexRow(HistoryIndexEntity.Type.ONRAMP, "tx2", "addrP"))
}
@Test
fun `GIVEN ISO-8601 created_at WHEN indexed THEN parsed into sort time millis`() {
// Arrange
val entity = onramp(txId = "tx2", payout = "addrP", createdAt = "2026-07-03T10:00:00.000Z")
// Act
val row = entity.toHistoryIndexEntity()
// Assert
assertThat(row.sortTimeMillis).isEqualTo(DateTime.parse("2026-07-03T10:00:00.000Z").millis)
}
private fun exchange(txId: String, from: String, payout: String, createdAt: String) =
mockk<ExpressExchangeEntity> {
every { this@mockk.txId } returns txId
every { fromAddress } returns from
every { payoutAddress } returns payout
every { this@mockk.createdAt } returns createdAt
}
private fun onramp(txId: String, payout: String, createdAt: String) =
mockk<ExpressOnrampEntity> {
every { this@mockk.txId } returns txId
every { payoutAddress } returns payout
every { this@mockk.createdAt } returns createdAt
}
private fun indexRow(type: HistoryIndexEntity.Type, txId: String, address: String) = HistoryIndexEntity(
type = type.value,
entityId = txId,
address = address,
sortTimeMillis = DateTime.parse(CREATED_AT).millis,
)
private companion object {
const val CREATED_AT = "2026-07-03T10:00:00.000Z"
}
}

View file

@ -0,0 +1,129 @@
package com.tangem.data.txhistory.repository.factory
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ExpressTransactionAssetFactoryTest {
private val tokenInfoRepository: TokenInfoRepository = mockk()
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val userWallet = MockUserWalletFactory.create()
private val factory = ExpressTransactionAssetFactory(
multiAccountListSupplier = multiAccountListSupplier,
userWalletsListRepository = userWalletsListRepository,
tokenInfoRepository = tokenInfoRepository,
excludedBlockchains = ExcludedBlockchains(),
)
@BeforeEach
fun setup() {
clearMocks(tokenInfoRepository, multiAccountListSupplier, userWalletsListRepository)
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
coEvery { tokenInfoRepository.getCached(any()) } returns emptyList()
}
@Test
fun `GIVEN token only in cache WHEN create THEN builds token from cache`() = runTest {
// Arrange
coEvery { tokenInfoRepository.getCached(any()) } returns listOf(
TokenInfoEntity(
networkId = "ethereum",
contractAddress = TOKEN_CONTRACT,
coinId = "tether",
name = "Tether",
symbol = "USDT",
decimals = 6,
updatedAt = 0,
),
)
// Act
val result = factory.create(userWallet.walletId, listOf(coinToTokenSwap()), emptyList(), emptyList())
// Assert
val token = result[ExpressAsset.ID(networkId = "ethereum", contractAddress = TOKEN_CONTRACT)]
assertThat(token).isInstanceOf(CryptoCurrency.Token::class.java)
assertThat((token as CryptoCurrency.Token).symbol).isEqualTo("USDT")
}
@Test
fun `GIVEN coin asset WHEN create THEN builds coin`() = runTest {
// Act
val result = factory.create(userWallet.walletId, listOf(coinToTokenSwap()), emptyList(), emptyList())
// Assert
val coin = result[ExpressAsset.ID(networkId = "ethereum", contractAddress = ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE)]
assertThat(coin).isInstanceOf(CryptoCurrency.Coin::class.java)
}
@Test
fun `GIVEN token absent from portfolio and cache WHEN create THEN omits it`() = runTest {
// Act
val result = factory.create(userWallet.walletId, listOf(coinToTokenSwap()), emptyList(), emptyList())
// Assert
assertThat(result).doesNotContainKey(ExpressAsset.ID(networkId = "ethereum", contractAddress = TOKEN_CONTRACT))
}
/** A swap from a native coin (empty contract) to an Ethereum token. */
private fun coinToTokenSwap() = ExpressExchangeEntity(
txId = "tx-1",
providerId = "provider",
fromAddress = "owner",
payinAddress = "payin-addr",
payinExtraId = null,
payoutAddress = "payout-addr",
refundAddress = null,
refundExtraId = null,
rateType = "float",
status = "finished",
externalTxId = null,
externalTxUrl = null,
payinHash = "payin",
payoutHash = "payout",
refundNetwork = null,
refundContractAddress = null,
createdAt = "2026-06-01T00:00:00Z",
updatedAt = "2026-06-01T00:00:00Z",
payTill = null,
averageDuration = null,
from = ExpressExchangeEntity.AssetEmbedded(
contractAddress = ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE,
network = "ethereum",
decimals = 18,
amount = "1.0",
actualAmount = null,
),
to = ExpressExchangeEntity.AssetEmbedded(
contractAddress = TOKEN_CONTRACT,
network = "ethereum",
decimals = 6,
amount = "100.0",
actualAmount = null,
),
)
private companion object {
const val TOKEN_CONTRACT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
}
}

View file

@ -0,0 +1,216 @@
package com.tangem.data.txhistory.repository.factory
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TokenInfoRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()
private val tokenInfoDao: TokenInfoDao = mockk(relaxUnitFun = true)
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
private val repository = TokenInfoRepository(
tangemTechApi = tangemTechApi,
tokenInfoDao = tokenInfoDao,
multiAccountListSupplier = multiAccountListSupplier,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun setup() {
clearMocks(tangemTechApi, tokenInfoDao, multiAccountListSupplier)
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListOf()))
}
@Test
fun `GIVEN only coins WHEN fetchMissing THEN nothing is read or fetched`() = runTest {
// Act
repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = COIN_CONTRACT)))
// Assert
coVerify(exactly = 0) { tokenInfoDao.getCached(any(), any(), any()) }
coVerify(exactly = 0) { tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any()) }
coVerify(exactly = 0) { tokenInfoDao.upsert(any()) }
}
@Test
fun `GIVEN token already fresh in cache WHEN fetchMissing THEN does not fetch`() = runTest {
// Arrange
coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns listOf(
tokenInfoEntity(networkId = "ethereum", contractAddress = "0xUSDT"),
)
// Act
repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xUSDT")))
// Assert
coVerify(exactly = 0) { tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any()) }
coVerify(exactly = 0) { tokenInfoDao.upsert(any()) }
}
@Test
fun `GIVEN token already in portfolio WHEN fetchMissing THEN does not fetch`() = runTest {
// Arrange
coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns emptyList()
val portfolioToken = MockCryptoCurrencyFactory().createToken(
blockchain = Blockchain.Ethereum,
contractAddress = "0xPortfolioToken",
)
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListOf(portfolioToken)))
// Act — same contract, different casing
repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0XPORTFOLIOTOKEN")))
// Assert
coVerify(exactly = 0) { tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any()) }
coVerify(exactly = 0) { tokenInfoDao.upsert(any()) }
}
@Test
fun `GIVEN unresolved token WHEN fetchMissing THEN fetches and caches it`() = runTest {
// Arrange
coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns emptyList()
coEvery {
tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any())
} returns ApiResponse.Success(
coinsResponse(coin(network("ethereum", "0xUSDT", BigDecimal(6)))),
)
val saved = slot<List<TokenInfoEntity>>()
coEvery { tokenInfoDao.upsert(capture(saved)) } returns Unit
// Act
repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xUSDT")))
// Assert
val entity = saved.captured.single()
assertThat(entity.copy(updatedAt = 0)).isEqualTo(
TokenInfoEntity(
networkId = "ethereum",
contractAddress = "0xUSDT",
coinId = COIN_ID,
name = COIN_NAME,
symbol = COIN_SYMBOL,
decimals = 6,
updatedAt = 0,
),
)
}
@Test
fun `GIVEN response with extra networks WHEN fetchMissing THEN keeps only requested pairs ignoring case`() = runTest {
// Arrange
coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns emptyList()
coEvery {
tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any())
} returns ApiResponse.Success(
coinsResponse(
coin(
network("ethereum", "0xabc", BigDecimal(6)), // requested (different case)
network("bsc", "0xabc", BigDecimal(18)), // other network — not requested
network("ethereum", "0xother", null), // missing decimals — dropped
),
),
)
val saved = slot<List<TokenInfoEntity>>()
coEvery { tokenInfoDao.upsert(capture(saved)) } returns Unit
// Act
repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xAbC")))
// Assert
assertThat(saved.captured.map { it.networkId to it.contractAddress })
.containsExactly("ethereum" to "0xabc")
}
@Test
fun `GIVEN getCoins fails WHEN fetchMissing THEN nothing is cached`() = runTest {
// Arrange
coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns emptyList()
coEvery {
tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any())
} returns ApiResponse.Error(
ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.INTERNAL_SERVER_ERROR,
message = "boom",
errorBody = null,
),
).cast()
// Act
repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xUSDT")))
// Assert
coVerify(exactly = 0) { tokenInfoDao.upsert(any()) }
}
private fun accountListOf(vararg currencies: CryptoCurrency): AccountList = mockk {
every { flattenCurrencies() } returns currencies.toList()
}
private fun tokenInfoEntity(networkId: String, contractAddress: String) = TokenInfoEntity(
networkId = networkId,
contractAddress = contractAddress,
coinId = COIN_ID,
name = COIN_NAME,
symbol = COIN_SYMBOL,
decimals = 6,
updatedAt = 0,
)
private fun coinsResponse(vararg coins: CoinsResponse.Coin) = CoinsResponse(
imageHost = null,
coins = coins.toList(),
total = coins.size,
)
private fun coin(vararg networks: CoinsResponse.Coin.Network) = CoinsResponse.Coin(
id = COIN_ID,
name = COIN_NAME,
symbol = COIN_SYMBOL,
active = true,
networks = networks.toList(),
)
private fun network(networkId: String, contractAddress: String?, decimalCount: BigDecimal?) =
CoinsResponse.Coin.Network(
networkId = networkId,
contractAddress = contractAddress,
decimalCount = decimalCount,
exchangeable = true,
)
@Suppress("UNCHECKED_CAST")
private fun <T : Any> ApiResponse.Error.cast(): ApiResponse<T> = this as ApiResponse<T>
private companion object {
const val COIN_CONTRACT = "0"
const val COIN_ID = "tether"
const val COIN_NAME = "Tether"
const val COIN_SYMBOL = "USDT"
}
}

View file

@ -1,13 +1,9 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.ksp)
id("configuration")
}
android {
namespace = "com.tangem.data.virtualaccount"
}
dependencies {
}
}

View file

@ -23,61 +23,82 @@ android {
}
dependencies {
/** Project - Data */
implementation(projects.core.analytics)
implementation(projects.core.datasource)
implementation(projects.core.error)
implementation(projects.core.error.ext)
implementation(projects.core.security)
implementation(projects.data.common)
implementation(projects.data.wallets)
/** Project - Domain */
implementation(projects.domain.visa)
implementation(projects.domain.card)
implementation(projects.domain.wallets)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.tokens)
implementation(projects.domain.networks)
implementation(projects.domain.walletManager)
implementation(projects.domain.quotes)
implementation(projects.domain.common)
implementation(projects.features.swap.domain)
/** Project - Utils */
implementation(projects.core.utils)
implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk)
/** Project - Libs */
implementation(projects.libs.visa)
/** Libs - Other */
implementation(deps.androidx.datastore)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Other libraries
api(deps.moshi)
implementation(deps.androidx.datastore)
implementation(deps.androidx.paging.runtime)
implementation(deps.arrow.core)
implementation(deps.arrow.fx)
implementation(deps.jodatime)
implementation(deps.androidx.paging.runtime)
implementation(deps.moshi.kotlin)
implementation(deps.okio)
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
/** Libs - Tangem */
// region Libs - Tangem
api(tangemDeps.hot.core)
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
implementation(tangemDeps.hot.core)
implementation(projects.libs.tangemSdkApi)
// endregion
/** DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Test */
// region Project - Core
api(projects.core.analytics)
api(projects.core.datasource)
api(projects.core.security)
api(projects.core.utils)
implementation(projects.core.analytics.models)
implementation(projects.core.error)
implementation(projects.core.error.ext)
implementation(projects.core.pagination)
// endregion
// region Project - Data
api(projects.data.common)
implementation(projects.data.wallets)
// endregion
// region Project - Domain
api(projects.domain.common)
api(projects.domain.core)
api(projects.domain.networks)
api(projects.domain.quotes)
api(projects.domain.virtualAccount)
api(projects.domain.visa)
api(projects.domain.wallets)
implementation(projects.domain.card)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.walletManager)
runtimeOnly(projects.domain.tokens)
// endregion
// region Project - Domain models
api(projects.domain.visa.models)
implementation(projects.domain.appCurrency.models)
// endregion
// region Project - Features
api(projects.features.swap.domain)
api(projects.features.virtualAccounts.details.api)
// endregion
// region Project - Libs
api(projects.libs.blockchainSdk)
api(projects.libs.tangemSdkApi)
implementation(projects.libs.visa)
// endregion
// region Test
testImplementation(projects.test.core)
// endregion
}

View file

@ -1,17 +1,17 @@
package com.tangem.data.pay
import com.tangem.common.card.FirmwareVersion
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.pay.TangemPayEligibilityType
import com.tangem.domain.models.pay.isTangemPayType
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.models.wallet.isTangemPayCompatible
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.hot.sdk.model.HotWalletId
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.sync.Mutex
@ -85,7 +85,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
val wallets = userWalletsListRepository.userWallets.value ?: return emptyList()
val candidates = wallets.filter { wallet ->
wallet.isMultiCurrency && !wallet.isLocked && wallet.isCompatible() &&
wallet.isMultiCurrency && !wallet.isLocked && wallet.isTangemPayCompatible &&
!onboardingRepository.isTangemPayDeactivated(wallet.walletId)
}
@ -98,11 +98,6 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
return candidates
}
private fun UserWallet.isCompatible(): Boolean = when (this) {
is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable
is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword
}
private suspend fun List<UserWallet>.addPaeraCustomersData(): List<UserWalletData> {
if (isEmpty()) return emptyList()
@ -139,7 +134,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
onboardingRepository.checkCustomerEligibility()
}
return if (entryPoint == null) {
eligibility.isNotEmpty()
eligibility.any { it.isTangemPayType }
} else {
eligibility.any { it == entryPoint.toEligibilityType() }
}
@ -148,6 +143,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
private fun TangemPayEntryPoint.toEligibilityType(): TangemPayEligibilityType = when (this) {
TangemPayEntryPoint.BANNER -> TangemPayEligibilityType.BANNER
TangemPayEntryPoint.DETAILS -> TangemPayEligibilityType.DETAILS
TangemPayEntryPoint.DEEPLINK -> TangemPayEligibilityType.DEEPLINK
}
private data class UserWalletData(

View file

@ -0,0 +1,18 @@
package com.tangem.data.pay
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.domain.models.wallet.UserWalletId
import javax.inject.Inject
internal class TangemPayUserWalletDataCleaner @Inject constructor(
private val paymentAccountStatusesStore: PaymentAccountStatusesStore,
private val txHistoryItemsStore: TangemPayTxHistoryItemsStore,
) : UserWalletDataCleaner {
override suspend fun clear(userWalletIds: List<UserWalletId>) {
paymentAccountStatusesStore.remove(userWalletIds)
txHistoryItemsStore.remove(userWalletIds.map { it.stringValue })
}
}

View file

@ -118,6 +118,8 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
},
error = null,
virtualAccount = null,
tariffPlan = null,
)
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(
source = StatusSource.CACHE,

View file

@ -0,0 +1,37 @@
package com.tangem.data.pay.converter
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.domain.models.account.TangemPayTariffPlan
internal object TangemPayTariffPlanConverter {
fun convert(value: CustomerMeResponse.TariffPlan?): TangemPayTariffPlan? {
val id = value?.id ?: return null
val name = value.name ?: return null
return TangemPayTariffPlan(
id = id,
type = TangemPayTariffPlan.Type.fromString(value.type),
name = name,
descriptionItems = value.descriptionItems.orEmpty().mapNotNull(::convertDescriptionItem),
images = value.images.orEmpty().mapNotNull(::convertImage),
)
}
private fun convertDescriptionItem(item: CustomerMeResponse.DescriptionItem): TangemPayTariffPlan.DescriptionItem? {
val title = item.title ?: return null
return TangemPayTariffPlan.DescriptionItem(
section = TangemPayTariffPlan.Section.fromString(item.type),
order = item.order ?: 0,
title = title,
body = item.body.orEmpty(),
)
}
private fun convertImage(image: CustomerMeResponse.Image): TangemPayTariffPlan.Image? {
val url = image.url ?: return null
return TangemPayTariffPlan.Image(
type = TangemPayTariffPlan.Image.Type.fromString(image.type),
url = url,
)
}
}

Some files were not shown because too many files have changed in this diff Show more