Updated on 2026-08-14
This commit is contained in:
parent
6bc50e8fe7
commit
d0d268e59a
11 changed files with 110 additions and 22 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue