diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeStateStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeStateStore.kt index b28130b286..05f3c620af 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeStateStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeStateStore.kt @@ -14,11 +14,18 @@ interface RuntimeStateStore { /** Get flow of elements [T] */ fun get(): StateFlow + /** 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 { override fun get(): StateFlow = flow + override suspend fun getSyncOrNull(): T? = flow.value + override suspend fun store(value: T) { flow.value = value } @@ -39,6 +48,10 @@ interface RuntimeStateStore { override suspend fun update(function: (T) -> T) { flow.update(function) } + + override fun clear() { + flow.value = defaultValue + } } } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt new file mode 100644 index 0000000000..cbbe1ca0b9 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt @@ -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?>, +) { + + private var timestamp: Long? = null + + /** Retrieves a flow of archived accounts, filtering out null values */ + fun get(): Flow> { + 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? { + if (isDataExpired()) return null + + return runtimeStore.getSyncOrNull() + } + + /** Stores the provided list of archived accounts [value] */ + suspend fun store(value: List) { + 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 + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt new file mode 100644 index 0000000000..e89a9fa130 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt @@ -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() + + /** + * 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 = createdRuntimeStores.toMap() + + @VisibleForTesting + fun clearStores() { + createdRuntimeStores.clear() + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt new file mode 100644 index 0000000000..977c3c4169 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt new file mode 100644 index 0000000000..70e6ddb2db --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt @@ -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?> = 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, + ) + } +} \ No newline at end of file