From d0d268e59a2e17c8da0f70976815fddbe70589cb Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 15:15:41 +0100 Subject: [PATCH 1/3] Updated on 2026-08-14 --- .../DefaultAddressBookRepository.kt | 2 - .../DefaultAddressBookRepositoryTest.kt | 17 +++----- .../addressbook/usecase/GetContactsUseCase.kt | 5 ++- .../usecase/GetContactsUseCaseTest.kt | 30 +++++++++++++- .../block/model/ContactsBlockModel.kt | 6 +++ .../list/DefaultAddressBookListComponent.kt | 2 +- .../list/model/AddressBookListModel.kt | 21 ++++++++-- .../block/model/ContactsBlockModelTest.kt | 3 ++ .../list/model/AddressBookListModelTest.kt | 40 +++++++++++++++++-- .../destination/model/SendDestinationModel.kt | 3 ++ .../model/SendDestinationModelTest.kt | 3 ++ 11 files changed, 110 insertions(+), 22 deletions(-) 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 index 2fbb2ee921..7638d8d970 100644 --- 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 @@ -50,7 +50,6 @@ internal class DefaultAddressBookRepository( override fun getContacts(userWalletId: UserWalletId): Flow> { return getContactsForWallet(userWalletId) - .onStart { syncAddressBooks() } .distinctUntilChanged() .flowOn(dispatchers.default) } @@ -70,7 +69,6 @@ internal class DefaultAddressBookRepository( } } } - .onStart { syncAddressBooks() } .distinctUntilChanged() .flowOn(dispatchers.default) } 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 index 079d762fe6..732cb1d8b9 100644 --- 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 @@ -27,7 +27,6 @@ import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -88,7 +87,7 @@ internal class DefaultAddressBookRepositoryTest { } @Test - fun `GIVEN blob WHEN getContacts THEN syncs before reading contacts`() = runTest { + fun `GIVEN blob WHEN getContacts THEN does not sync on collection`() = runTest { // Arrange val contact = createContact(id = "c1", name = "Alice") val blob = createBlob() @@ -99,10 +98,8 @@ internal class DefaultAddressBookRepositoryTest { repository.getContacts(UserWalletId(WALLET_A)).first() // Assert - coVerifyOrder { - addressBookApi.syncAddressBooks(any()) - cipher.decrypt(blob, userWallet) - } + // Sync is now triggered by the feature entry points, not on flow collection. + coVerify(exactly = 0) { addressBookApi.syncAddressBooks(any()) } } @Test @@ -122,7 +119,7 @@ internal class DefaultAddressBookRepositoryTest { } @Test - fun `GIVEN blob WHEN getAllContacts THEN syncs before reading contacts`() = runTest { + fun `GIVEN blob WHEN getAllContacts THEN does not sync on collection`() = runTest { // Arrange val contact = createContact(id = "c1", name = "Alice") val blob = createBlob() @@ -134,10 +131,8 @@ internal class DefaultAddressBookRepositoryTest { repository.getAllContacts().first() // Assert - coVerifyOrder { - addressBookApi.syncAddressBooks(any()) - cipher.decrypt(blob, userWallet) - } + // Sync is now triggered by the feature entry points, not on flow collection. + coVerify(exactly = 0) { addressBookApi.syncAddressBooks(any()) } } @Test 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 d7cd9b2bb3..6cae1951be 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 @@ -32,6 +32,9 @@ class GetContactsUseCase( val isAddressContaining = addresses.any { addressEntry -> addressEntry.address.contains(other = query, ignoreCase = false) } - return isNameContaining || isAddressContaining + val isNetworkContaining = addresses.any { addressEntry -> + addressEntry.networkId.value.contains(other = query, ignoreCase = true) + } + return isNameContaining || isAddressContaining || isNetworkContaining } } \ 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 index fbe1f2d4a2..fa60b3c50d 100644 --- 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 @@ -79,6 +79,33 @@ class GetContactsUseCaseTest { assertThat(result).isEmpty() } + @Test + fun `GIVEN query matches a network id WHEN invoke THEN returns only contacts on that network`() = runTest { + // Arrange + val ethContact = contact(name = "Eth", address = "0x1", networkId = "ethereum") + val tronContact = contact(name = "Tron", address = "T1", networkId = "tron") + every { repository.getAllContacts() } returns flowOf(listOf(ethContact, tronContact)) + + // Act + val result = useCase(query = "tron").first() + + // Assert + assertThat(result).containsExactly(tronContact) + } + + @Test + fun `GIVEN network query with different case WHEN invoke THEN returns matching contact`() = runTest { + // Arrange + val tronContact = contact(name = "Tron", address = "T1", networkId = "tron") + every { repository.getAllContacts() } returns flowOf(listOf(alice, tronContact)) + + // Act + val result = useCase(query = "TRON").first() + + // Assert + assertThat(result).containsExactly(tronContact) + } + @Test fun `GIVEN name query with different case WHEN invoke THEN returns matching contact`() = runTest { // Act @@ -139,6 +166,7 @@ class GetContactsUseCaseTest { name: String, address: String, createdAt: String = "2026-01-01T00:00:00.000Z", + networkId: String = "ethereum", ): Contact = Contact( id = ContactId("id-$name"), walletId = UserWalletId("011"), @@ -151,7 +179,7 @@ class GetContactsUseCaseTest { AddressEntry( id = AddressEntryId("addr-$name"), address = address, - networkId = Network.RawID("ethereum"), + networkId = Network.RawID(networkId), memo = null, signature = "sig", ), diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt index 88648c1649..278d95d8dd 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt @@ -4,6 +4,7 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.addressbook.AddressBookContactsBlockComponent import com.tangem.features.addressbook.MatchedContact @@ -15,15 +16,18 @@ import com.tangem.features.addressbook.common.ContactMatcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @ModelScoped +@Suppress("LongParameterList") internal class ContactsBlockModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val stateController: ContactsBlockStateController, private val analyticsSender: AddressBookAnalyticsSender, + private val syncAddressBooksUseCase: SyncAddressBooksUseCase, getContactsUseCase: GetContactsUseCase, getWalletsUseCase: GetWalletsUseCase, ) : Model() { @@ -33,6 +37,8 @@ internal class ContactsBlockModel @Inject constructor( val state: StateFlow get() = stateController.uiState init { + modelScope.launch { syncAddressBooksUseCase() } + combine( params.queryFlow.flatMapLatest { query -> getContactsUseCase(query = query, userWalletId = null) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt index 735b5798b6..995ebd9b60 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt @@ -71,7 +71,7 @@ internal class DefaultAddressBookListComponent( /** * @property mode Default (management) or Selector (pick a contact for a network) - * @property onContactClick management mode — opens the contact editor (TODO [REDACTED_TASK_KEY]) + * @property onContactClick management mode — opens the contact editor * @property onAddContactClick opens the new-contact editor */ data class Params( diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt index 80625ad2e7..e8fc7f8aa7 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt @@ -9,6 +9,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor import com.tangem.domain.addressbook.model.VerifiedContact +import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.usecase.GetWalletsUseCase @@ -26,12 +27,13 @@ import com.tangem.features.addressbook.route.AddressBookRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import javax.inject.Inject /** * Backs the contacts list. The list content is the same however the address book was opened — the open * [AddressBookRoute.ListMode] only decides what tapping a contact does: - * - [AddressBookRoute.ListMode.Default]: browse / manage contacts (editor is TODO [REDACTED_TASK_KEY]). + * - [AddressBookRoute.ListMode.Default]: browse / manage contacts * - [AddressBookRoute.ListMode.Selector]: pick a recipient for the given network — a single matching address is * returned right away, several open the address selector first. */ @@ -45,6 +47,7 @@ internal class AddressBookListModel @Inject constructor( private val router: Router, private val contactSelectionTrigger: ContactSelectionTrigger, private val analyticsSender: AddressBookAnalyticsSender, + private val syncAddressBooksUseCase: SyncAddressBooksUseCase, getVerifiedContactsInteractor: GetVerifiedContactsInteractor, getWalletsUseCase: GetWalletsUseCase, ) : Model() { @@ -60,11 +63,22 @@ internal class AddressBookListModel @Inject constructor( private val searchActive = MutableStateFlow(value = false) private val selectedWalletId = MutableStateFlow(value = null) + // We keep skeletons during stale state + private val isInitialSyncDone = MutableStateFlow(value = false) + private val allContacts: SharedFlow> = getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) .shareIn(modelScope, SharingStarted.Lazily, replay = 1) init { + modelScope.launch { + try { + syncAddressBooksUseCase() + } finally { + isInitialSyncDone.value = true + } + } + val matchedContacts = searchQuery.flatMapLatest { query -> if (query.isBlank()) { allContacts @@ -72,7 +86,7 @@ internal class AddressBookListModel @Inject constructor( getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null) } } - combine( + val listInputs = combine( allContacts, matchedContacts, searchQuery, @@ -87,7 +101,8 @@ internal class AddressBookListModel @Inject constructor( wallets = wallets, ) } - .onEach(::updateState) + combine(listInputs, isInitialSyncDone) { inputs, syncDone -> inputs to syncDone } + .onEach { (inputs, syncDone) -> if (syncDone) updateState(inputs) } .flowOn(dispatchers.default) .launchIn(modelScope) diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModelTest.kt index 0dc7ed6959..1f7e03f325 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModelTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.domain.addressbook.model.* import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet @@ -37,6 +38,7 @@ internal class ContactsBlockModelTest { private val getContactsUseCase: GetContactsUseCase = mockk() private val getWalletsUseCase: GetWalletsUseCase = mockk() + private val syncAddressBooksUseCase: SyncAddressBooksUseCase = mockk(relaxed = true) private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true) private val network: Network = mockk { every { rawId } returns ETHEREUM } @@ -134,6 +136,7 @@ internal class ContactsBlockModelTest { dispatchers = testScope.createTestingCoroutineDispatcherProvider(), stateController = ContactsBlockStateController(), analyticsSender = analyticsSender, + syncAddressBooksUseCase = syncAddressBooksUseCase, getContactsUseCase = getContactsUseCase, getWalletsUseCase = getWalletsUseCase, ).also { model = it } diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt index 16ff3913ec..53294d8d38 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt @@ -1,13 +1,14 @@ package com.tangem.features.addressbook.list.model +import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor import com.tangem.domain.addressbook.model.* +import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.addressbook.ContactSelectionTrigger @@ -20,9 +21,11 @@ import com.tangem.features.addressbook.list.ui.state.ContentMode import com.tangem.features.addressbook.route.AddressBookRoute import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf @@ -44,14 +47,21 @@ internal class AddressBookListModelTest { private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true) private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor = mockk() private val getWalletsUseCase: GetWalletsUseCase = mockk() + private val syncAddressBooksUseCase: SyncAddressBooksUseCase = mockk(relaxed = true) private var model: AddressBookListModel? = null @BeforeEach fun resetMocks() { - clearMocks(getVerifiedContactsInteractor, getWalletsUseCase, analyticsSender, contactSelectionTrigger) + clearMocks( + getVerifiedContactsInteractor, + getWalletsUseCase, + analyticsSender, + contactSelectionTrigger, + syncAddressBooksUseCase, + ) every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns - flowOf(linkedMapOf()) + flowOf(linkedMapOf()) } @AfterEach @@ -72,6 +82,29 @@ internal class AddressBookListModelTest { assertThat(model.state.value).isEqualTo(AddressBookListUM.Loading) } + @Test + fun `GIVEN cached contacts AND sync in progress WHEN created THEN stays Loading until sync completes`() = runTest { + // Arrange — contacts are already cached locally, but the open-time sync has not returned yet. + val syncGate = CompletableDeferred() + coEvery { syncAddressBooksUseCase() } coAnswers { syncGate.await(); Unit.right() } + every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns + flowOf(listOf(verifiedContact(id = "1", name = "Alice"))) + + // Act + val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default) + advanceUntilIdle() + + // Assert — the possibly-stale cache is not revealed while the sync is still running. + assertThat(model.state.value).isEqualTo(AddressBookListUM.Loading) + + // Act — the sync finishes. + syncGate.complete(Unit) + advanceUntilIdle() + + // Assert — the list is revealed only after the sync completed. + assertThat(model.state.value).isInstanceOf(AddressBookListUM.Content::class.java) + } + @Test fun `GIVEN default mode AND verified contacts WHEN created THEN content shown`() = runTest { // Arrange @@ -212,6 +245,7 @@ internal class AddressBookListModelTest { router = router, contactSelectionTrigger = contactSelectionTrigger, analyticsSender = analyticsSender, + syncAddressBooksUseCase = syncAddressBooksUseCase, getVerifiedContactsInteractor = getVerifiedContactsInteractor, getWalletsUseCase = getWalletsUseCase, ).also { model = it } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt index df4f3b2b1c..eac6616290 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt @@ -18,6 +18,7 @@ import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAdd import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase import com.tangem.domain.feedback.SendBackupProblemEmailUseCase import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue @@ -88,6 +89,7 @@ internal class SendDestinationModel @Inject constructor( private val sendDestinationAlertFactory: SendDestinationAlertFactory, private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase, private val addressBookSendAnalytics: AddressBookSendAnalytics, + private val syncAddressBooksUseCase: SyncAddressBooksUseCase, getContactsUseCase: GetContactsUseCase, contactSelectionListener: ContactSelectionListener, ) : Model(), SendDestinationClickIntents { @@ -139,6 +141,7 @@ internal class SendDestinationModel @Inject constructor( private val backupProblematicWalletCache = AtomicReference?>(null) init { + modelScope.launch { syncAddressBooksUseCase() } subscribeOnQRScannerResult() initialState() resetContactOnEdit() diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt index 58a5da71fc..1e11a2c4b0 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt @@ -16,6 +16,7 @@ import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAdd import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.addressbook.model.* import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase import com.tangem.domain.feedback.SendBackupProblemEmailUseCase import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.CryptoCurrencyAddress @@ -93,6 +94,7 @@ internal class SendDestinationModelTest { private val sendDestinationAlertFactory: SendDestinationAlertFactory = mockk(relaxed = true) private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true) private val getContactsUseCase: GetContactsUseCase = mockk(relaxed = true) + private val syncAddressBooksUseCase: SyncAddressBooksUseCase = mockk(relaxed = true) private val contactSelectionListener: ContactSelectionListener = mockk(relaxed = true) private val addressBookSendAnalytics: AddressBookSendAnalytics = mockk(relaxed = true) private val callback: SendDestinationComponent.ModelCallback = mockk(relaxed = true) @@ -571,6 +573,7 @@ internal class SendDestinationModelTest { sendDestinationAlertFactory = sendDestinationAlertFactory, sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase, addressBookSendAnalytics = addressBookSendAnalytics, + syncAddressBooksUseCase = syncAddressBooksUseCase, getContactsUseCase = getContactsUseCase, contactSelectionListener = contactSelectionListener, ) From 0b43caf85316735aad76b61733281ee19989661c Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 16:39:11 +0100 Subject: [PATCH 2/3] Updated on 2026-08-14 --- .../block/model/ContactsBlockModel.kt | 2 +- .../list/model/AddressBookListModel.kt | 46 ++++++++----------- .../destination/model/SendDestinationModel.kt | 2 +- 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt index 278d95d8dd..f5c36a478b 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt @@ -37,7 +37,7 @@ internal class ContactsBlockModel @Inject constructor( val state: StateFlow get() = stateController.uiState init { - modelScope.launch { syncAddressBooksUseCase() } + modelScope.launch(context = dispatchers.default) { syncAddressBooksUseCase() } combine( params.queryFlow.flatMapLatest { query -> diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt index e8fc7f8aa7..c91002fbfa 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt @@ -48,8 +48,8 @@ internal class AddressBookListModel @Inject constructor( private val contactSelectionTrigger: ContactSelectionTrigger, private val analyticsSender: AddressBookAnalyticsSender, private val syncAddressBooksUseCase: SyncAddressBooksUseCase, - getVerifiedContactsInteractor: GetVerifiedContactsInteractor, - getWalletsUseCase: GetWalletsUseCase, + private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor, + private val getWalletsUseCase: GetWalletsUseCase, ) : Model() { private val params = paramsContainer.require() @@ -63,22 +63,19 @@ internal class AddressBookListModel @Inject constructor( private val searchActive = MutableStateFlow(value = false) private val selectedWalletId = MutableStateFlow(value = null) - // We keep skeletons during stale state - private val isInitialSyncDone = MutableStateFlow(value = false) - private val allContacts: SharedFlow> = getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) .shareIn(modelScope, SharingStarted.Lazily, replay = 1) init { - modelScope.launch { - try { - syncAddressBooksUseCase() - } finally { - isInitialSyncDone.value = true - } + modelScope.launch(context = dispatchers.default) { + syncAddressBooksUseCase() + sendContactListScreenOpenedEvent() + observeContacts() } + } + private suspend fun observeContacts() { val matchedContacts = searchQuery.flatMapLatest { query -> if (query.isBlank()) { allContacts @@ -86,7 +83,7 @@ internal class AddressBookListModel @Inject constructor( getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null) } } - val listInputs = combine( + combine( allContacts, matchedContacts, searchQuery, @@ -101,12 +98,9 @@ internal class AddressBookListModel @Inject constructor( wallets = wallets, ) } - combine(listInputs, isInitialSyncDone) { inputs, syncDone -> inputs to syncDone } - .onEach { (inputs, syncDone) -> if (syncDone) updateState(inputs) } + .onEach(::updateState) .flowOn(dispatchers.default) - .launchIn(modelScope) - - sendContactListScreenOpenedEvent() + .collect() } fun deliverSelection(contact: SelectedContact) { @@ -170,17 +164,13 @@ internal class AddressBookListModel @Inject constructor( } } - private fun sendContactListScreenOpenedEvent() { - allContacts - .take(count = 1) - .onEach { contacts -> - analyticsSender.sendContactListScreenOpened( - source = params.mode.toAnalyticsSource(), - contactsCount = contacts.size, - scope = modelScope, - ) - } - .launchIn(modelScope) + private suspend fun sendContactListScreenOpenedEvent() { + val contacts = allContacts.first() + analyticsSender.sendContactListScreenOpened( + source = params.mode.toAnalyticsSource(), + contactsCount = contacts.size, + scope = modelScope, + ) } private fun AddressBookRoute.ListMode.toAnalyticsSource(): Source = when (this) { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt index eac6616290..9ebade81e7 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt @@ -141,7 +141,7 @@ internal class SendDestinationModel @Inject constructor( private val backupProblematicWalletCache = AtomicReference?>(null) init { - modelScope.launch { syncAddressBooksUseCase() } + modelScope.launch(context = dispatchers.default) { syncAddressBooksUseCase() } subscribeOnQRScannerResult() initialState() resetContactOnEdit() From 2e581a252fa0a48c78d1e788924c41239ec38084 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Jul 2026 12:35:01 +0100 Subject: [PATCH 3/3] Updated on 2026-08-14 --- domain/address-book/build.gradle.kts | 4 ++ .../addressbook/usecase/GetContactsUseCase.kt | 6 ++- .../usecase/GetContactsUseCaseTest.kt | 45 ++++++++++++------- .../list/model/AddressBookListModel.kt | 2 +- .../destination/model/SendDestinationModel.kt | 10 ++++- .../model/SendDestinationModelTest.kt | 33 ++++++++++++++ 6 files changed, 81 insertions(+), 19 deletions(-) diff --git a/domain/address-book/build.gradle.kts b/domain/address-book/build.gradle.kts index a422d47b73..79b86b8be8 100644 --- a/domain/address-book/build.gradle.kts +++ b/domain/address-book/build.gradle.kts @@ -25,6 +25,10 @@ dependencies { implementation(projects.core.utils) // endregion + // region Libs + implementation(projects.libs.crypto) + // endregion + // region Domain api(projects.domain.common) api(projects.domain.tokens) 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 6cae1951be..e85ba5940b 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 @@ -3,6 +3,7 @@ package com.tangem.domain.addressbook.usecase import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.lib.crypto.BlockchainUtils import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -30,7 +31,10 @@ class GetContactsUseCase( private fun Contact.matches(query: String): Boolean { val isNameContaining = name.value.contains(other = query, ignoreCase = true) val isAddressContaining = addresses.any { addressEntry -> - addressEntry.address.contains(other = query, ignoreCase = false) + val isCaseInsensitiveContractAddress = BlockchainUtils.isCaseInsensitiveContractAddress( + networkId = addressEntry.networkId.value, + ) + addressEntry.address.contains(other = query, ignoreCase = isCaseInsensitiveContractAddress) } val isNetworkContaining = addresses.any { addressEntry -> addressEntry.networkId.value.contains(other = query, ignoreCase = true) 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 index fa60b3c50d..d5a26d4a10 100644 --- 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 @@ -54,31 +54,44 @@ class GetContactsUseCaseTest { } @Test - fun `GIVEN address query matching case WHEN invoke THEN returns matching contact`() = runTest { - // Arrange - val carol = contact(name = "Carol", address = "0xAbCdEf") - every { repository.getAllContacts() } returns flowOf(listOf(alice, carol)) - - // Act - val result = useCase(query = "0xAbCdEf").first() - - // Assert - assertThat(result).containsExactly(carol) - } - - @Test - fun `GIVEN address query with different case WHEN invoke THEN returns empty`() = runTest { - // Arrange - val carol = contact(name = "Carol", address = "0xAbCdEf") + fun `GIVEN EVM address query differing only in case WHEN invoke THEN returns matching contact`() = runTest { + // Arrange — EVM (ethereum) addresses are case-insensitive, so a lowercased query matches a checksummed address + val carol = contact(name = "Carol", address = "0xAbCdEf", networkId = "ethereum") every { repository.getAllContacts() } returns flowOf(listOf(alice, carol)) // Act val result = useCase(query = "0xabcdef").first() + // Assert + assertThat(result).containsExactly(carol) + } + + @Test + fun `GIVEN non-EVM address query differing only in case WHEN invoke THEN returns empty`() = runTest { + // Arrange — non-EVM (solana) addresses are case-sensitive, so a differently-cased query must not match + val dave = contact(name = "Dave", address = "SoLAnaAddr", networkId = "solana") + every { repository.getAllContacts() } returns flowOf(listOf(alice, dave)) + + // Act + val result = useCase(query = "solanaaddr").first() + // Assert assertThat(result).isEmpty() } + @Test + fun `GIVEN non-EVM address query with exact case WHEN invoke THEN returns matching contact`() = runTest { + // Arrange + val dave = contact(name = "Dave", address = "SoLAnaAddr", networkId = "solana") + every { repository.getAllContacts() } returns flowOf(listOf(alice, dave)) + + // Act + val result = useCase(query = "SoLAnaAddr").first() + + // Assert + assertThat(result).containsExactly(dave) + } + @Test fun `GIVEN query matches a network id WHEN invoke THEN returns only contacts on that network`() = runTest { // Arrange diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt index c91002fbfa..7203095b1b 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt @@ -68,7 +68,7 @@ internal class AddressBookListModel @Inject constructor( .shareIn(modelScope, SharingStarted.Lazily, replay = 1) init { - modelScope.launch(context = dispatchers.default) { + modelScope.launch { syncAddressBooksUseCase() sendContactListScreenOpenedEvent() observeContacts() diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt index 9ebade81e7..74f6520ad9 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt @@ -38,6 +38,7 @@ import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.addressbook.AddressBookFeatureToggles import com.tangem.features.addressbook.AddressBookSendAnalytics import com.tangem.features.addressbook.ContactSelectionListener import com.tangem.features.addressbook.MatchedContact @@ -90,6 +91,7 @@ internal class SendDestinationModel @Inject constructor( private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase, private val addressBookSendAnalytics: AddressBookSendAnalytics, private val syncAddressBooksUseCase: SyncAddressBooksUseCase, + private val addressBookFeatureToggles: AddressBookFeatureToggles, getContactsUseCase: GetContactsUseCase, contactSelectionListener: ContactSelectionListener, ) : Model(), SendDestinationClickIntents { @@ -141,7 +143,7 @@ internal class SendDestinationModel @Inject constructor( private val backupProblematicWalletCache = AtomicReference?>(null) init { - modelScope.launch(context = dispatchers.default) { syncAddressBooksUseCase() } + syncAddressBooksIfNeeded() subscribeOnQRScannerResult() initialState() resetContactOnEdit() @@ -289,6 +291,12 @@ internal class SendDestinationModel @Inject constructor( }.launchIn(modelScope) } + private fun syncAddressBooksIfNeeded() { + if (addressBookFeatureToggles.isAddressBookEnabled) { + modelScope.launch(context = dispatchers.default) { syncAddressBooksUseCase() } + } + } + private fun subscribeOnQRScannerResult() { listenToQrScanningUseCase(SourceType.SEND) .getOrElse { emptyFlow() } diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt index 1e11a2c4b0..8c81544ec3 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt @@ -34,6 +34,7 @@ import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.addressbook.AddressBookFeatureToggles import com.tangem.features.addressbook.AddressBookSendAnalytics import com.tangem.features.addressbook.ContactSelectionListener import com.tangem.features.addressbook.MatchedContact @@ -95,6 +96,7 @@ internal class SendDestinationModelTest { private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true) private val getContactsUseCase: GetContactsUseCase = mockk(relaxed = true) private val syncAddressBooksUseCase: SyncAddressBooksUseCase = mockk(relaxed = true) + private val addressBookFeatureToggles: AddressBookFeatureToggles = mockk(relaxed = true) private val contactSelectionListener: ContactSelectionListener = mockk(relaxed = true) private val addressBookSendAnalytics: AddressBookSendAnalytics = mockk(relaxed = true) private val callback: SendDestinationComponent.ModelCallback = mockk(relaxed = true) @@ -515,6 +517,36 @@ internal class SendDestinationModelTest { ) } + @Nested + inner class SyncAddressBooks { + + @Test + fun `GIVEN address book enabled WHEN model initialized THEN sync address books`() = runTest { + // Arrange + every { addressBookFeatureToggles.isAddressBookEnabled } returns true + + // Act + buildModel() + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { syncAddressBooksUseCase() } + } + + @Test + fun `GIVEN address book disabled WHEN model initialized THEN do NOT sync address books`() = runTest { + // Arrange + every { addressBookFeatureToggles.isAddressBookEnabled } returns false + + // Act + buildModel() + advanceUntilIdle() + + // Assert + coVerify(exactly = 0) { syncAddressBooksUseCase() } + } + } + // region fixtures private fun TestScope.buildModel( @@ -574,6 +606,7 @@ internal class SendDestinationModelTest { sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase, addressBookSendAnalytics = addressBookSendAnalytics, syncAddressBooksUseCase = syncAddressBooksUseCase, + addressBookFeatureToggles = addressBookFeatureToggles, getContactsUseCase = getContactsUseCase, contactSelectionListener = contactSelectionListener, )