Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-24 17:21:35 +03:00
commit b2da68216a
501 changed files with 16767 additions and 5720 deletions

View file

@ -0,0 +1,42 @@
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 Project - Core
implementation(projects.core.datasource)
implementation(projects.core.utils)
// endregion
// region Project - Domain
implementation(projects.domain.addressBook)
implementation(projects.domain.common)
implementation(projects.domain.models)
// endregion
// region SDK
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.serialization)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Testing
testImplementation(projects.test.core)
testImplementation(deps.moshi.kotlin)
// endregion
}

View file

@ -0,0 +1,116 @@
package com.tangem.data.addressbook
import com.tangem.data.addressbook.store.AddressBookBlobStore
import com.tangem.domain.addressbook.crypto.AddressBookCipher
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.joda.time.DateTime
internal class DefaultAddressBookRepository(
private val blobStore: AddressBookBlobStore,
private val cipher: AddressBookCipher,
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)
.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()
}
}
}
.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 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) = withContext(dispatchers.default) {
writeMutex.withLock {
val userWallet = findUserWallet(contact.walletId.stringValue) ?: return@withLock
val current = currentContacts(contact.walletId, userWallet)
val merged = current.filterNot { it.id == contact.id } + contact
persist(userWallet, AddressBook(walletId = contact.walletId, contacts = merged))
}
}
override suspend fun deleteContact(id: ContactId) = 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 }
persist(userWallet, addressBook.copy(contacts = remaining))
return@withLock
}
}
}
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)
}
private suspend fun persist(userWallet: UserWallet, addressBook: AddressBook) {
val updatedAt = DateTime.parse(timestampProvider.now())
cipher.encrypt(addressBook, userWallet, updatedAt)
.onRight { blobStore.storeBlob(it) }
}
private suspend fun findUserWallet(walletId: String): UserWallet? =
userWalletsListRepository.userWalletsSync().find { it.walletId.stringValue == walletId }
}

View file

@ -0,0 +1,68 @@
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.addressbook.store.StoredAddressBookBlob
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
import com.tangem.domain.addressbook.crypto.AddressBookCipher
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 = StoredAddressBookBlob.serializer(),
),
),
produceFile = { context.dataStoreFile(fileName = "address_book_blobs") },
scope = appScope,
),
)
}
@Provides
@Singleton
fun provideAddressBookRepository(
blobStore: AddressBookBlobStore,
cipher: AddressBookCipher,
userWalletsListRepository: UserWalletsListRepository,
timestampProvider: IsoTimestampProvider,
dispatchers: CoroutineDispatcherProvider,
): AddressBookRepository {
return DefaultAddressBookRepository(
blobStore = blobStore,
cipher = cipher,
userWalletsListRepository = userWalletsListRepository,
timestampProvider = timestampProvider,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,25 @@
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?
/** Persists [blob] optimistically with `isBESynchronized = false`. Keyed by [AddressBookBlob.walletId]. */
suspend fun storeBlob(blob: AddressBookBlob)
/** Flips the BE-sync flag to `true` once the backend confirms the push. No-op if the blob is absent. */
suspend fun markAsSynchronized(userWalletId: UserWalletId)
/** Blobs still pending a backend push — the entry point for the future sync service. */
suspend fun getUnsynchronizedBlobs(): List<AddressBookBlob>
suspend fun deleteBlob(userWalletId: UserWalletId)
}

View file

@ -0,0 +1,58 @@
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, StoredAddressBookBlob>
internal class DefaultAddressBookBlobStore(
private val dataStore: DataStore<AddressBookBlobs>,
) : AddressBookBlobStore {
override fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?> {
return dataStore.data
.map { it[userWalletId.stringValue]?.blob }
.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.map { it.blob } }
.distinctUntilChanged()
}
override suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? {
return getStoredBlobs()[userWalletId.stringValue]?.blob
}
override suspend fun storeBlob(blob: AddressBookBlob) {
dataStore.updateData { stored ->
stored + (blob.walletId to StoredAddressBookBlob(blob = blob, isBESynchronized = false))
}
}
override suspend fun markAsSynchronized(userWalletId: UserWalletId) {
dataStore.updateData { stored ->
val current = stored[userWalletId.stringValue] ?: return@updateData stored
stored + (userWalletId.stringValue to current.copy(isBESynchronized = true))
}
}
override suspend fun getUnsynchronizedBlobs(): List<AddressBookBlob> {
return getStoredBlobs().values
.filterNot { it.isBESynchronized }
.map { it.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,15 @@
package com.tangem.data.addressbook.store
import com.tangem.domain.addressbook.model.AddressBookBlob
import kotlinx.serialization.Serializable
/**
* [isBESynchronized] tracks whether the blob has already been pushed to the backend. A freshly
* stored blob is written optimistically with `false`; a future BE-sync service flips it to `true`
* once the push is confirmed.
*/
@Serializable
internal data class StoredAddressBookBlob(
val blob: AddressBookBlob,
val isBESynchronized: Boolean,
)

View file

@ -0,0 +1,221 @@
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.domain.addressbook.crypto.AddressBookCipher
import com.tangem.domain.addressbook.error.AddressBookCryptoError
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.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 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,
userWalletsListRepository = userWalletsListRepository,
timestampProvider = timestampProvider,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun setup() {
clearMocks(blobStore, cipher, userWalletsListRepository, timestampProvider)
every { timestampProvider.now() } returns TIMESTAMP
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
}
@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(UserWalletId(WALLET_A), listOf(contact)).right()
// Act
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).containsExactly(contact)
}
@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(UserWalletId(WALLET_A), listOf(contact)).right()
// Act
val result = repository.getAllContacts().first()
// Assert
assertThat(result).containsExactly(contact)
}
@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 new contact WHEN saveContact THEN encrypts merged book and stores blob`() = 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(UserWalletId(WALLET_A), 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
// Act
repository.saveContact(added)
// Assert
assertThat(bookSlot.captured.contacts).containsExactly(existing, added)
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
}
@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(UserWalletId(WALLET_A), listOf(original)).right()
val bookSlot = slot<AddressBook>()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right()
coEvery { blobStore.storeBlob(any()) } returns Unit
// Act
repository.saveContact(updated)
// Assert
assertThat(bookSlot.captured.contacts).containsExactly(updated)
}
@Test
fun `GIVEN contact in wallet WHEN deleteContact THEN 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(UserWalletId(WALLET_A), 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
// Act
repository.deleteContact(ContactId("c2"))
// Assert
assertThat(bookSlot.captured.contacts).containsExactly(kept)
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
}
@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(UserWalletId(WALLET_A), listOf(alice, bob)).right()
// Act
val result = repository.getContact(UserWalletId(WALLET_A), name = "Bob")
// Assert
assertThat(result).isEqualTo(bob)
}
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,
addressEntries = 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 TIMESTAMP = "2026-05-22T09:00:00.000Z"
}
}

View file

@ -0,0 +1,112 @@
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 AND it is unsynchronized`() = 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)
assertThat(store.getUnsynchronizedBlobs()).containsExactly(blob)
}
@Test
fun `GIVEN stored blob WHEN markAsSynchronized THEN getUnsynchronizedBlobs excludes it`() = runTest {
// Arrange
val blob = createBlob(walletId = WALLET_A)
store.storeBlob(blob)
// Act
store.markAsSynchronized(UserWalletId(WALLET_A))
// Assert
assertThat(store.getUnsynchronizedBlobs()).isEmpty()
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).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)
assertThat(store.getUnsynchronizedBlobs()).containsExactly(blobA, blobB)
}
@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

@ -115,7 +115,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 +123,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

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

@ -31,6 +31,8 @@ dependencies {
implementation(projects.domain.express.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.wallets)
implementation(projects.domain.onramp)
implementation(projects.domain.onramp.models)
implementation(projects.domain.account)
implementation(projects.domain.account.status)

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

@ -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(
@ -84,38 +93,80 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
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,
)
}
@ -51,47 +55,62 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
payoutHash = entity.payoutHash,
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,
),
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,
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,
),
)
}
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,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,95 @@
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.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 portfolio [CryptoCurrency] for every express asset (network id + contract address) referenced by a
* batch of exchange/onramp entities.
*
* Strategy: read every account of every wallet ONCE (via [MultiAccountListSupplier]) and match each express asset
* against the flattened portfolio currencies by network id + contract address. When nothing matches notably
* tokens that are not present in any portfolio a coin is built for the asset's network as a fallback (for now).
*/
internal class ExpressTransactionAssetFactory @Inject constructor(
private val multiAccountListSupplier: MultiAccountListSupplier,
private val userWalletsListRepository: UserWalletsListRepository,
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 (no match and no fallback coin) 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 }
return buildMap {
assetIds.forEach { id ->
val currency = portfolioCurrencies.findMatching(id) ?: createFallbackCoin(id, userWallet)
if (currency != null) put(id, currency)
}
}
}
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)
}
}
}
// TODO txHistory: tokens that are not in any portfolio cannot be resolved yet — fall back to a coin on the asset's
// network.
private fun createFallbackCoin(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

@ -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

@ -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,17 +74,18 @@ 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",
@ -111,7 +113,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(
@ -139,13 +142,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,25 @@
package com.tangem.data.walletmanager.utils
import com.tangem.domain.models.network.SdkAmount
import com.tangem.domain.models.network.SdkAmountType
import com.tangem.blockchain.common.Amount as BlockchainAmount
import com.tangem.blockchain.common.AmountType as BlockchainAmountType
/** Maps the blockchain SDK [BlockchainAmount] to the serializable domain [SdkAmount]. */
internal fun BlockchainAmount.toDomain(): SdkAmount = SdkAmount(
currencySymbol = currencySymbol,
value = value,
decimals = decimals,
type = type.toDomain(),
)
private fun BlockchainAmountType.toDomain(): SdkAmountType = when (this) {
BlockchainAmountType.Coin -> SdkAmountType.Coin
BlockchainAmountType.Reserve -> SdkAmountType.Reserve
is BlockchainAmountType.FeeResource -> SdkAmountType.FeeResource(name = name)
is BlockchainAmountType.Token -> SdkAmountType.Token(contractAddress = token.contractAddress, id = token.id)
is BlockchainAmountType.TokenYieldSupply -> SdkAmountType.Token(
contractAddress = token.contractAddress,
id = token.id,
)
}

View file

@ -32,6 +32,7 @@ internal class SdkTransactionHistoryItemConverter(
},
type = typeConverter.convert(value),
amount = requireNotNull(value.amount.value) { "Transaction amount value must not be null" },
fee = value.fee.toDomain(),
)
private fun SdkTransactionHistoryItem.SourceType.toDomain(): TxInfo.SourceType = when (this) {

View file

@ -47,6 +47,7 @@ internal class TransactionDataToTxHistoryItemConverter(
},
type = getTransactionType(value),
amount = amount,
fee = value.fee?.amount?.toDomain(),
)
}