Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-10 15:15:41 +01:00
parent 6bc50e8fe7
commit d0d268e59a
11 changed files with 110 additions and 22 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

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

View file

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

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 { 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,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<String?>(value = null)
// We keep skeletons during stale state
private val isInitialSyncDone = MutableStateFlow(value = false)
private val allContacts: SharedFlow<List<VerifiedContact>> =
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)

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
@ -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<Pair<String, UserWalletId?>?>(null)
init {
modelScope.launch { syncAddressBooksUseCase() }
subscribeOnQRScannerResult()
initialState()
resetContactOnEdit()

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