Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 12:35:01 +01:00
parent 0b43caf853
commit 2e581a252f
6 changed files with 81 additions and 19 deletions

View file

@ -25,6 +25,10 @@ dependencies {
implementation(projects.core.utils) implementation(projects.core.utils)
// endregion // endregion
// region Libs
implementation(projects.libs.crypto)
// endregion
// region Domain // region Domain
api(projects.domain.common) api(projects.domain.common)
api(projects.domain.tokens) 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.model.Contact
import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.lib.crypto.BlockchainUtils
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -30,7 +31,10 @@ class GetContactsUseCase(
private fun Contact.matches(query: String): Boolean { private fun Contact.matches(query: String): Boolean {
val isNameContaining = name.value.contains(other = query, ignoreCase = true) val isNameContaining = name.value.contains(other = query, ignoreCase = true)
val isAddressContaining = addresses.any { addressEntry -> 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 -> val isNetworkContaining = addresses.any { addressEntry ->
addressEntry.networkId.value.contains(other = query, ignoreCase = true) addressEntry.networkId.value.contains(other = query, ignoreCase = true)

View file

@ -54,31 +54,44 @@ class GetContactsUseCaseTest {
} }
@Test @Test
fun `GIVEN address query matching case WHEN invoke THEN returns matching contact`() = runTest { fun `GIVEN EVM address query differing only in case WHEN invoke THEN returns matching contact`() = runTest {
// Arrange // Arrange — EVM (ethereum) addresses are case-insensitive, so a lowercased query matches a checksummed address
val carol = contact(name = "Carol", address = "0xAbCdEf") 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 address query with different case WHEN invoke THEN returns empty`() = runTest {
// Arrange
val carol = contact(name = "Carol", address = "0xAbCdEf")
every { repository.getAllContacts() } returns flowOf(listOf(alice, carol)) every { repository.getAllContacts() } returns flowOf(listOf(alice, carol))
// Act // Act
val result = useCase(query = "0xabcdef").first() 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 // Assert
assertThat(result).isEmpty() 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 @Test
fun `GIVEN query matches a network id WHEN invoke THEN returns only contacts on that network`() = runTest { fun `GIVEN query matches a network id WHEN invoke THEN returns only contacts on that network`() = runTest {
// Arrange // Arrange

View file

@ -68,7 +68,7 @@ internal class AddressBookListModel @Inject constructor(
.shareIn(modelScope, SharingStarted.Lazily, replay = 1) .shareIn(modelScope, SharingStarted.Lazily, replay = 1)
init { init {
modelScope.launch(context = dispatchers.default) { modelScope.launch {
syncAddressBooksUseCase() syncAddressBooksUseCase()
sendContactListScreenOpenedEvent() sendContactListScreenOpenedEvent()
observeContacts() observeContacts()

View file

@ -38,6 +38,7 @@ import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookFeatureToggles
import com.tangem.features.addressbook.AddressBookSendAnalytics import com.tangem.features.addressbook.AddressBookSendAnalytics
import com.tangem.features.addressbook.ContactSelectionListener import com.tangem.features.addressbook.ContactSelectionListener
import com.tangem.features.addressbook.MatchedContact import com.tangem.features.addressbook.MatchedContact
@ -90,6 +91,7 @@ internal class SendDestinationModel @Inject constructor(
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase, private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
private val addressBookSendAnalytics: AddressBookSendAnalytics, private val addressBookSendAnalytics: AddressBookSendAnalytics,
private val syncAddressBooksUseCase: SyncAddressBooksUseCase, private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
private val addressBookFeatureToggles: AddressBookFeatureToggles,
getContactsUseCase: GetContactsUseCase, getContactsUseCase: GetContactsUseCase,
contactSelectionListener: ContactSelectionListener, contactSelectionListener: ContactSelectionListener,
) : Model(), SendDestinationClickIntents { ) : Model(), SendDestinationClickIntents {
@ -141,7 +143,7 @@ internal class SendDestinationModel @Inject constructor(
private val backupProblematicWalletCache = AtomicReference<Pair<String, UserWalletId?>?>(null) private val backupProblematicWalletCache = AtomicReference<Pair<String, UserWalletId?>?>(null)
init { init {
modelScope.launch(context = dispatchers.default) { syncAddressBooksUseCase() } syncAddressBooksIfNeeded()
subscribeOnQRScannerResult() subscribeOnQRScannerResult()
initialState() initialState()
resetContactOnEdit() resetContactOnEdit()
@ -289,6 +291,12 @@ internal class SendDestinationModel @Inject constructor(
}.launchIn(modelScope) }.launchIn(modelScope)
} }
private fun syncAddressBooksIfNeeded() {
if (addressBookFeatureToggles.isAddressBookEnabled) {
modelScope.launch(context = dispatchers.default) { syncAddressBooksUseCase() }
}
}
private fun subscribeOnQRScannerResult() { private fun subscribeOnQRScannerResult() {
listenToQrScanningUseCase(SourceType.SEND) listenToQrScanningUseCase(SourceType.SEND)
.getOrElse { emptyFlow() } .getOrElse { emptyFlow() }

View file

@ -34,6 +34,7 @@ import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookFeatureToggles
import com.tangem.features.addressbook.AddressBookSendAnalytics import com.tangem.features.addressbook.AddressBookSendAnalytics
import com.tangem.features.addressbook.ContactSelectionListener import com.tangem.features.addressbook.ContactSelectionListener
import com.tangem.features.addressbook.MatchedContact import com.tangem.features.addressbook.MatchedContact
@ -95,6 +96,7 @@ internal class SendDestinationModelTest {
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true) private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
private val getContactsUseCase: GetContactsUseCase = mockk(relaxed = true) private val getContactsUseCase: GetContactsUseCase = mockk(relaxed = true)
private val syncAddressBooksUseCase: SyncAddressBooksUseCase = 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 contactSelectionListener: ContactSelectionListener = mockk(relaxed = true)
private val addressBookSendAnalytics: AddressBookSendAnalytics = mockk(relaxed = true) private val addressBookSendAnalytics: AddressBookSendAnalytics = mockk(relaxed = true)
private val callback: SendDestinationComponent.ModelCallback = 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 // region fixtures
private fun TestScope.buildModel( private fun TestScope.buildModel(
@ -574,6 +606,7 @@ internal class SendDestinationModelTest {
sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase, sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase,
addressBookSendAnalytics = addressBookSendAnalytics, addressBookSendAnalytics = addressBookSendAnalytics,
syncAddressBooksUseCase = syncAddressBooksUseCase, syncAddressBooksUseCase = syncAddressBooksUseCase,
addressBookFeatureToggles = addressBookFeatureToggles,
getContactsUseCase = getContactsUseCase, getContactsUseCase = getContactsUseCase,
contactSelectionListener = contactSelectionListener, contactSelectionListener = contactSelectionListener,
) )