Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 14:50:24 +03:00
commit 50fe9e603d
1919 changed files with 94940 additions and 15178 deletions

View file

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

View file

@ -0,0 +1,44 @@
package com.tangem.data.account.cleaner
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.LegacyUserTokensResponseStore
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Clears per-wallet accounts and user tokens caches when wallets are deleted.
*
* Since [UserWalletId] is derived from the wallet key, re-adding the same wallet reuses the same id. Without this
* cleanup the stale accounts response and its ETag survive the deletion, the server answers `304 Not Modified` on
* re-add and the cached (non-empty) accounts short-circuit the fetch flow, so public key derivation never runs.
*
* @property accountsResponseStoreFactory factory owning the per-wallet accounts response stores
* @property legacyUserTokensResponseStore legacy per-wallet user tokens store
* @property eTagsStore store of caching ETags
*/
internal class AccountsUserWalletDataCleaner @Inject constructor(
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val legacyUserTokensResponseStore: LegacyUserTokensResponseStore,
private val eTagsStore: ETagsStore,
) : UserWalletDataCleaner {
override suspend fun clear(userWalletIds: List<UserWalletId>) {
// Best-effort: isolate failures per store so one failing store does not cancel the others.
coroutineScope {
launch { clearSafely(store = "accounts response") { accountsResponseStoreFactory.clear(userWalletIds) } }
launch { clearSafely(store = "legacy user tokens") { legacyUserTokensResponseStore.clear(userWalletIds) } }
launch { clearSafely(store = "ETags") { eTagsStore.clear(userWalletIds) } }
}
}
private suspend fun clearSafely(store: String, clear: suspend () -> Unit) {
runSuspendCatching { clear() }
.onFailure { TangemLogger.e("Failed to clear $store store", it) }
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.account.di
import android.content.Context
import com.tangem.data.account.cleaner.AccountsUserWalletDataCleaner
import com.tangem.data.account.converter.AccountConverterFactoryContainer
import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher
import com.tangem.data.account.repository.DefaultAccountsCRUDRepository
@ -16,6 +17,7 @@ import com.tangem.datasource.local.accounts.AccountTokenMigrationStore
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -23,6 +25,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import javax.inject.Singleton
@Module
@ -53,6 +56,10 @@ internal object AccountDataModule {
)
}
@Provides
@IntoSet
fun provideAccountsUserWalletDataCleaner(impl: AccountsUserWalletDataCleaner): UserWalletDataCleaner = impl
@Provides
@Singleton
fun provideWalletAccountsFetcher(impl: DefaultWalletAccountsFetcher): WalletAccountsFetcher = impl

View file

@ -11,8 +11,12 @@ import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResp
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import com.tangem.domain.models.wallet.UserWalletId
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@ -56,6 +60,26 @@ internal class AccountsResponseStoreFactory @Inject constructor(
}
}
/**
* Clears the persisted accounts responses for the given [userWalletIds].
*
* Each wallet owns a separate store file, so they are reset concurrently. The store instances are kept alive
* (DataStore forbids multiple instances for the same file), only their content is reset to the default `null`.
*
* @param userWalletIds the unique identifiers of the user's wallets
*/
suspend fun clear(userWalletIds: List<UserWalletId>) = coroutineScope {
// Best-effort: a failure clearing one wallet must not cancel clearing the others.
userWalletIds.forEach { userWalletId ->
launch {
runSuspendCatching { create(userWalletId).updateData { null } }
.onFailure {
TangemLogger.e("Failed to clear accounts response for wallet: ${userWalletId.stringValue}", it)
}
}
}
}
@VisibleForTesting
fun getAllStores(): Map<UserWalletId, AccountsResponseStore> = createdDataStores.toMap()

View file

@ -25,10 +25,18 @@ internal class LegacyUserTokensResponseStore @Inject constructor(
}
suspend fun clear(userWalletId: UserWalletId) {
appPreferencesStore.updateData { preferences ->
val key = createPreferencesKey(userWalletId = userWalletId.stringValue)
clear(userWalletIds = listOf(userWalletId))
}
preferences.toMutablePreferences().apply { remove(key) }
suspend fun clear(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
val keys = userWalletIds.map { createPreferencesKey(userWalletId = it.stringValue) }
appPreferencesStore.updateData { preferences ->
preferences.toMutablePreferences().apply {
keys.forEach { key -> remove(key) }
}
}
}

View file

@ -0,0 +1,57 @@
package com.tangem.data.account.cleaner
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.LegacyUserTokensResponseStore
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.Runs
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.mockk
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 AccountsUserWalletDataCleanerTest {
private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk()
private val legacyUserTokensResponseStore: LegacyUserTokensResponseStore = mockk()
private val eTagsStore: ETagsStore = mockk()
private val cleaner = AccountsUserWalletDataCleaner(
accountsResponseStoreFactory = accountsResponseStoreFactory,
legacyUserTokensResponseStore = legacyUserTokensResponseStore,
eTagsStore = eTagsStore,
)
@BeforeEach
fun resetMocks() {
clearMocks(accountsResponseStoreFactory, legacyUserTokensResponseStore, eTagsStore)
coEvery { accountsResponseStoreFactory.clear(any<List<UserWalletId>>()) } just Runs
coEvery { legacyUserTokensResponseStore.clear(any<List<UserWalletId>>()) } just Runs
coEvery { eTagsStore.clear(any<List<UserWalletId>>()) } just Runs
}
@Test
fun `GIVEN wallets WHEN clear THEN each store is cleared once with all ids in a single call`() = runTest {
// Arrange
val ids = listOf(WALLET_A, WALLET_B)
// Act
cleaner.clear(ids)
// Assert
coVerify(exactly = 1) { accountsResponseStoreFactory.clear(ids) }
coVerify(exactly = 1) { legacyUserTokensResponseStore.clear(ids) }
coVerify(exactly = 1) { eTagsStore.clear(ids) }
}
private companion object {
val WALLET_A = UserWalletId("011")
val WALLET_B = UserWalletId("022")
}
}

View file

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

View file

@ -0,0 +1,291 @@
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()
private val logger = TangemLogger.withTag(LOG_TAG)
override fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>> {
return getContactsForWallet(userWalletId)
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun getAllContacts(): Flow<List<Contact>> {
return userWalletsListRepository.userWallets
.filterNotNull()
.flatMapLatest { wallets ->
val walletsById = wallets.associateBy { it.walletId.stringValue }
val ids = wallets.mapTo(mutableSetOf()) { it.walletId }
blobStore.getBlobs(ids).map { blobs ->
blobs.flatMap { blob ->
walletsById[blob.walletId]?.let { userWallet ->
decryptContacts(blob, userWallet)
}.orEmpty()
}
}
}
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
private fun getContactsForWallet(userWalletId: UserWalletId): Flow<List<Contact>> {
return blobStore.getBlob(userWalletId).map { blob ->
val userWallet = blob?.let { findUserWallet(it.walletId) } ?: return@map emptyList()
decryptContacts(blob, userWallet)
}
}
override suspend fun 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()
currentContacts(contact.walletId, userWallet).flatMap { current ->
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()
// Metadata only (wallet count) — persisted to the prod log so sync activity is traceable.
logger.i("Syncing address books for ${wallets.size} wallet(s)")
// 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.
logger.i("Sync response: ${response.items.size} updated book(s) out of ${wallets.size} requested")
response.items.forEach { item ->
val userWalletId = UserWalletId(stringValue = item.walletId)
// Metadata only (no keys/ciphertext/plaintext) — helps QA verify what the backend delivered
// (esp. for cross-platform books).
logger.i(
"Storing synced address book for wallet ${item.walletId}: " +
"updatedAt=${item.updatedAt}, nonceLen=${item.nonce.length}, " +
"ciphertextLen=${item.ciphertext.length}, authTagLen=${item.authTag.length}",
)
blobStore.storeBlob(item.toBlob())
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, item.etag)
}
Unit.right()
},
onError = { error ->
logger.e("Failed to sync address books: $error")
error.toSyncError().left()
},
)
}
private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List<Contact> {
return cipher.decrypt(blob, userWallet).fold(
ifLeft = { error ->
// The cipher already logged the low-level cause; this ties the failure to the read path so QA
// can see that a stored/synced book (e.g. one created on iOS) could not be shown to the user.
logger.e("Skipping address book for wallet ${blob.walletId}: decrypt failed with $error")
emptyList()
},
ifRight = { it.contacts },
)
}
/**
* The contacts currently stored for [userWalletId], as the base a write is merged onto.
*
* A missing blob means no book exists yet an empty base, so the first contact legitimately creates it.
* But a blob that fails to decrypt must surface as [AddressBookSyncError.DecryptionFailed] rather than an empty
* list: treating a broken book as empty would let a merged write overwrite the (non-empty) backend copy with a
* book built from a single new contact, wiping every existing one.
*/
private suspend fun currentContacts(
userWalletId: UserWalletId,
userWallet: UserWallet,
): Either<AddressBookSyncError, List<Contact>> {
val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList<Contact>().right()
return cipher.decrypt(blob, userWallet)
.map { it.contacts }
.mapLeft { error ->
logger.e(
"Refusing to overwrite address book for wallet $userWalletId: it is stored but decrypt " +
"failed with $error",
)
AddressBookSyncError.DecryptionFailed
}
}
/**
* 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 ->
logger.e("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 ->
logger.e("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 LOG_TAG = "AddressBook"
const val MAX_SYNC_WALLETS = 20
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,456 @@
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.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 does not sync on collection`() = 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
// Sync is now triggered by the feature entry points, not on flow collection.
coVerify(exactly = 0) { addressBookApi.syncAddressBooks(any()) }
}
@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 does not sync on collection`() = 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
// Sync is now triggered by the feature entry points, not on flow collection.
coVerify(exactly = 0) { addressBookApi.syncAddressBooks(any()) }
}
@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 stored book that fails to decrypt WHEN saveContact THEN returns DecryptionFailed and pushes nothing`() =
runTest {
// Arrange
val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns AddressBookCryptoError.DecryptionFailed.left()
// Act
val result = repository.saveContact(createContact(id = "c2", name = "Bob"))
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.DecryptionFailed.left())
// The broken book is left untouched: nothing is encrypted, pushed, or stored.
coVerify(exactly = 0) { addressBookApi.updateAddressBook(any(), any(), any()) }
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
coVerify(exactly = 0) { eTagsStore.store(any(), any(), any()) }
}
@Test
fun `GIVEN existing contact id WHEN saveContact THEN replaces it`() = runTest {
// Arrange
val original = createContact(id = "c1", name = "Alice")
val updated = createContact(id = "c1", name = "Alice Updated")
val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(listOf(original)).right()
val bookSlot = slot<AddressBook>()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right()
coEvery { blobStore.storeBlob(any()) } returns Unit
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
// Act
repository.saveContact(updated)
// Assert
assertThat(bookSlot.captured.contacts).containsExactly(updated)
}
@Test
fun `GIVEN contact in wallet WHEN deleteContact THEN pushes and re-stores book without it`() = runTest {
// Arrange
val kept = createContact(id = "c1", name = "Alice")
val removed = createContact(id = "c2", name = "Bob")
val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(listOf(kept, removed)).right()
val bookSlot = slot<AddressBook>()
val newBlob = createBlob()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
coEvery { blobStore.storeBlob(newBlob) } returns Unit
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
// Act
val result = repository.deleteContact(ContactId("c2"))
// Assert
assertThat(result).isEqualTo(Unit.right())
assertThat(bookSlot.captured.contacts).containsExactly(kept)
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
}
@Test
fun `GIVEN backend returns changed item WHEN syncAddressBooks THEN stores blob and etag for it`() = runTest {
// Arrange
val walletB: UserWallet = mockk { every { walletId } returns UserWalletId(WALLET_B) }
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet, walletB)
val requestSlot = slot<SyncAddressBooksRequest>()
// Only wallet A changed; wallet B is omitted (matching etag) → must keep its local copy.
coEvery { addressBookApi.syncAddressBooks(capture(requestSlot)) } returns
ApiResponse.Success(SyncAddressBooksResponse(items = listOf(syncItem(WALLET_A))))
val blobSlot = slot<AddressBookBlob>()
coEvery { blobStore.storeBlob(capture(blobSlot)) } returns Unit
// Act
val result = repository.syncAddressBooks()
// Assert
assertThat(result).isEqualTo(Unit.right())
assertThat(requestSlot.captured.wallets.map { it.walletId }).containsExactly(WALLET_A, WALLET_B)
assertThat(blobSlot.captured.walletId).isEqualTo(WALLET_A)
coVerify(exactly = 1) { blobStore.storeBlob(any()) }
coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) }
coVerify(exactly = 0) { blobStore.storeBlob(match { it.walletId == WALLET_B }) }
}
@Test
fun `GIVEN unauthorized WHEN syncAddressBooks THEN returns Unauthorized and stores nothing`() = runTest {
// Arrange
coEvery { addressBookApi.syncAddressBooks(any()) } returns
errorResponse(ApiResponseError.HttpException.Code.UNAUTHORIZED)
// Act
val result = repository.syncAddressBooks()
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.Unauthorized.left())
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
}
@Test
fun `GIVEN matching name WHEN getContact THEN returns it`() = runTest {
// Arrange
val alice = createContact(id = "c1", name = "Alice")
val bob = createContact(id = "c2", name = "Bob")
val blob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns blob
every { cipher.decrypt(blob, userWallet) } returns
AddressBook(listOf(alice, bob)).right()
// Act
val result = repository.getContact(UserWalletId(WALLET_A), name = "Bob")
// Assert
assertThat(result).isEqualTo(bob)
}
private fun successPutResponse(etag: String = ETAG_NEW): ApiResponse<UpdateAddressBookResponse> =
ApiResponse.Success(
data = UpdateAddressBookResponse(walletId = WALLET_A, etag = etag, updatedAt = TIMESTAMP),
)
@Suppress("UNCHECKED_CAST")
private fun <T : Any> errorResponse(code: ApiResponseError.HttpException.Code): ApiResponse<T> =
ApiResponse.Error(
cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null),
) as ApiResponse<T>
@Suppress("UNCHECKED_CAST")
private fun <T : Any> networkErrorResponse(): ApiResponse<T> =
ApiResponse.Error(cause = ApiResponseError.NetworkException()) as ApiResponse<T>
private fun syncItem(walletId: String): SyncAddressBooksResponse.Item = SyncAddressBooksResponse.Item(
walletId = walletId,
etag = ETAG_NEW,
version = AddressBookBlob.CURRENT_VERSION,
updatedAt = TIMESTAMP,
nonce = "00112233445566778899aabb",
ciphertext = "deadbeef",
authTag = "cafebabecafebabecafebabecafebabe",
)
private fun createContact(id: String, name: String, iconColor: String = "KekColor"): Contact = Contact(
id = ContactId(id),
walletId = UserWalletId(WALLET_A),
name = ContactName(name).getOrNull()!!,
icon = "",
iconColor = iconColor,
createdAt = TIMESTAMP,
updatedAt = TIMESTAMP,
addresses = emptyList(),
)
private fun createBlob(): AddressBookBlob = AddressBookBlob(
walletId = WALLET_A,
updatedAt = TIMESTAMP,
nonce = "00112233445566778899aabb",
ciphertext = "deadbeef",
authTag = "cafebabecafebabecafebabecafebabe",
)
private companion object {
const val WALLET_A = "0a0a0a"
const val WALLET_B = "0b0b0b"
const val TIMESTAMP = "2026-05-22T09:00:00.000Z"
const val ETAG_OLD = "etag-old"
const val ETAG_NEW = "etag-new"
}
}

View file

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

View file

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

View file

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

View file

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

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

@ -0,0 +1 @@
/build

View file

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

View file

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

View file

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

View file

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

View file

@ -1,25 +1,18 @@
package com.tangem.data.appsflyer
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
import javax.inject.Inject
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource as StoreDeeplinkSource
internal class DefaultAppsFlyerRepository @Inject constructor(
private val appsFlyerStore: AppsFlyerStore,
) : AppsFlyerRepository {
override suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String? {
return appsFlyerStore.getDeeplink(source.toStoreSource())
override suspend fun getDeeplink(): String? {
return appsFlyerStore.getNavigationDeeplink()
}
override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) {
appsFlyerStore.clearDeeplink(source.toStoreSource())
}
private fun AppsFlyerDeeplinkSource.toStoreSource() = when (this) {
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> StoreDeeplinkSource.TangemPayHotWalletOnboarding
AppsFlyerDeeplinkSource.Referral -> StoreDeeplinkSource.Referral
override suspend fun clearDeeplink() {
appsFlyerStore.clearNavigationDeeplink()
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -39,6 +39,18 @@ internal class DefaultETagsStore(
appPreferencesStore.editData { it.remove(storeKey) }
}
override suspend fun clear(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
appPreferencesStore.editData { preferences ->
userWalletIds.forEach { userWalletId ->
ETagsStore.Key.entries.forEach { key ->
preferences.remove(getAccountsETagKey(userWalletId = userWalletId, key = key))
}
}
}
}
private fun getAccountsETagKey(userWalletId: UserWalletId, key: ETagsStore.Key): Preferences.Key<String> {
return stringPreferencesKey(name = "etag_${key}_${userWalletId.stringValue}")
}

View file

@ -33,9 +33,17 @@ interface ETagsStore {
*/
suspend fun clear(userWalletId: UserWalletId, key: Key)
/**
* Clears all stored ETag values (every [Key]) for the specified wallets in a single operation
*
* @param userWalletIds identifiers of the user wallets
*/
suspend fun clear(userWalletIds: List<UserWalletId>)
/** Enumeration of possible keys for storing ETag values */
enum class Key {
WalletAccounts,
UserTokens,
AddressBook,
}
}

View file

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

View file

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

View file

@ -0,0 +1,68 @@
package com.tangem.data.common.cache.etag
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.mutablePreferencesOf
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
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 DefaultETagsStoreTest {
private val appPreferencesStore: AppPreferencesStore = mockk()
private val store = DefaultETagsStore(appPreferencesStore = appPreferencesStore)
@BeforeEach
fun resetMocks() {
clearMocks(appPreferencesStore)
}
@Test
fun `GIVEN wallets WHEN clear list THEN all keys of all wallets removed in a single edit`() = runTest {
// Arrange
val transform = slot<suspend AppPreferencesStore.(MutablePreferences) -> Unit>()
coEvery { appPreferencesStore.editData(capture(transform)) } returns mutablePreferencesOf()
val preferences = mutablePreferencesOf().apply {
ETagsStore.Key.entries.forEach { key ->
set(stringPreferencesKey("etag_${key}_${WALLET_A.stringValue}"), "a")
set(stringPreferencesKey("etag_${key}_${WALLET_B.stringValue}"), "b")
}
set(stringPreferencesKey("unrelated"), "keep")
}
// Act
store.clear(userWalletIds = listOf(WALLET_A, WALLET_B))
transform.captured.invoke(appPreferencesStore, preferences)
// Assert
coVerify(exactly = 1) { appPreferencesStore.editData(any()) }
assertThat(preferences.asMap().keys.map(Preferences.Key<*>::name)).containsExactly("unrelated")
}
@Test
fun `GIVEN empty list WHEN clear list THEN nothing is edited`() = runTest {
// Act
store.clear(userWalletIds = emptyList())
// Assert
coVerify(exactly = 0) { appPreferencesStore.editData(any()) }
}
private companion object {
val WALLET_A = UserWalletId("011")
val WALLET_B = UserWalletId("022")
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -95,6 +95,26 @@ internal class DefaultExpressServiceFetcher @Inject constructor(
return flow { getInitializationStatusInternal(userWalletId).collect { emit(it) } }
}
override suspend fun getOrFetch(
userWalletId: UserWalletId,
assetId: ExpressAsset.ID,
): Either<Throwable, ExpressAsset> = either {
// Return the already-loaded asset (in-memory status or persisted cache) without hitting the network.
findLoadedAsset(userWalletId, assetId)?.let { return@either it }
// Not loaded yet — fetch it from the API, then take it from the refreshed status.
fetch(userWalletId = userWalletId, assetIds = setOf(assetId)).bind()
findLoadedAsset(userWalletId, assetId)
?: raise(IllegalStateException("Express asset $assetId is not available after fetch"))
}
private suspend fun findLoadedAsset(userWalletId: UserWalletId, assetId: ExpressAsset.ID): ExpressAsset? {
return getInitializationStatusInternal(userWalletId).value
.getOrNull()
?.firstOrNull { it.id == assetId }
}
@Suppress("SuspendFunWithFlowReturnType")
private suspend fun getInitializationStatusInternal(userWalletId: UserWalletId): InitializationStatusFlow {
val initializationStatus = initializationStatuses.value[userWalletId]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,30 @@
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.marketing"
}
dependencies {
implementation(deps.androidx.datastore)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
implementation(projects.domain.marketing)
implementation(projects.domain.marketing.models)
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.core.configToggles)
testImplementation(projects.test.core)
}

View file

@ -0,0 +1,144 @@
package com.tangem.data.marketing
import arrow.core.Either
import com.tangem.data.marketing.converter.MarketingCampaignConverter
import com.tangem.data.marketing.store.MarketingCampaignsCacheStore
import com.tangem.data.marketing.store.MarketingDismissStore
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ETAG_HEADER
import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry
import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.marketing.MarketingRepository
import com.tangem.domain.marketing.models.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.MarketingScreenType
import com.tangem.utils.SupportedLanguages
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
internal class DefaultMarketingRepository(
private val tangemTechApi: TangemTechApi,
private val cacheStore: MarketingCampaignsCacheStore,
private val dismissStore: MarketingDismissStore,
private val converter: MarketingCampaignConverter,
private val dispatchers: CoroutineDispatcherProvider,
) : MarketingRepository {
// In-memory per-session cache for background (cacheable) types. Serves repeated reads within a session
// without hitting the network; DataStore ETag cache remains the cross-session layer inside fetchAndCacheByType.
private val sessionCache = MutableStateFlow<Map<MarketingScreenType, List<MarketingCampaign>>>(emptyMap())
private val cacheMutex = Mutex()
override suspend fun getCampaigns(screen: MarketingScreen): Either<Throwable, List<MarketingCampaign>> =
withContext(dispatchers.io) {
Either.catch {
if (screen.type.isCacheable) {
loadCacheableByType(screen.type)
} else {
// swap/onramp — always fresh, never cached
when (val response = requestCampaigns(screen, eTag = null)) {
is ApiResponse.Success -> convert(response.data)
is ApiResponse.Error -> emptyList()
}
}
}
}
override suspend fun prefetchBackgroundCampaigns(type: MarketingScreenType) {
if (!type.isCacheable) return
try {
loadCacheableByType(type)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Fire-and-forget warm-up: failures are non-fatal, the next getCampaigns() call will retry.
}
}
override suspend fun getDismissedBannerIds(): Set<Int> = dismissStore.getDismissedIds()
override suspend fun dismissBanner(campaignId: Int) = dismissStore.dismiss(campaignId)
private suspend fun loadCacheableByType(type: MarketingScreenType): List<MarketingCampaign> {
sessionCache.value[type]?.let { return it }
return cacheMutex.withLock {
sessionCache.value[type]?.let { return@withLock it } // double-check under lock
val result = fetchAndCacheByType(type)
// Only cache authoritative results. A pure error fallback (error + no DataStore cache -> null)
// must NOT poison the session cache, so a later screen open still retries the network.
if (result != null) {
sessionCache.update { it + (type to result) }
}
result.orEmpty()
}
}
private suspend fun fetchAndCacheByType(type: MarketingScreenType): List<MarketingCampaign>? {
val cached = cacheStore.get(type.value)
return when (val response = requestByType(type, eTag = cached?.eTag)) {
is ApiResponse.Success -> {
// eTag may be null if the server omits it; we still cache the body for the 5xx
// fallback path. A null eTag simply means the next request sends no If-None-Match
// (Retrofit omits null headers) and receives a fresh 200.
val eTag = response.headers[ETAG_HEADER]?.firstOrNull()
cacheStore.store(type.value, MarketingCampaignsCacheEntry(eTag, response.data))
convert(response.data) // authoritative (may be empty = real "no banners")
}
// Cached fallback is authoritative-ish; null when there is nothing cached (do not session-cache).
is ApiResponse.Error -> cached?.response?.let(::convert)
}
}
private suspend fun requestByType(
type: MarketingScreenType,
eTag: String?,
): ApiResponse<MarketingCampaignsResponse> {
return tangemTechApi.getMarketingCampaigns(
type = type.value,
language = SupportedLanguages.getCurrentSupportedLanguageCode(),
eTag = eTag,
)
}
private suspend fun requestCampaigns(
screen: MarketingScreen,
eTag: String?,
): ApiResponse<MarketingCampaignsResponse> {
val language = SupportedLanguages.getCurrentSupportedLanguageCode()
return when (screen) {
is MarketingScreen.Swap -> tangemTechApi.getMarketingCampaigns(
type = screen.type.value,
language = language,
fromNetwork = screen.fromNetwork,
// Omit the contract for a native coin (blank) — Retrofit drops null query params.
fromContractAddress = screen.fromContractAddress.ifBlank { null },
toNetwork = screen.toNetwork,
toContractAddress = screen.toContractAddress.ifBlank { null },
)
is MarketingScreen.Onramp -> tangemTechApi.getMarketingCampaigns(
type = screen.type.value,
language = language,
fromFiat = screen.fromFiat,
toNetwork = screen.toNetwork,
toContractAddress = screen.toContractAddress.ifBlank { null },
)
is MarketingScreen.TokenDetails,
is MarketingScreen.TokenMarkets,
is MarketingScreen.Staking,
is MarketingScreen.Yield,
-> requestByType(screen.type, eTag)
}
}
private fun convert(response: MarketingCampaignsResponse): List<MarketingCampaign> =
converter.convertListIgnoreErrors(response.campaigns) { throwable ->
TangemLogger.w("Skipped invalid marketing campaign: ${throwable.message}")
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.data.marketing.converter
import com.tangem.datasource.api.marketing.models.BannerDto
import com.tangem.datasource.api.marketing.models.CampaignDto
import com.tangem.datasource.api.marketing.models.CampaignTokenDto
import com.tangem.domain.marketing.models.MarketingBanner
import com.tangem.domain.marketing.models.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingCampaignTarget
import com.tangem.domain.marketing.models.MarketingScreenType
import com.tangem.utils.converter.Converter
internal class MarketingCampaignConverter : Converter<CampaignDto, MarketingCampaign> {
override fun convert(value: CampaignDto): MarketingCampaign {
val type = requireNotNull(MarketingScreenType.fromValue(value.type)) { "Unknown campaign type: ${value.type}" }
val banner = convertBanner(value.banner)
require(banner.uiType != MarketingBanner.UiType.LINKED_TO_PROVIDER || !value.providerIds.isNullOrEmpty()) {
"linked_to_provider campaign ${value.id} has no providerIds"
}
return MarketingCampaign(
id = value.id,
type = type,
priority = value.priority,
startAt = value.startAt,
endAt = value.endAt,
minAmount = value.minAmount,
maxAmount = value.maxAmount,
providerIds = value.providerIds,
banner = banner,
targets = value.tokens.orEmpty().mapNotNull(::convertTarget),
)
}
private fun convertBanner(dto: BannerDto) = MarketingBanner(
uiType = when (dto.uiType) {
"linked_to_provider" -> MarketingBanner.UiType.LINKED_TO_PROVIDER
else -> MarketingBanner.UiType.STANDALONE
},
text = dto.text,
iconUrl = dto.icon,
iconAlign = when (dto.iconAlign) {
"left" -> MarketingBanner.IconAlign.LEFT
"right" -> MarketingBanner.IconAlign.RIGHT
else -> null
},
bgColor = dto.bgColor,
deeplink = dto.deeplink,
isDismissible = dto.isDismissible,
)
private fun convertTarget(dto: CampaignTokenDto): MarketingCampaignTarget? {
val coingeckoId = dto.id
val networkId = dto.networkId
return when {
coingeckoId != null -> MarketingCampaignTarget.CoingeckoId(id = coingeckoId)
// contractAddress may be null — that's a native coin of [networkId], not an invalid target.
networkId != null -> MarketingCampaignTarget.NetworkContract(
networkId = networkId,
contractAddress = dto.contractAddress,
)
else -> null
}
}
}

View file

@ -0,0 +1,91 @@
package com.tangem.data.marketing.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.marketing.DefaultMarketingRepository
import com.tangem.data.marketing.converter.MarketingCampaignConverter
import com.tangem.data.marketing.featuretoggle.DefaultMarketingFeatureToggles
import com.tangem.data.marketing.store.DefaultMarketingCampaignsCacheStore
import com.tangem.data.marketing.store.DefaultMarketingDismissStore
import com.tangem.data.marketing.store.MarketingCampaignsCacheStore
import com.tangem.data.marketing.store.MarketingDismissStore
import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.datasource.utils.setTypes
import com.tangem.domain.marketing.MarketingFeatureToggles
import com.tangem.domain.marketing.MarketingRepository
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 javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object MarketingDataModule {
@Provides
@Singleton
fun provideMarketingCampaignsCacheStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
appScope: AppCoroutineScope,
): MarketingCampaignsCacheStore = DefaultMarketingCampaignsCacheStore(
dataStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<MarketingCampaignsCacheEntry>(),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "marketing_campaigns_cache") },
scope = appScope,
),
)
@Provides
@Singleton
fun provideMarketingDismissStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
appScope: AppCoroutineScope,
): MarketingDismissStore = DefaultMarketingDismissStore(
dataStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = setTypes<Int>(),
defaultValue = emptySet(),
),
produceFile = { context.dataStoreFile(fileName = "marketing_dismissed_banner_ids") },
scope = appScope,
),
)
@Provides
@Singleton
fun provideMarketingFeatureToggles(featureTogglesManager: FeatureTogglesManager): MarketingFeatureToggles =
DefaultMarketingFeatureToggles(featureTogglesManager)
@Provides
@Singleton
fun provideMarketingRepository(
tangemTechApi: TangemTechApi,
cacheStore: MarketingCampaignsCacheStore,
dismissStore: MarketingDismissStore,
dispatchers: CoroutineDispatcherProvider,
): MarketingRepository = DefaultMarketingRepository(
tangemTechApi = tangemTechApi,
cacheStore = cacheStore,
dismissStore = dismissStore,
converter = MarketingCampaignConverter(),
dispatchers = dispatchers,
)
}

View file

@ -0,0 +1,13 @@
package com.tangem.data.marketing.featuretoggle
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.marketing.MarketingFeatureToggles
internal class DefaultMarketingFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : MarketingFeatureToggles {
override val isMarketingBannersEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1522_MARKETING_BANNERS_ENABLED)
}

View file

@ -0,0 +1,21 @@
package com.tangem.data.marketing.store
import androidx.datastore.core.DataStore
import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry
import kotlinx.coroutines.flow.first
interface MarketingCampaignsCacheStore {
suspend fun get(type: String): MarketingCampaignsCacheEntry?
suspend fun store(type: String, entry: MarketingCampaignsCacheEntry)
}
internal class DefaultMarketingCampaignsCacheStore(
private val dataStore: DataStore<Map<String, MarketingCampaignsCacheEntry>>,
) : MarketingCampaignsCacheStore {
override suspend fun get(type: String): MarketingCampaignsCacheEntry? = dataStore.data.first()[type]
override suspend fun store(type: String, entry: MarketingCampaignsCacheEntry) {
dataStore.updateData { it + (type to entry) }
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.marketing.store
import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.first
interface MarketingDismissStore {
suspend fun getDismissedIds(): Set<Int>
suspend fun dismiss(id: Int)
}
internal class DefaultMarketingDismissStore(
private val dataStore: DataStore<Set<Int>>,
) : MarketingDismissStore {
override suspend fun getDismissedIds(): Set<Int> = dataStore.data.first()
override suspend fun dismiss(id: Int) {
dataStore.updateData { it + id }
}
}

View file

@ -0,0 +1,262 @@
package com.tangem.data.marketing
import com.google.common.truth.Truth.assertThat
import com.tangem.data.marketing.converter.MarketingCampaignConverter
import com.tangem.data.marketing.store.MarketingCampaignsCacheStore
import com.tangem.data.marketing.store.MarketingDismissStore
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
import com.tangem.datasource.api.common.response.ETAG_HEADER
import com.tangem.datasource.api.marketing.models.BannerDto
import com.tangem.datasource.api.marketing.models.CampaignDto
import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry
import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.MarketingScreenType
import com.tangem.utils.SupportedLanguages
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.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runCurrent
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 DefaultMarketingRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()
private val cacheStore: MarketingCampaignsCacheStore = mockk(relaxed = true)
private val dismissStore: MarketingDismissStore = mockk(relaxed = true)
private val language = SupportedLanguages.getCurrentSupportedLanguageCode()
// Recreated per test (not a val): DefaultMarketingRepository now holds mutable in-memory session-cache
// state, which would otherwise leak between tests sharing this PER_CLASS instance.
private lateinit var repository: DefaultMarketingRepository
@BeforeEach
fun reset() {
clearMocks(tangemTechApi, cacheStore, dismissStore)
repository = DefaultMarketingRepository(
tangemTechApi = tangemTechApi,
cacheStore = cacheStore,
dismissStore = dismissStore,
converter = MarketingCampaignConverter(),
dispatchers = TestingCoroutineDispatcherProvider(),
)
}
private fun response(id: Int) = MarketingCampaignsResponse(
campaigns = listOf(CampaignDto(id = id, type = "token_details", priority = 1, banner = BannerDto(uiType = "standalone"))),
)
@Suppress("UNCHECKED_CAST")
private fun httpError(code: Code): ApiResponse<MarketingCampaignsResponse> = ApiResponse.Error(
cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null),
) as ApiResponse<MarketingCampaignsResponse>
@Test
fun `GIVEN 200 for background type WHEN getCampaigns THEN stores etag and returns campaigns`() = runTest {
// Arrange
coEvery { cacheStore.get("token_details") } returns null
coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } returns
ApiResponse.Success(data = response(id = 7), headers = mapOf(ETAG_HEADER to listOf("new-etag")))
// Act
val result = repository.getCampaigns(MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x"))
// Assert
assertThat(result.getOrNull()!!.map { it.id }).containsExactly(7)
coVerify(exactly = 1) {
cacheStore.store("token_details", MarketingCampaignsCacheEntry(eTag = "new-etag", response = response(id = 7)))
}
}
@Test
fun `GIVEN 304 for background type WHEN getCampaigns THEN returns cached campaigns`() = runTest {
// Arrange
coEvery { cacheStore.get("token_details") } returns
MarketingCampaignsCacheEntry(eTag = "etag", response = response(id = 9))
coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = "etag") } returns
httpError(Code.NOT_MODIFIED)
// Act
val result = repository.getCampaigns(MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x"))
// Assert
assertThat(result.getOrNull()!!.map { it.id }).containsExactly(9)
coVerify(exactly = 0) { cacheStore.store(any(), any()) }
}
@Test
fun `GIVEN 5xx with cache WHEN getCampaigns THEN returns cached`() = runTest {
// Arrange
coEvery { cacheStore.get("staking") } returns
MarketingCampaignsCacheEntry(eTag = "etag", response = response(id = 5))
coEvery { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = "etag") } returns
httpError(Code.SERVICE_UNAVAILABLE)
// Act
val result = repository.getCampaigns(MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0x"))
// Assert
assertThat(result.getOrNull()!!.map { it.id }).containsExactly(5)
}
@Test
fun `GIVEN 5xx without cache WHEN getCampaigns THEN returns empty`() = runTest {
// Arrange
coEvery { cacheStore.get("staking") } returns null
coEvery { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) } returns
httpError(Code.INTERNAL_SERVER_ERROR)
// Act
val result = repository.getCampaigns(MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0x"))
// Assert
assertThat(result.getOrNull()).isEmpty()
}
@Test
fun `GIVEN 5xx without cache WHEN getCampaigns twice THEN not session-cached and retried`() = runTest {
// Arrange
coEvery { cacheStore.get("staking") } returns null
coEvery { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) } returns
httpError(Code.SERVICE_UNAVAILABLE)
val screen = MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0x")
// Act
val first = repository.getCampaigns(screen)
val second = repository.getCampaigns(screen)
// Assert
assertThat(first.getOrNull()).isEmpty()
assertThat(second.getOrNull()).isEmpty()
coVerify(exactly = 2) { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) }
}
@Test
fun `GIVEN swap screen WHEN getCampaigns THEN sends pair params and does not touch cache`() = runTest {
// Arrange
coEvery {
tangemTechApi.getMarketingCampaigns(
type = "swap", language = language,
fromNetwork = "ethereum", fromContractAddress = "0xFrom",
toNetwork = "bitcoin", toContractAddress = "0xTo",
)
} returns ApiResponse.Success(data = response(id = 3))
// Act
val result = repository.getCampaigns(
MarketingScreen.Swap(
fromNetwork = "ethereum", fromContractAddress = "0xFrom",
toNetwork = "bitcoin", toContractAddress = "0xTo",
),
)
// Assert
assertThat(result.getOrNull()!!.map { it.id }).containsExactly(3)
coVerify(exactly = 0) { cacheStore.get(any()) }
coVerify(exactly = 0) { cacheStore.store(any(), any()) }
}
@Test
fun `GIVEN cached in session WHEN getCampaigns twice THEN api called once`() = runTest {
// Arrange
coEvery { cacheStore.get("token_details") } returns null
coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } returns
ApiResponse.Success(data = response(id = 7))
// Act
val screen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x")
val first = repository.getCampaigns(screen)
val second = repository.getCampaigns(screen)
// Assert
assertThat(first.getOrNull()!!.map { it.id }).containsExactly(7)
assertThat(second.getOrNull()!!.map { it.id }).containsExactly(7)
coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) }
}
@Test
fun `GIVEN two concurrent getCampaigns for same type WHEN both in flight THEN api called once`() = runTest {
// Arrange
val gate = CompletableDeferred<Unit>()
coEvery { cacheStore.get("token_details") } returns null
coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } coAnswers {
gate.await() // first caller suspends inside the lock, second blocks on the mutex
ApiResponse.Success(data = response(id = 7))
}
val screen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x")
// Act — launch both before either completes, then release the API
val a = async { repository.getCampaigns(screen) }
val b = async { repository.getCampaigns(screen) }
runCurrent()
gate.complete(Unit)
val first = a.await()
val second = b.await()
// Assert
assertThat(first.getOrNull()!!.map { it.id }).containsExactly(7)
assertThat(second.getOrNull()!!.map { it.id }).containsExactly(7)
coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) }
}
@Test
fun `GIVEN prefetch WHEN getCampaigns THEN served from session cache without extra api call`() = runTest {
// Arrange
coEvery { cacheStore.get("markets_token") } returns null
coEvery { tangemTechApi.getMarketingCampaigns(type = "markets_token", language = language, eTag = null) } returns
ApiResponse.Success(data = response(id = 3))
// Act
repository.prefetchBackgroundCampaigns(MarketingScreenType.TOKEN_MARKETS)
val result = repository.getCampaigns(MarketingScreen.TokenMarkets(coingeckoId = "id"))
// Assert
assertThat(result.getOrNull()!!.map { it.id }).containsExactly(3)
coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "markets_token", language = language, eTag = null) }
}
@Test
fun `GIVEN swap WHEN getCampaigns twice THEN never session-cached (api called each time)`() = runTest {
// Arrange
val swap = MarketingScreen.Swap("eth", "0xF", "btc", "0xT")
coEvery {
tangemTechApi.getMarketingCampaigns(
type = "swap", language = language,
fromNetwork = "eth", fromContractAddress = "0xF", toNetwork = "btc", toContractAddress = "0xT",
)
} returns ApiResponse.Success(data = response(id = 1))
// Act
repository.getCampaigns(swap)
repository.getCampaigns(swap)
// Assert
coVerify(exactly = 2) {
tangemTechApi.getMarketingCampaigns(
type = "swap", language = language,
fromNetwork = "eth", fromContractAddress = "0xF", toNetwork = "btc", toContractAddress = "0xT",
)
}
}
@Test
fun `GIVEN dismiss WHEN dismissBanner THEN delegates to dismiss store`() = runTest {
// Act
repository.dismissBanner(42)
// Assert
coVerify(exactly = 1) { dismissStore.dismiss(42) }
}
}

View file

@ -0,0 +1,125 @@
package com.tangem.data.marketing.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.marketing.models.BannerDto
import com.tangem.datasource.api.marketing.models.CampaignDto
import com.tangem.datasource.api.marketing.models.CampaignTokenDto
import com.tangem.domain.marketing.models.MarketingBanner
import com.tangem.domain.marketing.models.MarketingCampaignTarget
import com.tangem.domain.marketing.models.MarketingScreenType
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class MarketingCampaignConverterTest {
private val converter = MarketingCampaignConverter()
private fun banner(uiType: String = "standalone", isDismissible: Boolean = true) = BannerDto(
uiType = uiType,
text = "Cashback",
icon = "https://x/star.webp",
iconAlign = "left",
bgColor = "#FF0011",
deeplink = "https://tangem.com",
isDismissible = isDismissible,
)
@Test
fun `GIVEN swap campaign WHEN convert THEN mapped with amounts and no targets`() {
// Arrange
val dto = CampaignDto(
id = 12, type = "swap", priority = 1,
minAmount = BigDecimal(50), maxAmount = BigDecimal(300),
providerIds = listOf("provider1"), tokens = null,
banner = banner(uiType = "linked_to_provider"),
)
// Act
val result = converter.convert(dto)
// Assert
assertThat(result.id).isEqualTo(12)
assertThat(result.type).isEqualTo(MarketingScreenType.SWAP)
assertThat(result.minAmount).isEqualTo(BigDecimal(50))
assertThat(result.banner.uiType).isEqualTo(MarketingBanner.UiType.LINKED_TO_PROVIDER)
assertThat(result.banner.iconAlign).isEqualTo(MarketingBanner.IconAlign.LEFT)
assertThat(result.targets).isEmpty()
}
@Test
fun `GIVEN token_details campaign WHEN convert THEN network targets mapped`() {
// Arrange
val dto = CampaignDto(
id = 1, type = "token_details", priority = 2, banner = banner(),
tokens = listOf(CampaignTokenDto(networkId = "ethereum", contractAddress = "0xA0b8")),
)
// Act
val targets = converter.convert(dto).targets
// Assert
assertThat(targets).containsExactly(
MarketingCampaignTarget.NetworkContract(networkId = "ethereum", contractAddress = "0xA0b8"),
)
}
@Test
fun `GIVEN native coin token (null contract) WHEN convert THEN network target with null contract`() {
// Arrange
val dto = CampaignDto(
id = 1, type = "yield", priority = 1, banner = banner(),
tokens = listOf(CampaignTokenDto(networkId = "bitcoin", contractAddress = null)),
)
// Act
val targets = converter.convert(dto).targets
// Assert
assertThat(targets).containsExactly(
MarketingCampaignTarget.NetworkContract(networkId = "bitcoin", contractAddress = null),
)
}
@Test
fun `GIVEN markets campaign WHEN convert THEN coingecko targets mapped`() {
// Arrange
val dto = CampaignDto(
id = 1, type = "markets_token", priority = 1, banner = banner(),
tokens = listOf(CampaignTokenDto(id = "1696501400")),
)
// Act
val targets = converter.convert(dto).targets
// Assert
assertThat(targets).containsExactly(MarketingCampaignTarget.CoingeckoId(id = "1696501400"))
}
@Test
fun `GIVEN linked_to_provider without providerIds WHEN convertListIgnoreErrors THEN dropped`() {
// Arrange
val invalid = CampaignDto(
id = 1, type = "onramp", priority = 1, providerIds = emptyList(),
banner = banner(uiType = "linked_to_provider"),
)
val valid = CampaignDto(id = 2, type = "onramp", priority = 2, banner = banner())
// Act
val result = converter.convertListIgnoreErrors(listOf(invalid, valid))
// Assert
assertThat(result.map { it.id }).containsExactly(2)
}
@Test
fun `GIVEN unknown type WHEN convertListIgnoreErrors THEN dropped`() {
// Arrange
val dto = CampaignDto(id = 1, type = "carousel", priority = 1, banner = banner())
// Act
val result = converter.convertListIgnoreErrors(listOf(dto))
// Assert
assertThat(result).isEmpty()
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.data.marketing.featuretoggle
import com.google.common.truth.Truth.assertThat
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
internal class DefaultMarketingFeatureTogglesTest {
private val featureTogglesManager: FeatureTogglesManager = mockk()
private val featureToggles = DefaultMarketingFeatureToggles(featureTogglesManager)
@Test
fun `GIVEN toggle enabled WHEN isMarketingBannersEnabled THEN true`() {
// Arrange
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1522_MARKETING_BANNERS_ENABLED) } returns true
// Assert
assertThat(featureToggles.isMarketingBannersEnabled).isTrue()
}
@Test
fun `GIVEN toggle disabled WHEN isMarketingBannersEnabled THEN false`() {
// Arrange
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1522_MARKETING_BANNERS_ENABLED) } returns false
// Assert
assertThat(featureToggles.isMarketingBannersEnabled).isFalse()
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.data.marketing.store
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.marketing.models.BannerDto
import com.tangem.datasource.api.marketing.models.CampaignDto
import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry
import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse
import com.tangem.test.core.datastore.MockStateDataStore
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class MarketingStoresTest {
private val cacheStore = DefaultMarketingCampaignsCacheStore(
dataStore = MockStateDataStore<Map<String, MarketingCampaignsCacheEntry>>(default = emptyMap()),
)
private val dismissStore = DefaultMarketingDismissStore(
dataStore = MockStateDataStore<Set<Int>>(default = emptySet()),
)
private fun entry(eTag: String?) = MarketingCampaignsCacheEntry(
eTag = eTag,
response = MarketingCampaignsResponse(
campaigns = listOf(
CampaignDto(id = 1, type = "token_details", priority = 1, banner = BannerDto(uiType = "standalone")),
),
),
)
@Test
fun `GIVEN no cache WHEN get THEN null`() = runTest {
assertThat(cacheStore.get("token_details")).isNull()
}
@Test
fun `GIVEN stored entry WHEN get same type THEN returns it`() = runTest {
// Arrange
cacheStore.store("token_details", entry(eTag = "abc"))
// Act
val result = cacheStore.get("token_details")
// Assert
assertThat(result?.eTag).isEqualTo("abc")
assertThat(result?.response?.campaigns).hasSize(1)
assertThat(cacheStore.get("staking")).isNull()
}
@Test
fun `GIVEN no dismissed WHEN getDismissedIds THEN empty`() = runTest {
assertThat(dismissStore.getDismissedIds()).isEmpty()
}
@Test
fun `GIVEN dismissed ids WHEN dismiss again THEN accumulates without duplicates`() = runTest {
// Act
dismissStore.dismiss(12)
dismissStore.dismiss(12)
dismissStore.dismiss(34)
// Assert
assertThat(dismissStore.getDismissedIds()).containsExactly(12, 34)
}
}

View file

@ -5,7 +5,6 @@ plugins {
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.ksp)
id("configuration")
}
@ -14,40 +13,49 @@ 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
// region Tests dependencies

View file

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

View file

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

View file

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

View file

@ -13,6 +13,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.extensions.addOrReplace
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.coroutineScope
@ -123,6 +124,24 @@ internal class DefaultNetworksStatusesStore(
}
}
override suspend fun remove(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
val keys = userWalletIds.mapTo(hashSetOf()) { it.stringValue }
// Best-effort: attempt both runtime and persistence removal even if one fails.
coroutineScope {
launch {
runSuspendCatching { runtimeStore.update(default = emptyMap()) { stored -> stored - keys } }
.onFailure { TangemLogger.e("Failed to remove network statuses from runtime", it) }
}
launch {
runSuspendCatching { persistenceDataStore.updateData { stored -> stored - keys } }
.onFailure { TangemLogger.e("Failed to remove network statuses from persistence", it) }
}
}
}
override suspend fun contains(userWalletId: UserWalletId): Boolean {
return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue)
}

View file

@ -45,6 +45,9 @@ internal interface NetworksStatusesStore {
/** Clear statuses of [networks] by [userWalletId] */
suspend fun clear(userWalletId: UserWalletId, networks: Set<Network>)
/** Remove all statuses of the given [userWalletIds] */
suspend fun remove(userWalletIds: List<UserWalletId>)
/** Check if there are statuses for given [userWalletId] */
suspend fun contains(userWalletId: UserWalletId): Boolean
}

View file

@ -28,6 +28,11 @@ internal class DefaultNetworksCleaner(
private val dispatchers: CoroutineDispatcherProvider,
) : NetworksCleaner {
override suspend fun clear(userWalletIds: List<UserWalletId>) {
runSuspendCatching { networksStatusesStore.remove(userWalletIds) }
.onFailure { TangemLogger.e("Failed to remove network statuses for wallets: $userWalletIds", it) }
}
override suspend fun invoke(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
if (currencies.isEmpty()) {
TangemLogger.d("No currencies to clear for wallet: $userWalletId")

View file

@ -8,6 +8,7 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
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.mockk
import kotlinx.coroutines.test.runTest
@ -103,6 +104,22 @@ class DefaultNetworksCleanerTest {
}
}
@Test
fun `GIVEN wallets WHEN clear THEN statuses removed once and Blockchain SDK untouched`() = runTest {
// Arrange
val ids = listOf(UserWalletId("011"), UserWalletId("022"))
// Act
cleaner.clear(userWalletIds = ids)
// Assert
coVerify(exactly = 1) { networksStatusesStore.remove(ids) }
coVerifyOrder(inverse = true) {
walletManagersFacade.remove(userWalletId = any(), networks = any())
walletManagersFacade.removeTokens(userWalletId = any(), tokens = any())
}
}
@Test
fun `should handle exception during cleaning`() = runTest {
// Arrange

View file

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

View file

@ -4,55 +4,61 @@ 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(deps.test.truth)
testImplementation(projects.common.test)
// endregion
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,46 @@
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.promo"
}
dependencies {
// region Kotlin
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.datetime)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Core
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Domain
api(projects.domain.promo)
// endregion
// region Domain models
implementation(projects.domain.models)
implementation(projects.domain.promo.models)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.moshi.kotlin)
// endregion
}

View file

@ -0,0 +1,97 @@
package com.tangem.data.promo
import com.squareup.moshi.Moshi
import com.tangem.data.promo.converter.PromoCampaignConverter
import com.tangem.data.promo.store.PromoEnrollmentStore
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody
import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.EnrollResult
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoCampaignState
import com.tangem.domain.promo.models.TokenReward
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
internal class DefaultPromoRepository(
private val promotionsSupplier: PromotionsSupplier,
private val tangemApi: TangemTechApi,
private val enrollmentStore: PromoEnrollmentStore,
private val moshi: Moshi,
private val dispatchers: CoroutineDispatcherProvider,
) : PromoRepository {
override suspend fun getCampaignState(
campaign: PromoCampaignId,
userWalletId: UserWalletId,
forceRefresh: Boolean,
): PromoCampaignState = withContext(dispatchers.io) {
enrollmentStore.getSyncOrNull(campaign)?.let {
return@withContext PromoCampaignState.Enrolled(campaign, it)
}
val all = promotionsSupplier.getPromotions(userWalletId, forceRefresh)
.promotions.firstOrNull { it.name == campaign.slug }?.all
when {
all == null -> PromoCampaignState.NotActive(campaign)
all.status == ACTIVE_STATUS -> PromoCampaignConverter.toAvailable(campaign, all)
else -> PromoCampaignState.NotActive(campaign)
}
}
override suspend fun enroll(
campaign: PromoCampaignId,
tokenReward: TokenReward,
walletIds: List<UserWalletId>,
): EnrollResult = withContext(dispatchers.io) {
val body = CreatePromotionRegistrationBody(
campaignId = campaign.slug,
walletIds = walletIds.map { it.stringValue },
tokenReward = tokenReward.toDto(),
)
when (val response = tangemApi.createPromotionRegistration(body)) {
is ApiResponse.Success -> {
val saved = response.data.data.tokenReward.toDomain()
enrollmentStore.store(campaign, saved)
EnrollResult.Success(saved)
}
is ApiResponse.Error -> {
val cause = response.cause
val conflict = (cause as? ApiResponseError.HttpException)
?.takeIf { it.code == ApiResponseError.HttpException.Code.CONFLICT }
if (conflict != null) {
val existing = parseConflict(conflict.errorBody)?.data?.tokenReward?.toDomain() ?: tokenReward
enrollmentStore.store(campaign, existing)
EnrollResult.AlreadyEnrolled(existing)
} else {
throw cause
}
}
}
}
private fun parseConflict(body: String?): PromotionRegistrationResponse? {
if (body.isNullOrBlank()) return null
return runCatching {
moshi.adapter(PromotionRegistrationResponse::class.java).fromJson(body)
}.getOrNull()
}
private fun TokenReward.toDto() = CreatePromotionRegistrationBody.TokenRewardDto(
tokenAddress = tokenAddress,
networkId = networkId,
)
private fun CreatePromotionRegistrationBody.TokenRewardDto.toDomain() = TokenReward(
tokenAddress = tokenAddress,
networkId = networkId,
)
private companion object {
const val ACTIVE_STATUS = "active"
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.data.promo.converter
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.All
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoCampaignState
import com.tangem.domain.promo.models.PromoPayoutToken
import com.tangem.domain.promo.models.PromoTimeline
import kotlinx.datetime.Instant
internal object PromoCampaignConverter {
fun toAvailable(campaign: PromoCampaignId, all: All): PromoCampaignState.Available {
return PromoCampaignState.Available(
campaign = campaign,
payoutTokens = all.tokens.orEmpty().map { token ->
PromoPayoutToken(
tokenId = token.tokenId,
tokenAddress = token.tokenAddress,
tokenSymbol = token.tokenSymbol,
tokenName = token.tokenName,
networkId = token.networkId,
decimals = token.decimals,
)
},
timeline = PromoTimeline(
start = Instant.parse(all.timeline.start),
end = Instant.parse(all.timeline.end),
),
)
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.data.promo.di
import com.squareup.moshi.Moshi
import com.tangem.data.promo.DefaultPromoRepository
import com.tangem.data.promo.store.DefaultPromoEnrollmentStore
import com.tangem.data.promo.store.PromoEnrollmentStore
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.domain.promo.PromoRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PromoDataModule {
@Provides
@Singleton
fun providePromoEnrollmentStore(appPreferencesStore: AppPreferencesStore): PromoEnrollmentStore {
return DefaultPromoEnrollmentStore(appPreferencesStore)
}
@Provides
@Singleton
fun providePromoRepository(
promotionsSupplier: PromotionsSupplier,
tangemApi: TangemTechApi,
enrollmentStore: PromoEnrollmentStore,
@NetworkMoshi moshi: Moshi,
dispatchers: CoroutineDispatcherProvider,
): PromoRepository {
return DefaultPromoRepository(
promotionsSupplier = promotionsSupplier,
tangemApi = tangemApi,
enrollmentStore = enrollmentStore,
moshi = moshi,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.data.promo.store
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.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.TokenReward
internal class DefaultPromoEnrollmentStore(
private val appPreferencesStore: AppPreferencesStore,
) : PromoEnrollmentStore {
override suspend fun getSyncOrNull(campaign: PromoCampaignId): TokenReward? {
return appPreferencesStore
.getObjectMapSync<TokenReward>(PreferencesKeys.PROMO_ENROLLMENTS_KEY)[campaign.slug]
}
override suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward) {
appPreferencesStore.editData { mutablePreferences ->
val current = mutablePreferences.getObjectMap<TokenReward>(PreferencesKeys.PROMO_ENROLLMENTS_KEY)
mutablePreferences.setObjectMap(
key = PreferencesKeys.PROMO_ENROLLMENTS_KEY,
value = current + (campaign.slug to tokenReward),
)
}
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.data.promo.store
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.TokenReward
interface PromoEnrollmentStore {
suspend fun getSyncOrNull(campaign: PromoCampaignId): TokenReward?
suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward)
}

View file

@ -0,0 +1,217 @@
package com.tangem.data.promo
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.tangem.data.promo.store.PromoEnrollmentStore
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody
import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.All
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.PromoToken
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.Timeline
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.promotion.PromotionsSupplier
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.models.EnrollResult
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoCampaignState
import com.tangem.domain.promo.models.TokenReward
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
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultPromoRepositoryTest {
private val promotionsSupplier: PromotionsSupplier = mockk()
private val tangemApi: TangemTechApi = mockk()
private val enrollmentStore: PromoEnrollmentStore = mockk(relaxed = true)
private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
private val repository = DefaultPromoRepository(
promotionsSupplier = promotionsSupplier,
tangemApi = tangemApi,
enrollmentStore = enrollmentStore,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val campaign = PromoCampaignId.WhaleSwapCashback
private val userWalletId = UserWalletId("abcdef012345")
private val tokenReward = TokenReward("0xToken", "ethereum")
private fun activeDto() = PromotionDto(
name = campaign.slug,
all = All(
timeline = Timeline("2026-06-23T00:00:00.000Z", "2026-08-31T20:59:59.000Z"),
tokens = listOf(
PromoToken(
tokenId = "tether",
tokenAddress = "0xToken",
tokenSymbol = "USDT",
tokenName = "Tether USD",
networkId = "ethereum",
decimals = 6,
),
),
status = "active",
link = "",
),
)
@BeforeEach
fun setUp() = clearMocks(promotionsSupplier, tangemApi, enrollmentStore)
@Test
fun `GIVEN locally enrolled WHEN getCampaignState THEN Enrolled without api`() = runTest {
// Arrange
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns tokenReward
// Act
val result = repository.getCampaignState(campaign, userWalletId)
// Assert
assertThat(result).isEqualTo(PromoCampaignState.Enrolled(campaign, tokenReward))
coVerify(exactly = 0) { promotionsSupplier.getPromotions(any(), any()) }
}
@Test
fun `GIVEN active campaign present and not enrolled WHEN getCampaignState THEN Available`() = runTest {
// Arrange
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns
PromotionsResponse(promotions = listOf(activeDto()))
// Act
val result = repository.getCampaignState(campaign, userWalletId)
// Assert
assertThat(result).isInstanceOf(PromoCampaignState.Available::class.java)
}
@Test
fun `GIVEN campaign absent WHEN getCampaignState THEN NotActive`() = runTest {
// Arrange
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns
PromotionsResponse(promotions = emptyList())
// Act
val result = repository.getCampaignState(campaign, userWalletId)
// Assert
assertThat(result).isEqualTo(PromoCampaignState.NotActive(campaign))
}
@Test
fun `GIVEN campaign present but finished WHEN getCampaignState THEN NotActive`() = runTest {
// Arrange
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null
val finished = activeDto().copy(all = activeDto().all!!.copy(status = "finished"))
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns
PromotionsResponse(promotions = listOf(finished))
// Act
val result = repository.getCampaignState(campaign, userWalletId)
// Assert
assertThat(result).isEqualTo(PromoCampaignState.NotActive(campaign))
}
@Test
fun `GIVEN api returns 201 with canonical token WHEN enroll THEN Success and persists backend token`() = runTest {
// Arrange
val data = PromotionRegistrationResponse.RegistrationData(
campaignId = campaign.slug,
registeredAt = "2026-07-06T09:27:13.363Z",
tokenReward = CreatePromotionRegistrationBody.TokenRewardDto("0xCanonical", "ethereum"),
)
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Success(
PromotionRegistrationResponse(status = "saved", message = null, data = data),
)
// Act
val result = repository.enroll(campaign, tokenReward, listOf(userWalletId))
// Assert
val backendToken = TokenReward("0xCanonical", "ethereum")
assertThat(result).isEqualTo(EnrollResult.Success(backendToken))
coVerify(exactly = 1) { enrollmentStore.store(campaign, backendToken) }
}
@Test
fun `GIVEN api returns 409 WHEN enroll THEN AlreadyEnrolled with existing token`() = runTest {
// Arrange
val existing = """
{"status":"already_exists","message":"exists","data":{"campaignId":"${campaign.slug}",
"registeredAt":"2026-07-01T10:00:00.000Z","tokenReward":{"tokenAddress":"0xOther",
"networkId":"base","userAddress":"0xExisting"}}}
""".trimIndent()
@Suppress("UNCHECKED_CAST")
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error(
ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.CONFLICT,
message = "conflict",
errorBody = existing,
),
) as ApiResponse<PromotionRegistrationResponse>
// Act
val result = repository.enroll(campaign, tokenReward, listOf(userWalletId))
// Assert
val expectedToken = TokenReward("0xOther", "base")
assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(expectedToken))
coVerify(exactly = 1) { enrollmentStore.store(campaign, expectedToken) }
}
@Test
fun `GIVEN 409 with null errorBody WHEN enroll THEN AlreadyEnrolled with submitted token`() = runTest {
// Arrange
@Suppress("UNCHECKED_CAST")
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error(
ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.CONFLICT,
message = "conflict",
errorBody = null,
),
) as ApiResponse<PromotionRegistrationResponse>
// Act
val result = repository.enroll(campaign, tokenReward, listOf(userWalletId))
// Assert
assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(tokenReward))
coVerify(exactly = 1) { enrollmentStore.store(campaign, tokenReward) }
}
@Test
fun `GIVEN api returns 500 WHEN enroll THEN throws`() = runTest {
// Arrange
@Suppress("UNCHECKED_CAST")
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error(
ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.INTERNAL_SERVER_ERROR,
message = "server",
errorBody = null,
),
) as ApiResponse<PromotionRegistrationResponse>
// Act
val error = runCatching { repository.enroll(campaign, tokenReward, listOf(userWalletId)) }.exceptionOrNull()
// Assert
assertThat(error).isNotNull()
coVerify(exactly = 0) { enrollmentStore.store(any(), any()) }
}
}

View file

@ -0,0 +1,70 @@
package com.tangem.data.promo.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.All
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.PromoToken
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.Timeline
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoPayoutToken
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Test
internal class PromoCampaignConverterTest {
private val campaign = PromoCampaignId.WhaleSwapCashback
@Test
fun `GIVEN dto with tokens WHEN toAvailable THEN maps tokens and timeline`() {
// Arrange
val all = All(
timeline = Timeline(start = "2026-06-23T00:00:00.000Z", end = "2026-08-31T20:59:59.000Z"),
tokens = listOf(
PromoToken(
tokenId = "tether",
tokenAddress = "0xdac1",
tokenSymbol = "USDT",
tokenName = "Tether USD",
networkId = "ethereum",
decimals = 6,
),
),
status = "active",
link = "",
)
// Act
val result = PromoCampaignConverter.toAvailable(campaign, all)
// Assert
assertThat(result.campaign).isEqualTo(campaign)
assertThat(result.payoutTokens).containsExactly(
PromoPayoutToken(
tokenId = "tether",
tokenAddress = "0xdac1",
tokenSymbol = "USDT",
tokenName = "Tether USD",
networkId = "ethereum",
decimals = 6,
),
)
assertThat(result.timeline.start).isEqualTo(Instant.parse("2026-06-23T00:00:00.000Z"))
assertThat(result.timeline.end).isEqualTo(Instant.parse("2026-08-31T20:59:59.000Z"))
}
@Test
fun `GIVEN dto with null tokens WHEN toAvailable THEN empty payout list`() {
// Arrange
val all = All(
timeline = Timeline(start = "2026-06-23T00:00:00.000Z", end = "2026-08-31T20:59:59.000Z"),
tokens = null,
status = "active",
link = null,
)
// Act
val result = PromoCampaignConverter.toAvailable(campaign, all)
// Assert
assertThat(result.payoutTokens).isEmpty()
}
}

View file

@ -11,29 +11,36 @@ 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(projects.test.core)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.turbine)
testImplementation(deps.moshi)
testImplementation(deps.moshi.kotlin)
// endregion
}

View file

@ -1,54 +1,64 @@
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.datasource.local.preferences.utils.getSyncOrNull
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 appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : WalletPushNotificationPreferencesRepository {
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)
private val walletMutexes = ConcurrentHashMap<String, Mutex>()
override suspend fun isFirstActivationDone(userWalletId: UserWalletId): Boolean =
appPreferencesStore.getSyncOrNull(PreferencesKeys.PUSH_NOTIFICATION_FIRST_ACTIVATION_DONE_WALLET_IDS_KEY)
?.contains(userWalletId.stringValue) == true
override suspend fun markFirstActivationDone(userWalletId: UserWalletId) {
appPreferencesStore.editData { preferences ->
val key = PreferencesKeys.PUSH_NOTIFICATION_FIRST_ACTIVATION_DONE_WALLET_IDS_KEY
preferences[key] = preferences.getOrDefault(key, emptySet()) + userWalletId.stringValue
}
cache.update(default = emptyMap()) { current ->
if (current.containsKey(userWalletId.stringValue)) {
current
} else {
current + (userWalletId.stringValue to preferences)
}
override suspend fun preload(userWalletId: UserWalletId) {
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 +74,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 +87,45 @@ internal class DefaultWalletPushNotificationPreferencesRepository(
offersUpdates: Boolean,
priceAlerts: Boolean,
): Either<Throwable, Unit> = Either.catch {
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
val updated = current.copy(
transactionAlerts = current.transactionAlerts.copy(isEnabled = transactionAlerts),
offersUpdates = current.offersUpdates.copy(isEnabled = offersUpdates),
priceAlerts = current.priceAlerts.copy(isEnabled = priceAlerts),
)
putAndCommit(userWalletId, updated)
mutexFor(userWalletId).withLock {
val current = currentOrFetch(userWalletId)
val updated = current.copy(
transactionAlerts = current.transactionAlerts.copy(isEnabled = transactionAlerts),
offersUpdates = current.offersUpdates.copy(isEnabled = offersUpdates),
priceAlerts = current.priceAlerts.copy(isEnabled = priceAlerts),
)
putAndCommit(userWalletId, updated)
}
}
// Cache, or a freshly fetched server snapshot, so a full-replace PUT never carries fabricated defaults.
private suspend fun currentOrFetch(userWalletId: UserWalletId): WalletPushNotificationPreferences =
cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: fetch(userWalletId)
private suspend fun fetch(userWalletId: UserWalletId): WalletPushNotificationPreferences =
withContext(dispatchers.io) {
val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow()
PushNotificationPreferencesConverter.convert(response)
}
private suspend fun isCached(userWalletId: UserWalletId): Boolean =
cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true
private fun mutexFor(userWalletId: UserWalletId): Mutex =
walletMutexes.computeIfAbsent(userWalletId.stringValue) { Mutex() }
private suspend fun putAndCommit(userWalletId: UserWalletId, updated: WalletPushNotificationPreferences) {
withContext(dispatchers.io) {
// TODO: uncomment when api is ready
// tangemTechApi.updatePushNotificationPreferences(
// walletId = userWalletId.stringValue,
// body = PushNotificationPreferencesBody(
// areTransactionAlertsEnabled = updated.transactionAlerts.isEnabled,
// areOffersUpdatesEnabled = updated.offersUpdates.isEnabled,
// arePriceAlertsEnabled = updated.priceAlerts.isEnabled,
// ),
// ).getOrThrow()
val applied = withContext(dispatchers.io) {
val response = tangemTechApi.updatePushNotificationPreferences(
walletId = userWalletId.stringValue,
body = PushNotificationPreferencesBody(
areTransactionEventsEnabled = updated.transactionAlerts.isEnabled,
areOfferUpdatesEnabled = updated.offersUpdates.isEnabled,
arePriceAlertsEnabled = updated.priceAlerts.isEnabled,
),
).getOrThrow()
PushNotificationPreferencesConverter.convert(response)
}
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to updated) }
}
// TODO remove when api is ready, use api methods to load real settings
private suspend fun loadDefaults(userWalletId: UserWalletId): WalletPushNotificationPreferences {
val areTransactionAlertsEnabled = appPreferencesStore
.getObjectMapSync<Boolean>(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY)[userWalletId.stringValue] !=
false
return WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = areTransactionAlertsEnabled, isVisible = true),
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
)
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to applied) }
}
}

View file

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

View file

@ -19,13 +19,13 @@ internal object PushNotificationPreferencesModule {
@Singleton
@Provides
fun providesWalletPushNotificationPreferencesRepository(
appPreferencesStore: AppPreferencesStore,
tangemTechApi: TangemTechApi,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): WalletPushNotificationPreferencesRepository = DefaultWalletPushNotificationPreferencesRepository(
appPreferencesStore = appPreferencesStore,
tangemTechApi = tangemTechApi,
cache = RuntimeSharedStore(),
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
)
}

View file

@ -1,141 +1,254 @@
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.test.core.datastore.MockStateDataStore
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")
// Real in-memory store so the persisted first-activation flag (a Set<String> merge) is genuinely exercised.
private val appPreferencesStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = MockStateDataStore(default = emptyPreferences()),
)
private val repository = DefaultWalletPushNotificationPreferencesRepository(
appPreferencesStore = appPreferencesStore,
tangemTechApi = tangemTechApi,
cache = RuntimeSharedStore(),
appPreferencesStore = appPreferencesStore,
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()
}
fun `GIVEN wallet never activated WHEN isFirstActivationDone THEN false`() = runTest {
assertThat(repository.isFirstActivationDone(userWalletId)).isFalse()
}
@Test
fun `GIVEN preloaded state WHEN updatePreference for each category THEN updates only that category`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
fun `GIVEN wallet marked WHEN isFirstActivationDone THEN true and persisted`() = runTest {
repository.markFirstActivationDone(userWalletId)
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()
}
assertThat(repository.isFirstActivationDone(userWalletId)).isTrue()
}
@Test
fun `GIVEN no subscription yet WHEN observePreferences subscribed THEN triggers preload and emits defaults`() =
runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
fun `GIVEN one wallet marked WHEN another wallet marked THEN both stay done`() = runTest {
// Guards the additive-merge backbone: a regression to a single-id overwrite would drop the first wallet.
repository.markFirstActivationDone(userWalletId)
repository.markFirstActivationDone(otherWalletId)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item).isEqualTo(defaults(transactionAlertsEnabled = true))
}
}
assertThat(repository.isFirstActivationDone(userWalletId)).isTrue()
assertThat(repository.isFirstActivationDone(otherWalletId)).isTrue()
}
@Test
fun `GIVEN updates for different wallets WHEN observed independently THEN each wallet has its own state`() =
runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
private fun stubGet(id: UserWalletId, transaction: Boolean, offers: Boolean, price: Boolean) {
coEvery { tangemTechApi.getPushNotificationPreferences(id.stringValue) } returns
ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price))
}
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
repository.updatePreference(otherWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
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))
}
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isFalse()
assertThat(item.priceAlerts.isEnabled).isFalse()
}
repository.observePreferences(otherWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isTrue()
assertThat(item.priceAlerts.isEnabled).isTrue()
}
}
private fun defaults(transactionAlertsEnabled: Boolean) = WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = transactionAlertsEnabled, isVisible = true),
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
private fun prefs(transaction: Boolean, offers: Boolean, price: Boolean) = WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = transaction),
offersUpdates = PushNotificationPreference(isEnabled = offers),
priceAlerts = PushNotificationPreference(isEnabled = price),
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -32,4 +32,7 @@ interface BaseStakingBalancesStore {
/** Clear staking balances */
suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
/** Remove all staking balances for the given [userWalletIds] */
suspend fun remove(userWalletIds: List<UserWalletId>)
}

View file

@ -5,6 +5,8 @@ import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingBalanceConver
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
@ -108,6 +110,25 @@ internal class DefaultP2PEthPoolBalancesStore(
}
}
override suspend fun remove(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
val walletIds = userWalletIds.toSet()
val stringKeys = userWalletIds.mapTo(hashSetOf()) { it.stringValue }
// Best-effort: attempt both runtime and persistence removal even if one fails.
coroutineScope {
launch {
runSuspendCatching { runtimeStore.update(default = emptyMap()) { stored -> stored - walletIds } }
.onFailure { TangemLogger.e("Failed to remove P2PEthPool staking balances from runtime", it) }
}
launch {
runSuspendCatching { persistenceStore.updateData { stored -> stored - stringKeys } }
.onFailure { TangemLogger.e("Failed to remove P2PEthPool staking balances from persistence", it) }
}
}
}
private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>) {
val newBalances = P2PEthPoolStakingBalanceConverter.convertAll(
responses = values,

View file

@ -5,6 +5,8 @@ import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrap
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.token.converter.StakingBalanceConverter
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
@ -99,6 +101,25 @@ internal class DefaultStakeKitBalancesStore(
}
}
override suspend fun remove(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
val walletIds = userWalletIds.toSet()
val stringKeys = userWalletIds.mapTo(hashSetOf()) { it.stringValue }
// Best-effort: attempt both runtime and persistence removal even if one fails.
coroutineScope {
launch {
runSuspendCatching { runtimeStore.update(default = emptyMap()) { stored -> stored - walletIds } }
.onFailure { TangemLogger.e("Failed to remove StakeKit staking balances from runtime", it) }
}
launch {
runSuspendCatching { persistenceStore.updateData { stored -> stored - stringKeys } }
.onFailure { TangemLogger.e("Failed to remove StakeKit staking balances from persistence", it) }
}
}
}
private suspend fun clearInRuntime(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {

View file

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

View file

@ -30,6 +30,23 @@ internal class DefaultStakingCleaner(
private val dispatchers: CoroutineDispatcherProvider,
) : StakingCleaner {
override suspend fun clear(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
// Best-effort: isolate failures per store so one failing store does not cancel the other.
withContext(dispatchers.default) {
awaitAll(
async { removeSafely(store = "StakeKit") { stakeKitBalancesStore.remove(userWalletIds) } },
async { removeSafely(store = "P2PEthPool") { p2pEthPoolBalancesStore.remove(userWalletIds) } },
)
}
}
private suspend fun removeSafely(store: String, remove: suspend () -> Unit) {
runSuspendCatching { remove() }
.onFailure { TangemLogger.e("Failed to remove $store staking balances", it) }
}
override suspend fun invoke(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
if (currencies.isEmpty()) {
TangemLogger.d("No currencies to clear for wallet: $userWalletId")

View file

@ -1,9 +1,9 @@
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
import com.tangem.core.configtoggle.FeatureToggles
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
@ -24,21 +24,10 @@ internal class DefaultStakingFeatureTogglesTest {
}
@Test
fun `P2PEthPool returns true when STAKING_ETH_ENABLED is enabled`() {
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns true
fun `P2PEthPool integration is always enabled`() {
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isTrue()
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
}
@Test
fun `P2PEthPool returns false when STAKING_ETH_ENABLED is disabled`() {
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns false
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isFalse()
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) }
}
@Test

View file

@ -167,4 +167,36 @@ class DefaultStakingCleanerTest {
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ClearByWalletIds {
@Test
fun `GIVEN wallets WHEN clear THEN both balances stores removed once with all ids`() = runTest {
// Arrange
val ids = listOf(UserWalletId("011"), UserWalletId("022"))
// Act
cleaner.clear(userWalletIds = ids)
// Assert
coVerify(exactly = 1) {
stakeKitBalancesStore.remove(ids)
p2pEthPoolBalancesStore.remove(ids)
}
}
@Test
fun `GIVEN empty list WHEN clear THEN stores are not touched`() = runTest {
// Act
cleaner.clear(userWalletIds = emptyList())
// Assert
coVerify(inverse = true) {
stakeKitBalancesStore.remove(any())
p2pEthPoolBalancesStore.remove(any())
}
}
}
}

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