Updated on 2026-08-14
This commit is contained in:
commit
b2da68216a
501 changed files with 16767 additions and 5720 deletions
42
data/address-book/build.gradle.kts
Normal file
42
data/address-book/build.gradle.kts
Normal 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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ internal class TransactionDataToTxHistoryItemConverter(
|
|||
},
|
||||
type = getTransactionType(value),
|
||||
amount = amount,
|
||||
fee = value.fee?.amount?.toDomain(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue