Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-26 14:49:43 +04:00
parent dcee3b8ba2
commit 20dad876f0
5 changed files with 336 additions and 0 deletions

View file

@ -14,11 +14,18 @@ interface RuntimeStateStore<T> {
/** Get flow of elements [T] */
fun get(): StateFlow<T>
/** Get element [T] synchronously or null */
suspend fun getSyncOrNull(): T?
/** Store [value] */
suspend fun store(value: T)
/** Update current value by [function] */
suspend fun update(function: (T) -> T)
/** Clear stored value */
fun clear()
companion object {
/**
@ -32,6 +39,8 @@ interface RuntimeStateStore<T> {
override fun get(): StateFlow<T> = flow
override suspend fun getSyncOrNull(): T? = flow.value
override suspend fun store(value: T) {
flow.value = value
}
@ -39,6 +48,10 @@ interface RuntimeStateStore<T> {
override suspend fun update(function: (T) -> T) {
flow.update(function)
}
override fun clear() {
flow.value = defaultValue
}
}
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.data.account.store
import androidx.annotation.VisibleForTesting
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.account.models.ArchivedAccount
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlin.time.Duration.Companion.seconds
/**
* Store for managing archived accounts with support for data expiration
*
* @property runtimeStore the underlying runtime shared store for storing the list of archived accounts
*
[REDACTED_AUTHOR]
*/
internal class ArchivedAccountsStore(
private val runtimeStore: RuntimeStateStore<List<ArchivedAccount>?>,
) {
private var timestamp: Long? = null
/** Retrieves a flow of archived accounts, filtering out null values */
fun get(): Flow<List<ArchivedAccount>> {
return runtimeStore.get()
.map {
if (isDataExpired()) null else it
}
.filterNotNull()
}
/** Retrieves the list of archived accounts synchronously, or null if the data is expired */
suspend fun getSyncOrNull(): List<ArchivedAccount>? {
if (isDataExpired()) return null
return runtimeStore.getSyncOrNull()
}
/** Stores the provided list of archived accounts [value] */
suspend fun store(value: List<ArchivedAccount>) {
timestamp = System.currentTimeMillis()
runtimeStore.store(value)
}
private fun isDataExpired(): Boolean {
val currentTime = System.currentTimeMillis()
val storedTime = timestamp ?: return true
return currentTime - storedTime >= EXPIRATION_DURATION_MS
}
@VisibleForTesting
fun setTimestamp(time: Long) {
timestamp = time
}
@VisibleForTesting
fun clear() {
timestamp = null
runtimeStore.clear()
}
private companion object Companion {
val EXPIRATION_DURATION_MS = 120.seconds.inWholeMicroseconds
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.data.account.store
import androidx.annotation.VisibleForTesting
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.models.wallet.UserWalletId
import java.util.concurrent.ConcurrentHashMap
/**
* Factory for creating and managing instances of [ArchivedAccountsStore].
* and reused for each unique [UserWalletId].
*
[REDACTED_AUTHOR]
*/
internal class ArchivedAccountsStoreFactory {
private val createdRuntimeStores = ConcurrentHashMap<UserWalletId, ArchivedAccountsStore>()
/**
* Creates or retrieves an existing instance of [ArchivedAccountsStore] for the given [userWalletId].
*
* @param userWalletId the unique identifier for the user wallet
*/
fun create(userWalletId: UserWalletId): ArchivedAccountsStore {
return createdRuntimeStores.computeIfAbsent(userWalletId) {
ArchivedAccountsStore(runtimeStore = RuntimeStateStore(defaultValue = null))
}
}
@VisibleForTesting
fun getAllStores(): Map<UserWalletId, ArchivedAccountsStore> = createdRuntimeStores.toMap()
@VisibleForTesting
fun clearStores() {
createdRuntimeStores.clear()
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.data.account.store
import com.google.common.truth.Truth
import com.tangem.domain.models.wallet.UserWalletId
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ArchivedAccountsStoreFactoryTest {
private val factory = ArchivedAccountsStoreFactory()
@AfterEach
fun tearDownEach() {
factory.clearStores()
}
@Test
fun `creates new store for unique userWalletId`() {
// Arrange
val userWalletId = UserWalletId("001")
val createdStore = factory.create(userWalletId)
// Act
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

@ -0,0 +1,139 @@
package com.tangem.data.account.store
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import kotlin.time.Duration.Companion.seconds
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ArchivedAccountsStoreTest {
private val runtimeStore: RuntimeStateStore<List<ArchivedAccount>?> = RuntimeStateStore(defaultValue = null)
private val archivedAccountsStore: ArchivedAccountsStore = ArchivedAccountsStore(runtimeStore = runtimeStore)
@AfterEach
fun tearDown() {
archivedAccountsStore.clear()
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Get {
@Test
fun `get returns empty flow`() = runTest {
// Act
val actual = getEmittedValues(archivedAccountsStore.get())
// Assert
Truth.assertThat(actual).isEmpty() // nothing emmited
}
@Test
fun `get returns flow with not expired data`() = runTest {
// Arrange
val archivedAccount = createArchivedAccount()
archivedAccountsStore.store(value = listOf(archivedAccount))
// Act
val actual = getEmittedValues(archivedAccountsStore.get())
// Assert
val expected = listOf(archivedAccount)
Truth.assertThat(actual).containsExactly(expected)
}
@Test
fun `get returns flow with expired data`() = runTest {
// Arrange
archivedAccountsStore.store(value = listOf(createArchivedAccount()))
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds)
// Act
val actual = getEmittedValues(archivedAccountsStore.get())
// Assert
Truth.assertThat(actual).isEmpty() // nothing emmited
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetSyncOrnNull {
@Test
fun `getSyncOrNull returns null`() = runTest {
// Act
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
Truth.assertThat(actual).isNull()
}
@Test
fun `get returns flow with not expired data`() = runTest {
// Arrange
val archivedAccount = createArchivedAccount()
archivedAccountsStore.store(value = listOf(archivedAccount))
// Act
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
val expected = archivedAccount
Truth.assertThat(actual).containsExactly(expected)
}
@Test
fun `get returns flow with expired data`() = runTest {
// Arrange
archivedAccountsStore.store(value = listOf(createArchivedAccount()))
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds)
// Act
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
Truth.assertThat(actual).isNull()
}
}
@Test
fun store() = runTest {
// Arrange
val archivedAccount = createArchivedAccount()
// Act
archivedAccountsStore.store(value = listOf(archivedAccount))
val actual = runtimeStore.getSyncOrNull()
// Assert
val expected = archivedAccount
Truth.assertThat(actual).containsExactly(expected)
}
private fun createArchivedAccount(): ArchivedAccount {
return ArchivedAccount(
accountId = AccountId.forCryptoPortfolio(
userWalletId = UserWalletId("011"),
derivationIndex = DerivationIndex.Main,
),
name = AccountName("Archived Account").getOrNull()!!,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = DerivationIndex.Main,
tokensCount = 2,
networksCount = 1,
)
}
}