From 99b517455ad9e1392cacfcea1a6f3e225434072a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Jun 2026 10:07:44 +0100 Subject: [PATCH] Updated on 2026-08-14 --- app/build.gradle.kts | 1 + data/address-book/build.gradle.kts | 42 ++++ .../DefaultAddressBookRepository.kt | 116 ++++++++++ .../addressbook/di/AddressBookDataModule.kt | 68 ++++++ .../addressbook/store/AddressBookBlobStore.kt | 25 ++ .../store/DefaultAddressBookBlobStore.kt | 58 +++++ .../store/StoredAddressBookBlob.kt | 15 ++ .../DefaultAddressBookRepositoryTest.kt | 219 ++++++++++++++++++ .../store/DefaultAddressBookBlobStoreTest.kt | 112 +++++++++ .../repository/AddressBookRepository.kt | 5 +- .../addressbook/usecase/GetContactsUseCase.kt | 21 +- .../usecase/GetContactsUseCaseTest.kt | 105 +++++++++ settings.gradle.kts | 1 + 13 files changed, 785 insertions(+), 3 deletions(-) create mode 100644 data/address-book/build.gradle.kts create mode 100644 data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt create mode 100644 data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt create mode 100644 data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt create mode 100644 data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt create mode 100644 data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt create mode 100644 data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt create mode 100644 data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c307bb3f9b..33ba9e4946 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/data/address-book/build.gradle.kts b/data/address-book/build.gradle.kts new file mode 100644 index 0000000000..734a559f8c --- /dev/null +++ b/data/address-book/build.gradle.kts @@ -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 +} \ No newline at end of file diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt new file mode 100644 index 0000000000..8ce2414259 --- /dev/null +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt @@ -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> { + return getContactsForWallet(userWalletId) + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun getAllContacts(): Flow> { + 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> { + 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 { + return cipher.decrypt(blob, userWallet).getOrNull()?.contacts.orEmpty() + } + + private suspend fun currentContacts(userWalletId: UserWalletId, userWallet: UserWallet): List { + 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 } +} \ No newline at end of file diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt new file mode 100644 index 0000000000..24ffcd6120 --- /dev/null +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt new file mode 100644 index 0000000000..de3e180d81 --- /dev/null +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt @@ -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 + + fun getBlobs(userWalletIds: Set): Flow> + + 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 + + suspend fun deleteBlob(userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt new file mode 100644 index 0000000000..8aa6c8558e --- /dev/null +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt @@ -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 + +internal class DefaultAddressBookBlobStore( + private val dataStore: DataStore, +) : AddressBookBlobStore { + + override fun getBlob(userWalletId: UserWalletId): Flow { + return dataStore.data + .map { it[userWalletId.stringValue]?.blob } + .distinctUntilChanged() + } + + override fun getBlobs(userWalletIds: Set): Flow> { + 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 { + 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() +} \ No newline at end of file diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt new file mode 100644 index 0000000000..9e82e17205 --- /dev/null +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt @@ -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, +) \ No newline at end of file diff --git a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt new file mode 100644 index 0000000000..e796387263 --- /dev/null +++ b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt @@ -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() + 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() + 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() + 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" + } +} \ No newline at end of file diff --git a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt new file mode 100644 index 0000000000..14f271eaa2 --- /dev/null +++ b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt @@ -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" + } +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt index 71858fae3e..41fd877268 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt @@ -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> - /** Contacts across several wallets, flattened. Each [Contact] keeps its own [Contact.walletId]. */ - fun getContacts(userWalletIds: Set): Flow> + /** Contacts across all wallets (flattened). Each [Contact] keeps its own [Contact.walletId]. */ + fun getAllContacts(): Flow> suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt index 71f56c7d7c..af19f1f2dc 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt @@ -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): Flow> = repository.getContacts(userWalletIds) + operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow> { + 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 + } + } + } } \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt new file mode 100644 index 0000000000..bb0253a442 --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt @@ -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", + ), + ), + ) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index b61afb33b1..b1fabd1412 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")