Updated on 2026-08-14
This commit is contained in:
parent
3b0867821e
commit
99b517455a
13 changed files with 785 additions and 3 deletions
|
|
@ -191,6 +191,7 @@ dependencies {
|
|||
implementation(projects.libs.tangemSdkApi)
|
||||
|
||||
implementation(projects.data.account)
|
||||
implementation(projects.data.addressBook)
|
||||
implementation(projects.data.appCurrency)
|
||||
implementation(projects.data.appTheme)
|
||||
implementation(projects.data.balanceHiding)
|
||||
|
|
|
|||
42
data/address-book/build.gradle.kts
Normal file
42
data/address-book/build.gradle.kts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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 Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
implementation(projects.domain.addressBook)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
// region SDK
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.serialization)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Testing
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(deps.moshi.kotlin)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package com.tangem.data.addressbook
|
||||
|
||||
import com.tangem.data.addressbook.store.AddressBookBlobStore
|
||||
import com.tangem.domain.addressbook.crypto.AddressBookCipher
|
||||
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 kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.joda.time.DateTime
|
||||
|
||||
internal class DefaultAddressBookRepository(
|
||||
private val blobStore: AddressBookBlobStore,
|
||||
private val cipher: AddressBookCipher,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val timestampProvider: IsoTimestampProvider,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : AddressBookRepository {
|
||||
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
override fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>> {
|
||||
return getContactsForWallet(userWalletId)
|
||||
.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 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) = withContext(dispatchers.default) {
|
||||
writeMutex.withLock {
|
||||
val userWallet = findUserWallet(contact.walletId.stringValue) ?: return@withLock
|
||||
val current = currentContacts(contact.walletId, userWallet)
|
||||
val merged = current.filterNot { it.id == contact.id } + contact
|
||||
persist(userWallet, AddressBook(walletId = contact.walletId, contacts = merged))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteContact(id: ContactId) = 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 }
|
||||
persist(userWallet, addressBook.copy(contacts = remaining))
|
||||
return@withLock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List<Contact> {
|
||||
return cipher.decrypt(blob, userWallet).getOrNull()?.contacts.orEmpty()
|
||||
}
|
||||
|
||||
private suspend fun currentContacts(userWalletId: UserWalletId, userWallet: UserWallet): List<Contact> {
|
||||
val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList()
|
||||
return decryptContacts(blob, userWallet)
|
||||
}
|
||||
|
||||
private suspend fun persist(userWallet: UserWallet, addressBook: AddressBook) {
|
||||
val updatedAt = DateTime.parse(timestampProvider.now())
|
||||
cipher.encrypt(addressBook, userWallet, updatedAt)
|
||||
.onRight { blobStore.storeBlob(it) }
|
||||
}
|
||||
|
||||
private suspend fun findUserWallet(walletId: String): UserWallet? =
|
||||
userWalletsListRepository.userWalletsSync().find { it.walletId.stringValue == walletId }
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
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.addressbook.store.StoredAddressBookBlob
|
||||
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
|
||||
import com.tangem.domain.addressbook.crypto.AddressBookCipher
|
||||
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 javax.inject.Singleton
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
|
||||
@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 = StoredAddressBookBlob.serializer(),
|
||||
),
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = "address_book_blobs") },
|
||||
scope = appScope,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAddressBookRepository(
|
||||
blobStore: AddressBookBlobStore,
|
||||
cipher: AddressBookCipher,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
timestampProvider: IsoTimestampProvider,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): AddressBookRepository {
|
||||
return DefaultAddressBookRepository(
|
||||
blobStore = blobStore,
|
||||
cipher = cipher,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
timestampProvider = timestampProvider,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
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?
|
||||
|
||||
/** Persists [blob] optimistically with `isBESynchronized = false`. Keyed by [AddressBookBlob.walletId]. */
|
||||
suspend fun storeBlob(blob: AddressBookBlob)
|
||||
|
||||
/** Flips the BE-sync flag to `true` once the backend confirms the push. No-op if the blob is absent. */
|
||||
suspend fun markAsSynchronized(userWalletId: UserWalletId)
|
||||
|
||||
/** Blobs still pending a backend push — the entry point for the future sync service. */
|
||||
suspend fun getUnsynchronizedBlobs(): List<AddressBookBlob>
|
||||
|
||||
suspend fun deleteBlob(userWalletId: UserWalletId)
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
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, StoredAddressBookBlob>
|
||||
|
||||
internal class DefaultAddressBookBlobStore(
|
||||
private val dataStore: DataStore<AddressBookBlobs>,
|
||||
) : AddressBookBlobStore {
|
||||
|
||||
override fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?> {
|
||||
return dataStore.data
|
||||
.map { it[userWalletId.stringValue]?.blob }
|
||||
.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.map { it.blob } }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
override suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? {
|
||||
return getStoredBlobs()[userWalletId.stringValue]?.blob
|
||||
}
|
||||
|
||||
override suspend fun storeBlob(blob: AddressBookBlob) {
|
||||
dataStore.updateData { stored ->
|
||||
stored + (blob.walletId to StoredAddressBookBlob(blob = blob, isBESynchronized = false))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markAsSynchronized(userWalletId: UserWalletId) {
|
||||
dataStore.updateData { stored ->
|
||||
val current = stored[userWalletId.stringValue] ?: return@updateData stored
|
||||
stored + (userWalletId.stringValue to current.copy(isBESynchronized = true))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getUnsynchronizedBlobs(): List<AddressBookBlob> {
|
||||
return getStoredBlobs().values
|
||||
.filterNot { it.isBESynchronized }
|
||||
.map { it.blob }
|
||||
}
|
||||
|
||||
override suspend fun deleteBlob(userWalletId: UserWalletId) {
|
||||
dataStore.updateData { stored -> stored - userWalletId.stringValue }
|
||||
}
|
||||
|
||||
private suspend fun getStoredBlobs(): AddressBookBlobs = dataStore.data.first()
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.data.addressbook.store
|
||||
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* [isBESynchronized] tracks whether the blob has already been pushed to the backend. A freshly
|
||||
* stored blob is written optimistically with `false`; a future BE-sync service flips it to `true`
|
||||
* once the push is confirmed.
|
||||
*/
|
||||
@Serializable
|
||||
internal data class StoredAddressBookBlob(
|
||||
val blob: AddressBookBlob,
|
||||
val isBESynchronized: Boolean,
|
||||
)
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
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.domain.addressbook.crypto.AddressBookCipher
|
||||
import com.tangem.domain.addressbook.error.AddressBookCryptoError
|
||||
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 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,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
timestampProvider = timestampProvider,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(blobStore, cipher, userWalletsListRepository, timestampProvider)
|
||||
every { timestampProvider.now() } returns TIMESTAMP
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
|
||||
}
|
||||
|
||||
@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(UserWalletId(WALLET_A), listOf(contact)).right()
|
||||
|
||||
// Act
|
||||
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(contact)
|
||||
}
|
||||
|
||||
@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(UserWalletId(WALLET_A), listOf(contact)).right()
|
||||
|
||||
// Act
|
||||
val result = repository.getAllContacts().first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(contact)
|
||||
}
|
||||
|
||||
@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 new contact WHEN saveContact THEN encrypts merged book and stores blob`() = 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(UserWalletId(WALLET_A), 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
|
||||
|
||||
// Act
|
||||
repository.saveContact(added)
|
||||
|
||||
// Assert
|
||||
assertThat(bookSlot.captured.contacts).containsExactly(existing, added)
|
||||
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
|
||||
}
|
||||
|
||||
@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(UserWalletId(WALLET_A), listOf(original)).right()
|
||||
val bookSlot = slot<AddressBook>()
|
||||
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { blobStore.storeBlob(any()) } returns Unit
|
||||
|
||||
// Act
|
||||
repository.saveContact(updated)
|
||||
|
||||
// Assert
|
||||
assertThat(bookSlot.captured.contacts).containsExactly(updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact in wallet WHEN deleteContact THEN 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(UserWalletId(WALLET_A), 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
|
||||
|
||||
// Act
|
||||
repository.deleteContact(ContactId("c2"))
|
||||
|
||||
// Assert
|
||||
assertThat(bookSlot.captured.contacts).containsExactly(kept)
|
||||
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
|
||||
}
|
||||
|
||||
@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(UserWalletId(WALLET_A), listOf(alice, bob)).right()
|
||||
|
||||
// Act
|
||||
val result = repository.getContact(UserWalletId(WALLET_A), name = "Bob")
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(bob)
|
||||
}
|
||||
|
||||
private fun createContact(id: String, name: String): Contact = Contact(
|
||||
id = ContactId(id),
|
||||
walletId = UserWalletId(WALLET_A),
|
||||
name = ContactName(name).getOrNull()!!,
|
||||
createdAt = TIMESTAMP,
|
||||
updatedAt = TIMESTAMP,
|
||||
addressEntries = 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 TIMESTAMP = "2026-05-22T09:00:00.000Z"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
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 AND it is unsynchronized`() = 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)
|
||||
assertThat(store.getUnsynchronizedBlobs()).containsExactly(blob)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored blob WHEN markAsSynchronized THEN getUnsynchronizedBlobs excludes it`() = runTest {
|
||||
// Arrange
|
||||
val blob = createBlob(walletId = WALLET_A)
|
||||
store.storeBlob(blob)
|
||||
|
||||
// Act
|
||||
store.markAsSynchronized(UserWalletId(WALLET_A))
|
||||
|
||||
// Assert
|
||||
assertThat(store.getUnsynchronizedBlobs()).isEmpty()
|
||||
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).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)
|
||||
assertThat(store.getUnsynchronizedBlobs()).containsExactly(blobA, blobB)
|
||||
}
|
||||
|
||||
@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"
|
||||
}
|
||||
}
|
||||
|
|
@ -8,10 +8,11 @@ import kotlinx.coroutines.flow.Flow
|
|||
/** Persistence port for the address book. The implementation is provided by the data layer. */
|
||||
interface AddressBookRepository {
|
||||
|
||||
/** Contacts for a single wallet. Each [Contact] keeps its own [Contact.walletId]. */
|
||||
fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>>
|
||||
|
||||
/** Contacts across several wallets, flattened. Each [Contact] keeps its own [Contact.walletId]. */
|
||||
fun getContacts(userWalletIds: Set<UserWalletId>): Flow<List<Contact>>
|
||||
/** Contacts across all wallets (flattened). Each [Contact] keeps its own [Contact.walletId]. */
|
||||
fun getAllContacts(): Flow<List<Contact>>
|
||||
|
||||
suspend fun getContact(userWalletId: UserWalletId, name: String): Contact?
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,29 @@ import com.tangem.domain.addressbook.model.Contact
|
|||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class GetContactsUseCase(
|
||||
private val repository: AddressBookRepository,
|
||||
) {
|
||||
|
||||
operator fun invoke(userWalletIds: Set<UserWalletId>): Flow<List<Contact>> = repository.getContacts(userWalletIds)
|
||||
operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow<List<Contact>> {
|
||||
val source = if (userWalletId == null) {
|
||||
repository.getAllContacts()
|
||||
} else {
|
||||
repository.getContacts(userWalletId)
|
||||
}
|
||||
val normalizedQuery = query.trim()
|
||||
if (normalizedQuery.isEmpty()) return source
|
||||
return source
|
||||
.map { contacts ->
|
||||
contacts.filter { contact ->
|
||||
val isNameContaining = contact.name.value.contains(other = normalizedQuery, ignoreCase = true)
|
||||
val isAddressContaining = contact.addressEntries.any { addressEntry ->
|
||||
addressEntry.address.contains(other = normalizedQuery, ignoreCase = true)
|
||||
}
|
||||
isNameContaining || isAddressContaining
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.AddressEntryId
|
||||
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.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
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)
|
||||
class GetContactsUseCaseTest {
|
||||
|
||||
private val repository: AddressBookRepository = mockk()
|
||||
private val useCase = GetContactsUseCase(repository)
|
||||
|
||||
private val alice = contact(name = "Alice", address = "0xaaa")
|
||||
private val bob = contact(name = "Bob", address = "0xbbb")
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository)
|
||||
every { repository.getAllContacts() } returns flowOf(listOf(alice, bob))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN query matches a name WHEN invoke THEN returns only matching contacts`() = runTest {
|
||||
// Act
|
||||
val result = useCase(query = "ali").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(alice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN query matches an address WHEN invoke THEN returns only matching contacts`() = runTest {
|
||||
// Act
|
||||
val result = useCase(query = "0xbbb").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(bob)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blank query WHEN invoke THEN returns all contacts unfiltered`() = runTest {
|
||||
// Act
|
||||
val result = useCase(query = " ").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(alice, bob)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN query matches nothing WHEN invoke THEN returns empty list`() = runTest {
|
||||
// Act
|
||||
val result = useCase(query = "charlie").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN userWalletId WHEN invoke THEN reads single wallet contacts AND not all contacts`() = runTest {
|
||||
// Arrange
|
||||
val walletId = UserWalletId("011")
|
||||
every { repository.getContacts(walletId) } returns flowOf(listOf(alice))
|
||||
|
||||
// Act
|
||||
val result = useCase(query = "", userWalletId = walletId).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(alice)
|
||||
verify(exactly = 1) { repository.getContacts(walletId) }
|
||||
verify(exactly = 0) { repository.getAllContacts() }
|
||||
}
|
||||
|
||||
private fun contact(name: String, address: String): Contact = Contact(
|
||||
id = ContactId("id-$name"),
|
||||
walletId = UserWalletId("011"),
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addressEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = address,
|
||||
networkId = Network.RawID("ethereum"),
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -439,6 +439,7 @@ include(":domain:search")
|
|||
|
||||
// region Data modules
|
||||
include(":data:account")
|
||||
include(":data:address-book")
|
||||
include(":data:app-currency")
|
||||
include(":data:app-theme")
|
||||
include(":data:balance-hiding")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue