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, )