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

@ -50,7 +50,6 @@ internal class DefaultAddressBookRepository(
override fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>> {
return getContactsForWallet(userWalletId)
.onStart { syncAddressBooks() }
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
@ -70,7 +69,6 @@ internal class DefaultAddressBookRepository(
}
}
}
.onStart { syncAddressBooks() }
.distinctUntilChanged()
.flowOn(dispatchers.default)
}

View file

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

View file

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

View file

@ -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,8 +31,14 @@ 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)
}
return isNameContaining || isAddressContaining
val isNetworkContaining = addresses.any { addressEntry ->
addressEntry.networkId.value.contains(other = query, ignoreCase = true)
}
return isNameContaining || isAddressContaining || isNetworkContaining
}
}

View file

@ -54,31 +54,71 @@ 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
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 +179,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 +192,7 @@ class GetContactsUseCaseTest {
AddressEntry(
id = AddressEntryId("addr-$name"),
address = address,
networkId = Network.RawID("ethereum"),
networkId = Network.RawID(networkId),
memo = null,
signature = "sig",
),

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