Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-25 19:31:47 +04:00
parent 3a96b203e0
commit 710002e992
5 changed files with 198 additions and 7 deletions

View file

@ -25,18 +25,24 @@ dependencies {
api(projects.domain.models)
// endregion
// Project - Data
// region Project - Data
implementation(projects.data.common)
// endregion
// region DI
implementation(deps.hilt.core)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region AndroidX libraries
implementation(deps.androidx.datastore)
// endregion
// region Other Dependencies
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.timber)
// endregion

View file

@ -0,0 +1,20 @@
package com.tangem.data.account.converter
import javax.inject.Inject
/**
* Container for converter factories related to accounts.
*
* @property accountsListCF factory for creating an account list converter
* @property getWalletAccountsResponseCF factory for creating a wallet accounts response converter
* @property cryptoPortfolioCF factory for creating a crypto portfolio converter
*
* @constructor Creates an instance of the container with injected factories.
*
[REDACTED_AUTHOR]
*/
internal class AccountConverterFactoryContainer @Inject constructor(
val accountsListCF: AccountListConverter.Factory,
val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory,
val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
)

View file

@ -0,0 +1,66 @@
package com.tangem.data.account.store
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
typealias AccountsResponseStore = DataStore<GetWalletAccountsResponse?>
/**
* Factory class for creating and managing instances of [AccountsResponseStore].
* This class is responsible for creating a [DataStore] for each unique [UserWalletId].
*
* @property context application context used to access the file system
* @property moshi moshi instance for JSON serialization and deserialization
* @property dispatchers coroutine dispatcher provider
*
[REDACTED_AUTHOR]
*/
internal class AccountsResponseStoreFactory @Inject constructor(
@ApplicationContext private val context: Context,
@NetworkMoshi private val moshi: Moshi,
private val dispatchers: CoroutineDispatcherProvider,
) {
@OptIn(ExperimentalStdlibApi::class)
private val adapter by lazy { moshi.adapter<GetWalletAccountsResponse?>() }
private val createdDataStores = ConcurrentHashMap<UserWalletId, AccountsResponseStore>()
/**
* Creates or retrieves an [AccountsResponseStore] for the given [UserWalletId].
*
* @param userWalletId the unique identifier of the user's wallet
*/
fun create(userWalletId: UserWalletId): AccountsResponseStore {
return createdDataStores.computeIfAbsent(userWalletId) {
DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(defaultValue = null, adapter = adapter),
produceFile = { context.dataStoreFile(fileName = "wallet_accounts_${userWalletId.stringValue}") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
}
}
@VisibleForTesting
fun getAllStores(): Map<UserWalletId, AccountsResponseStore> = createdDataStores.toMap()
@VisibleForTesting
fun clearStores() {
createdDataStores.clear()
}
}

View file

@ -0,0 +1,91 @@
package com.tangem.data.account.store
import android.content.Context
import com.google.common.truth.Truth
import com.squareup.moshi.Moshi
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.mockk
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class AccountsResponseStoreFactoryTest {
private val context: Context = mockk()
private val moshi: Moshi = Moshi.Builder().build()
private val factory: AccountsResponseStoreFactory = AccountsResponseStoreFactory(
context = context,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@AfterEach
fun setup() {
clearMocks(context)
factory.clearStores()
}
@Test
fun `creates new data store for unique userWalletId`() {
// Arrange
val userWalletId = UserWalletId("011")
val createdStore = factory.create(userWalletId = userWalletId)
// Actual
val actual = factory.getAllStores()
// Assert
Truth.assertThat(actual).containsExactly(userWalletId, createdStore)
}
@Test
fun `reuses existing data store for same userWalletId`() {
val userWalletId = UserWalletId("011")
// Arrange (first creation)
val firstStore = factory.create(userWalletId = userWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(userWalletId, firstStore)
// Arrange (second creation)
val secondStore = factory.create(userWalletId = userWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
Truth.assertThat(actual2).containsExactly(userWalletId, secondStore)
Truth.assertThat(firstStore).isSameInstanceAs(secondStore)
}
@Test
fun `creates separate data stores for different userWalletIds`() {
// Arrange (first creation)
val firstWalletId = UserWalletId("011")
val firstStore = factory.create(userWalletId = firstWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(firstWalletId, firstStore)
// Arrange (second creation)
val secondWalletId = UserWalletId("011")
val secondStore = factory.create(userWalletId = secondWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
val expected = mapOf(firstWalletId to firstStore, secondWalletId to secondStore)
Truth.assertThat(actual2).containsExactlyEntriesIn(expected)
}
}

View file

@ -1,8 +1,8 @@
package com.tangem.features.account.createedit
import com.tangem.common.ui.account.toDomain
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.common.ui.account.toDomain
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -170,10 +170,14 @@ internal class AccountCreateEditModel @Inject constructor(
it.updateDerivationIndex(derivationIndex = derivationIndex.value)
}
}
.onLeft {
.onLeft { cause ->
handleError(
error = AccountFeatureError.CreateAccount.UnableToGetDerivationIndex,
params = mapOf("userWalletId" to userWalletId.stringValue),
message = cause.toString(),
params = mapOf(
"userWalletId" to userWalletId.stringValue,
"cause" to cause.toString(),
),
)
return@launch
@ -181,8 +185,12 @@ internal class AccountCreateEditModel @Inject constructor(
}
}
private fun handleError(error: AccountFeatureError, params: Map<String, String> = mapOf()) {
val exception = IllegalStateException(error.toString())
private fun handleError(
error: AccountFeatureError,
message: String? = null,
params: Map<String, String> = mapOf(),
) {
val exception = IllegalStateException("$error. Cause: $message")
Timber.e(exception)