Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 13:50:04 +01:00
commit dc2e0ef506
12 changed files with 193 additions and 53 deletions

View file

@ -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<ContactsBlockUM> get() = stateController.uiState
init {
modelScope.launch(context = dispatchers.default) { syncAddressBooksUseCase() }
combine(
params.queryFlow.flatMapLatest { query ->
getContactsUseCase(query = query, userWalletId = null)

View file

@ -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(

View file

@ -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,8 +47,9 @@ internal class AddressBookListModel @Inject constructor(
private val router: Router,
private val contactSelectionTrigger: ContactSelectionTrigger,
private val analyticsSender: AddressBookAnalyticsSender,
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
getWalletsUseCase: GetWalletsUseCase,
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
private val getWalletsUseCase: GetWalletsUseCase,
) : Model() {
private val params = paramsContainer.require<DefaultAddressBookListComponent.Params>()
@ -65,6 +68,14 @@ internal class AddressBookListModel @Inject constructor(
.shareIn(modelScope, SharingStarted.Lazily, replay = 1)
init {
modelScope.launch {
syncAddressBooksUseCase()
sendContactListScreenOpenedEvent()
observeContacts()
}
}
private suspend fun observeContacts() {
val matchedContacts = searchQuery.flatMapLatest { query ->
if (query.isBlank()) {
allContacts
@ -89,9 +100,7 @@ internal class AddressBookListModel @Inject constructor(
}
.onEach(::updateState)
.flowOn(dispatchers.default)
.launchIn(modelScope)
sendContactListScreenOpenedEvent()
.collect()
}
fun deliverSelection(contact: SelectedContact) {
@ -155,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) {

View file

@ -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 }

View file

@ -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<UserWalletId, UserWallet>())
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<Unit>()
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 }

View file

@ -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
@ -37,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
@ -88,6 +90,8 @@ internal class SendDestinationModel @Inject constructor(
private val sendDestinationAlertFactory: SendDestinationAlertFactory,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
private val addressBookSendAnalytics: AddressBookSendAnalytics,
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
private val addressBookFeatureToggles: AddressBookFeatureToggles,
getContactsUseCase: GetContactsUseCase,
contactSelectionListener: ContactSelectionListener,
) : Model(), SendDestinationClickIntents {
@ -139,6 +143,7 @@ internal class SendDestinationModel @Inject constructor(
private val backupProblematicWalletCache = AtomicReference<Pair<String, UserWalletId?>?>(null)
init {
syncAddressBooksIfNeeded()
subscribeOnQRScannerResult()
initialState()
resetContactOnEdit()
@ -286,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() }

View file

@ -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
@ -33,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
@ -93,6 +95,8 @@ 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 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)
@ -513,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(
@ -571,6 +605,8 @@ internal class SendDestinationModelTest {
sendDestinationAlertFactory = sendDestinationAlertFactory,
sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase,
addressBookSendAnalytics = addressBookSendAnalytics,
syncAddressBooksUseCase = syncAddressBooksUseCase,
addressBookFeatureToggles = addressBookFeatureToggles,
getContactsUseCase = getContactsUseCase,
contactSelectionListener = contactSelectionListener,
)