Updated on 2026-08-14
This commit is contained in:
commit
92849d43c2
1407 changed files with 64092 additions and 11990 deletions
|
|
@ -2,6 +2,7 @@ plugins {
|
|||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
|
|
@ -10,41 +11,21 @@ android {
|
|||
}
|
||||
dependencies {
|
||||
|
||||
implementation(projects.features.virtualAccounts.details.api) // VIRTUAL_ACCOUNTS_ENABLED
|
||||
|
||||
// region Project - Common
|
||||
implementation(projects.common.ui) // It's needed for getting AccountName.DefaultMain value
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.res)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.account)
|
||||
api(projects.domain.card)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.tokens)
|
||||
api(projects.domain.wallets)
|
||||
api(projects.domain.visa)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.crypto)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
implementation(deps.androidx.annotation)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region Tangem dependencies
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(tangemDeps.hot.core)
|
||||
// endregion
|
||||
|
||||
|
|
@ -53,20 +34,44 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region AndroidX libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
// region Project - Core
|
||||
api(projects.core.configToggles)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.res)
|
||||
// endregion
|
||||
|
||||
// region Other Dependencies
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
// region Project - Domain
|
||||
api(projects.domain.account)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.core)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.tokens)
|
||||
implementation(projects.domain.card)
|
||||
runtimeOnly(projects.domain.visa)
|
||||
runtimeOnly(projects.domain.wallets)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Features
|
||||
api(projects.features.virtualAccounts.details.api) // VIRTUAL_ACCOUNTS_ENABLED
|
||||
// endregion
|
||||
|
||||
// region Project - Common
|
||||
implementation(projects.common.ui) // It's needed for getting AccountName.DefaultMain value
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
// endregion
|
||||
|
||||
// region Test
|
||||
testImplementation(deps.test.turbine)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(deps.test.turbine)
|
||||
// endregion
|
||||
}
|
||||
49
data/address-book/build.gradle.kts
Normal file
49
data/address-book/build.gradle.kts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.kotlin.serialization)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.addressbook"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
// region Kotlin
|
||||
api(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.serialization)
|
||||
// endregion
|
||||
|
||||
// region SDK
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.addressBook)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
// region Testing
|
||||
testImplementation(projects.test.core)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
package com.tangem.data.addressbook
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.flatMap
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.tangem.data.addressbook.store.AddressBookBlobStore
|
||||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.common.cache.etag.ETagsStore
|
||||
import com.tangem.datasource.api.addressbook.AddressBookApi
|
||||
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
|
||||
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
|
||||
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
|
||||
import com.tangem.domain.addressbook.crypto.AddressBookCipher
|
||||
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
||||
import com.tangem.domain.addressbook.model.AddressBook
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.joda.time.DateTime
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultAddressBookRepository(
|
||||
private val blobStore: AddressBookBlobStore,
|
||||
private val cipher: AddressBookCipher,
|
||||
private val addressBookApi: AddressBookApi,
|
||||
private val eTagsStore: ETagsStore,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val timestampProvider: IsoTimestampProvider,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : AddressBookRepository {
|
||||
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
override fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>> {
|
||||
return getContactsForWallet(userWalletId)
|
||||
.onStart { syncAddressBooks() }
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getAllContacts(): Flow<List<Contact>> {
|
||||
return userWalletsListRepository.userWallets
|
||||
.filterNotNull()
|
||||
.flatMapLatest { wallets ->
|
||||
val walletsById = wallets.associateBy { it.walletId.stringValue }
|
||||
val ids = wallets.mapTo(mutableSetOf()) { it.walletId }
|
||||
blobStore.getBlobs(ids).map { blobs ->
|
||||
blobs.flatMap { blob ->
|
||||
walletsById[blob.walletId]?.let { userWallet ->
|
||||
decryptContacts(blob, userWallet)
|
||||
}.orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onStart { syncAddressBooks() }
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
private fun getContactsForWallet(userWalletId: UserWalletId): Flow<List<Contact>> {
|
||||
return blobStore.getBlob(userWalletId).map { blob ->
|
||||
val userWallet = blob?.let { findUserWallet(it.walletId) } ?: return@map emptyList()
|
||||
decryptContacts(blob, userWallet)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getContactsSync(userWalletId: UserWalletId): List<Contact> {
|
||||
return withContext(dispatchers.io) {
|
||||
val blob = blobStore.getBlobSync(userWalletId) ?: return@withContext emptyList()
|
||||
val userWallet = findUserWallet(blob.walletId) ?: return@withContext emptyList()
|
||||
decryptContacts(blob, userWallet)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? =
|
||||
withContext(dispatchers.default) {
|
||||
val blob = blobStore.getBlobSync(userWalletId) ?: return@withContext null
|
||||
val userWallet = findUserWallet(blob.walletId) ?: return@withContext null
|
||||
decryptContacts(blob, userWallet).find { it.name.value == name }
|
||||
}
|
||||
|
||||
override suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit> =
|
||||
withContext(dispatchers.default) {
|
||||
writeMutex.withLock {
|
||||
val userWallet = findUserWallet(contact.walletId.stringValue)
|
||||
?: return@withLock AddressBookSyncError.Unknown.left()
|
||||
val current = currentContacts(contact.walletId, userWallet)
|
||||
val merged = current.filterNot { it.id == contact.id } + contact
|
||||
persist(userWallet, AddressBook(contacts = merged))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit> =
|
||||
withContext(dispatchers.default) {
|
||||
writeMutex.withLock {
|
||||
userWalletsListRepository.userWalletsSync().forEach { userWallet ->
|
||||
val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach
|
||||
val addressBook = cipher.decrypt(blob, userWallet).getOrNull() ?: return@forEach
|
||||
if (addressBook.contacts.none { it.id == id }) return@forEach
|
||||
|
||||
val remaining = addressBook.contacts.filterNot { it.id == id }
|
||||
return@withLock persist(userWallet, addressBook.copy(contacts = remaining))
|
||||
}
|
||||
// No wallet held the contact — nothing to push, treat as success.
|
||||
Unit.right()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun syncAddressBooks(): Either<AddressBookSyncError, Unit> = withContext(dispatchers.default) {
|
||||
val wallets = userWalletsListRepository.userWalletsSync()
|
||||
// The backend rejects more than MAX_SYNC_WALLETS per request, so sync in chunks and stop on the
|
||||
// first failed chunk.
|
||||
wallets.chunked(MAX_SYNC_WALLETS)
|
||||
.fold(initial = Unit.right() as Either<AddressBookSyncError, Unit>) { acc, chunk ->
|
||||
acc.flatMap { syncWalletsChunk(chunk) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncWalletsChunk(wallets: List<UserWallet>): Either<AddressBookSyncError, Unit> {
|
||||
val request = SyncAddressBooksRequest(
|
||||
wallets = wallets.map { wallet ->
|
||||
SyncAddressBooksRequest.Wallet(
|
||||
walletId = wallet.walletId.stringValue,
|
||||
etag = eTagsStore.getSyncOrNull(wallet.walletId, ETagsStore.Key.AddressBook),
|
||||
)
|
||||
},
|
||||
)
|
||||
return safeApiCall(
|
||||
call = {
|
||||
val response = withContext(dispatchers.io) { addressBookApi.syncAddressBooks(request).bind() }
|
||||
// Only wallets whose etag changed are returned; the rest keep their local copy.
|
||||
response.items.forEach { item ->
|
||||
val userWalletId = UserWalletId(stringValue = item.walletId)
|
||||
blobStore.storeBlob(item.toBlob())
|
||||
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, item.etag)
|
||||
}
|
||||
Unit.right()
|
||||
},
|
||||
onError = { error ->
|
||||
TangemLogger.e(messageString = "Failed to sync address books: $error")
|
||||
error.toSyncError().left()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List<Contact> {
|
||||
return cipher.decrypt(blob, userWallet).getOrNull()?.contacts.orEmpty()
|
||||
}
|
||||
|
||||
private suspend fun currentContacts(userWalletId: UserWalletId, userWallet: UserWallet): List<Contact> {
|
||||
val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList()
|
||||
return decryptContacts(blob, userWallet)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts [addressBook], pushes it to the backend, and persists it locally **only** on success.
|
||||
* On any failure (encryption, network, etag conflict, …) nothing is written locally.
|
||||
*/
|
||||
private suspend fun persist(userWallet: UserWallet, addressBook: AddressBook): Either<AddressBookSyncError, Unit> {
|
||||
val updatedAt = DateTime.parse(timestampProvider.now())
|
||||
return cipher.encrypt(addressBook, userWallet, updatedAt)
|
||||
.mapLeft { error ->
|
||||
TangemLogger.e(
|
||||
messageString = "Failed to encrypt address book for wallet ${userWallet.walletId}: $error",
|
||||
)
|
||||
AddressBookSyncError.Unknown
|
||||
}
|
||||
.flatMap { blob -> pushBlob(userWallet.walletId, blob) }
|
||||
}
|
||||
|
||||
private suspend fun pushBlob(
|
||||
userWalletId: UserWalletId,
|
||||
blob: AddressBookBlob,
|
||||
): Either<AddressBookSyncError, Unit> {
|
||||
// Absent etag means the book has not been created on the backend yet → omit If-Match to create it.
|
||||
val eTag = eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.AddressBook)
|
||||
return safeApiCall(
|
||||
call = {
|
||||
val response = withContext(dispatchers.io) {
|
||||
addressBookApi.updateAddressBook(
|
||||
walletId = blob.walletId,
|
||||
eTag = eTag,
|
||||
body = UpdateAddressBookRequest(
|
||||
version = blob.version,
|
||||
nonce = blob.nonce,
|
||||
ciphertext = blob.ciphertext,
|
||||
authTag = blob.authTag,
|
||||
),
|
||||
).bind()
|
||||
}
|
||||
blobStore.storeBlob(blob)
|
||||
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, response.etag)
|
||||
Unit.right()
|
||||
},
|
||||
onError = { error ->
|
||||
TangemLogger.e(messageString = "Failed to push address book for wallet $userWalletId: $error")
|
||||
val syncError = error.toSyncError()
|
||||
// A 412 means the local etag is stale relative to the backend. Refresh the local blob + etag so the
|
||||
// next save attempt (user re-taps Save) starts from the current backend state. We do NOT re-push here
|
||||
// on purpose — the write stays single-shot; the conflict is still surfaced so the UI can prompt.
|
||||
if (syncError is AddressBookSyncError.Conflict) {
|
||||
syncAddressBooks()
|
||||
}
|
||||
syncError.left()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun SyncAddressBooksResponse.Item.toBlob(): AddressBookBlob = AddressBookBlob(
|
||||
version = version,
|
||||
walletId = walletId,
|
||||
updatedAt = updatedAt,
|
||||
nonce = nonce,
|
||||
ciphertext = ciphertext,
|
||||
authTag = authTag,
|
||||
)
|
||||
|
||||
private fun ApiResponseError.toSyncError(): AddressBookSyncError = when (this) {
|
||||
is ApiResponseError.HttpException -> when (code) {
|
||||
Code.PRECONDITION_FAILED -> AddressBookSyncError.Conflict
|
||||
Code.NOT_FOUND -> AddressBookSyncError.NotFound
|
||||
Code.UNAUTHORIZED -> AddressBookSyncError.Unauthorized
|
||||
Code.BAD_REQUEST -> AddressBookSyncError.BadRequest
|
||||
else -> AddressBookSyncError.Unknown
|
||||
}
|
||||
is ApiResponseError.NetworkException,
|
||||
is ApiResponseError.TimeoutException,
|
||||
-> AddressBookSyncError.Network
|
||||
is ApiResponseError.UnknownException -> AddressBookSyncError.Unknown
|
||||
}
|
||||
|
||||
private suspend fun findUserWallet(walletId: String): UserWallet? =
|
||||
userWalletsListRepository.userWalletsSync().find { it.walletId.stringValue == walletId }
|
||||
|
||||
private companion object {
|
||||
const val MAX_SYNC_WALLETS = 20
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package com.tangem.data.addressbook.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.dataStoreFile
|
||||
import com.tangem.data.addressbook.DefaultAddressBookRepository
|
||||
import com.tangem.data.addressbook.store.AddressBookBlobStore
|
||||
import com.tangem.data.addressbook.store.DefaultAddressBookBlobStore
|
||||
import com.tangem.data.common.cache.etag.ETagsStore
|
||||
import com.tangem.datasource.api.addressbook.AddressBookApi
|
||||
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
|
||||
import com.tangem.domain.addressbook.crypto.AddressBookCipher
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object AddressBookDataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAddressBookBlobStore(
|
||||
@ApplicationContext context: Context,
|
||||
appScope: AppCoroutineScope,
|
||||
): AddressBookBlobStore {
|
||||
return DefaultAddressBookBlobStore(
|
||||
dataStore = DataStoreFactory.create(
|
||||
serializer = KotlinxDataStoreSerializer(
|
||||
defaultValue = emptyMap(),
|
||||
serializer = MapSerializer(
|
||||
keySerializer = String.serializer(),
|
||||
valueSerializer = AddressBookBlob.serializer(),
|
||||
),
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = "address_book_blobs") },
|
||||
scope = appScope,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Suppress("LongParameterList")
|
||||
fun provideAddressBookRepository(
|
||||
blobStore: AddressBookBlobStore,
|
||||
cipher: AddressBookCipher,
|
||||
addressBookApi: AddressBookApi,
|
||||
eTagsStore: ETagsStore,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
timestampProvider: IsoTimestampProvider,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): AddressBookRepository {
|
||||
return DefaultAddressBookRepository(
|
||||
blobStore = blobStore,
|
||||
cipher = cipher,
|
||||
addressBookApi = addressBookApi,
|
||||
eTagsStore = eTagsStore,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
timestampProvider = timestampProvider,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.tangem.data.addressbook.store
|
||||
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AddressBookBlobStore {
|
||||
|
||||
fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?>
|
||||
|
||||
fun getBlobs(userWalletIds: Set<UserWalletId>): Flow<List<AddressBookBlob>>
|
||||
|
||||
suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob?
|
||||
|
||||
suspend fun storeBlob(blob: AddressBookBlob)
|
||||
|
||||
suspend fun deleteBlob(userWalletId: UserWalletId)
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.tangem.data.addressbook.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
internal typealias AddressBookBlobs = Map<String, AddressBookBlob>
|
||||
|
||||
internal class DefaultAddressBookBlobStore(
|
||||
private val dataStore: DataStore<AddressBookBlobs>,
|
||||
) : AddressBookBlobStore {
|
||||
|
||||
override fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?> {
|
||||
return dataStore.data
|
||||
.map { it[userWalletId.stringValue] }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
override fun getBlobs(userWalletIds: Set<UserWalletId>): Flow<List<AddressBookBlob>> {
|
||||
val ids = userWalletIds.mapTo(mutableSetOf()) { it.stringValue }
|
||||
return dataStore.data
|
||||
.map { stored -> stored.filterKeys { it in ids }.values.toList() }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
override suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? {
|
||||
return getStoredBlobs()[userWalletId.stringValue]
|
||||
}
|
||||
|
||||
override suspend fun storeBlob(blob: AddressBookBlob) {
|
||||
dataStore.updateData { stored -> stored + (blob.walletId to blob) }
|
||||
}
|
||||
|
||||
override suspend fun deleteBlob(userWalletId: UserWalletId) {
|
||||
dataStore.updateData { stored -> stored - userWalletId.stringValue }
|
||||
}
|
||||
|
||||
private suspend fun getStoredBlobs(): AddressBookBlobs = dataStore.data.first()
|
||||
}
|
||||
|
|
@ -0,0 +1,442 @@
|
|||
package com.tangem.data.addressbook
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.addressbook.store.AddressBookBlobStore
|
||||
import com.tangem.data.common.cache.etag.ETagsStore
|
||||
import com.tangem.datasource.api.addressbook.AddressBookApi
|
||||
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
|
||||
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
|
||||
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.domain.addressbook.crypto.AddressBookCipher
|
||||
import com.tangem.domain.addressbook.error.AddressBookCryptoError
|
||||
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
||||
import com.tangem.domain.addressbook.model.AddressBook
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultAddressBookRepositoryTest {
|
||||
|
||||
private val blobStore: AddressBookBlobStore = mockk()
|
||||
private val cipher: AddressBookCipher = mockk()
|
||||
private val addressBookApi: AddressBookApi = mockk()
|
||||
private val eTagsStore: ETagsStore = mockk(relaxed = true)
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
||||
private val timestampProvider: IsoTimestampProvider = mockk()
|
||||
|
||||
private val userWallet: UserWallet = mockk {
|
||||
every { walletId } returns UserWalletId(WALLET_A)
|
||||
}
|
||||
|
||||
private val repository = DefaultAddressBookRepository(
|
||||
blobStore = blobStore,
|
||||
cipher = cipher,
|
||||
addressBookApi = addressBookApi,
|
||||
eTagsStore = eTagsStore,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
timestampProvider = timestampProvider,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(blobStore, cipher, addressBookApi, eTagsStore, userWalletsListRepository, timestampProvider)
|
||||
every { timestampProvider.now() } returns TIMESTAMP
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
|
||||
coEvery { addressBookApi.syncAddressBooks(any()) } returns
|
||||
ApiResponse.Success(SyncAddressBooksResponse(items = emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN decryptable blob WHEN getContacts THEN emits decrypted contacts`() = runTest {
|
||||
// Arrange
|
||||
val contact = createContact(id = "c1", name = "Alice")
|
||||
val blob = createBlob()
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
|
||||
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
|
||||
|
||||
// Act
|
||||
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(contact)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blob WHEN getContacts THEN syncs before reading contacts`() = runTest {
|
||||
// Arrange
|
||||
val contact = createContact(id = "c1", name = "Alice")
|
||||
val blob = createBlob()
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
|
||||
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
|
||||
|
||||
// Act
|
||||
repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
coVerifyOrder {
|
||||
addressBookApi.syncAddressBooks(any())
|
||||
cipher.decrypt(blob, userWallet)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multiple wallets WHEN getAllContacts THEN emits contacts from all wallets`() = runTest {
|
||||
// Arrange
|
||||
val contact = createContact(id = "c1", name = "Alice")
|
||||
val blob = createBlob()
|
||||
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
|
||||
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob))
|
||||
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
|
||||
|
||||
// Act
|
||||
val result = repository.getAllContacts().first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(contact)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blob WHEN getAllContacts THEN syncs before reading contacts`() = runTest {
|
||||
// Arrange
|
||||
val contact = createContact(id = "c1", name = "Alice")
|
||||
val blob = createBlob()
|
||||
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
|
||||
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob))
|
||||
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
|
||||
|
||||
// Act
|
||||
repository.getAllContacts().first()
|
||||
|
||||
// Assert
|
||||
coVerifyOrder {
|
||||
addressBookApi.syncAddressBooks(any())
|
||||
cipher.decrypt(blob, userWallet)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no blob WHEN getContacts THEN emits empty`() = runTest {
|
||||
// Arrange
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(null)
|
||||
|
||||
// Act
|
||||
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN decryption fails WHEN getContacts THEN emits empty`() = runTest {
|
||||
// Arrange
|
||||
val blob = createBlob()
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
|
||||
every { cipher.decrypt(blob, userWallet) } returns AddressBookCryptoError.DecryptionFailed.left()
|
||||
|
||||
// Act
|
||||
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend accepts WHEN saveContact THEN pushes merged book and stores blob and etag`() = runTest {
|
||||
// Arrange
|
||||
val existing = createContact(id = "c1", name = "Alice")
|
||||
val added = createContact(id = "c2", name = "Bob")
|
||||
val storedBlob = createBlob()
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||
every { cipher.decrypt(storedBlob, userWallet) } returns
|
||||
AddressBook(listOf(existing)).right()
|
||||
val bookSlot = slot<AddressBook>()
|
||||
val newBlob = createBlob()
|
||||
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
|
||||
coEvery { blobStore.storeBlob(newBlob) } returns Unit
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
|
||||
|
||||
// Act
|
||||
val result = repository.saveContact(added)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(Unit.right())
|
||||
assertThat(bookSlot.captured.contacts).containsExactly(existing, added)
|
||||
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
|
||||
coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no stored etag WHEN saveContact THEN PUT is sent without If-Match`() = runTest {
|
||||
// Arrange
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
|
||||
val newBlob = createBlob()
|
||||
every { cipher.encrypt(any(), userWallet, any()) } returns newBlob.right()
|
||||
coEvery { blobStore.storeBlob(any()) } returns Unit
|
||||
coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns null
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, null, any()) } returns successPutResponse()
|
||||
|
||||
// Act
|
||||
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(Unit.right())
|
||||
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, null, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored etag WHEN saveContact THEN PUT carries it in If-Match`() = runTest {
|
||||
// Arrange
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
|
||||
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { blobStore.storeBlob(any()) } returns Unit
|
||||
coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns ETAG_OLD
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) } returns successPutResponse()
|
||||
|
||||
// Act
|
||||
repository.saveContact(createContact(id = "c1", name = "Alice"))
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN etag conflict WHEN saveContact THEN returns Conflict and does not store locally`() = runTest {
|
||||
// Arrange
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
|
||||
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns
|
||||
errorResponse(ApiResponseError.HttpException.Code.PRECONDITION_FAILED)
|
||||
|
||||
// Act
|
||||
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(AddressBookSyncError.Conflict.left())
|
||||
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
|
||||
coVerify(exactly = 0) { eTagsStore.store(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN etag conflict WHEN saveContact THEN re-syncs once without retrying the write`() = runTest {
|
||||
// Arrange
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
|
||||
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns
|
||||
errorResponse(ApiResponseError.HttpException.Code.PRECONDITION_FAILED)
|
||||
|
||||
// Act
|
||||
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(AddressBookSyncError.Conflict.left())
|
||||
coVerify(exactly = 1) { addressBookApi.syncAddressBooks(any()) }
|
||||
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN network error WHEN saveContact THEN does not re-sync`() = runTest {
|
||||
// Arrange
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
|
||||
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns networkErrorResponse()
|
||||
|
||||
// Act
|
||||
repository.saveContact(createContact(id = "c1", name = "Alice"))
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 0) { addressBookApi.syncAddressBooks(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no network WHEN saveContact THEN returns Network and does not store locally`() = runTest {
|
||||
// Arrange
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
|
||||
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns networkErrorResponse()
|
||||
|
||||
// Act
|
||||
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(AddressBookSyncError.Network.left())
|
||||
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN existing contact id WHEN saveContact THEN replaces it`() = runTest {
|
||||
// Arrange
|
||||
val original = createContact(id = "c1", name = "Alice")
|
||||
val updated = createContact(id = "c1", name = "Alice Updated")
|
||||
val storedBlob = createBlob()
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||
every { cipher.decrypt(storedBlob, userWallet) } returns
|
||||
AddressBook(listOf(original)).right()
|
||||
val bookSlot = slot<AddressBook>()
|
||||
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { blobStore.storeBlob(any()) } returns Unit
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
|
||||
|
||||
// Act
|
||||
repository.saveContact(updated)
|
||||
|
||||
// Assert
|
||||
assertThat(bookSlot.captured.contacts).containsExactly(updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact in wallet WHEN deleteContact THEN pushes and re-stores book without it`() = runTest {
|
||||
// Arrange
|
||||
val kept = createContact(id = "c1", name = "Alice")
|
||||
val removed = createContact(id = "c2", name = "Bob")
|
||||
val storedBlob = createBlob()
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||
every { cipher.decrypt(storedBlob, userWallet) } returns
|
||||
AddressBook(listOf(kept, removed)).right()
|
||||
val bookSlot = slot<AddressBook>()
|
||||
val newBlob = createBlob()
|
||||
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
|
||||
coEvery { blobStore.storeBlob(newBlob) } returns Unit
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
|
||||
|
||||
// Act
|
||||
val result = repository.deleteContact(ContactId("c2"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(Unit.right())
|
||||
assertThat(bookSlot.captured.contacts).containsExactly(kept)
|
||||
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend returns changed item WHEN syncAddressBooks THEN stores blob and etag for it`() = runTest {
|
||||
// Arrange
|
||||
val walletB: UserWallet = mockk { every { walletId } returns UserWalletId(WALLET_B) }
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet, walletB)
|
||||
val requestSlot = slot<SyncAddressBooksRequest>()
|
||||
// Only wallet A changed; wallet B is omitted (matching etag) → must keep its local copy.
|
||||
coEvery { addressBookApi.syncAddressBooks(capture(requestSlot)) } returns
|
||||
ApiResponse.Success(SyncAddressBooksResponse(items = listOf(syncItem(WALLET_A))))
|
||||
val blobSlot = slot<AddressBookBlob>()
|
||||
coEvery { blobStore.storeBlob(capture(blobSlot)) } returns Unit
|
||||
|
||||
// Act
|
||||
val result = repository.syncAddressBooks()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(Unit.right())
|
||||
assertThat(requestSlot.captured.wallets.map { it.walletId }).containsExactly(WALLET_A, WALLET_B)
|
||||
assertThat(blobSlot.captured.walletId).isEqualTo(WALLET_A)
|
||||
coVerify(exactly = 1) { blobStore.storeBlob(any()) }
|
||||
coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) }
|
||||
coVerify(exactly = 0) { blobStore.storeBlob(match { it.walletId == WALLET_B }) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unauthorized WHEN syncAddressBooks THEN returns Unauthorized and stores nothing`() = runTest {
|
||||
// Arrange
|
||||
coEvery { addressBookApi.syncAddressBooks(any()) } returns
|
||||
errorResponse(ApiResponseError.HttpException.Code.UNAUTHORIZED)
|
||||
|
||||
// Act
|
||||
val result = repository.syncAddressBooks()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(AddressBookSyncError.Unauthorized.left())
|
||||
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN matching name WHEN getContact THEN returns it`() = runTest {
|
||||
// Arrange
|
||||
val alice = createContact(id = "c1", name = "Alice")
|
||||
val bob = createContact(id = "c2", name = "Bob")
|
||||
val blob = createBlob()
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns blob
|
||||
every { cipher.decrypt(blob, userWallet) } returns
|
||||
AddressBook(listOf(alice, bob)).right()
|
||||
|
||||
// Act
|
||||
val result = repository.getContact(UserWalletId(WALLET_A), name = "Bob")
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(bob)
|
||||
}
|
||||
|
||||
private fun successPutResponse(etag: String = ETAG_NEW): ApiResponse<UpdateAddressBookResponse> =
|
||||
ApiResponse.Success(
|
||||
data = UpdateAddressBookResponse(walletId = WALLET_A, etag = etag, updatedAt = TIMESTAMP),
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Any> errorResponse(code: ApiResponseError.HttpException.Code): ApiResponse<T> =
|
||||
ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null),
|
||||
) as ApiResponse<T>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Any> networkErrorResponse(): ApiResponse<T> =
|
||||
ApiResponse.Error(cause = ApiResponseError.NetworkException()) as ApiResponse<T>
|
||||
|
||||
private fun syncItem(walletId: String): SyncAddressBooksResponse.Item = SyncAddressBooksResponse.Item(
|
||||
walletId = walletId,
|
||||
etag = ETAG_NEW,
|
||||
version = AddressBookBlob.CURRENT_VERSION,
|
||||
updatedAt = TIMESTAMP,
|
||||
nonce = "00112233445566778899aabb",
|
||||
ciphertext = "deadbeef",
|
||||
authTag = "cafebabecafebabecafebabecafebabe",
|
||||
)
|
||||
|
||||
private fun createContact(id: String, name: String, iconColor: String = "KekColor"): Contact = Contact(
|
||||
id = ContactId(id),
|
||||
walletId = UserWalletId(WALLET_A),
|
||||
name = ContactName(name).getOrNull()!!,
|
||||
icon = "",
|
||||
iconColor = iconColor,
|
||||
createdAt = TIMESTAMP,
|
||||
updatedAt = TIMESTAMP,
|
||||
addresses = emptyList(),
|
||||
)
|
||||
|
||||
private fun createBlob(): AddressBookBlob = AddressBookBlob(
|
||||
walletId = WALLET_A,
|
||||
updatedAt = TIMESTAMP,
|
||||
nonce = "00112233445566778899aabb",
|
||||
ciphertext = "deadbeef",
|
||||
authTag = "cafebabecafebabecafebabecafebabe",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val WALLET_A = "0a0a0a"
|
||||
const val WALLET_B = "0b0b0b"
|
||||
const val TIMESTAMP = "2026-05-22T09:00:00.000Z"
|
||||
const val ETAG_OLD = "etag-old"
|
||||
const val ETAG_NEW = "etag-new"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.tangem.data.addressbook.store
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.test.core.datastore.MockStateDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultAddressBookBlobStoreTest {
|
||||
|
||||
private lateinit var store: DefaultAddressBookBlobStore
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
store = DefaultAddressBookBlobStore(
|
||||
dataStore = MockStateDataStore(default = emptyMap()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blob WHEN storeBlob THEN getBlob emits it`() = runTest {
|
||||
// Arrange
|
||||
val blob = createBlob(walletId = WALLET_A)
|
||||
|
||||
// Act
|
||||
store.storeBlob(blob)
|
||||
|
||||
// Assert
|
||||
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isEqualTo(blob)
|
||||
assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isEqualTo(blob)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blobs for two wallets WHEN getBlob walletA THEN only walletA blob emitted`() = runTest {
|
||||
// Arrange
|
||||
val blobA = createBlob(walletId = WALLET_A)
|
||||
val blobB = createBlob(walletId = WALLET_B)
|
||||
store.storeBlob(blobA)
|
||||
store.storeBlob(blobB)
|
||||
|
||||
// Act
|
||||
val result = store.getBlob(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(blobA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blobs for two wallets WHEN getBlobs THEN only requested wallets returned`() = runTest {
|
||||
// Arrange
|
||||
val blobA = createBlob(walletId = WALLET_A)
|
||||
val blobB = createBlob(walletId = WALLET_B)
|
||||
store.storeBlob(blobA)
|
||||
store.storeBlob(blobB)
|
||||
|
||||
// Act
|
||||
val result = store.getBlobs(setOf(UserWalletId(WALLET_A), UserWalletId(WALLET_B))).first()
|
||||
val onlyA = store.getBlobs(setOf(UserWalletId(WALLET_A))).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(blobA, blobB)
|
||||
assertThat(onlyA).containsExactly(blobA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored blob WHEN deleteBlob THEN getBlob emits null`() = runTest {
|
||||
// Arrange
|
||||
val blob = createBlob(walletId = WALLET_A)
|
||||
store.storeBlob(blob)
|
||||
|
||||
// Act
|
||||
store.deleteBlob(UserWalletId(WALLET_A))
|
||||
|
||||
// Assert
|
||||
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isNull()
|
||||
assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isNull()
|
||||
}
|
||||
|
||||
private fun createBlob(walletId: String): AddressBookBlob = AddressBookBlob(
|
||||
walletId = walletId,
|
||||
updatedAt = "2026-05-22T09:00:00.000Z",
|
||||
nonce = "00112233445566778899aabb",
|
||||
ciphertext = "deadbeef",
|
||||
authTag = "cafebabecafebabecafebabecafebabe",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val WALLET_A = "0a0a0a"
|
||||
const val WALLET_B = "0b0b0b"
|
||||
}
|
||||
}
|
||||
|
|
@ -11,26 +11,27 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/** Project - Domain */
|
||||
implementation(projects.domain.analytics)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Project - Analytics */
|
||||
implementation(projects.core.analytics.models)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Project - Data */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.data.common)
|
||||
|
||||
/** AndroidX */
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.core)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.analytics)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -11,26 +11,31 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/** Project - Domain */
|
||||
implementation(projects.domain.core)
|
||||
implementation(projects.domain.appCurrency)
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Project - Data */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.data.common)
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
// endregion
|
||||
|
||||
/** Project - Utils */
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** AndroidX */
|
||||
implementation(deps.androidx.datastore)
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.core)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Other */
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.appCurrency)
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -11,23 +11,27 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/** Project - Domain */
|
||||
implementation(projects.domain.appTheme)
|
||||
implementation(projects.domain.appTheme.models)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Project - Data */
|
||||
implementation(projects.core.datasource)
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
/** Project - Utils */
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.core)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
/** Local storages */
|
||||
implementation(deps.androidx.datastore)
|
||||
// region Project - Domain
|
||||
api(projects.domain.appTheme)
|
||||
implementation(projects.domain.appTheme.models)
|
||||
// endregion
|
||||
}
|
||||
1
data/app-update/.gitignore
vendored
Normal file
1
data/app-update/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
27
data/app-update/build.gradle.kts
Normal file
27
data/app-update/build.gradle.kts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.appupdate"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
implementation(projects.domain.appUpdate)
|
||||
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.tangem.data.appupdate
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.ApplicationVersionsResponse
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
|
||||
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
|
||||
import com.tangem.datasource.local.preferences.utils.store
|
||||
import com.tangem.datasource.local.preferences.utils.storeObject
|
||||
import com.tangem.domain.appupdate.model.AppVersionInfo
|
||||
import com.tangem.domain.appupdate.model.OptionalUpdateShown
|
||||
import com.tangem.domain.appupdate.repository.AppUpdateRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class DefaultAppUpdateRepository(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val currentTimeMillis: () -> Long = System::currentTimeMillis,
|
||||
) : AppUpdateRepository {
|
||||
|
||||
override suspend fun getCachedAppVersionInfo(): AppVersionInfo? = appPreferencesStore
|
||||
.getObjectSyncOrNull<ApplicationVersionsResponse>(PreferencesKeys.CACHED_APP_VERSIONS_KEY)
|
||||
?.toDomain()
|
||||
|
||||
override suspend fun getCachedAppVersionTimestamp(): Long? =
|
||||
appPreferencesStore.getSyncOrNull(PreferencesKeys.CACHED_APP_VERSIONS_AT_KEY)
|
||||
|
||||
override suspend fun refreshAppVersionInfo(): Either<Throwable, AppVersionInfo> = withContext(dispatchers.io) {
|
||||
Either.catch {
|
||||
val response = tangemTechApi.getApplicationVersions().getOrThrow()
|
||||
appPreferencesStore.storeObject(PreferencesKeys.CACHED_APP_VERSIONS_KEY, response)
|
||||
appPreferencesStore.store(PreferencesKeys.CACHED_APP_VERSIONS_AT_KEY, currentTimeMillis())
|
||||
response.toDomain()
|
||||
}.onLeft { error -> TangemLogger.e("Unable to fetch application versions", error) }
|
||||
}
|
||||
|
||||
override suspend fun getOptionalUpdateShown(): OptionalUpdateShown? {
|
||||
val version = appPreferencesStore.getSyncOrNull(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_VERSION_KEY)
|
||||
?: return null
|
||||
val shownAtMillis = appPreferencesStore.getSyncOrNull(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_AT_KEY)
|
||||
?: return null
|
||||
|
||||
return OptionalUpdateShown(version = version, shownAtMillis = shownAtMillis)
|
||||
}
|
||||
|
||||
override suspend fun setOptionalUpdateShown(shown: OptionalUpdateShown) {
|
||||
appPreferencesStore.store(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_VERSION_KEY, shown.version)
|
||||
appPreferencesStore.store(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_AT_KEY, shown.shownAtMillis)
|
||||
}
|
||||
|
||||
private fun ApplicationVersionsResponse.toDomain() = AppVersionInfo(
|
||||
minSupportedVersion = minSupportedVersion,
|
||||
minSupportedOSVersion = minSupportedOSVersion,
|
||||
criticalVersion = criticalVersion,
|
||||
criticalOSVersion = criticalOSVersion,
|
||||
latestVersion = latestVersion,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.tangem.data.appupdate.di
|
||||
|
||||
import com.tangem.data.appupdate.DefaultAppUpdateRepository
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.appupdate.repository.AppUpdateRepository
|
||||
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.info.AppInfoProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object AppUpdateDataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppUpdateRepository(
|
||||
tangemTechApi: TangemTechApi,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): AppUpdateRepository = DefaultAppUpdateRepository(
|
||||
tangemTechApi = tangemTechApi,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGetAppUpdateStateUseCase(
|
||||
repository: AppUpdateRepository,
|
||||
appInfoProvider: AppInfoProvider,
|
||||
): GetAppUpdateStateUseCase = GetAppUpdateStateUseCase(
|
||||
repository = repository,
|
||||
appInfoProvider = appInfoProvider,
|
||||
)
|
||||
}
|
||||
|
|
@ -11,10 +11,17 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.datasource)
|
||||
|
||||
implementation(projects.domain.appsflyer)
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.appsflyer)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -11,27 +11,45 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.domain.assetsdiscovery)
|
||||
implementation(projects.domain.tokens)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.walletManager)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.tangemSdkApi)
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
implementation(deps.androidx.datastore)
|
||||
// endregion
|
||||
|
||||
// region Tangem SDK
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
implementation(deps.androidx.datastore)
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.moshi)
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Data
|
||||
api(projects.data.common)
|
||||
implementation(projects.data.walletManager)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.assetsdiscovery)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.models)
|
||||
implementation(projects.domain.wallets)
|
||||
runtimeOnly(projects.domain.tokens)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -11,18 +11,29 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.androidx.appCompat)
|
||||
|
||||
/** DI */
|
||||
// region Kotlin
|
||||
api(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
api(deps.lifecycle.runtime.ktx)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
implementation(deps.kotlin.coroutines)
|
||||
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.datasource)
|
||||
// endregion
|
||||
|
||||
implementation(projects.domain.balanceHiding)
|
||||
// region Domain
|
||||
api(projects.domain.balanceHiding)
|
||||
implementation(projects.domain.balanceHiding.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,35 +10,43 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
/* Project - Domain */
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.blockaid)
|
||||
implementation(projects.domain.blockaid.models)
|
||||
|
||||
/* Project - Data */
|
||||
implementation(projects.core.datasource)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/* Project - Core */
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Other libraries
|
||||
api(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
/* DI */
|
||||
// region Tangem libraries
|
||||
api(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.core)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/* Tangem libraries */
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
/* Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
// region Domain
|
||||
api(projects.domain.blockaid)
|
||||
api(projects.domain.blockaid.models)
|
||||
api(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
/* Tests */
|
||||
// region Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.turbine)
|
||||
testImplementation(deps.test.truth)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -11,27 +11,40 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(deps.androidx.datastore)
|
||||
|
||||
implementation(tangemDeps.blockchain) {
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
api(deps.androidx.fragment)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
// region Tangem SDK
|
||||
api(tangemDeps.blockchain) {
|
||||
exclude(module = "joda-time")
|
||||
}
|
||||
api(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
implementation(tangemDeps.card.android)
|
||||
implementation(tangemDeps.card.core)
|
||||
|
||||
implementation(projects.core.datasource)
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.models)
|
||||
// region Domain
|
||||
api(projects.domain.card)
|
||||
api(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(projects.test.core)
|
||||
// end
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -9,41 +9,56 @@ android {
|
|||
namespace = "com.tangem.data.common"
|
||||
}
|
||||
dependencies {
|
||||
/* Core */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/* Domain */
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.demo)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.networks)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.wallets)
|
||||
// region Kotlin
|
||||
api(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/* Libs - SDK */
|
||||
implementation(tangemDeps.blockchain)
|
||||
// region Other libraries
|
||||
api(deps.arrow.core)
|
||||
api(deps.jodatime)
|
||||
implementation(deps.androidx.annotation)
|
||||
implementation(deps.androidx.datastore)
|
||||
// endregion
|
||||
|
||||
// region Libs - SDK
|
||||
api(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(projects.libs.crypto)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
/* DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/* Libs - Other */
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// region Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
/* Test */
|
||||
// region Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.walletManager)
|
||||
api(projects.domain.wallets)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.core)
|
||||
runtimeOnly(projects.domain.account)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
api(projects.domain.demo.models)
|
||||
api(projects.domain.express.models)
|
||||
api(projects.domain.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
// endregion
|
||||
|
||||
// region Test
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(deps.moshi)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -37,5 +37,6 @@ interface ETagsStore {
|
|||
enum class Key {
|
||||
WalletAccounts,
|
||||
UserTokens,
|
||||
AddressBook,
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting
|
|||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.FeePaidCurrency
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.domain.card.common.extensions.canHandleToken
|
||||
|
|
@ -217,173 +218,6 @@ class NetworkFactory @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun Blockchain.getSupportedTransactionExtras(): Network.TransactionExtrasType {
|
||||
return when (this) {
|
||||
Blockchain.XRP -> Network.TransactionExtrasType.DESTINATION_TAG
|
||||
Blockchain.Binance,
|
||||
Blockchain.TON,
|
||||
Blockchain.Cosmos,
|
||||
Blockchain.Gonka,
|
||||
Blockchain.TerraV1,
|
||||
Blockchain.TerraV2,
|
||||
Blockchain.Stellar,
|
||||
Blockchain.Hedera,
|
||||
Blockchain.Algorand,
|
||||
Blockchain.Sei,
|
||||
Blockchain.InternetComputer,
|
||||
Blockchain.Casper,
|
||||
-> Network.TransactionExtrasType.MEMO
|
||||
// region Other blockchains
|
||||
Blockchain.Unknown,
|
||||
Blockchain.Alephium,
|
||||
Blockchain.AlephiumTestnet,
|
||||
Blockchain.Arbitrum,
|
||||
Blockchain.ArbitrumTestnet,
|
||||
Blockchain.Avalanche,
|
||||
Blockchain.AvalancheTestnet,
|
||||
Blockchain.BinanceTestnet,
|
||||
Blockchain.BSC,
|
||||
Blockchain.BSCTestnet,
|
||||
Blockchain.Bitcoin,
|
||||
Blockchain.BitcoinTestnet,
|
||||
Blockchain.BitcoinCash,
|
||||
Blockchain.BitcoinCashTestnet,
|
||||
Blockchain.Cardano,
|
||||
Blockchain.CosmosTestnet,
|
||||
Blockchain.Dogecoin,
|
||||
Blockchain.Ducatus,
|
||||
Blockchain.Ethereum,
|
||||
Blockchain.EthereumTestnet,
|
||||
Blockchain.EthereumClassic,
|
||||
Blockchain.EthereumClassicTestnet,
|
||||
Blockchain.Fantom,
|
||||
Blockchain.FantomTestnet,
|
||||
Blockchain.Litecoin,
|
||||
Blockchain.Near,
|
||||
Blockchain.NearTestnet,
|
||||
Blockchain.Polkadot,
|
||||
Blockchain.PolkadotTestnet,
|
||||
Blockchain.Kava,
|
||||
Blockchain.KavaTestnet,
|
||||
Blockchain.Kusama,
|
||||
Blockchain.Polygon,
|
||||
Blockchain.PolygonTestnet,
|
||||
Blockchain.RSK,
|
||||
Blockchain.SeiTestnet,
|
||||
Blockchain.StellarTestnet,
|
||||
Blockchain.Solana,
|
||||
Blockchain.SolanaTestnet,
|
||||
Blockchain.Tezos,
|
||||
Blockchain.Tron,
|
||||
Blockchain.TronTestnet,
|
||||
Blockchain.Gnosis,
|
||||
Blockchain.Dash,
|
||||
Blockchain.Optimism,
|
||||
Blockchain.OptimismTestnet,
|
||||
Blockchain.Dischain,
|
||||
Blockchain.EthereumPow,
|
||||
Blockchain.EthereumPowTestnet,
|
||||
Blockchain.Kaspa,
|
||||
Blockchain.KaspaTestnet,
|
||||
Blockchain.Telos,
|
||||
Blockchain.TelosTestnet,
|
||||
Blockchain.TONTestnet,
|
||||
Blockchain.Ravencoin,
|
||||
Blockchain.Clore,
|
||||
Blockchain.RavencoinTestnet,
|
||||
Blockchain.Cronos,
|
||||
Blockchain.AlephZero,
|
||||
Blockchain.AlephZeroTestnet,
|
||||
Blockchain.OctaSpace,
|
||||
Blockchain.OctaSpaceTestnet,
|
||||
Blockchain.Chia,
|
||||
Blockchain.ChiaTestnet,
|
||||
Blockchain.Decimal,
|
||||
Blockchain.DecimalTestnet,
|
||||
Blockchain.XDC,
|
||||
Blockchain.XDCTestnet,
|
||||
Blockchain.VeChain,
|
||||
Blockchain.VeChainTestnet,
|
||||
Blockchain.Aptos,
|
||||
Blockchain.AptosTestnet,
|
||||
Blockchain.Playa3ull,
|
||||
Blockchain.Shibarium,
|
||||
Blockchain.ShibariumTestnet,
|
||||
Blockchain.AlgorandTestnet,
|
||||
Blockchain.HederaTestnet,
|
||||
Blockchain.Aurora,
|
||||
Blockchain.AuroraTestnet,
|
||||
Blockchain.Areon,
|
||||
Blockchain.AreonTestnet,
|
||||
Blockchain.PulseChain,
|
||||
Blockchain.PulseChainTestnet,
|
||||
Blockchain.ZkSyncEra,
|
||||
Blockchain.ZkSyncEraTestnet,
|
||||
Blockchain.Nexa,
|
||||
Blockchain.NexaTestnet,
|
||||
Blockchain.Moonbeam,
|
||||
Blockchain.MoonbeamTestnet,
|
||||
Blockchain.Manta,
|
||||
Blockchain.MantaTestnet,
|
||||
Blockchain.PolygonZkEVM,
|
||||
Blockchain.PolygonZkEVMTestnet,
|
||||
Blockchain.Radiant,
|
||||
Blockchain.Fact0rn,
|
||||
Blockchain.Base,
|
||||
Blockchain.BaseTestnet,
|
||||
Blockchain.Moonriver,
|
||||
Blockchain.MoonriverTestnet,
|
||||
Blockchain.Mantle,
|
||||
Blockchain.MantleTestnet,
|
||||
Blockchain.Flare,
|
||||
Blockchain.FlareTestnet,
|
||||
Blockchain.Taraxa,
|
||||
Blockchain.TaraxaTestnet,
|
||||
Blockchain.Koinos,
|
||||
Blockchain.KoinosTestnet,
|
||||
Blockchain.Joystream,
|
||||
Blockchain.Bittensor,
|
||||
Blockchain.Filecoin,
|
||||
Blockchain.Blast,
|
||||
Blockchain.BlastTestnet,
|
||||
Blockchain.Cyber,
|
||||
Blockchain.CyberTestnet,
|
||||
Blockchain.Sui,
|
||||
Blockchain.SuiTestnet,
|
||||
Blockchain.EnergyWebChain,
|
||||
Blockchain.EnergyWebChainTestnet,
|
||||
Blockchain.EnergyWebX,
|
||||
Blockchain.EnergyWebXTestnet,
|
||||
Blockchain.CasperTestnet,
|
||||
Blockchain.Core,
|
||||
Blockchain.CoreTestnet,
|
||||
Blockchain.Xodex,
|
||||
Blockchain.Canxium,
|
||||
Blockchain.Chiliz,
|
||||
Blockchain.ChilizTestnet,
|
||||
Blockchain.VanarChain,
|
||||
Blockchain.VanarChainTestnet,
|
||||
Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet,
|
||||
Blockchain.Bitrock, Blockchain.BitrockTestnet,
|
||||
Blockchain.Sonic, Blockchain.SonicTestnet,
|
||||
Blockchain.ApeChain, Blockchain.ApeChainTestnet,
|
||||
Blockchain.Scroll, Blockchain.ScrollTestnet,
|
||||
Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet,
|
||||
Blockchain.Pepecoin, Blockchain.PepecoinTestnet,
|
||||
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet,
|
||||
Blockchain.Quai, Blockchain.QuaiTestnet,
|
||||
Blockchain.Linea, Blockchain.LineaTestnet,
|
||||
Blockchain.ArbitrumNova,
|
||||
Blockchain.Plasma, Blockchain.PlasmaTestnet,
|
||||
Blockchain.Adi, Blockchain.AdiTestnet,
|
||||
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet,
|
||||
Blockchain.Monad, Blockchain.MonadTestnet,
|
||||
-> Network.TransactionExtrasType.NONE
|
||||
// endregion
|
||||
}
|
||||
}
|
||||
|
||||
private fun Blockchain.getNameResolvingType(): Network.NameResolvingType {
|
||||
return when (this) {
|
||||
Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.NameResolvingType.ENS
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.data.common.txhistory
|
||||
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
|
||||
|
||||
/**
|
||||
* Persists express (exchange/onramp) transactions into the local tx-history database and fetches any missing token
|
||||
* metadata for the referenced assets.
|
||||
*/
|
||||
interface ExpressHistoryRepository {
|
||||
|
||||
suspend fun storeExchanges(items: List<ExchangeItemResponse>)
|
||||
|
||||
suspend fun storeOnramps(items: List<OnrampItemResponse>)
|
||||
}
|
||||
|
|
@ -387,6 +387,7 @@ class NetworkFactoryTest {
|
|||
Blockchain.CosmosTestnet,
|
||||
Blockchain.Dogecoin,
|
||||
Blockchain.Ducatus,
|
||||
Blockchain.Gonka,
|
||||
Blockchain.Ethereum, Blockchain.EthereumTestnet,
|
||||
Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet,
|
||||
Blockchain.Fantom, Blockchain.FantomTestnet,
|
||||
|
|
|
|||
|
|
@ -9,28 +9,17 @@ android {
|
|||
namespace = "com.tangem.data.dynamicaddresses"
|
||||
}
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.dynamicAddresses)
|
||||
implementation(projects.domain.dynamicAddresses.models)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.walletManager)
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(tangemDeps.blockchain) { exclude(module = "joda-time") }
|
||||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -38,7 +27,29 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Project - Core
|
||||
api(projects.core.configToggles)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.datasource)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.account)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.dynamicAddresses)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.walletManager)
|
||||
implementation(projects.domain.dynamicAddresses.models)
|
||||
// endregion
|
||||
|
||||
// region Testing
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -10,23 +10,14 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
implementation(projects.domain.earn)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.account.status)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -34,10 +25,24 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(tangemDeps.blockchain)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.pagination)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.earn)
|
||||
implementation(projects.domain.models)
|
||||
runtimeOnly(projects.domain.account.status)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ plugins {
|
|||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
|
|
@ -13,26 +12,35 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Data */
|
||||
implementation(projects.data.common)
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.express)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.txhistory)
|
||||
api(projects.domain.models)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.express)
|
||||
api(projects.domain.express.models)
|
||||
api(projects.domain.txhistory)
|
||||
implementation(projects.domain.core)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -10,8 +10,9 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
// region AndroidX libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -24,27 +25,23 @@ dependencies {
|
|||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
// Other libraries
|
||||
// endregion
|
||||
|
||||
// region Core modules
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
api(projects.core.configToggles)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.navigation)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// Feature modules
|
||||
implementation(projects.features.hotWallet.api)
|
||||
|
||||
implementation(projects.domain.feedback)
|
||||
implementation(projects.domain.feedback.models)
|
||||
implementation(projects.domain.legacy)
|
||||
// region Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.feedback)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.feedback.models)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -9,5 +9,5 @@ internal class DefaultFeedbackFeatureToggles(
|
|||
) : FeedbackFeatureToggles {
|
||||
|
||||
override val isUsedeskEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.USEDESK_ENABLED)
|
||||
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.TWI_485_USEDESK_ENABLED)
|
||||
}
|
||||
|
|
@ -11,17 +11,29 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
implementation(projects.domain.hotWallet)
|
||||
implementation(projects.domain.models)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.annotation)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.hotWallet)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ plugins {
|
|||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
|
|
@ -13,44 +12,52 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
/** Libs */
|
||||
implementation(projects.libs.crypto)
|
||||
|
||||
/** Project - Domain */
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.demo)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.manageTokens)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.legacy)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Project - Data */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.tokens)
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
|
||||
/** Project - Utils */
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.pagination)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
/** Tangem SDKs */
|
||||
// region Tangem SDKs
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
/** AndroidX */
|
||||
implementation(deps.androidx.datastore)
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.core)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Other */
|
||||
implementation(deps.moshi.kotlin)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.pagination)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
runtimeOnly(projects.data.tokens)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.manageTokens)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.wallets)
|
||||
runtimeOnly(projects.domain.account)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain models
|
||||
implementation(projects.domain.manageTokens.models)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -24,6 +24,8 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.wallets.config.curvesConfig
|
||||
import com.tangem.lib.crypto.derivation.supportsDerivationPath
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
|
@ -217,6 +219,20 @@ internal class DefaultCustomTokensRepository(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun isDerivationPathSupported(
|
||||
userWalletId: UserWalletId,
|
||||
networkId: Network.ID,
|
||||
derivationPath: Network.DerivationPath,
|
||||
): Boolean = withContext(dispatchers.io) {
|
||||
val rawPath = derivationPath.value ?: return@withContext true
|
||||
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
|
||||
val blockchain = networkId.toBlockchain()
|
||||
val curve = userWallet.curvesConfig.primaryCurve(blockchain) ?: return@withContext false
|
||||
val path = runCatching { DerivationPath(rawPath) }.getOrNull() ?: return@withContext false
|
||||
|
||||
curve.supportsDerivationPath(path)
|
||||
}
|
||||
|
||||
override suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> = withContext(dispatchers.io) {
|
||||
when (val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)) {
|
||||
is UserWallet.Hot -> {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ plugins {
|
|||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
|
|
@ -14,39 +13,48 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
/** Libs */
|
||||
implementation(projects.libs.crypto)
|
||||
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.pagination)
|
||||
implementation(projects.core.analytics)
|
||||
implementation(projects.core.analytics.models)
|
||||
implementation(projects.core.configToggles)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.markets)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.tokens)
|
||||
|
||||
implementation(projects.data.common)
|
||||
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
implementation(tangemDeps.blockchain)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Others dependencies
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(tangemDeps.blockchain)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// region Core modules
|
||||
api(projects.core.analytics)
|
||||
api(projects.core.analytics.models)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.pagination)
|
||||
// endregion
|
||||
|
||||
// region Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.markets)
|
||||
implementation(projects.domain.models)
|
||||
runtimeOnly(projects.domain.tokens)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
implementation(projects.domain.markets.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
// endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,27 +9,18 @@ android {
|
|||
namespace = "com.tangem.data.networks"
|
||||
}
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.dynamicAddresses)
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.networks)
|
||||
implementation(projects.domain.walletManager)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Project - Libs (SDK)
|
||||
implementation(tangemDeps.blockchain) { exclude(module = "joda-time") }
|
||||
// endregion
|
||||
|
||||
|
|
@ -38,15 +29,32 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// region Project - Core
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.datasource)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
api(projects.data.dynamicAddresses)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.core)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.networks)
|
||||
api(projects.domain.walletManager)
|
||||
runtimeOnly(projects.domain.card)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(tangemDeps.blockchain)
|
||||
testImplementation(tangemDeps.card.core)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.domain.card)
|
||||
testImplementation(projects.test.core)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
|
|||
commonNetworkStatusFetcher.fetch(
|
||||
userWalletId = params.userWalletId,
|
||||
network = network,
|
||||
networkCurrencies = networksCurrencies[network].orEmpty().toSet(),
|
||||
networkCurrencies = networksCurrencies[network].orEmpty().toSet() + params.extraTokens,
|
||||
xpub = xpubByNetwork[network],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ internal class DefaultSingleNetworkStatusFetcher @Inject constructor(
|
|||
params = MultiNetworkStatusFetcher.Params(
|
||||
userWalletId = params.userWalletId,
|
||||
networks = setOf(params.network),
|
||||
extraTokens = params.extraTokens,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,21 +9,13 @@ android {
|
|||
namespace = "com.tangem.data.news"
|
||||
}
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
implementation(projects.domain.news)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -31,16 +23,19 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi.kotlin)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.pagination)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.news)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(projects.common.test)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -4,55 +4,60 @@ plugins {
|
|||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.nft"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
/** Project - Data */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.data.common)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Project - Domain */
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.arrow.fx)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
|
||||
// region Libs - Tangem
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.walletManager)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.nft)
|
||||
implementation(projects.domain.nft.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// endregion
|
||||
|
||||
/** Project - Utils */
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Project - Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
/** Feature Api modules */
|
||||
implementation(projects.features.nft.api)
|
||||
|
||||
/** Libs - Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.arrow.fx)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.androidx.paging.runtime)
|
||||
implementation(deps.moshi.kotlin)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
|
||||
/** Libs - Tangem */
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
testImplementation(projects.test.core)
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(projects.common.test)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -10,13 +10,19 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
// region sdk
|
||||
implementation(tangemDeps.blockchain)
|
||||
|
||||
// region Kotlin
|
||||
api(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region AndroidX libraries
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
// region SDK
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -24,29 +30,23 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Arrow
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region Core modules
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.ui)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.common.ui)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region Domain modules
|
||||
api(projects.domain.notifications)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.notifications.models)
|
||||
implementation(projects.domain.notifications)
|
||||
// endregion
|
||||
|
||||
// region tests
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.moshi)
|
||||
testImplementation(deps.moshi.kotlin)
|
||||
testImplementation(deps.test.truth)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -11,8 +11,13 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
// region AndroidX libraries
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -21,11 +26,12 @@ dependencies {
|
|||
// endregion
|
||||
|
||||
// region Core modules
|
||||
implementation(projects.core.datasource)
|
||||
api(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Domain modules
|
||||
implementation(projects.domain.onboarding)
|
||||
api(projects.domain.onboarding)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -14,52 +14,61 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
/** Core modules */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.analytics)
|
||||
|
||||
/** Common modules */
|
||||
implementation(projects.data.common)
|
||||
|
||||
/** Domain modules */
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.onramp)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.appTheme.models)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.txhistory)
|
||||
|
||||
// region DI
|
||||
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.serialization)
|
||||
// endregion
|
||||
|
||||
// region Others dependencies
|
||||
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
implementation(deps.kotlin.serialization)
|
||||
// endregion
|
||||
|
||||
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(tangemDeps.blockchain) {
|
||||
// region Tangem SDK
|
||||
api(tangemDeps.blockchain) {
|
||||
exclude(module = "joda-time")
|
||||
}
|
||||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Core modules
|
||||
api(projects.core.analytics)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.analytics.models)
|
||||
// endregion
|
||||
|
||||
// region Data modules
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Domain modules
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.onramp)
|
||||
api(projects.domain.txhistory)
|
||||
api(projects.domain.walletManager)
|
||||
implementation(projects.domain.card)
|
||||
runtimeOnly(projects.domain.account)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.onramp.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.net.Uri
|
|||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.blockchain.extensions.toBigDecimalOrDefault
|
||||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.common.txhistory.ExpressHistoryRepository
|
||||
import com.tangem.data.onramp.converters.CountryConverter
|
||||
import com.tangem.data.onramp.converters.CurrencyConverter
|
||||
import com.tangem.data.onramp.converters.PaymentMethodConverter
|
||||
|
|
@ -76,6 +77,7 @@ internal class DefaultOnrampRepository(
|
|||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val dataSignatureVerifier: DataSignatureVerifier,
|
||||
private val expressHistoryDao: ExpressHistoryDao,
|
||||
private val expressHistoryRepository: ExpressHistoryRepository,
|
||||
private val txHistoryFeatureToggles: TxHistoryFeatureToggles,
|
||||
moshi: Moshi,
|
||||
) : OnrampRepository {
|
||||
|
|
@ -115,7 +117,7 @@ internal class DefaultOnrampRepository(
|
|||
override suspend fun fetchCountries(userWallet: UserWallet): List<OnrampCountry> = withContext(dispatchers.io) {
|
||||
if (!countriesStore.getSyncOrNull(COUNTRIES_KEY).isNullOrEmpty()) return@withContext emptyList()
|
||||
|
||||
val result = onrampApi.getCountries(
|
||||
val response = onrampApi.getCountries(
|
||||
userWalletId = userWallet.walletId.stringValue,
|
||||
refCode = ExpressUtils.getRefCode(
|
||||
userWallet = userWallet,
|
||||
|
|
@ -123,8 +125,12 @@ internal class DefaultOnrampRepository(
|
|||
),
|
||||
)
|
||||
.getOrThrow()
|
||||
.map(countryConverter::convert)
|
||||
|
||||
if (txHistoryFeatureToggles.isNewTxHistoryEnabled) {
|
||||
expressHistoryDao.upsertCountries(response.map { it.toEntity() })
|
||||
}
|
||||
|
||||
val result = response.map(countryConverter::convert)
|
||||
countriesStore.store(COUNTRIES_KEY, result)
|
||||
|
||||
result
|
||||
|
|
@ -168,7 +174,7 @@ internal class DefaultOnrampRepository(
|
|||
.getOrThrow()
|
||||
|
||||
if (txHistoryFeatureToggles.isNewTxHistoryEnabled) {
|
||||
expressHistoryDao.upsertOnramps(listOf(response.toEntity(ownerAddress = response.payoutAddress)))
|
||||
expressHistoryRepository.storeOnramps(items = listOf(response))
|
||||
}
|
||||
|
||||
statusConverter.convert(response)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.squareup.moshi.Moshi
|
|||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.data.common.account.WalletAccountsFetcher
|
||||
import com.tangem.data.common.txhistory.ExpressHistoryRepository
|
||||
import com.tangem.data.onramp.DefaultHotCryptoRepository
|
||||
import com.tangem.data.onramp.DefaultOnrampErrorResolver
|
||||
import com.tangem.data.onramp.DefaultOnrampRepository
|
||||
|
|
@ -59,6 +60,7 @@ internal object OnrampDataModule {
|
|||
dataSignatureVerifier: DataSignatureVerifier,
|
||||
onrampCurrentCountryByIPStore: OnrampCurrentCountryByIPStore,
|
||||
expressHistoryDao: ExpressHistoryDao,
|
||||
expressHistoryRepository: ExpressHistoryRepository,
|
||||
txHistoryFeatureToggles: TxHistoryFeatureToggles,
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
): OnrampRepository {
|
||||
|
|
@ -76,6 +78,7 @@ internal object OnrampDataModule {
|
|||
walletManagersFacade = walletManagersFacade,
|
||||
dataSignatureVerifier = dataSignatureVerifier,
|
||||
expressHistoryDao = expressHistoryDao,
|
||||
expressHistoryRepository = expressHistoryRepository,
|
||||
txHistoryFeatureToggles = txHistoryFeatureToggles,
|
||||
moshi = moshi,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,48 +1,9 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.payment"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Project - Data */
|
||||
implementation(projects.core.error)
|
||||
implementation(projects.core.error.ext)
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.wallets)
|
||||
|
||||
/** Project - Domain */
|
||||
implementation(projects.domain.payment)
|
||||
implementation(projects.domain.payment.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.common)
|
||||
|
||||
/** Project - Utils */
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
/** Libs - Tangem */
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(tangemDeps.card.android)
|
||||
implementation(tangemDeps.hot.core)
|
||||
implementation(projects.libs.tangemSdkApi)
|
||||
|
||||
/** Libs - Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.moshi.kotlin)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,29 +11,35 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
/** Domain */
|
||||
implementation(projects.domain.pushNotificationPreferences)
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** DI */
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Tests */
|
||||
testImplementation(deps.test.junit5)
|
||||
// region Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.pushNotificationPreferences)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.turbine)
|
||||
testImplementation(deps.moshi)
|
||||
testImplementation(deps.moshi.kotlin)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -12,25 +12,37 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.qrScanning)
|
||||
implementation(projects.domain.qrScanning.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
/** SdK */
|
||||
// region SDK
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Tests */
|
||||
testImplementation(deps.test.junit5)
|
||||
// region Core modules
|
||||
implementation(projects.core.ui)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.qrScanning)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.qrScanning.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -9,22 +9,20 @@ android {
|
|||
namespace = "com.tangem.data.quotes"
|
||||
}
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
implementation(deps.androidx.annotation)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.quotes)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Tangem SDKs
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -32,13 +30,23 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Tangem SDKs
|
||||
implementation(tangemDeps.blockchain)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi.kotlin)
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.core)
|
||||
api(projects.domain.quotes)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
|||
import com.tangem.datasource.utils.MoshiDataStoreSerializer
|
||||
import com.tangem.datasource.utils.mapWithStringKeyTypes
|
||||
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
|
||||
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
|
||||
import com.tangem.domain.quotes.QuotesRepository
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
|
||||
|
|
@ -79,4 +80,12 @@ internal object QuotesDataModule {
|
|||
fun provideGetCurrencyUSDQuoteUseCase(quotesRepository: QuotesRepository): GetCurrencyUSDQuoteUseCase {
|
||||
return GetCurrencyUSDQuoteUseCase(quotesRepository)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideIsHighNetworkFeeUseCase(
|
||||
getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase,
|
||||
): IsHighNetworkFeeUseCase {
|
||||
return IsHighNetworkFeeUseCase(getCurrencyUSDQuoteUseCase)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ plugins {
|
|||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
|
|
@ -10,22 +11,15 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
implementation(projects.domain.search)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.account.status)
|
||||
implementation(projects.domain.markets.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.appCurrency)
|
||||
// region Other libraries
|
||||
api(deps.androidx.datastore)
|
||||
api(deps.moshi)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -33,8 +27,15 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi.kotlin)
|
||||
// region Project - Core
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.datasource)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.account.status)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.search)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -5,38 +5,40 @@ plugins {
|
|||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.settings"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
implementation(projects.domain.balanceHiding.models)
|
||||
implementation(projects.domain.settings)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Others dependencies
|
||||
implementation(deps.androidx.core)
|
||||
implementation(deps.androidx.datastore)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.settings)
|
||||
// endregion
|
||||
|
||||
// region Test
|
||||
testImplementation(projects.test.core)
|
||||
// endregion
|
||||
|
||||
// region Others dependencies
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
package com.tangem.data.settings
|
||||
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.utils.get
|
||||
import com.tangem.domain.settings.UsedeskTokenTtlManager
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Development implementation of [UsedeskTokenTtlManager].
|
||||
*
|
||||
* Reads and writes the Usedesk chat token TTL from [AppPreferencesStore],
|
||||
* allowing testers to override it via the Tester Menu.
|
||||
* The preference [kotlinx.coroutines.flow.Flow] is converted to a [StateFlow] on construction,
|
||||
* so [getTokenTtlMillisSync] can be called from non-suspending contexts.
|
||||
* Defaults to [UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS] when no value is stored.
|
||||
*/
|
||||
internal class DevUsedeskTokenTtlManager(
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : UsedeskTokenTtlManager {
|
||||
|
||||
private val ttlMillisState: StateFlow<Long> =
|
||||
appPreferencesStore
|
||||
.get(key = USEDESK_TOKEN_TTL_MILLIS_KEY, default = UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
|
||||
.stateIn(
|
||||
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS,
|
||||
)
|
||||
|
||||
override fun getTokenTtlMillis(): StateFlow<Long> = ttlMillisState
|
||||
|
||||
override fun getTokenTtlMillisSync(): Long = ttlMillisState.value
|
||||
|
||||
override suspend fun setTokenTtlMillis(millis: Long) {
|
||||
appPreferencesStore.editData { preferences ->
|
||||
preferences[USEDESK_TOKEN_TTL_MILLIS_KEY] = millis
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val USEDESK_TOKEN_TTL_MILLIS_KEY = longPreferencesKey(name = "usedeskTokenTtlMillis")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.tangem.data.settings
|
||||
|
||||
import com.tangem.domain.settings.UsedeskTokenTtlManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Production implementation of [UsedeskTokenTtlManager].
|
||||
*
|
||||
* The Usedesk chat token TTL is fixed to [UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS] in production —
|
||||
* a tester override must never leak into release builds.
|
||||
* [setTokenTtlMillis] is a no-op since the TTL cannot be changed at runtime.
|
||||
*/
|
||||
internal class ProdUsedeskTokenTtlManager : UsedeskTokenTtlManager {
|
||||
|
||||
private val state: StateFlow<Long> = MutableStateFlow(UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
|
||||
|
||||
override fun getTokenTtlMillis(): StateFlow<Long> = state
|
||||
override fun getTokenTtlMillisSync(): Long = UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS
|
||||
override suspend fun setTokenTtlMillis(millis: Long) = Unit
|
||||
}
|
||||
|
|
@ -6,11 +6,14 @@ import com.tangem.data.settings.DefaultAppRatingRepository
|
|||
import com.tangem.data.settings.DefaultPermissionRepository
|
||||
import com.tangem.data.settings.DefaultSettingsRepository
|
||||
import com.tangem.data.settings.DevHotWalletRestrictionManager
|
||||
import com.tangem.data.settings.DevUsedeskTokenTtlManager
|
||||
import com.tangem.data.settings.ProdHotWalletRestrictionManager
|
||||
import com.tangem.data.settings.ProdUsedeskTokenTtlManager
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.logs.AppLogsStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.settings.HotWalletRestrictionManager
|
||||
import com.tangem.domain.settings.UsedeskTokenTtlManager
|
||||
import com.tangem.domain.settings.repositories.AppRatingRepository
|
||||
import com.tangem.domain.settings.repositories.PermissionRepository
|
||||
import com.tangem.domain.settings.repositories.SettingsRepository
|
||||
|
|
@ -72,4 +75,17 @@ internal object SettingsDataModule {
|
|||
ProdHotWalletRestrictionManager()
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUsedeskTokenTtlManager(
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): UsedeskTokenTtlManager {
|
||||
return if (BuildConfig.TESTER_MENU_ENABLED) {
|
||||
DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
|
||||
} else {
|
||||
ProdUsedeskTokenTtlManager()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package com.tangem.data.settings
|
||||
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.utils.get
|
||||
import com.tangem.domain.settings.UsedeskTokenTtlManager
|
||||
import com.tangem.test.core.getEmittedValues
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class DevUsedeskTokenTtlManagerTest {
|
||||
|
||||
private val appPreferencesStore = mockk<AppPreferencesStore>(relaxed = true)
|
||||
private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored ttl WHEN getTokenTtlMillis THEN emits stored value`() = runTest {
|
||||
// Arrange
|
||||
val storedTtl = 15L * 60 * 1000
|
||||
every {
|
||||
appPreferencesStore.get(key = any<Preferences.Key<Long>>(), default = DEFAULT)
|
||||
} returns flowOf(storedTtl)
|
||||
|
||||
val manager = DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
|
||||
|
||||
// Act
|
||||
val emitted = getEmittedValues(manager.getTokenTtlMillis())
|
||||
|
||||
// Assert
|
||||
assertThat(emitted).containsExactly(storedTtl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no stored ttl WHEN getTokenTtlMillisSync THEN returns default`() = runTest {
|
||||
// Arrange
|
||||
every {
|
||||
appPreferencesStore.get(key = any<Preferences.Key<Long>>(), default = DEFAULT)
|
||||
} returns flowOf(DEFAULT)
|
||||
|
||||
val manager = DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
|
||||
|
||||
// Act & Assert
|
||||
assertThat(manager.getTokenTtlMillisSync()).isEqualTo(DEFAULT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored ttl WHEN getTokenTtlMillisSync THEN returns cached value`() = runTest {
|
||||
// Arrange
|
||||
val storedTtl = 60L * 60 * 1000
|
||||
every {
|
||||
appPreferencesStore.get(key = any<Preferences.Key<Long>>(), default = DEFAULT)
|
||||
} returns flowOf(storedTtl)
|
||||
|
||||
val manager = DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
|
||||
|
||||
// Act & Assert
|
||||
assertThat(manager.getTokenTtlMillisSync()).isEqualTo(storedTtl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN setTokenTtlMillis THEN opens editData transaction`() = runTest {
|
||||
// Arrange
|
||||
every {
|
||||
appPreferencesStore.get(key = any<Preferences.Key<Long>>(), default = DEFAULT)
|
||||
} returns flowOf(DEFAULT)
|
||||
coEvery { appPreferencesStore.editData(any()) } returns mockk(relaxed = true)
|
||||
|
||||
val manager = DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
|
||||
|
||||
// Act
|
||||
manager.setTokenTtlMillis(millis = 15L * 60 * 1000)
|
||||
|
||||
// Assert
|
||||
coVerify { appPreferencesStore.editData(any()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val DEFAULT = UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.data.settings
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.settings.UsedeskTokenTtlManager
|
||||
import com.tangem.test.core.getEmittedValues
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class ProdUsedeskTokenTtlManagerTest {
|
||||
|
||||
private val manager = ProdUsedeskTokenTtlManager()
|
||||
|
||||
@Test
|
||||
fun `WHEN getTokenTtlMillis THEN always emits default`() = runTest {
|
||||
val emitted = getEmittedValues(manager.getTokenTtlMillis())
|
||||
|
||||
assertThat(emitted).containsExactly(UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN getTokenTtlMillisSync THEN returns default`() = runTest {
|
||||
assertThat(manager.getTokenTtlMillisSync()).isEqualTo(UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN override attempt WHEN setTokenTtlMillis THEN sync still returns default`() = runTest {
|
||||
manager.setTokenTtlMillis(millis = 15L * 60 * 1000)
|
||||
|
||||
assertThat(manager.getTokenTtlMillisSync()).isEqualTo(UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,66 +5,76 @@ plugins {
|
|||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.staking"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Core modules */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Common modules */
|
||||
implementation(projects.data.common)
|
||||
|
||||
/** Domain modules */
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.staking)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/** Feature Api modules */
|
||||
implementation(projects.features.staking.api)
|
||||
|
||||
// region DI
|
||||
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
// region Kotlin
|
||||
api(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.datetime)
|
||||
// endregion
|
||||
|
||||
// region Others dependencies
|
||||
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.datetime)
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
// region Other libraries
|
||||
api(deps.androidx.datastore)
|
||||
api(deps.moshi)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.firebase.crashlytics)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
|
||||
implementation(tangemDeps.card.core)
|
||||
// region Tangem SDK
|
||||
implementation(tangemDeps.blockchain) {
|
||||
exclude(module = "joda-time")
|
||||
}
|
||||
|
||||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
testImplementation(tangemDeps.card.core)
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Core modules
|
||||
api(projects.core.analytics)
|
||||
api(projects.core.configToggles)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.analytics.models)
|
||||
// endregion
|
||||
|
||||
// region Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.core)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.staking)
|
||||
api(projects.domain.walletManager)
|
||||
api(projects.domain.wallets)
|
||||
implementation(projects.domain.card)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
api(projects.domain.staking.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ internal class DefaultStakingFeatureToggles(
|
|||
}
|
||||
|
||||
private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) {
|
||||
is StakingIntegrationID.P2PEthPool -> FeatureToggles.STAKING_ETH_ENABLED
|
||||
is StakingIntegrationID.P2PEthPool -> null
|
||||
is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.tangem.data.staking.toggles
|
||||
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.domain.staking.model.StakingIntegrationID
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
|
@ -24,21 +23,10 @@ internal class DefaultStakingFeatureTogglesTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `P2PEthPool returns true when STAKING_ETH_ENABLED is enabled`() {
|
||||
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns true
|
||||
|
||||
fun `P2PEthPool integration is always enabled`() {
|
||||
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isTrue()
|
||||
|
||||
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `P2PEthPool returns false when STAKING_ETH_ENABLED is disabled`() {
|
||||
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns false
|
||||
|
||||
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isFalse()
|
||||
|
||||
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
|
||||
verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -11,17 +11,27 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(deps.androidx.datastore)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
implementation(projects.domain.stories)
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.stories)
|
||||
implementation(projects.domain.stories.models)
|
||||
api(projects.domain.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.features.referral.domain)
|
||||
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -11,56 +11,68 @@ android {
|
|||
namespace = "com.tangem.data.swap"
|
||||
}
|
||||
dependencies {
|
||||
/** Core */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Data */
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.express)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.express)
|
||||
implementation(projects.domain.swap.models)
|
||||
implementation(projects.domain.swap)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.tokens)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.quotes)
|
||||
implementation(projects.domain.networks)
|
||||
implementation(projects.domain.staking.models)
|
||||
implementation(projects.domain.staking)
|
||||
implementation(projects.domain.account)
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
// endregion
|
||||
|
||||
/** Tangem SDK */
|
||||
// region Tangem SDK
|
||||
implementation(tangemDeps.blockchain) {
|
||||
exclude(module = "joda-time")
|
||||
}
|
||||
// endregion
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.configToggles)
|
||||
|
||||
/** Libs */
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Test */
|
||||
testImplementation(tangemDeps.card.core)
|
||||
// region Core
|
||||
api(projects.core.configToggles)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Data
|
||||
api(projects.data.common)
|
||||
implementation(projects.data.express)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.account)
|
||||
api(projects.domain.express)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.quotes)
|
||||
api(projects.domain.swap)
|
||||
implementation(projects.domain.core)
|
||||
implementation(projects.domain.tokens)
|
||||
runtimeOnly(projects.domain.staking)
|
||||
runtimeOnly(projects.domain.wallets)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
api(projects.domain.swap.models)
|
||||
implementation(projects.domain.express.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
// endregion
|
||||
|
||||
// region Test
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -4,58 +4,29 @@ plugins {
|
|||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.tokens"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.networks)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.core)
|
||||
implementation(projects.domain.demo)
|
||||
implementation(projects.domain.express)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.staking)
|
||||
implementation(projects.domain.staking.models)
|
||||
implementation(projects.domain.tokens)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.txhistory.models)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.transaction)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// endregion
|
||||
|
||||
// region Project - Utils
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Features API
|
||||
implementation(projects.features.send.api)
|
||||
// endregion
|
||||
|
||||
// region Tangem SDKs
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
// region AndroidX
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.androidx.paging.runtime)
|
||||
implementation(deps.arrow.atomic)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.moshi)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
|
||||
// region Tangem SDK
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -63,13 +34,39 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Other
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(deps.retrofit) // For HttpException
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Data
|
||||
api(projects.data.common)
|
||||
runtimeOnly(projects.data.networks)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.tokens)
|
||||
api(projects.domain.transaction)
|
||||
api(projects.domain.walletManager)
|
||||
implementation(projects.domain.core)
|
||||
implementation(projects.domain.models)
|
||||
runtimeOnly(projects.domain.account)
|
||||
runtimeOnly(projects.domain.card)
|
||||
runtimeOnly(projects.domain.staking)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.walletManager.models)
|
||||
// endregion
|
||||
|
||||
// region Common
|
||||
implementation(projects.common)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
|
|
|
|||
|
|
@ -11,41 +11,55 @@ android {
|
|||
}
|
||||
dependencies {
|
||||
|
||||
/** Tangem SDKs */
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.core.ktx)
|
||||
// endregion
|
||||
|
||||
/** Common */
|
||||
implementation(projects.data.common)
|
||||
// region Tangem SDKs
|
||||
api(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.transaction.models)
|
||||
implementation(projects.domain.transaction)
|
||||
implementation(projects.domain.demo)
|
||||
|
||||
/** Api */
|
||||
implementation(projects.features.send.api)
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Other */
|
||||
// region Core
|
||||
api(projects.core.configToggles)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
/** tests */
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(deps.test.junit5)
|
||||
// region Common
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.transaction)
|
||||
api(projects.domain.walletManager)
|
||||
implementation(projects.domain.demo)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
api(projects.domain.wallets.models)
|
||||
implementation(projects.domain.demo.models)
|
||||
implementation(projects.domain.transaction.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
// endregion
|
||||
|
||||
// region tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -3,17 +3,23 @@ package com.tangem.data.transaction
|
|||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.data.transaction.convertes.GaslessBatchTransactionRequestBuilder
|
||||
import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter
|
||||
import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder
|
||||
import com.tangem.data.transaction.convertes.GaslessTxDataToGaslessRequestConverter
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
|
@ -23,17 +29,19 @@ import java.math.BigInteger
|
|||
|
||||
class DefaultGaslessTransactionRepository(
|
||||
private val gaslessTxServiceApi: GaslessTxServiceApi,
|
||||
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2,
|
||||
private val isGaslessV2Enabled: Boolean,
|
||||
private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
) : GaslessTransactionRepository {
|
||||
|
||||
private val supportedTokensState = MutableStateFlow<Map<Network.ID, Set<CryptoCurrency>>>(hashMapOf())
|
||||
private val allFeeRecipientAddress = mutableSetOf<String>()
|
||||
private val allAddressesMutex = Mutex()
|
||||
private val receiverAddressMutex = Mutex()
|
||||
private var feeReceiverAddress: String? = null
|
||||
|
||||
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder()
|
||||
private val requestConverter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = isGaslessV2Enabled)
|
||||
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder(requestConverter)
|
||||
private val gaslessBatchTransactionRequestBuilder = GaslessBatchTransactionRequestBuilder(requestConverter)
|
||||
private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter()
|
||||
|
||||
override suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency> {
|
||||
|
|
@ -109,7 +117,11 @@ class DefaultGaslessTransactionRepository(
|
|||
eip7702Auth = eip7702Auth,
|
||||
)
|
||||
|
||||
val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow()
|
||||
val response = if (isGaslessV2Enabled) {
|
||||
gaslessTxServiceApiV2.signGaslessTransaction(transactionRequest)
|
||||
} else {
|
||||
gaslessTxServiceApi.signGaslessTransaction(transactionRequest)
|
||||
}.getOrThrow()
|
||||
|
||||
if (!response.isSuccess) {
|
||||
error("Gasless service returned unsuccessful response")
|
||||
|
|
@ -119,6 +131,31 @@ class DefaultGaslessTransactionRepository(
|
|||
signedTransactionResultConverter.convert(response.result)
|
||||
}
|
||||
|
||||
override suspend fun signGaslessBatchTransaction(
|
||||
gaslessBatchTransactionData: GaslessBatchTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
network: Network,
|
||||
eip7702Auth: Eip7702Authorization?,
|
||||
): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) {
|
||||
val blockchain = network.toBlockchain()
|
||||
val transactionRequest = gaslessBatchTransactionRequestBuilder.build(
|
||||
gaslessBatchTransaction = gaslessBatchTransactionData,
|
||||
signature = signature,
|
||||
userAddress = userAddress,
|
||||
chainId = blockchain.getChainId() ?: error("ChainId is null for blockchain: $blockchain"),
|
||||
eip7702Auth = eip7702Auth,
|
||||
)
|
||||
|
||||
val response = gaslessTxServiceApiV2.signGaslessBatchTransaction(transactionRequest).getOrThrow()
|
||||
|
||||
if (!response.isSuccess) {
|
||||
error("Gasless service returned unsuccessful response")
|
||||
}
|
||||
|
||||
signedTransactionResultConverter.convert(response.result)
|
||||
}
|
||||
|
||||
override fun getBaseGasForTransaction(): BigInteger {
|
||||
return BASE_GAS_FOR_TRANSACTION
|
||||
}
|
||||
|
|
@ -129,21 +166,20 @@ class DefaultGaslessTransactionRepository(
|
|||
}
|
||||
|
||||
override suspend fun getGaslessFeeAddresses(): Set<String> {
|
||||
return allAddressesMutex.withLock {
|
||||
allFeeRecipientAddress.ifEmpty {
|
||||
val allFeeAddresses = getAllFeeRecipientAddresses()
|
||||
allFeeRecipientAddress.addAll(allFeeAddresses)
|
||||
allFeeRecipientAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getAllFeeRecipientAddresses(): Set<String> {
|
||||
// TODO Replace with other backend call to get all fee recipient addresses when available
|
||||
return setOf(getTokenFeeReceiverAddress())
|
||||
val backendAddress = runSuspendCatching { getTokenFeeReceiverAddress() }
|
||||
.onFailure { TangemLogger.e("Failed to load gasless fee recipient; serving hardcoded addresses", it) }
|
||||
.getOrNull()
|
||||
return KNOWN_FEE_COLLECTION_ADDRESSES + setOfNotNull(backendAddress)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("60000")
|
||||
|
||||
|
||||
val KNOWN_FEE_COLLECTION_ADDRESSES = setOf(
|
||||
"0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c",
|
||||
"0xAf722F46145fbb106379d506ED3a5B96f110c8E5",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import java.math.BigInteger
|
||||
|
|
@ -53,6 +54,16 @@ class MockedGaslessTransactionRepository(
|
|||
txHash = "0x000",
|
||||
)
|
||||
|
||||
override suspend fun signGaslessBatchTransaction(
|
||||
gaslessBatchTransactionData: GaslessBatchTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
network: Network,
|
||||
eip7702Auth: Eip7702Authorization?,
|
||||
): GaslessSignedTransactionResult = GaslessSignedTransactionResult(
|
||||
txHash = "0x000",
|
||||
)
|
||||
|
||||
override fun getBaseGasForTransaction(): BigInteger {
|
||||
return BASE_GAS_FOR_TRANSACTION
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
/**
|
||||
* Converts domain [Eip7702Authorization] to its DTO representation.
|
||||
* Shared by both single-transaction and batch-transaction request builders.
|
||||
*/
|
||||
class Eip7702AuthorizationConverter : Converter<Eip7702Authorization, Eip7702AuthorizationDTO> {
|
||||
|
||||
override fun convert(value: Eip7702Authorization): Eip7702AuthorizationDTO {
|
||||
return Eip7702AuthorizationDTO(
|
||||
chainId = value.chainId,
|
||||
address = value.address,
|
||||
nonce = value.nonce.toString(),
|
||||
yParity = value.yParity,
|
||||
r = value.r,
|
||||
s = value.s,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionDataDTO
|
||||
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionRequest
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
|
||||
/**
|
||||
* Builder for creating complete [GaslessBatchTransactionRequest] from domain model.
|
||||
* Combines batch transaction data with signature and user information.
|
||||
*
|
||||
* Reuses [GaslessTxDataToGaslessRequestConverter] for transaction and fee conversion
|
||||
* to avoid duplicating mapping logic.
|
||||
*/
|
||||
class GaslessBatchTransactionRequestBuilder(
|
||||
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
|
||||
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
|
||||
) {
|
||||
|
||||
/**
|
||||
* Creates complete gasless batch transaction request.
|
||||
*
|
||||
* @param gaslessBatchTransaction domain model of batch transaction
|
||||
* @param signature transaction signature in hex format (with 0x prefix)
|
||||
* @param userAddress user's Ethereum address
|
||||
* @param chainId blockchain network chain ID
|
||||
* @param eip7702Auth optional EIP-7702 authorization for account abstraction
|
||||
* @return complete request ready for API submission
|
||||
*/
|
||||
fun build(
|
||||
gaslessBatchTransaction: GaslessBatchTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
chainId: Int,
|
||||
eip7702Auth: Eip7702Authorization? = null,
|
||||
): GaslessBatchTransactionRequest {
|
||||
return GaslessBatchTransactionRequest(
|
||||
gaslessTransaction = GaslessBatchTransactionDataDTO(
|
||||
transactions = gaslessBatchTransaction.transactions.map { converter.convertTransaction(it) },
|
||||
fee = converter.convertFee(gaslessBatchTransaction.fee),
|
||||
nonce = gaslessBatchTransaction.nonce.toString(),
|
||||
),
|
||||
signature = signature,
|
||||
userAddress = userAddress,
|
||||
chainId = chainId,
|
||||
eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package com.tangem.data.transaction.convertes
|
|||
import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
||||
|
||||
/**
|
||||
* Builder for creating complete GaslessTransactionRequest from domain model.
|
||||
|
|
@ -11,6 +10,7 @@ import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
|||
*/
|
||||
class GaslessTransactionRequestBuilder(
|
||||
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
|
||||
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
|
||||
) {
|
||||
|
||||
/**
|
||||
|
|
@ -35,21 +35,7 @@ class GaslessTransactionRequestBuilder(
|
|||
signature = signature,
|
||||
userAddress = userAddress,
|
||||
chainId = chainId,
|
||||
eip7702Auth = eip7702Auth?.toDTO(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts domain Eip7702Authorization to DTO.
|
||||
*/
|
||||
private fun Eip7702Authorization.toDTO(): Eip7702AuthorizationDTO {
|
||||
return Eip7702AuthorizationDTO(
|
||||
chainId = chainId,
|
||||
address = address,
|
||||
nonce = nonce.toString(),
|
||||
yParity = yParity,
|
||||
r = r,
|
||||
s = s,
|
||||
eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,8 +13,13 @@ import com.tangem.datasource.api.gasless.models.GaslessTransactionData as Gasles
|
|||
* Note: This converter only handles the transaction data conversion.
|
||||
* Additional fields (signature, userAddress, chainId) must be added separately
|
||||
* to create complete GaslessTransactionRequest.
|
||||
*
|
||||
* @param shouldIncludeGasLimit when true (v2), serializes the per-call `gasLimit`; when false (v1), omits it so the
|
||||
* request matches the legacy v1 service. Must stay in sync with the EIP-712 message that was signed.
|
||||
*/
|
||||
class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
|
||||
class GaslessTxDataToGaslessRequestConverter(
|
||||
private val shouldIncludeGasLimit: Boolean = true,
|
||||
) : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
|
||||
|
||||
override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO {
|
||||
return GaslessTransactionDataDTO(
|
||||
|
|
@ -24,15 +29,16 @@ class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData,
|
|||
)
|
||||
}
|
||||
|
||||
private fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
|
||||
internal fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
|
||||
return TransactionData(
|
||||
to = transaction.to,
|
||||
value = transaction.value.toString(),
|
||||
gasLimit = transaction.gasLimit.toString().takeIf { shouldIncludeGasLimit },
|
||||
data = transaction.data.toHexString().formatHex(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
|
||||
internal fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
|
||||
return FeeData(
|
||||
feeToken = fee.feeToken,
|
||||
maxTokenFee = fee.maxTokenFee.toString(),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
|||
import com.tangem.data.transaction.*
|
||||
import com.tangem.data.transaction.error.DefaultFeeErrorResolver
|
||||
import com.tangem.blockchainsdk.BlockchainSDKFactory
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.walletmanager.WalletManagersStore
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
|
|
@ -84,10 +87,17 @@ internal object TransactionDataModule {
|
|||
fun provideGaslessTransactionRepository(
|
||||
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
gaslessTxServiceApi: GaslessTxServiceApi,
|
||||
gaslessTxServiceApiV2: GaslessTxServiceApiV2,
|
||||
featureTogglesManager: FeatureTogglesManager,
|
||||
coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
||||
): GaslessTransactionRepository {
|
||||
return DefaultGaslessTransactionRepository(
|
||||
gaslessTxServiceApi = gaslessTxServiceApi,
|
||||
gaslessTxServiceApiV2 = gaslessTxServiceApiV2,
|
||||
// Single master toggle for the whole gasless v2 protocol (+ yield-withdraw batch).
|
||||
isGaslessV2Enabled = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
|
||||
),
|
||||
coroutineDispatcherProvider = coroutineDispatcherProvider,
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
package com.tangem.data.transaction
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||
import com.tangem.datasource.api.gasless.models.GaslessFeeRecipient
|
||||
import com.tangem.datasource.api.gasless.models.GaslessServiceResponse
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class DefaultGaslessTransactionRepositoryTest {
|
||||
|
||||
private val gaslessTxServiceApi: GaslessTxServiceApi = mockk()
|
||||
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2 = mockk()
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(gaslessTxServiceApi, gaslessTxServiceApiV2, responseCryptoCurrenciesFactory)
|
||||
}
|
||||
|
||||
private fun createRepository() = DefaultGaslessTransactionRepository(
|
||||
gaslessTxServiceApi = gaslessTxServiceApi,
|
||||
gaslessTxServiceApiV2 = gaslessTxServiceApiV2,
|
||||
isGaslessV2Enabled = true,
|
||||
coroutineDispatcherProvider = TestingCoroutineDispatcherProvider(),
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
)
|
||||
|
||||
private fun stubFeeRecipientSuccess(address: String) {
|
||||
coEvery { gaslessTxServiceApi.getFeeRecipient() } returns ApiResponse.Success(
|
||||
data = GaslessServiceResponse(
|
||||
result = GaslessFeeRecipient(address = address),
|
||||
isSuccess = true,
|
||||
timestamp = "2026-06-11T00:00:00.000Z",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun stubFeeRecipientFailure() {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val error = ApiResponse.Error(cause = ApiResponseError.NetworkException())
|
||||
as ApiResponse<GaslessServiceResponse<GaslessFeeRecipient>>
|
||||
coEvery { gaslessTxServiceApi.getFeeRecipient() } returns error
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend returns recipient WHEN getGaslessFeeAddresses THEN hardcoded plus backend address`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientSuccess(BACKEND_ADDRESS)
|
||||
val repository = createRepository()
|
||||
|
||||
// Act
|
||||
val actual = repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend fails WHEN getGaslessFeeAddresses THEN hardcoded addresses only`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientFailure()
|
||||
val repository = createRepository()
|
||||
|
||||
// Act
|
||||
val actual = repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend fails then recovers WHEN called twice THEN second call includes backend address`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientFailure()
|
||||
val repository = createRepository()
|
||||
val firstResult = repository.getGaslessFeeAddresses()
|
||||
stubFeeRecipientSuccess(BACKEND_ADDRESS)
|
||||
|
||||
// Act
|
||||
val secondResult = repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
assertThat(firstResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2)
|
||||
assertThat(secondResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend succeeds WHEN called twice THEN fee recipient requested once`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientSuccess(BACKEND_ADDRESS)
|
||||
val repository = createRepository()
|
||||
|
||||
// Act
|
||||
repository.getGaslessFeeAddresses()
|
||||
repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { gaslessTxServiceApi.getFeeRecipient() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HARDCODED_ADDRESS_1 = "0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c"
|
||||
const val HARDCODED_ADDRESS_2 = "0xAf722F46145fbb106379d506ED3a5B96f110c8E5"
|
||||
const val BACKEND_ADDRESS = "0x1111111111111111111111111111111111111111"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigInteger
|
||||
|
||||
class GaslessBatchTransactionRequestBuilderTest {
|
||||
|
||||
private val builder = GaslessBatchTransactionRequestBuilder()
|
||||
|
||||
// byteArrayOf(0x12, 0x34).toHexString() == "1234" (uppercase), .formatHex() prepends "0x" → "0x1234"
|
||||
private val tx1Data = byteArrayOf(0x12, 0x34)
|
||||
private val tx1DataHex = "0x1234"
|
||||
|
||||
// byteArrayOf(0xAB.toByte(), 0xCD.toByte()).toHexString() == "ABCD", .formatHex() → "0xABCD"
|
||||
private val tx2Data = byteArrayOf(0xAB.toByte(), 0xCD.toByte())
|
||||
private val tx2DataHex = "0xABCD"
|
||||
|
||||
private val tx1 = GaslessTransactionData.Transaction(
|
||||
to = "0xContractA",
|
||||
value = BigInteger("100"),
|
||||
gasLimit = BigInteger("120000"),
|
||||
data = tx1Data,
|
||||
)
|
||||
private val tx2 = GaslessTransactionData.Transaction(
|
||||
to = "0xContractB",
|
||||
value = BigInteger("0"),
|
||||
gasLimit = BigInteger("150000"),
|
||||
data = tx2Data,
|
||||
)
|
||||
private val fee = GaslessTransactionData.Fee(
|
||||
feeToken = "0xFeeToken",
|
||||
maxTokenFee = BigInteger("500"),
|
||||
coinPriceInToken = BigInteger("200"),
|
||||
feeTransferGasLimit = BigInteger("21000"),
|
||||
baseGas = BigInteger("60000"),
|
||||
feeReceiver = "0xFeeReceiver",
|
||||
)
|
||||
private val nonce = BigInteger("42")
|
||||
|
||||
private val batchData = GaslessBatchTransactionData(
|
||||
transactions = listOf(tx1, tx2),
|
||||
fee = fee,
|
||||
nonce = nonce,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `build - transactions list has correct size and order`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
assertThat(result.gaslessTransaction.transactions).hasSize(2)
|
||||
assertThat(result.gaslessTransaction.transactions[0].to).isEqualTo("0xContractA")
|
||||
assertThat(result.gaslessTransaction.transactions[1].to).isEqualTo("0xContractB")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - transaction data fields are encoded correctly`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
val txDtoList = result.gaslessTransaction.transactions
|
||||
// data bytes are hex-encoded with 0x prefix (uppercase)
|
||||
assertThat(txDtoList[0].data).isEqualTo(tx1DataHex)
|
||||
assertThat(txDtoList[1].data).isEqualTo(tx2DataHex)
|
||||
// value is BigInteger.toString()
|
||||
assertThat(txDtoList[0].value).isEqualTo("100")
|
||||
assertThat(txDtoList[1].value).isEqualTo("0")
|
||||
// v2: per-call gasLimit is BigInteger.toString()
|
||||
assertThat(txDtoList[0].gasLimit).isEqualTo("120000")
|
||||
assertThat(txDtoList[1].gasLimit).isEqualTo("150000")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - v1 converter omits per-call gasLimit`() {
|
||||
// Arrange: a builder whose converter is in v1 mode (shouldIncludeGasLimit = false)
|
||||
val v1Builder = GaslessBatchTransactionRequestBuilder(
|
||||
converter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = false),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = v1Builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
// Assert: gasLimit is null so Moshi omits it, restoring the legacy v1 {to, value, data} shape
|
||||
val txDtoList = result.gaslessTransaction.transactions
|
||||
assertThat(txDtoList[0].gasLimit).isNull()
|
||||
assertThat(txDtoList[1].gasLimit).isNull()
|
||||
// other fields are unaffected
|
||||
assertThat(txDtoList[0].value).isEqualTo("100")
|
||||
assertThat(txDtoList[0].data).isEqualTo(tx1DataHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - fee fields are all toString of BigInteger inputs`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
val feeDto = result.gaslessTransaction.fee
|
||||
assertThat(feeDto.feeToken).isEqualTo("0xFeeToken")
|
||||
assertThat(feeDto.maxTokenFee).isEqualTo("500")
|
||||
assertThat(feeDto.coinPriceInToken).isEqualTo("200")
|
||||
assertThat(feeDto.feeTransferGasLimit).isEqualTo("21000")
|
||||
assertThat(feeDto.baseGas).isEqualTo("60000")
|
||||
assertThat(feeDto.feeReceiver).isEqualTo("0xFeeReceiver")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - nonce is toString of BigInteger input`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
assertThat(result.gaslessTransaction.nonce).isEqualTo("42")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - top-level signature, userAddress, chainId pass through`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xDeadBeef",
|
||||
userAddress = "0xAlice",
|
||||
chainId = 137,
|
||||
)
|
||||
|
||||
assertThat(result.signature).isEqualTo("0xDeadBeef")
|
||||
assertThat(result.userAddress).isEqualTo("0xAlice")
|
||||
assertThat(result.chainId).isEqualTo(137)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - eip7702Auth is null when not provided`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
assertThat(result.eip7702Auth).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - eip7702Auth maps correctly when provided`() {
|
||||
val auth = Eip7702Authorization(
|
||||
chainId = 1,
|
||||
address = "0xEntryPoint",
|
||||
nonce = BigInteger("7"),
|
||||
yParity = 0,
|
||||
r = "0xRValue",
|
||||
s = "0xSValue",
|
||||
)
|
||||
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
eip7702Auth = auth,
|
||||
)
|
||||
|
||||
val authDto = result.eip7702Auth
|
||||
assertThat(authDto).isNotNull()
|
||||
assertThat(authDto!!.chainId).isEqualTo(1)
|
||||
assertThat(authDto.address).isEqualTo("0xEntryPoint")
|
||||
assertThat(authDto.nonce).isEqualTo("7")
|
||||
assertThat(authDto.yParity).isEqualTo(0)
|
||||
assertThat(authDto.r).isEqualTo("0xRValue")
|
||||
assertThat(authDto.s).isEqualTo("0xSValue")
|
||||
}
|
||||
}
|
||||
|
|
@ -9,45 +9,66 @@ android {
|
|||
namespace = "com.tangem.data.txhistory"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.data.common)
|
||||
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.pagination)
|
||||
implementation(projects.core.analytics)
|
||||
// region Kotlin
|
||||
api(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.txhistory)
|
||||
implementation(projects.domain.txhistory.models)
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.account.status)
|
||||
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.annotation)
|
||||
implementation(deps.androidx.paging.runtime)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(deps.room.runtime)
|
||||
// endregion
|
||||
|
||||
// region Tangem SDK
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.core)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Core modules
|
||||
api(projects.core.analytics)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.pagination)
|
||||
// endregion
|
||||
|
||||
// region Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.account)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.express)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.onramp)
|
||||
api(projects.domain.txhistory)
|
||||
api(projects.domain.walletManager)
|
||||
api(projects.domain.wallets)
|
||||
implementation(projects.domain.account.status)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.onramp.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.txhistory.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region Test
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(projects.test.mock)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
package com.tangem.data.txhistory.di
|
||||
|
||||
import com.tangem.data.common.txhistory.ExpressHistoryRepository
|
||||
import com.tangem.data.txhistory.fetcher.DefaultAppTxHistoryFetcher
|
||||
import com.tangem.data.txhistory.fetcher.DefaultTxHistoryFetcherUtils
|
||||
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils
|
||||
import com.tangem.data.txhistory.repository.DefaultExpressHistoryRepository
|
||||
import com.tangem.data.txhistory.repository.DefaultTxHistoryRepository
|
||||
import com.tangem.data.txhistory.repository.RefactoredTxHistoryRepository
|
||||
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
|
||||
|
|
@ -32,4 +34,8 @@ internal interface TxHistoryDataModule {
|
|||
|
||||
@Binds
|
||||
fun provideTxHistoryFetcherUtils(default: DefaultTxHistoryFetcherUtils): TxHistoryFetcherUtils
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun provideExpressHistoryRepository(default: DefaultExpressHistoryRepository): ExpressHistoryRepository
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.cancelS
|
|||
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.defaultLaunchIn
|
||||
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.receiveTriggerInstance
|
||||
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.retryThreeTimes
|
||||
import com.tangem.data.txhistory.repository.ExpressHistoryRepository
|
||||
import com.tangem.data.txhistory.repository.DefaultExpressHistoryRepository
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
|
||||
|
|
@ -31,7 +31,7 @@ internal class DefaultExpressTxHistoryFetcher @AssistedInject constructor(
|
|||
@Assisted private val accountId: AccountId,
|
||||
private val utils: TxHistoryFetcherUtils,
|
||||
private val expressSyncStateDao: ExpressSyncStateDao,
|
||||
private val expressHistoryRepository: ExpressHistoryRepository,
|
||||
private val expressHistoryRepository: DefaultExpressHistoryRepository,
|
||||
) : ExpressTxHistoryFetcher, TxHistoryFetcherUtils by utils {
|
||||
|
||||
private val userWalletId: UserWalletId get() = accountId.userWalletId
|
||||
|
|
|
|||
|
|
@ -1,34 +1,46 @@
|
|||
package com.tangem.data.txhistory.repository
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import com.tangem.data.common.txhistory.ExpressHistoryRepository
|
||||
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntities
|
||||
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntity
|
||||
import com.tangem.data.txhistory.repository.factory.TokenInfoRepository
|
||||
import com.tangem.data.txhistory.repository.factory.toAssetId
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.express.TangemExpressApi
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
|
||||
import com.tangem.datasource.api.express.models.response.ExpressPagination
|
||||
import com.tangem.datasource.api.express.models.response.ExpressPaginationDelta
|
||||
import com.tangem.datasource.api.express.models.response.*
|
||||
import com.tangem.datasource.api.onramp.OnrampApi
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
|
||||
import com.tangem.datasource.local.converter.toEntity
|
||||
import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase
|
||||
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
|
||||
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
|
||||
import com.tangem.datasource.local.txhistory.db.dao.HistoryIndexDao
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Fetches express (exchange & onramp) transaction history from the API and persists it into the local database.
|
||||
*
|
||||
* Fetches express (exchange & onramp) transaction history from the API, persists it into the local database, and
|
||||
* fetches any missing token metadata for the referenced assets.
|
||||
*/
|
||||
internal class ExpressHistoryRepository @Inject constructor(
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultExpressHistoryRepository @Inject constructor(
|
||||
private val exchangeApi: TangemExpressApi,
|
||||
private val onrampApi: OnrampApi,
|
||||
private val expressHistoryDao: ExpressHistoryDao,
|
||||
private val historyIndexDao: HistoryIndexDao,
|
||||
private val expressSyncStateDao: ExpressSyncStateDao,
|
||||
) {
|
||||
private val tokenInfoRepository: TokenInfoRepository,
|
||||
private val database: TxHistoryDatabase,
|
||||
private val appScope: AppCoroutineScope,
|
||||
) : ExpressHistoryRepository {
|
||||
|
||||
suspend fun fetchExchangeHistory(
|
||||
fromAddress: String,
|
||||
|
|
@ -44,7 +56,7 @@ internal class ExpressHistoryRepository @Inject constructor(
|
|||
limit = limit,
|
||||
).getOrThrow()
|
||||
|
||||
saveExchanges(ownerAddress = fromAddress, items = response.items)
|
||||
storeExchanges(items = response.items)
|
||||
persistHistoryState(
|
||||
type = ExpressSyncStateEntity.Type.EXCHANGE,
|
||||
address = fromAddress,
|
||||
|
|
@ -68,7 +80,7 @@ internal class ExpressHistoryRepository @Inject constructor(
|
|||
limit = limit,
|
||||
).getOrThrow()
|
||||
|
||||
saveExchanges(ownerAddress = fromAddress, items = response.items)
|
||||
storeExchanges(items = response.items)
|
||||
persistDeltaState(
|
||||
type = ExpressSyncStateEntity.Type.EXCHANGE,
|
||||
address = fromAddress,
|
||||
|
|
@ -91,7 +103,7 @@ internal class ExpressHistoryRepository @Inject constructor(
|
|||
limit = limit,
|
||||
).getOrThrow()
|
||||
|
||||
saveOnramps(ownerAddress = payoutAddress, items = response.items)
|
||||
storeOnramps(items = response.items)
|
||||
persistHistoryState(
|
||||
type = ExpressSyncStateEntity.Type.ONRAMP,
|
||||
address = payoutAddress,
|
||||
|
|
@ -115,7 +127,7 @@ internal class ExpressHistoryRepository @Inject constructor(
|
|||
limit = limit,
|
||||
).getOrThrow()
|
||||
|
||||
saveOnramps(ownerAddress = payoutAddress, items = response.items)
|
||||
storeOnramps(items = response.items)
|
||||
persistDeltaState(
|
||||
type = ExpressSyncStateEntity.Type.ONRAMP,
|
||||
address = payoutAddress,
|
||||
|
|
@ -124,16 +136,39 @@ internal class ExpressHistoryRepository @Inject constructor(
|
|||
return response
|
||||
}
|
||||
|
||||
override suspend fun storeExchanges(items: List<ExchangeItemResponse>) {
|
||||
val entities = items.mapNotNull { it.toEntity() }
|
||||
if (entities.isEmpty()) return
|
||||
database.withTransaction {
|
||||
expressHistoryDao.upsertExchanges(entities)
|
||||
historyIndexDao.upsert(entities.flatMap { it.toHistoryIndexEntities() })
|
||||
}
|
||||
fetchMissingTokenInfo(
|
||||
buildSet {
|
||||
entities.forEach { entity ->
|
||||
add(entity.from.toAssetId())
|
||||
add(entity.to.toAssetId())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun storeOnramps(items: List<OnrampItemResponse>) {
|
||||
if (items.isEmpty()) return
|
||||
val entities = items.map { it.toEntity() }
|
||||
database.withTransaction {
|
||||
expressHistoryDao.upsertOnramps(entities)
|
||||
historyIndexDao.upsert(entities.map { it.toHistoryIndexEntity() })
|
||||
}
|
||||
fetchMissingTokenInfo(entities.mapTo(mutableSetOf()) { it.to.toAssetId() })
|
||||
}
|
||||
|
||||
suspend fun syncState(type: ExpressSyncStateEntity.Type, address: String): ExpressSyncStateEntity? {
|
||||
return expressSyncStateDao.observe(type = type.name, address = address).first()
|
||||
}
|
||||
|
||||
private suspend fun saveExchanges(ownerAddress: String, items: List<ExchangeItemResponse>) {
|
||||
expressHistoryDao.upsertExchanges(items.map { it.toEntity(ownerAddress) })
|
||||
}
|
||||
|
||||
private suspend fun saveOnramps(ownerAddress: String, items: List<OnrampItemResponse>) {
|
||||
expressHistoryDao.upsertOnramps(items.map { it.toEntity(ownerAddress) })
|
||||
private fun fetchMissingTokenInfo(assetIds: Set<ExpressAsset.ID>) {
|
||||
appScope.launch { tokenInfoRepository.fetchMissing(assetIds) }
|
||||
}
|
||||
|
||||
private suspend fun persistHistoryState(
|
||||
|
|
@ -5,9 +5,16 @@ import com.tangem.data.common.converter.ExpressProviderConverter
|
|||
import com.tangem.data.txhistory.repository.converter.ExpressStatusMapper
|
||||
import com.tangem.data.txhistory.repository.converter.ExpressOnrampConverter
|
||||
import com.tangem.data.txhistory.repository.converter.ExpressSwapConverter
|
||||
import com.tangem.data.txhistory.repository.converter.OnrampCountryConverter
|
||||
import com.tangem.data.txhistory.repository.factory.ExpressTransactionAssetFactory
|
||||
import com.tangem.data.txhistory.repository.factory.toAssetId
|
||||
import com.tangem.data.txhistory.repository.paging.TxHistoryPageBatchFetcher
|
||||
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
|
||||
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
|
|
@ -36,6 +43,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
|
|||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val txHistoryItemsStore: TxHistoryItemsStore,
|
||||
private val expressHistoryDao: ExpressHistoryDao,
|
||||
private val expressTransactionAssetFactory: ExpressTransactionAssetFactory,
|
||||
private val cacheRegistry: CacheRegistry,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : TxHistoryRepositoryV2 {
|
||||
|
|
@ -44,6 +52,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
|
|||
private val expressProviderConverter = ExpressProviderConverter()
|
||||
private val swapConverter = ExpressSwapConverter()
|
||||
private val onrampConverter = ExpressOnrampConverter()
|
||||
private val onrampCountryConverter = OnrampCountryConverter()
|
||||
private val TxHistoryListConfig.storeKey get() = TxHistoryItemsStore.Key(userWalletId, currency)
|
||||
|
||||
override fun getExpressHistory(
|
||||
|
|
@ -64,7 +73,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
|
|||
|
||||
val flow = combine(
|
||||
flow = expressHistoryDao.observeOutgoingSwaps(
|
||||
ownerAddress = address,
|
||||
fromAddress = address,
|
||||
network = rawNetwork,
|
||||
contract = contract,
|
||||
fromCreatedAtIso = fromCreatedAtIso,
|
||||
|
|
@ -77,45 +86,87 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
|
|||
activeStatuses = ExpressStatusMapper.activeExchangeStatuses,
|
||||
).distinctUntilChanged(),
|
||||
flow3 = expressHistoryDao.observeIncomingOnramps(
|
||||
ownerAddress = address,
|
||||
payoutAddress = address,
|
||||
network = rawNetwork,
|
||||
contract = contract,
|
||||
fromCreatedAtIso = fromCreatedAtIso,
|
||||
activeStatuses = ExpressStatusMapper.activeOnrampStatuses,
|
||||
).distinctUntilChanged(),
|
||||
flow4 = expressHistoryDao.getProvidersById().distinctUntilChanged(),
|
||||
transform = { outgoingSwaps, incomingSwaps, onramps, providers ->
|
||||
buildList<ExpressTx> {
|
||||
fun String.expressProvider() = providers[this]?.let(expressProviderConverter::convert)
|
||||
outgoingSwaps.forEach { entity ->
|
||||
val input = ExpressSwapConverter.Input(
|
||||
entity = entity,
|
||||
provider = entity.providerId.expressProvider(),
|
||||
isOutgoing = true,
|
||||
)
|
||||
add(swapConverter.convert(input))
|
||||
}
|
||||
incomingSwaps.forEach { entity ->
|
||||
val input = ExpressSwapConverter.Input(
|
||||
entity = entity,
|
||||
provider = entity.providerId.expressProvider(),
|
||||
isOutgoing = false,
|
||||
)
|
||||
add(swapConverter.convert(input))
|
||||
}
|
||||
onramps.forEach { entity ->
|
||||
val input = ExpressOnrampConverter.Input(entity, entity.providerId.expressProvider())
|
||||
add(onrampConverter.convert(input))
|
||||
}
|
||||
}
|
||||
// An exchange row may satisfy both swap queries only in degenerate cases;
|
||||
// keep the outgoing interpretation (added first).
|
||||
.distinctBy { it.txId }
|
||||
flow5 = expressHistoryDao.getCountriesByCode().distinctUntilChanged(),
|
||||
transform = { outgoingSwaps, incomingSwaps, onramps, providers, countries ->
|
||||
buildExpressHistory(
|
||||
userWalletId = userWalletId,
|
||||
sources = ExpressHistorySources(
|
||||
outgoingSwaps = outgoingSwaps,
|
||||
incomingSwaps = incomingSwaps,
|
||||
onramps = onramps,
|
||||
providers = providers,
|
||||
countries = countries,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
emitAll(flow)
|
||||
}.flowOn(dispatchers.io)
|
||||
|
||||
/** The reactive express-history inputs gathered from the DB in a single [combine] tick. */
|
||||
private data class ExpressHistorySources(
|
||||
val outgoingSwaps: List<ExpressExchangeEntity>,
|
||||
val incomingSwaps: List<ExpressExchangeEntity>,
|
||||
val onramps: List<ExpressOnrampEntity>,
|
||||
val providers: Map<String, ExpressProviderEntity>,
|
||||
val countries: Map<String, OnrampCountryEntity>,
|
||||
)
|
||||
|
||||
private suspend fun buildExpressHistory(
|
||||
userWalletId: UserWalletId,
|
||||
sources: ExpressHistorySources,
|
||||
): List<ExpressTx> {
|
||||
val currencies = expressTransactionAssetFactory.create(
|
||||
userWalletId = userWalletId,
|
||||
outgoingSwaps = sources.outgoingSwaps,
|
||||
incomingSwaps = sources.incomingSwaps,
|
||||
onramps = sources.onramps,
|
||||
)
|
||||
fun String.expressProvider() = sources.providers[this]?.let(expressProviderConverter::convert)
|
||||
fun String.onrampCountry() = sources.countries[this]?.let(onrampCountryConverter::convert)
|
||||
return buildList {
|
||||
sources.outgoingSwaps.forEach { entity ->
|
||||
val input = ExpressSwapConverter.Input(
|
||||
entity = entity,
|
||||
provider = entity.providerId.expressProvider(),
|
||||
isOutgoing = true,
|
||||
fromCurrency = currencies[entity.from.toAssetId()],
|
||||
toCurrency = currencies[entity.to.toAssetId()],
|
||||
)
|
||||
add(swapConverter.convert(input))
|
||||
}
|
||||
sources.incomingSwaps.forEach { entity ->
|
||||
val input = ExpressSwapConverter.Input(
|
||||
entity = entity,
|
||||
provider = entity.providerId.expressProvider(),
|
||||
isOutgoing = false,
|
||||
fromCurrency = currencies[entity.from.toAssetId()],
|
||||
toCurrency = currencies[entity.to.toAssetId()],
|
||||
)
|
||||
add(swapConverter.convert(input))
|
||||
}
|
||||
sources.onramps.forEach { entity ->
|
||||
val input = ExpressOnrampConverter.Input(
|
||||
entity = entity,
|
||||
provider = entity.providerId.expressProvider(),
|
||||
toCurrency = currencies[entity.to.toAssetId()],
|
||||
country = entity.countryCode.onrampCountry(),
|
||||
)
|
||||
add(onrampConverter.convert(input))
|
||||
}
|
||||
}
|
||||
// An exchange row may satisfy both swap queries only in degenerate cases;
|
||||
// keep the outgoing interpretation (added first).
|
||||
.distinctBy { it.txId }
|
||||
}
|
||||
|
||||
override fun getTxHistoryBatchFlow(batchSize: Int, context: TxHistoryListBatchingContext): TxHistoryListBatchFlow {
|
||||
return BatchListSource(
|
||||
fetchDispatcher = dispatchers.io,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import com.tangem.domain.express.models.ExpressOnrampStatus
|
|||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.models.ExpressTransactionAsset
|
||||
import com.tangem.domain.express.models.OnrampTransaction
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.onramp.model.OnrampCountry
|
||||
import com.tangem.domain.tokens.model.Amount
|
||||
import com.tangem.domain.tokens.model.AmountType
|
||||
import com.tangem.domain.txhistory.model.ExpressTx
|
||||
|
|
@ -26,7 +28,7 @@ import java.math.BigDecimal
|
|||
internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, ExpressTx.Swap> {
|
||||
|
||||
override fun convert(value: Input): ExpressTx.Swap = ExpressTx.Swap(
|
||||
tx = convertExchangeTransaction(value.entity, value.provider),
|
||||
tx = convertExchangeTransaction(value),
|
||||
isOutgoing = value.isOutgoing,
|
||||
txInfo = null,
|
||||
)
|
||||
|
|
@ -35,6 +37,8 @@ internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, Expr
|
|||
val entity: ExpressExchangeEntity,
|
||||
val provider: ExpressProvider?,
|
||||
val isOutgoing: Boolean,
|
||||
val fromCurrency: CryptoCurrency? = null,
|
||||
val toCurrency: CryptoCurrency? = null,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -49,49 +53,69 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
|
|||
createdAtMillis = parseIsoMillis(entity.createdAt),
|
||||
provider = value.provider,
|
||||
payoutHash = entity.payoutHash,
|
||||
payoutAddress = entity.payoutAddress,
|
||||
fromFiat = Amount(
|
||||
currencySymbol = entity.fromCurrencyCode,
|
||||
value = entity.fromAmount.toBigDecimalOrZero(),
|
||||
value = entity.fromAmount.toScaledBigDecimal(entity.fromPrecision),
|
||||
decimals = entity.fromPrecision,
|
||||
type = AmountType.FiatType(code = entity.fromCurrencyCode),
|
||||
),
|
||||
toAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
|
||||
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
|
||||
amount = (entity.to.actualAmount ?: entity.to.amount)?.toScaledBigDecimal(entity.to.decimals),
|
||||
decimals = entity.to.decimals,
|
||||
cryptoCurrency = value.toCurrency,
|
||||
),
|
||||
country = value.country,
|
||||
externalTxUrl = entity.externalTxUrl,
|
||||
),
|
||||
txInfo = null,
|
||||
)
|
||||
}
|
||||
|
||||
data class Input(val entity: ExpressOnrampEntity, val provider: ExpressProvider?)
|
||||
data class Input(
|
||||
val entity: ExpressOnrampEntity,
|
||||
val provider: ExpressProvider?,
|
||||
val toCurrency: CryptoCurrency? = null,
|
||||
val country: OnrampCountry? = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertExchangeTransaction(entity: ExpressExchangeEntity, provider: ExpressProvider?): ExchangeTransaction {
|
||||
private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): ExchangeTransaction {
|
||||
val entity = value.entity
|
||||
return ExchangeTransaction(
|
||||
txId = entity.txId,
|
||||
status = ExpressExchangeStatus.fromRaw(entity.status),
|
||||
createdAtMillis = parseIsoMillis(entity.createdAt),
|
||||
provider = provider,
|
||||
provider = value.provider,
|
||||
payinHash = entity.payinHash,
|
||||
payoutHash = entity.payoutHash,
|
||||
fromAddress = entity.fromAddress,
|
||||
payoutAddress = entity.payoutAddress,
|
||||
fromAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = entity.from.network, contractAddress = entity.from.contractAddress),
|
||||
amount = entity.from.amount.toBigDecimalOrZero(),
|
||||
amount = entity.from.amount.toScaledBigDecimal(entity.from.decimals),
|
||||
decimals = entity.from.decimals,
|
||||
cryptoCurrency = value.fromCurrency,
|
||||
),
|
||||
toAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
|
||||
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
|
||||
amount = (entity.to.actualAmount ?: entity.to.amount).toScaledBigDecimal(entity.to.decimals),
|
||||
decimals = entity.to.decimals,
|
||||
cryptoCurrency = value.toCurrency,
|
||||
),
|
||||
externalTxUrl = entity.externalTxUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseIsoMillis(iso: String): Long = DateTime.parse(iso).millis
|
||||
|
||||
private fun String?.toBigDecimalOrZero(): BigDecimal = this?.toBigDecimalOrNull() ?: BigDecimal.ZERO
|
||||
/**
|
||||
* Parses a raw minimal-unit amount string from the express backend and scales it to the human-readable
|
||||
* value promised by [ExpressTransactionAsset.amount] (and the onramp fiat [Amount.value]).
|
||||
*/
|
||||
private fun String.toScaledBigDecimal(decimals: Int): BigDecimal =
|
||||
(this.toBigDecimalOrNull() ?: BigDecimal.ZERO).movePointLeft(decimals)
|
||||
|
||||
/**
|
||||
* Active (non-terminal) RAW status values passed to the DAO `observe…` queries so in-progress deals
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
package com.tangem.data.txhistory.repository.converter
|
||||
|
||||
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
|
||||
import org.joda.time.DateTime
|
||||
|
||||
/**
|
||||
* Index rows for a swap: it shows on the from-token screen (outgoing, under `from_address`) and, when the payout lands
|
||||
* on another owned token, on that screen too (incoming, under `payout_address`). The set de-duplicates the addresses so
|
||||
* a swap whose from and payout addresses coincide is indexed once.
|
||||
*/
|
||||
internal fun ExpressExchangeEntity.toHistoryIndexEntities(): List<HistoryIndexEntity> {
|
||||
val sortTimeMillis = DateTime.parse(createdAt).millis
|
||||
return setOf(fromAddress, payoutAddress).map { address ->
|
||||
HistoryIndexEntity(
|
||||
type = HistoryIndexEntity.Type.EXCHANGE.value,
|
||||
entityId = txId,
|
||||
address = address,
|
||||
sortTimeMillis = sortTimeMillis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Onramp is always incoming: a single index row under the payout address. */
|
||||
internal fun ExpressOnrampEntity.toHistoryIndexEntity(): HistoryIndexEntity = HistoryIndexEntity(
|
||||
type = HistoryIndexEntity.Type.ONRAMP.value,
|
||||
entityId = txId,
|
||||
address = payoutAddress,
|
||||
sortTimeMillis = DateTime.parse(createdAt).millis,
|
||||
)
|
||||
|
|
@ -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,129 @@
|
|||
package com.tangem.data.txhistory.repository.factory
|
||||
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.data.common.currency.CryptoCurrencyFactory
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Resolves a [CryptoCurrency] for every express asset (network id + contract address) referenced by a batch of
|
||||
* exchange/onramp entities.
|
||||
*/
|
||||
internal class ExpressTransactionAssetFactory @Inject constructor(
|
||||
private val multiAccountListSupplier: MultiAccountListSupplier,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val tokenInfoRepository: TokenInfoRepository,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
) {
|
||||
|
||||
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
|
||||
|
||||
/**
|
||||
* Builds a `assetId -> resolved currency` map covering both legs of every swap and the to-leg of every onramp.
|
||||
* Entries whose currency could not be resolved at all are omitted.
|
||||
*/
|
||||
suspend fun create(
|
||||
userWalletId: UserWalletId,
|
||||
outgoingSwaps: List<ExpressExchangeEntity>,
|
||||
incomingSwaps: List<ExpressExchangeEntity>,
|
||||
onramps: List<ExpressOnrampEntity>,
|
||||
): Map<ExpressAsset.ID, CryptoCurrency> {
|
||||
val assetIds = buildSet {
|
||||
(outgoingSwaps + incomingSwaps).forEach { entity ->
|
||||
add(entity.from.toAssetId())
|
||||
add(entity.to.toAssetId())
|
||||
}
|
||||
onramps.forEach { entity -> add(entity.to.toAssetId()) }
|
||||
}
|
||||
if (assetIds.isEmpty()) return emptyMap()
|
||||
|
||||
val portfolioCurrencies = multiAccountListSupplier.invoke()
|
||||
.first()
|
||||
.flatMap { accountList -> accountList.flattenCurrencies() }
|
||||
|
||||
val userWallet = userWalletsListRepository.userWalletsSync()
|
||||
.firstOrNull { it.walletId == userWalletId }
|
||||
|
||||
val cachedTokens = loadCachedTokens(assetIds)
|
||||
|
||||
return buildMap {
|
||||
assetIds.forEach { id ->
|
||||
val currency = portfolioCurrencies.findMatching(id) ?: resolveUnmatched(id, cachedTokens, userWallet)
|
||||
if (currency != null) put(id, currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveUnmatched(
|
||||
id: ExpressAsset.ID,
|
||||
cachedTokens: Map<String, TokenInfoEntity>,
|
||||
userWallet: UserWallet?,
|
||||
): CryptoCurrency? {
|
||||
return if (id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE) {
|
||||
createCoin(id, userWallet)
|
||||
} else {
|
||||
cachedTokens[cacheKey(id)]?.let { createToken(it, userWallet) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadCachedTokens(assetIds: Set<ExpressAsset.ID>): Map<String, TokenInfoEntity> =
|
||||
tokenInfoRepository.getCached(assetIds).associateBy { cacheKey(it.networkId, it.contractAddress) }
|
||||
|
||||
private fun createToken(entity: TokenInfoEntity, userWallet: UserWallet?): CryptoCurrency.Token? {
|
||||
userWallet ?: return null
|
||||
return cryptoCurrencyFactory.createToken(
|
||||
token = CryptoCurrencyFactory.Token(
|
||||
name = entity.name,
|
||||
symbol = entity.symbol,
|
||||
contractAddress = entity.contractAddress,
|
||||
decimals = entity.decimals,
|
||||
id = entity.coinId,
|
||||
),
|
||||
networkId = entity.networkId,
|
||||
extraDerivationPath = null,
|
||||
userWallet = userWallet,
|
||||
)
|
||||
}
|
||||
|
||||
private fun cacheKey(id: ExpressAsset.ID): String = cacheKey(id.networkId, id.contractAddress)
|
||||
|
||||
private fun cacheKey(networkId: String, contractAddress: String): String =
|
||||
"$networkId|${contractAddress.lowercase()}"
|
||||
|
||||
private fun List<CryptoCurrency>.findMatching(id: ExpressAsset.ID): CryptoCurrency? {
|
||||
val isCoin = id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE
|
||||
return firstOrNull { currency ->
|
||||
currency.network.rawId == id.networkId &&
|
||||
if (isCoin) {
|
||||
currency is CryptoCurrency.Coin
|
||||
} else {
|
||||
currency is CryptoCurrency.Token &&
|
||||
currency.contractAddress.equals(id.contractAddress, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createCoin(id: ExpressAsset.ID, userWallet: UserWallet?): CryptoCurrency.Coin? {
|
||||
userWallet ?: return null
|
||||
return cryptoCurrencyFactory.createCoin(
|
||||
networkId = id.networkId,
|
||||
extraDerivationPath = null,
|
||||
userWallet = userWallet,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ExpressExchangeEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
|
||||
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)
|
||||
|
||||
internal fun ExpressOnrampEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
|
||||
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
package com.tangem.data.txhistory.repository.factory
|
||||
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Owns the cached token metadata (from [TangemTechApi.getCoins]) so [ExpressTransactionAssetFactory] can resolve
|
||||
* tokens that are not in any user portfolio. [fetchMissing] populates the cache; [getCached] reads it back.
|
||||
*/
|
||||
internal class TokenInfoRepository @Inject constructor(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val tokenInfoDao: TokenInfoDao,
|
||||
private val multiAccountListSupplier: MultiAccountListSupplier,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) {
|
||||
|
||||
/** Cached token infos for the token assets among [assetIds] (coins are skipped). */
|
||||
suspend fun getCached(assetIds: Set<ExpressAsset.ID>): List<TokenInfoEntity> = withContext(dispatchers.io) {
|
||||
val networkIds = mutableSetOf<String>()
|
||||
val contracts = mutableSetOf<String>()
|
||||
assetIds.forEach { id ->
|
||||
if (id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE) return@forEach
|
||||
networkIds += id.networkId
|
||||
contracts += id.contractAddress
|
||||
}
|
||||
if (networkIds.isEmpty()) emptyList() else tokenInfoDao.getCached(networkIds, contracts)
|
||||
}
|
||||
|
||||
/** Fetches and caches metadata for token assets that are stale/missing in the cache and not already owned. */
|
||||
suspend fun fetchMissing(assetIds: Set<ExpressAsset.ID>) = withContext(dispatchers.io) {
|
||||
// Coins resolve without the catalog — only tokens are looked up.
|
||||
val tokenIds = mutableListOf<ExpressAsset.ID>()
|
||||
val networkIds = mutableSetOf<String>()
|
||||
val contracts = mutableSetOf<String>()
|
||||
assetIds.forEach { id ->
|
||||
if (id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE) return@forEach
|
||||
tokenIds += id
|
||||
networkIds += id.networkId
|
||||
contracts += id.contractAddress
|
||||
}
|
||||
if (tokenIds.isEmpty()) return@withContext
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val freshKeys = tokenInfoDao.getCached(
|
||||
networkIds = networkIds,
|
||||
contractAddresses = contracts,
|
||||
minUpdatedAt = now - CACHE_TTL_MILLIS,
|
||||
).mapTo(hashSetOf()) { cacheKey(it.networkId, it.contractAddress) }
|
||||
|
||||
val notCached = tokenIds.filterNot { cacheKey(it.networkId, it.contractAddress) in freshKeys }
|
||||
if (notCached.isEmpty()) return@withContext
|
||||
|
||||
// Tokens already present in any portfolio don't need a catalog fetch.
|
||||
val portfolioKeys = portfolioTokenKeys()
|
||||
val unresolved = notCached.filterNot { cacheKey(it.networkId, it.contractAddress) in portfolioKeys }
|
||||
if (unresolved.isEmpty()) return@withContext
|
||||
|
||||
val entities = fetchTokenInfos(unresolved, now)
|
||||
if (entities.isNotEmpty()) tokenInfoDao.upsert(entities)
|
||||
}
|
||||
|
||||
/** Cache keys of every token owned across all wallets. */
|
||||
private suspend fun portfolioTokenKeys(): Set<String> = buildSet {
|
||||
multiAccountListSupplier.invoke().first().forEach { accountList ->
|
||||
accountList.flattenCurrencies().forEach { currency ->
|
||||
if (currency is CryptoCurrency.Token) {
|
||||
add(cacheKey(currency.network.rawId, currency.contractAddress))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchTokenInfos(ids: List<ExpressAsset.ID>, timestamp: Long): List<TokenInfoEntity> {
|
||||
val requestedPairs = mutableSetOf<String>()
|
||||
val networkIds = mutableSetOf<String>()
|
||||
val contracts = mutableSetOf<String>()
|
||||
ids.forEach { id ->
|
||||
requestedPairs += cacheKey(id.networkId, id.contractAddress)
|
||||
networkIds += id.networkId
|
||||
contracts += id.contractAddress
|
||||
}
|
||||
return try {
|
||||
// getCoins returns a cross-product of the queried networks×contracts, so the response is filtered back
|
||||
// to the exact requested pairs.
|
||||
val coins = tangemTechApi.getCoins(
|
||||
networkIds = networkIds.joinToString(separator = ","),
|
||||
contractAddresses = contracts.joinToString(separator = ","),
|
||||
active = true,
|
||||
).getOrThrow().coins
|
||||
|
||||
coins.flatMap { coin ->
|
||||
coin.networks.mapNotNull { network ->
|
||||
val contract = network.contractAddress
|
||||
val decimals = network.decimalCount?.toInt()
|
||||
if (contract == null || decimals == null ||
|
||||
cacheKey(network.networkId, contract) !in requestedPairs
|
||||
) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
TokenInfoEntity(
|
||||
networkId = network.networkId,
|
||||
contractAddress = contract,
|
||||
coinId = coin.id,
|
||||
name = coin.name,
|
||||
symbol = coin.symbol,
|
||||
decimals = decimals,
|
||||
updatedAt = timestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
TangemLogger.w("Failed to fetch token info for ${ids.size} express assets", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cacheKey(networkId: String, contractAddress: String): String =
|
||||
"$networkId|${contractAddress.lowercase()}"
|
||||
|
||||
private companion object {
|
||||
val CACHE_TTL_MILLIS: Long = TimeUnit.DAYS.toMillis(10)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package com.tangem.data.txhistory.fetcher
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.test.core.TestAppCoroutineScope
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.data.txhistory.repository.ExpressHistoryRepository
|
||||
import com.tangem.data.txhistory.repository.DefaultExpressHistoryRepository
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
|
||||
import com.tangem.datasource.api.express.models.response.ExpressPagination
|
||||
|
|
@ -30,7 +30,7 @@ import org.junit.jupiter.api.TestInstance
|
|||
internal class DefaultExpressTxHistoryFetcherTest {
|
||||
|
||||
private val expressSyncStateDao: ExpressSyncStateDao = mockk()
|
||||
private val expressHistoryRepository: ExpressHistoryRepository = mockk()
|
||||
private val expressHistoryRepository: DefaultExpressHistoryRepository = mockk()
|
||||
|
||||
private val coin: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
package com.tangem.data.txhistory.repository
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.local.converter.toEntity
|
||||
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntities
|
||||
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntity
|
||||
import com.tangem.data.txhistory.repository.factory.TokenInfoRepository
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.express.TangemExpressApi
|
||||
|
|
@ -14,40 +17,65 @@ import com.tangem.datasource.api.onramp.OnrampApi
|
|||
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
|
||||
import com.tangem.datasource.local.converter.toEntity
|
||||
import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase
|
||||
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
|
||||
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
|
||||
import com.tangem.datasource.local.txhistory.db.dao.HistoryIndexDao
|
||||
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.test.core.TestAppCoroutineScope
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkStatic
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class ExpressHistoryRepositoryTest {
|
||||
internal class DefaultExpressHistoryRepositoryTest {
|
||||
|
||||
private val exchangeApi: TangemExpressApi = mockk()
|
||||
private val onrampApi: OnrampApi = mockk()
|
||||
private val expressHistoryDao: ExpressHistoryDao = mockk(relaxUnitFun = true)
|
||||
private val historyIndexDao: HistoryIndexDao = mockk(relaxUnitFun = true)
|
||||
private val expressSyncStateDao: ExpressSyncStateDao = mockk(relaxUnitFun = true)
|
||||
private val tokenInfoRepository: TokenInfoRepository = mockk(relaxUnitFun = true)
|
||||
|
||||
private val repository = ExpressHistoryRepository(
|
||||
private val database: TxHistoryDatabase = mockk()
|
||||
|
||||
private val repository = DefaultExpressHistoryRepository(
|
||||
exchangeApi = exchangeApi,
|
||||
onrampApi = onrampApi,
|
||||
expressHistoryDao = expressHistoryDao,
|
||||
historyIndexDao = historyIndexDao,
|
||||
expressSyncStateDao = expressSyncStateDao,
|
||||
tokenInfoRepository = tokenInfoRepository,
|
||||
database = database,
|
||||
appScope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(exchangeApi, onrampApi, expressHistoryDao, expressSyncStateDao)
|
||||
clearMocks(exchangeApi, onrampApi, expressHistoryDao, historyIndexDao, expressSyncStateDao, tokenInfoRepository)
|
||||
// Run the withTransaction block inline so the DAO writes inside it actually happen and can be verified.
|
||||
mockkStatic("androidx.room.RoomDatabaseKt")
|
||||
val block = slot<suspend () -> Any?>()
|
||||
coEvery { database.withTransaction(capture(block)) } coAnswers { block.captured.invoke() }
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic("androidx.room.RoomDatabaseKt")
|
||||
}
|
||||
|
||||
// region exchange history
|
||||
|
|
@ -70,7 +98,7 @@ internal class ExpressHistoryRepositoryTest {
|
|||
coVerify(exactly = 1) {
|
||||
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
|
||||
}
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOfNotNull(item.toEntity())) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -91,24 +119,6 @@ internal class ExpressHistoryRepositoryTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN custom limit WHEN fetchExchangeHistory THEN forwards limit to api`() = runTest {
|
||||
// GIVEN
|
||||
val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination())
|
||||
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
|
||||
coEvery {
|
||||
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
|
||||
} returns ApiResponse.Success(response)
|
||||
|
||||
// WHEN
|
||||
repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID, limit = 25)
|
||||
|
||||
// THEN
|
||||
coVerify(exactly = 1) {
|
||||
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = 25)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN api error WHEN fetchExchangeHistory THEN throws and does not persist`() = runTest {
|
||||
// GIVEN
|
||||
|
|
@ -144,7 +154,7 @@ internal class ExpressHistoryRepositoryTest {
|
|||
coVerify(exactly = 1) {
|
||||
exchangeApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
|
||||
}
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOfNotNull(item.toEntity())) }
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -169,25 +179,7 @@ internal class ExpressHistoryRepositoryTest {
|
|||
coVerify(exactly = 1) {
|
||||
onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
|
||||
}
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no sync state WHEN fetchOnrampHistory THEN passes null cursor`() = runTest {
|
||||
// GIVEN
|
||||
val response = OnrampHistoryResponse(items = emptyList(), pagination = pagination())
|
||||
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, state = null)
|
||||
coEvery {
|
||||
onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = null, limit = any())
|
||||
} returns ApiResponse.Success(response)
|
||||
|
||||
// WHEN
|
||||
repository.fetchOnrampHistory(payoutAddress = ADDRESS, userWalletId = USER_WALLET_ID)
|
||||
|
||||
// THEN
|
||||
coVerify(exactly = 1) {
|
||||
onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = null, limit = DEFAULT_LIMIT)
|
||||
}
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity())) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -208,7 +200,7 @@ internal class ExpressHistoryRepositoryTest {
|
|||
coVerify(exactly = 1) {
|
||||
onrampApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
|
||||
}
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity())) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -230,42 +222,70 @@ internal class ExpressHistoryRepositoryTest {
|
|||
|
||||
// endregion
|
||||
|
||||
// region syncState
|
||||
// region store
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored sync state WHEN syncState THEN returns first emitted value`() = runTest {
|
||||
fun `GIVEN exchanges WHEN storeExchanges THEN persists entities and fetches missing info for both legs`() = runTest {
|
||||
// GIVEN
|
||||
val state = syncState(afterCursor = AFTER_CURSOR, deltaCursor = DELTA_CURSOR)
|
||||
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, state)
|
||||
val item = createExchangeItem()
|
||||
|
||||
// WHEN
|
||||
val result = repository.syncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS)
|
||||
repository.storeExchanges(items = listOf(item))
|
||||
|
||||
// THEN
|
||||
assertThat(result).isEqualTo(state)
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOfNotNull(item.toEntity())) }
|
||||
coVerify(exactly = 1) { historyIndexDao.upsert(item.toEntity()!!.toHistoryIndexEntities()) }
|
||||
coVerify(exactly = 1) {
|
||||
tokenInfoRepository.fetchMissing(
|
||||
setOf(
|
||||
ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xfromContract"),
|
||||
ExpressAsset.ID(networkId = "bitcoin", contractAddress = "0xtoContract"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multiple items WHEN fetchExchangeHistory THEN maps every item with owner address`() = runTest {
|
||||
fun `GIVEN onramps WHEN storeOnramps THEN persists entities and fetches missing info for to-leg`() = runTest {
|
||||
// GIVEN
|
||||
val items = listOf(
|
||||
createExchangeItem(txId = "tx-1"),
|
||||
createExchangeItem(txId = "tx-2"),
|
||||
)
|
||||
val response = ExchangeHistoryResponse(items = items, pagination = pagination())
|
||||
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
|
||||
coEvery {
|
||||
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
|
||||
} returns ApiResponse.Success(response)
|
||||
val saved = slot<List<ExpressExchangeEntity>>()
|
||||
coEvery { expressHistoryDao.upsertExchanges(capture(saved)) } returns Unit
|
||||
val item = createOnrampItem()
|
||||
|
||||
// WHEN
|
||||
repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID)
|
||||
repository.storeOnramps(items = listOf(item))
|
||||
|
||||
// THEN
|
||||
assertThat(saved.captured).isEqualTo(items.map { it.toEntity(ADDRESS) })
|
||||
assertThat(saved.captured.map { it.ownerAddress }.toSet()).containsExactly(ADDRESS)
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity())) }
|
||||
coVerify(exactly = 1) { historyIndexDao.upsert(listOf(item.toEntity().toHistoryIndexEntity())) }
|
||||
coVerify(exactly = 1) {
|
||||
tokenInfoRepository.fetchMissing(setOf(ExpressAsset.ID(networkId = "bitcoin", contractAddress = "0xtoContract")))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN empty items WHEN store THEN does nothing`() = runTest {
|
||||
// WHEN
|
||||
repository.storeExchanges(items = emptyList())
|
||||
repository.storeOnramps(items = emptyList())
|
||||
|
||||
// THEN
|
||||
coVerify(exactly = 0) { expressHistoryDao.upsertExchanges(any()) }
|
||||
coVerify(exactly = 0) { expressHistoryDao.upsertOnramps(any()) }
|
||||
coVerify(exactly = 0) { historyIndexDao.upsert(any<List<HistoryIndexEntity>>()) }
|
||||
coVerify(exactly = 0) { tokenInfoRepository.fetchMissing(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN exchange with null fromAddress WHEN storeExchanges THEN it is skipped`() = runTest {
|
||||
// GIVEN
|
||||
val item = createExchangeItem().copy(fromAddress = null)
|
||||
|
||||
// WHEN
|
||||
repository.storeExchanges(items = listOf(item))
|
||||
|
||||
// THEN
|
||||
coVerify(exactly = 0) { expressHistoryDao.upsertExchanges(any()) }
|
||||
coVerify(exactly = 0) { historyIndexDao.upsert(any<List<HistoryIndexEntity>>()) }
|
||||
coVerify(exactly = 0) { tokenInfoRepository.fetchMissing(any()) }
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -313,8 +333,6 @@ internal class ExpressHistoryRepositoryTest {
|
|||
refundNetwork = null,
|
||||
refundContractAddress = null,
|
||||
createdAt = "2026-06-01T00:00:00Z",
|
||||
// todo txHistory uncomment
|
||||
// updatedAt = "2026-06-01T00:05:00Z",
|
||||
payTill = null,
|
||||
averageDuration = null,
|
||||
fromContractAddress = "0xfromContract",
|
||||
|
|
@ -338,8 +356,6 @@ internal class ExpressHistoryRepositoryTest {
|
|||
externalTxUrl = null,
|
||||
payoutHash = "payout-hash",
|
||||
createdAt = "2026-06-01T00:00:00Z",
|
||||
// todo txHistory uncomment
|
||||
// updatedAt = "2026-06-01T00:05:00Z",
|
||||
fromCurrencyCode = "USD",
|
||||
fromAmount = "100.0",
|
||||
fromPrecision = 2,
|
||||
|
|
@ -31,8 +31,9 @@ internal class ExpressTxHistoryConverterTest {
|
|||
assertThat(swap.txInfo).isNull()
|
||||
assertThat(swap.tx.status).isEqualTo(ExpressExchangeStatus.Waiting)
|
||||
assertThat(swap.createdAtMillis).isEqualTo(DateTime.parse(CREATED_AT).millis)
|
||||
assertThat(swap.tx.fromAsset.amount).isEqualTo(BigDecimal("1.5"))
|
||||
assertThat(swap.tx.toAsset.amount).isEqualTo(BigDecimal("0.001"))
|
||||
// Raw backend amounts are scaled by decimals into the human-readable value the domain model promises.
|
||||
assertThat(swap.tx.fromAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("1.5"))
|
||||
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.001"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -50,14 +51,14 @@ internal class ExpressTxHistoryConverterTest {
|
|||
|
||||
@Test
|
||||
fun `GIVEN exchange entity with actual amount WHEN toOutgoingSwap THEN to-asset uses actual amount`() {
|
||||
// Arrange
|
||||
val entity = createExchangeEntity(toAmount = "0.001", toActualAmount = "0.00099")
|
||||
// Arrange (raw minimal-unit amounts, to-asset decimals = 8)
|
||||
val entity = createExchangeEntity(toAmount = "100000", toActualAmount = "99000")
|
||||
|
||||
// Act
|
||||
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = true))
|
||||
|
||||
// Assert
|
||||
assertThat(swap.tx.toAsset.amount).isEqualTo(BigDecimal("0.00099"))
|
||||
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.00099"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -73,21 +74,21 @@ internal class ExpressTxHistoryConverterTest {
|
|||
assertThat(onramp.txInfo).isNull()
|
||||
assertThat(onramp.tx.status).isEqualTo(ExpressOnrampStatus.Finished)
|
||||
assertThat(onramp.tx.fromFiat.currencySymbol).isEqualTo("USD")
|
||||
assertThat(onramp.tx.fromFiat.value).isEqualTo(BigDecimal("100.0"))
|
||||
assertThat(onramp.tx.fromFiat.value).isEquivalentAccordingToCompareTo(BigDecimal("100"))
|
||||
assertThat(onramp.tx.fromFiat.decimals).isEqualTo(2)
|
||||
assertThat(onramp.tx.fromFiat.type).isEqualTo(AmountType.FiatType("USD"))
|
||||
assertThat(onramp.tx.toAsset.amount).isEqualTo(BigDecimal("0.5"))
|
||||
assertThat(onramp.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.5"))
|
||||
}
|
||||
|
||||
private fun createExchangeEntity(
|
||||
payinHash: String? = "payin",
|
||||
payoutHash: String? = "payout",
|
||||
status: String = "waiting",
|
||||
toAmount: String = "0.001",
|
||||
// Raw minimal-unit amount (to-asset decimals = 8) → 0.001
|
||||
toAmount: String = "100000",
|
||||
toActualAmount: String? = null,
|
||||
) = ExpressExchangeEntity(
|
||||
txId = "tx-1",
|
||||
ownerAddress = "owner",
|
||||
providerId = "provider",
|
||||
fromAddress = "owner",
|
||||
payinAddress = "payin-addr",
|
||||
|
|
@ -111,7 +112,8 @@ internal class ExpressTxHistoryConverterTest {
|
|||
contractAddress = "",
|
||||
network = "ethereum",
|
||||
decimals = 18,
|
||||
amount = "1.5",
|
||||
// Raw minimal-unit amount (decimals = 18) → 1.5
|
||||
amount = "1500000000000000000",
|
||||
actualAmount = null,
|
||||
),
|
||||
to = ExpressExchangeEntity.AssetEmbedded(
|
||||
|
|
@ -128,7 +130,6 @@ internal class ExpressTxHistoryConverterTest {
|
|||
status: String = "finished",
|
||||
) = ExpressOnrampEntity(
|
||||
txId = "onramp-1",
|
||||
ownerAddress = "owner",
|
||||
providerId = "provider",
|
||||
payoutAddress = "owner",
|
||||
status = status,
|
||||
|
|
@ -139,13 +140,15 @@ internal class ExpressTxHistoryConverterTest {
|
|||
createdAt = CREATED_AT,
|
||||
updatedAt = CREATED_AT,
|
||||
fromCurrencyCode = "USD",
|
||||
fromAmount = "100.0",
|
||||
// Raw minimal-unit fiat amount (precision = 2) → 100
|
||||
fromAmount = "10000",
|
||||
fromPrecision = 2,
|
||||
to = ExpressOnrampEntity.AssetEmbedded(
|
||||
contractAddress = "0xtoken",
|
||||
network = "ethereum",
|
||||
decimals = 18,
|
||||
amount = "0.5",
|
||||
// Raw minimal-unit amount (decimals = 18) → 0.5
|
||||
amount = "500000000000000000",
|
||||
actualAmount = null,
|
||||
),
|
||||
paymentMethod = "card",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
package com.tangem.data.txhistory.repository.converter
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.joda.time.DateTime
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
/**
|
||||
* Verifies the indexing rules that build [HistoryIndexEntity] rows: a swap is indexed under both its from- and
|
||||
|
||||
* into the sort time.
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class HistoryIndexConverterTest {
|
||||
|
||||
@Test
|
||||
fun `GIVEN swap with distinct from and payout WHEN indexed THEN one row per address`() {
|
||||
// Arrange
|
||||
val entity = exchange(txId = "tx1", from = "addrA", payout = "addrB", createdAt = CREATED_AT)
|
||||
|
||||
// Act
|
||||
val rows = entity.toHistoryIndexEntities()
|
||||
|
||||
// Assert
|
||||
assertThat(rows).containsExactly(
|
||||
indexRow(HistoryIndexEntity.Type.EXCHANGE, "tx1", "addrA"),
|
||||
indexRow(HistoryIndexEntity.Type.EXCHANGE, "tx1", "addrB"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN swap with equal from and payout WHEN indexed THEN de-duplicated to one row`() {
|
||||
// Arrange
|
||||
val entity = exchange(txId = "tx1", from = "same", payout = "same", createdAt = CREATED_AT)
|
||||
|
||||
// Act
|
||||
val rows = entity.toHistoryIndexEntities()
|
||||
|
||||
// Assert
|
||||
assertThat(rows).containsExactly(indexRow(HistoryIndexEntity.Type.EXCHANGE, "tx1", "same"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN onramp WHEN indexed THEN single row under payout address`() {
|
||||
// Arrange
|
||||
val entity = onramp(txId = "tx2", payout = "addrP", createdAt = CREATED_AT)
|
||||
|
||||
// Act
|
||||
val row = entity.toHistoryIndexEntity()
|
||||
|
||||
// Assert
|
||||
assertThat(row).isEqualTo(indexRow(HistoryIndexEntity.Type.ONRAMP, "tx2", "addrP"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN ISO-8601 created_at WHEN indexed THEN parsed into sort time millis`() {
|
||||
// Arrange
|
||||
val entity = onramp(txId = "tx2", payout = "addrP", createdAt = "2026-07-03T10:00:00.000Z")
|
||||
|
||||
// Act
|
||||
val row = entity.toHistoryIndexEntity()
|
||||
|
||||
// Assert
|
||||
assertThat(row.sortTimeMillis).isEqualTo(DateTime.parse("2026-07-03T10:00:00.000Z").millis)
|
||||
}
|
||||
|
||||
private fun exchange(txId: String, from: String, payout: String, createdAt: String) =
|
||||
mockk<ExpressExchangeEntity> {
|
||||
every { this@mockk.txId } returns txId
|
||||
every { fromAddress } returns from
|
||||
every { payoutAddress } returns payout
|
||||
every { this@mockk.createdAt } returns createdAt
|
||||
}
|
||||
|
||||
private fun onramp(txId: String, payout: String, createdAt: String) =
|
||||
mockk<ExpressOnrampEntity> {
|
||||
every { this@mockk.txId } returns txId
|
||||
every { payoutAddress } returns payout
|
||||
every { this@mockk.createdAt } returns createdAt
|
||||
}
|
||||
|
||||
private fun indexRow(type: HistoryIndexEntity.Type, txId: String, address: String) = HistoryIndexEntity(
|
||||
type = type.value,
|
||||
entityId = txId,
|
||||
address = address,
|
||||
sortTimeMillis = DateTime.parse(CREATED_AT).millis,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val CREATED_AT = "2026-07-03T10:00:00.000Z"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package com.tangem.data.txhistory.repository.factory
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class ExpressTransactionAssetFactoryTest {
|
||||
|
||||
private val tokenInfoRepository: TokenInfoRepository = mockk()
|
||||
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
||||
private val userWallet = MockUserWalletFactory.create()
|
||||
|
||||
private val factory = ExpressTransactionAssetFactory(
|
||||
multiAccountListSupplier = multiAccountListSupplier,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
tokenInfoRepository = tokenInfoRepository,
|
||||
excludedBlockchains = ExcludedBlockchains(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(tokenInfoRepository, multiAccountListSupplier, userWalletsListRepository)
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
|
||||
coEvery { tokenInfoRepository.getCached(any()) } returns emptyList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN token only in cache WHEN create THEN builds token from cache`() = runTest {
|
||||
// Arrange
|
||||
coEvery { tokenInfoRepository.getCached(any()) } returns listOf(
|
||||
TokenInfoEntity(
|
||||
networkId = "ethereum",
|
||||
contractAddress = TOKEN_CONTRACT,
|
||||
coinId = "tether",
|
||||
name = "Tether",
|
||||
symbol = "USDT",
|
||||
decimals = 6,
|
||||
updatedAt = 0,
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = factory.create(userWallet.walletId, listOf(coinToTokenSwap()), emptyList(), emptyList())
|
||||
|
||||
// Assert
|
||||
val token = result[ExpressAsset.ID(networkId = "ethereum", contractAddress = TOKEN_CONTRACT)]
|
||||
assertThat(token).isInstanceOf(CryptoCurrency.Token::class.java)
|
||||
assertThat((token as CryptoCurrency.Token).symbol).isEqualTo("USDT")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN coin asset WHEN create THEN builds coin`() = runTest {
|
||||
// Act
|
||||
val result = factory.create(userWallet.walletId, listOf(coinToTokenSwap()), emptyList(), emptyList())
|
||||
|
||||
// Assert
|
||||
val coin = result[ExpressAsset.ID(networkId = "ethereum", contractAddress = ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE)]
|
||||
assertThat(coin).isInstanceOf(CryptoCurrency.Coin::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN token absent from portfolio and cache WHEN create THEN omits it`() = runTest {
|
||||
// Act
|
||||
val result = factory.create(userWallet.walletId, listOf(coinToTokenSwap()), emptyList(), emptyList())
|
||||
|
||||
// Assert
|
||||
assertThat(result).doesNotContainKey(ExpressAsset.ID(networkId = "ethereum", contractAddress = TOKEN_CONTRACT))
|
||||
}
|
||||
|
||||
/** A swap from a native coin (empty contract) to an Ethereum token. */
|
||||
private fun coinToTokenSwap() = ExpressExchangeEntity(
|
||||
txId = "tx-1",
|
||||
providerId = "provider",
|
||||
fromAddress = "owner",
|
||||
payinAddress = "payin-addr",
|
||||
payinExtraId = null,
|
||||
payoutAddress = "payout-addr",
|
||||
refundAddress = null,
|
||||
refundExtraId = null,
|
||||
rateType = "float",
|
||||
status = "finished",
|
||||
externalTxId = null,
|
||||
externalTxUrl = null,
|
||||
payinHash = "payin",
|
||||
payoutHash = "payout",
|
||||
refundNetwork = null,
|
||||
refundContractAddress = null,
|
||||
createdAt = "2026-06-01T00:00:00Z",
|
||||
updatedAt = "2026-06-01T00:00:00Z",
|
||||
payTill = null,
|
||||
averageDuration = null,
|
||||
from = ExpressExchangeEntity.AssetEmbedded(
|
||||
contractAddress = ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE,
|
||||
network = "ethereum",
|
||||
decimals = 18,
|
||||
amount = "1.0",
|
||||
actualAmount = null,
|
||||
),
|
||||
to = ExpressExchangeEntity.AssetEmbedded(
|
||||
contractAddress = TOKEN_CONTRACT,
|
||||
network = "ethereum",
|
||||
decimals = 6,
|
||||
amount = "100.0",
|
||||
actualAmount = null,
|
||||
),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val TOKEN_CONTRACT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
package com.tangem.data.txhistory.repository.factory
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
|
||||
import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
|
||||
import com.tangem.domain.account.models.AccountList
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class TokenInfoRepositoryTest {
|
||||
|
||||
private val tangemTechApi: TangemTechApi = mockk()
|
||||
private val tokenInfoDao: TokenInfoDao = mockk(relaxUnitFun = true)
|
||||
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
|
||||
|
||||
private val repository = TokenInfoRepository(
|
||||
tangemTechApi = tangemTechApi,
|
||||
tokenInfoDao = tokenInfoDao,
|
||||
multiAccountListSupplier = multiAccountListSupplier,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(tangemTechApi, tokenInfoDao, multiAccountListSupplier)
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListOf()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN only coins WHEN fetchMissing THEN nothing is read or fetched`() = runTest {
|
||||
// Act
|
||||
repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = COIN_CONTRACT)))
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 0) { tokenInfoDao.getCached(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any()) }
|
||||
coVerify(exactly = 0) { tokenInfoDao.upsert(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN token already fresh in cache WHEN fetchMissing THEN does not fetch`() = runTest {
|
||||
// Arrange
|
||||
coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns listOf(
|
||||
tokenInfoEntity(networkId = "ethereum", contractAddress = "0xUSDT"),
|
||||
)
|
||||
|
||||
// Act
|
||||
repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xUSDT")))
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 0) { tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any()) }
|
||||
coVerify(exactly = 0) { tokenInfoDao.upsert(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN token already in portfolio WHEN fetchMissing THEN does not fetch`() = runTest {
|
||||
// Arrange
|
||||
coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns emptyList()
|
||||
val portfolioToken = MockCryptoCurrencyFactory().createToken(
|
||||
blockchain = Blockchain.Ethereum,
|
||||
contractAddress = "0xPortfolioToken",
|
||||
)
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListOf(portfolioToken)))
|
||||
|
||||
// Act — same contract, different casing
|
||||
repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0XPORTFOLIOTOKEN")))
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 0) { tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any()) }
|
||||
coVerify(exactly = 0) { tokenInfoDao.upsert(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unresolved token WHEN fetchMissing THEN fetches and caches it`() = runTest {
|
||||
// Arrange
|
||||
coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns emptyList()
|
||||
coEvery {
|
||||
tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any())
|
||||
} returns ApiResponse.Success(
|
||||
coinsResponse(coin(network("ethereum", "0xUSDT", BigDecimal(6)))),
|
||||
)
|
||||
val saved = slot<List<TokenInfoEntity>>()
|
||||
coEvery { tokenInfoDao.upsert(capture(saved)) } returns Unit
|
||||
|
||||
// Act
|
||||
repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xUSDT")))
|
||||
|
||||
// Assert
|
||||
val entity = saved.captured.single()
|
||||
assertThat(entity.copy(updatedAt = 0)).isEqualTo(
|
||||
TokenInfoEntity(
|
||||
networkId = "ethereum",
|
||||
contractAddress = "0xUSDT",
|
||||
coinId = COIN_ID,
|
||||
name = COIN_NAME,
|
||||
symbol = COIN_SYMBOL,
|
||||
decimals = 6,
|
||||
updatedAt = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN response with extra networks WHEN fetchMissing THEN keeps only requested pairs ignoring case`() = runTest {
|
||||
// Arrange
|
||||
coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns emptyList()
|
||||
coEvery {
|
||||
tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any())
|
||||
} returns ApiResponse.Success(
|
||||
coinsResponse(
|
||||
coin(
|
||||
network("ethereum", "0xabc", BigDecimal(6)), // requested (different case)
|
||||
network("bsc", "0xabc", BigDecimal(18)), // other network — not requested
|
||||
network("ethereum", "0xother", null), // missing decimals — dropped
|
||||
),
|
||||
),
|
||||
)
|
||||
val saved = slot<List<TokenInfoEntity>>()
|
||||
coEvery { tokenInfoDao.upsert(capture(saved)) } returns Unit
|
||||
|
||||
// Act
|
||||
repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xAbC")))
|
||||
|
||||
// Assert
|
||||
assertThat(saved.captured.map { it.networkId to it.contractAddress })
|
||||
.containsExactly("ethereum" to "0xabc")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN getCoins fails WHEN fetchMissing THEN nothing is cached`() = runTest {
|
||||
// Arrange
|
||||
coEvery { tokenInfoDao.getCached(any(), any(), any()) } returns emptyList()
|
||||
coEvery {
|
||||
tangemTechApi.getCoins(networkIds = any(), contractAddresses = any(), active = any())
|
||||
} returns ApiResponse.Error(
|
||||
ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.INTERNAL_SERVER_ERROR,
|
||||
message = "boom",
|
||||
errorBody = null,
|
||||
),
|
||||
).cast()
|
||||
|
||||
// Act
|
||||
repository.fetchMissing(setOf(ExpressAsset.ID(networkId = "ethereum", contractAddress = "0xUSDT")))
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 0) { tokenInfoDao.upsert(any()) }
|
||||
}
|
||||
|
||||
private fun accountListOf(vararg currencies: CryptoCurrency): AccountList = mockk {
|
||||
every { flattenCurrencies() } returns currencies.toList()
|
||||
}
|
||||
|
||||
private fun tokenInfoEntity(networkId: String, contractAddress: String) = TokenInfoEntity(
|
||||
networkId = networkId,
|
||||
contractAddress = contractAddress,
|
||||
coinId = COIN_ID,
|
||||
name = COIN_NAME,
|
||||
symbol = COIN_SYMBOL,
|
||||
decimals = 6,
|
||||
updatedAt = 0,
|
||||
)
|
||||
|
||||
private fun coinsResponse(vararg coins: CoinsResponse.Coin) = CoinsResponse(
|
||||
imageHost = null,
|
||||
coins = coins.toList(),
|
||||
total = coins.size,
|
||||
)
|
||||
|
||||
private fun coin(vararg networks: CoinsResponse.Coin.Network) = CoinsResponse.Coin(
|
||||
id = COIN_ID,
|
||||
name = COIN_NAME,
|
||||
symbol = COIN_SYMBOL,
|
||||
active = true,
|
||||
networks = networks.toList(),
|
||||
)
|
||||
|
||||
private fun network(networkId: String, contractAddress: String?, decimalCount: BigDecimal?) =
|
||||
CoinsResponse.Coin.Network(
|
||||
networkId = networkId,
|
||||
contractAddress = contractAddress,
|
||||
decimalCount = decimalCount,
|
||||
exchangeable = true,
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Any> ApiResponse.Error.cast(): ApiResponse<T> = this as ApiResponse<T>
|
||||
|
||||
private companion object {
|
||||
const val COIN_CONTRACT = "0"
|
||||
const val COIN_ID = "tether"
|
||||
const val COIN_NAME = "Tether"
|
||||
const val COIN_SYMBOL = "USDT"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,9 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.virtualaccount"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
}
|
||||
}
|
||||
|
|
@ -23,61 +23,82 @@ android {
|
|||
}
|
||||
dependencies {
|
||||
|
||||
/** Project - Data */
|
||||
implementation(projects.core.analytics)
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.error)
|
||||
implementation(projects.core.error.ext)
|
||||
implementation(projects.core.security)
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.wallets)
|
||||
|
||||
/** Project - Domain */
|
||||
implementation(projects.domain.visa)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.tokens)
|
||||
implementation(projects.domain.networks)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.quotes)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.features.swap.domain)
|
||||
|
||||
|
||||
/** Project - Utils */
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
/** Project - Libs */
|
||||
implementation(projects.libs.visa)
|
||||
|
||||
/** Libs - Other */
|
||||
implementation(deps.androidx.datastore)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.androidx.paging.runtime)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.arrow.fx)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.androidx.paging.runtime)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(deps.okio)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
|
||||
/** Libs - Tangem */
|
||||
// region Libs - Tangem
|
||||
api(tangemDeps.hot.core)
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(tangemDeps.hot.core)
|
||||
implementation(projects.libs.tangemSdkApi)
|
||||
// endregion
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Test */
|
||||
// region Project - Core
|
||||
api(projects.core.analytics)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.security)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.analytics.models)
|
||||
implementation(projects.core.error)
|
||||
implementation(projects.core.error.ext)
|
||||
implementation(projects.core.pagination)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
implementation(projects.data.wallets)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.core)
|
||||
api(projects.domain.networks)
|
||||
api(projects.domain.quotes)
|
||||
api(projects.domain.virtualAccount)
|
||||
api(projects.domain.visa)
|
||||
api(projects.domain.wallets)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.walletManager)
|
||||
runtimeOnly(projects.domain.tokens)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain models
|
||||
api(projects.domain.visa.models)
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
// endregion
|
||||
|
||||
// region Project - Features
|
||||
api(projects.features.swap.domain)
|
||||
api(projects.features.virtualAccounts.details.api)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
api(projects.libs.tangemSdkApi)
|
||||
implementation(projects.libs.visa)
|
||||
// endregion
|
||||
|
||||
// region Test
|
||||
testImplementation(projects.test.core)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
package com.tangem.data.pay
|
||||
|
||||
import com.tangem.common.card.FirmwareVersion
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.pay.TangemPayEligibilityType
|
||||
import com.tangem.domain.models.pay.isTangemPayType
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.wallet.isLocked
|
||||
import com.tangem.domain.models.wallet.isMultiCurrency
|
||||
import com.tangem.domain.models.wallet.isTangemPayCompatible
|
||||
import com.tangem.domain.pay.TangemPayEligibilityManager
|
||||
import com.tangem.domain.pay.model.TangemPayEntryPoint
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.hot.sdk.model.HotWalletId
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
|
@ -85,7 +85,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
|
|||
val wallets = userWalletsListRepository.userWallets.value ?: return emptyList()
|
||||
|
||||
val candidates = wallets.filter { wallet ->
|
||||
wallet.isMultiCurrency && !wallet.isLocked && wallet.isCompatible() &&
|
||||
wallet.isMultiCurrency && !wallet.isLocked && wallet.isTangemPayCompatible &&
|
||||
!onboardingRepository.isTangemPayDeactivated(wallet.walletId)
|
||||
}
|
||||
|
||||
|
|
@ -98,11 +98,6 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
|
|||
return candidates
|
||||
}
|
||||
|
||||
private fun UserWallet.isCompatible(): Boolean = when (this) {
|
||||
is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable
|
||||
is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword
|
||||
}
|
||||
|
||||
private suspend fun List<UserWallet>.addPaeraCustomersData(): List<UserWalletData> {
|
||||
if (isEmpty()) return emptyList()
|
||||
|
||||
|
|
@ -139,7 +134,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
|
|||
onboardingRepository.checkCustomerEligibility()
|
||||
}
|
||||
return if (entryPoint == null) {
|
||||
eligibility.isNotEmpty()
|
||||
eligibility.any { it.isTangemPayType }
|
||||
} else {
|
||||
eligibility.any { it == entryPoint.toEligibilityType() }
|
||||
}
|
||||
|
|
@ -148,6 +143,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
|
|||
private fun TangemPayEntryPoint.toEligibilityType(): TangemPayEligibilityType = when (this) {
|
||||
TangemPayEntryPoint.BANNER -> TangemPayEligibilityType.BANNER
|
||||
TangemPayEntryPoint.DETAILS -> TangemPayEligibilityType.DETAILS
|
||||
TangemPayEntryPoint.DEEPLINK -> TangemPayEligibilityType.DEEPLINK
|
||||
}
|
||||
|
||||
private data class UserWalletData(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
package com.tangem.data.pay
|
||||
|
||||
import com.tangem.data.pay.store.PaymentAccountStatusesStore
|
||||
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
|
||||
import com.tangem.domain.common.wallets.UserWalletDataCleaner
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class TangemPayUserWalletDataCleaner @Inject constructor(
|
||||
private val paymentAccountStatusesStore: PaymentAccountStatusesStore,
|
||||
private val txHistoryItemsStore: TangemPayTxHistoryItemsStore,
|
||||
) : UserWalletDataCleaner {
|
||||
|
||||
override suspend fun clear(userWalletIds: List<UserWalletId>) {
|
||||
paymentAccountStatusesStore.remove(userWalletIds)
|
||||
txHistoryItemsStore.remove(userWalletIds.map { it.stringValue })
|
||||
}
|
||||
}
|
||||
|
|
@ -118,6 +118,8 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
|
|||
)
|
||||
},
|
||||
error = null,
|
||||
virtualAccount = null,
|
||||
tariffPlan = null,
|
||||
)
|
||||
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(
|
||||
source = StatusSource.CACHE,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
package com.tangem.data.pay.converter
|
||||
|
||||
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
|
||||
import com.tangem.domain.models.account.TangemPayTariffPlan
|
||||
|
||||
internal object TangemPayTariffPlanConverter {
|
||||
|
||||
fun convert(value: CustomerMeResponse.TariffPlan?): TangemPayTariffPlan? {
|
||||
val id = value?.id ?: return null
|
||||
val name = value.name ?: return null
|
||||
return TangemPayTariffPlan(
|
||||
id = id,
|
||||
type = TangemPayTariffPlan.Type.fromString(value.type),
|
||||
name = name,
|
||||
descriptionItems = value.descriptionItems.orEmpty().mapNotNull(::convertDescriptionItem),
|
||||
images = value.images.orEmpty().mapNotNull(::convertImage),
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertDescriptionItem(item: CustomerMeResponse.DescriptionItem): TangemPayTariffPlan.DescriptionItem? {
|
||||
val title = item.title ?: return null
|
||||
return TangemPayTariffPlan.DescriptionItem(
|
||||
section = TangemPayTariffPlan.Section.fromString(item.type),
|
||||
order = item.order ?: 0,
|
||||
title = title,
|
||||
body = item.body.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertImage(image: CustomerMeResponse.Image): TangemPayTariffPlan.Image? {
|
||||
val url = image.url ?: return null
|
||||
return TangemPayTariffPlan.Image(
|
||||
type = TangemPayTariffPlan.Image.Type.fromString(image.type),
|
||||
url = url,
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue