Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-07 15:11:25 +02:00
parent 9af7bad7b8
commit 909cb765de
16 changed files with 802 additions and 117 deletions

View file

@ -0,0 +1,21 @@
package com.tangem.features.addressbook
import com.tangem.domain.models.wallet.UserWalletId
/**
* Address-book analytics that are triggered from outside the address-book feature (i.e. from the Send flow).
*
* The [com.tangem.features.addressbook.analytics] events are internal to the address-book impl module, so the Send
* feature cannot construct them directly it reports the address-book funnel through this contract instead.
*/
interface AddressBookSendAnalytics {
/**
* Fired when the recipient address (and memo, if any) picked from the address book has been substituted into the
* Send form the final step of choosing a recipient from the book.
*
* @param walletId the current (sending) wallet
* @param contactId id of the picked contact
*/
fun onAddressSubstitutedInSend(walletId: UserWalletId, contactId: String)
}

View file

@ -22,6 +22,7 @@ import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddre
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressValidationTransformer
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateMemoInputTransformer
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.common.AddressMemoValidator
import com.tangem.features.addressbook.common.SelectNetworksResultHolder
import com.tangem.features.addressbook.common.SupportedNetworksMatcher
@ -46,6 +47,7 @@ internal class AddAddressModel @Inject constructor(
private val stateController: AddAddressStateController,
private val selectNetworksResultHolder: SelectNetworksResultHolder,
private val checkAddressDuplicateUseCase: CheckAddressDuplicateUseCase,
private val analyticsSender: AddressBookAnalyticsSender,
private val router: Router,
) : Model() {
@ -110,11 +112,13 @@ internal class AddAddressModel @Inject constructor(
init {
// Drop any selection left over from a previous AddAddress session before subscribing to it.
selectNetworksResultHolder.clear()
sendInitAnalytics()
updateInitialState()
subscribeToValidation()
subscribeToMemoValidation()
subscribeToSelectedNetworks()
subscribeToQrScanResult()
subscribeToAddressInvalid()
prefillData()
}
@ -189,6 +193,22 @@ internal class AddAddressModel @Inject constructor(
.launchIn(modelScope)
}
private fun subscribeToAddressInvalid() {
val walletId = params.walletId ?: return
validation
.map { it.address.isNotBlank() && it.matchedBlockchains.isEmpty() }
.distinctUntilChanged()
.filter { isInvalid -> isInvalid }
.onEach {
analyticsSender.sendAddressInvalid(
walletId = UserWalletId(walletId),
contactId = params.excludeContactId.orEmpty(),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun onPaste() {
onAddressChange(value = clipboardManager.getText().orEmpty())
}
@ -258,6 +278,10 @@ internal class AddAddressModel @Inject constructor(
return matched.filter { it.toNetworkId() in selected }
}
private fun sendInitAnalytics() {
analyticsSender.sendAddressScreenOpened()
}
private data class AddressValidation(
val address: String,
val matchedBlockchains: List<Blockchain>,

View file

@ -10,6 +10,7 @@ private const val ADDRESS_BOOK_CATEGORY = "Address Book"
private const val WALLET_ID = "Wallet Id"
private const val CONTACT_ID = "Contact Id"
private const val MODE = "Mode"
private const val CONTACTS_COUNT = "Contacts Count"
sealed class AddressBookEvents(
event: String,
@ -24,11 +25,13 @@ sealed class AddressBookEvents(
class ContactListScreenOpened(
walletId: UserWalletId,
source: Source,
contactsCount: Int,
) : AddressBookEvents(
event = "Contact List Screen Opened",
params = mapOf(
WALLET_ID to walletId.stringValue,
SOURCE to source.value,
CONTACTS_COUNT to contactsCount.toString(),
),
) {
enum class Source(val value: String) {
@ -92,11 +95,11 @@ sealed class AddressBookEvents(
// endregion
// region Contact editing
class ContactOpened(
class ContactScreenOpened(
walletId: UserWalletId,
contactId: String,
) : AddressBookEvents(
event = "Contact Opened",
event = "Contact Screen Opened",
params = mapOf(
WALLET_ID to walletId.stringValue,
CONTACT_ID to contactId,
@ -116,7 +119,7 @@ sealed class AddressBookEvents(
walletId: UserWalletId,
contactId: String,
) : AddressBookEvents(
event = "Contact Selected In Send",
event = "Contact Selected",
params = mapOf(
WALLET_ID to walletId.stringValue,
CONTACT_ID to contactId,

View file

@ -6,9 +6,11 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.block.state.ContactsBlockStateController
import com.tangem.features.addressbook.block.state.transformers.UpdateContactsBlockStateTransformer
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.common.ContactMatcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -21,12 +23,13 @@ internal class ContactsBlockModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val stateController: ContactsBlockStateController,
private val analyticsSender: AddressBookAnalyticsSender,
getContactsUseCase: GetContactsUseCase,
getWalletsUseCase: GetWalletsUseCase,
) : Model() {
private var isWidgetShownReported = false
private val params = paramsContainer.require<AddressBookContactsBlockComponent.Params>()
val state: StateFlow<ContactsBlockUM> get() = stateController.uiState
init {
@ -38,17 +41,30 @@ internal class ContactsBlockModel @Inject constructor(
) { contacts, wallets -> contacts to wallets.values.toList() }
.onEach { (contacts, wallets) ->
val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId)
reportWidgetShownIfNeeded(matched.isNotEmpty())
stateController.update(
UpdateContactsBlockStateTransformer(
matched = matched,
walletNamesById = wallets.associate { it.walletId.stringValue to it.name },
shouldShowWalletName = matched.mapTo(HashSet()) { it.walletId }.size > 1,
onSeeAllClick = params.onSeeAllClick,
onContactClick = params.onContactClick,
onContactClick = ::onContactClick,
),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun reportWidgetShownIfNeeded(isVisible: Boolean) {
if (isVisible && !isWidgetShownReported) {
isWidgetShownReported = true
analyticsSender.sendSendFlowWidgetShown(scope = modelScope)
}
}
private fun onContactClick(contact: MatchedContact) {
analyticsSender.sendContactSelectedInSend(contactId = contact.contactId, scope = modelScope)
params.onContactClick(contact)
}
}

View file

@ -1,14 +1,15 @@
package com.tangem.features.addressbook.common
import com.tangem.common.routing.entity.AddressBookOpenMode
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.addressbook.error.AddressBookSyncError
import com.tangem.domain.addressbook.error.SaveContactError
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.addressbook.AddressBookSendAnalytics
import com.tangem.features.addressbook.analytics.AddressBookEvents
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListScreenOpened.Source
import com.tangem.features.addressbook.analytics.AddressBookEvents.SaveErrorShown.ErrorType
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
@ -20,21 +21,75 @@ import javax.inject.Singleton
internal class AddressBookAnalyticsSender @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val userWalletsListRepository: UserWalletsListRepository,
) {
private val dispatcherProvider: CoroutineDispatcherProvider,
) : AddressBookSendAnalytics {
fun sendContactListScreenOpened(mode: AddressBookOpenMode, scope: CoroutineScope) {
scope.launch {
fun sendContactListScreenOpened(source: Source, contactsCount: Int, scope: CoroutineScope) {
scope.launch(dispatcherProvider.default) {
analyticsEventHandler.send(
AddressBookEvents.ContactListScreenOpened(
walletId = selectedWalletId(),
source = mode.toAnalyticsSource(),
source = source,
contactsCount = contactsCount,
),
)
}
}
fun sendContactScreenOpened(contactId: String, scope: CoroutineScope) {
scope.launch(dispatcherProvider.default) {
analyticsEventHandler.send(
AddressBookEvents.ContactScreenOpened(walletId = selectedWalletId(), contactId = contactId),
)
}
}
fun sendSendFlowWidgetShown(scope: CoroutineScope) {
scope.launch(dispatcherProvider.default) {
analyticsEventHandler.send(AddressBookEvents.SendFlowWidgetShown(walletId = selectedWalletId()))
}
}
fun sendContactSelectedInSend(contactId: String, scope: CoroutineScope) {
scope.launch(dispatcherProvider.default) {
analyticsEventHandler.send(
AddressBookEvents.ContactSelectedInSend(walletId = selectedWalletId(), contactId = contactId),
)
}
}
override fun onAddressSubstitutedInSend(walletId: UserWalletId, contactId: String) {
analyticsEventHandler.send(
AddressBookEvents.AddressSubstitutedInSend(walletId = walletId, contactId = contactId),
)
}
fun sendAddressInvalid(walletId: UserWalletId, contactId: String) {
analyticsEventHandler.send(
AddressBookEvents.AddressInvalid(walletId = walletId, contactId = contactId),
)
}
fun sendDuplicateNameErrorShown(walletId: UserWalletId, contactId: String?) {
analyticsEventHandler.send(
AddressBookEvents.DuplicateNameErrorShown(walletId = walletId, contactId = contactId),
)
}
fun sendAddressRemoved(walletId: UserWalletId, contactId: String) {
analyticsEventHandler.send(
AddressBookEvents.AddressRemoved(walletId = walletId, contactId = contactId),
)
}
fun sendContactDeleted(walletId: UserWalletId, contactId: String) {
analyticsEventHandler.send(
AddressBookEvents.ContactDeleted(walletId = walletId, contactId = contactId),
)
}
fun sendAddContactTapped(fromSendSuccess: Boolean, scope: CoroutineScope) {
scope.launch {
scope.launch(dispatcherProvider.default) {
analyticsEventHandler.send(
AddressBookEvents.AddContactTapped(
walletId = selectedWalletId(),
@ -99,11 +154,4 @@ internal class AddressBookAnalyticsSender @Inject constructor(
.filterNotNull()
.first()
.walletId
private fun AddressBookOpenMode.toAnalyticsSource(): Source = when (this) {
AddressBookOpenMode.Default -> Source.Settings
is AddressBookOpenMode.ContactSelection,
is AddressBookOpenMode.WithContactCreation,
-> Source.SendFlow
}
}

View file

@ -39,8 +39,9 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
// Drop any results left over from a previous session before the (possibly preloaded) stack starts collecting.
resultHolder.clear()
selectNetworksResultHolder.clear()
analyticsSender.sendContactListScreenOpened(mode = params.addressBookOpenMode, scope = componentScope)
if (params.addressBookOpenMode is AddressBookOpenMode.WithContactCreation) {
analyticsSender.sendAddContactTapped(fromSendSuccess = true, scope = componentScope)
}
}
private val clickIntents = object : AddressBookClickIntents {
@ -50,11 +51,14 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
}
override fun onAddContactClick() {
analyticsSender.sendAddContactTapped(fromSendSuccess = false, scope = componentScope)
navigation.pushNew(AddressBookRoute.EditContact())
}
override fun onEditContactBack() {
navigation.pop()
navigation.pop { isPopped ->
if (!isPopped) router.pop()
}
}
override fun onAddAddressClick(walletId: String, excludeContactId: String?, prefill: ValidatedAddress?) {
@ -132,8 +136,6 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
AddressBookRoute.List(mode = AddressBookRoute.ListMode.Selector(networkId = mode.networkId)),
)
is AddressBookOpenMode.WithContactCreation -> listOf(
AddressBookRoute.List(),
// Address + network are already known, so open the new contact with that address attached — no AddAddress.
AddressBookRoute.EditContact(
predefinedAddress = mode.address,
predefinedNetworkId = mode.networkId,

View file

@ -1,12 +1,9 @@
package com.tangem.features.addressbook.di
import com.tangem.features.addressbook.AddressBookComponent
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
import com.tangem.features.addressbook.AddressSelectorComponent
import com.tangem.features.addressbook.ContactSelectionListener
import com.tangem.features.addressbook.ContactSelectionTrigger
import com.tangem.features.addressbook.*
import com.tangem.features.addressbook.addressselector.DefaultAddressSelectorComponent
import com.tangem.features.addressbook.block.DefaultAddressBookContactsBlockComponent
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.common.DefaultAddressBookComponent
import com.tangem.features.addressbook.common.DefaultContactSelectionTrigger
import dagger.Binds
@ -42,4 +39,8 @@ internal interface AddressBookComponentModule {
@Binds
@Singleton
fun bindContactSelectionListener(impl: DefaultContactSelectionTrigger): ContactSelectionListener
@Binds
@Singleton
fun bindAddressBookSendAnalytics(impl: AddressBookAnalyticsSender): AddressBookSendAnalytics
}

View file

@ -8,7 +8,6 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
@ -132,12 +131,7 @@ internal class EditContactModel @Inject constructor(
observeNameValidation()
observeSaveButton()
loadExistingContact()
sendAddContactTappedEvent()
}
private fun sendAddContactTappedEvent() {
if (params.contactId != null) return
analyticsSender.sendAddContactTapped(fromSendSuccess = params.predefinedAddress != null, scope = modelScope)
sendContactScreenOpenedEvent()
}
// region Initialization
@ -231,9 +225,18 @@ internal class EditContactModel @Inject constructor(
stateController.uiState.map { it.name }.distinctUntilChanged().debounce(NAME_DEBOUNCE_MS),
selectedWallet.mapNotNull { it?.walletId }.distinctUntilChanged(),
) { name, walletId -> name to walletId }
.mapLatest { (name, walletId) -> validateName(name, walletId) }
.mapLatest { (name, walletId) -> walletId to validateName(name, walletId) }
.distinctUntilChanged()
.onEach { (walletId, error) ->
if (error == ContactNameValidationError.Duplicate) {
analyticsSender.sendDuplicateNameErrorShown(
walletId = walletId,
contactId = params.contactId?.value,
)
}
stateController.update(UpdateNameErrorTransformer(error?.let(ContactNameErrorConverter()::convert)))
}
.flowOn(dispatchers.default)
.onEach { error -> stateController.update(UpdateNameErrorTransformer(error)) }
.launchIn(modelScope)
}
@ -290,7 +293,6 @@ internal class EditContactModel @Inject constructor(
)
} else {
val walletId = selectedWallet.value?.walletId?.stringValue ?: return
analyticsSender.sendAddressScreenOpened()
params.onAddAddressClick(walletId, params.contactId?.value, null)
}
}
@ -332,11 +334,6 @@ internal class EditContactModel @Inject constructor(
},
ifRight = { contact ->
if (existing == null) {
analyticsSender.sendContactSaved(
walletId = userWallet.walletId,
contactId = contact.id.value,
isEdit = params.contactId != null,
)
messageSender.send(
SnackbarMessage(
message = resourceReference(R.string.address_book_create_success_message),
@ -344,6 +341,11 @@ internal class EditContactModel @Inject constructor(
),
)
}
analyticsSender.sendContactSaved(
walletId = userWallet.walletId,
contactId = contact.id.value,
isEdit = params.contactId != null,
)
params.onBackClick()
},
)
@ -356,6 +358,10 @@ internal class EditContactModel @Inject constructor(
}
private fun onDeleteClick() {
showDeleteContactDialog(fromLastAddressRemoval = false)
}
private fun showDeleteContactDialog(fromLastAddressRemoval: Boolean) {
messageSender.send(
DialogMessage(
message = resourceReference(R.string.address_book_delete_contact_description),
@ -363,7 +369,7 @@ internal class EditContactModel @Inject constructor(
EventMessageAction(
title = resourceReference(R.string.common_delete),
isWarning = true,
onClick = ::deleteContact,
onClick = { deleteContact(fromLastAddressRemoval = fromLastAddressRemoval) },
)
},
),
@ -396,8 +402,12 @@ internal class EditContactModel @Inject constructor(
addressInfoNavigation.dismiss()
val isLastAddress = stateController.uiState.value.addresses.size <= 1
if (isLastAddress && params.contactId != null) {
onDeleteClick()
// Removing the last address deletes the whole contact — analytics is emitted from the confirmed delete.
showDeleteContactDialog(fromLastAddressRemoval = true)
} else {
contactWalletId()?.let { walletId ->
analyticsSender.sendAddressRemoved(walletId = walletId, contactId = params.contactId?.value.orEmpty())
}
stateController.update(RemoveValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES))
}
}
@ -406,6 +416,10 @@ internal class EditContactModel @Inject constructor(
// region Helpers
private fun sendContactScreenOpenedEvent() {
analyticsSender.sendContactScreenOpened(contactId = params.contactId?.value.orEmpty(), scope = modelScope)
}
private fun addAddress(address: ValidatedAddress) {
stateController.update(AddValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES))
}
@ -415,12 +429,12 @@ internal class EditContactModel @Inject constructor(
return params.contactId == null && unlockedWalletsCount > 1
}
private suspend fun validateName(name: String, walletId: UserWalletId): TextReference? {
private suspend fun validateName(name: String, walletId: UserWalletId): ContactNameValidationError? {
if (name.isBlank()) return null
if (name == loadedContact.value?.name?.value) return null
val error = contactNameValidator.validate(walletId, name).leftOrNull() ?: return null
if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null
return ContactNameErrorConverter().convert(error)
return error
}
private fun refreshSaveButton() {
@ -456,16 +470,28 @@ internal class EditContactModel @Inject constructor(
}
}
private fun deleteContact() {
private fun deleteContact(fromLastAddressRemoval: Boolean) {
val contactId = params.contactId ?: return
val walletId = contactWalletId()
// The address removal precedes the backend delete; the contact-deleted event follows a successful response.
if (fromLastAddressRemoval && walletId != null) {
analyticsSender.sendAddressRemoved(walletId = walletId, contactId = contactId.value)
}
modelScope.launch {
deleteContactUseCase(contactId).fold(
ifLeft = { showDeleteError() },
ifRight = { params.onBackClick() },
ifRight = {
if (walletId != null) {
analyticsSender.sendContactDeleted(walletId = walletId, contactId = contactId.value)
}
params.onBackClick()
},
)
}
}
private fun contactWalletId(): UserWalletId? = loadedContact.value?.walletId ?: selectedWallet.value?.walletId
private fun showDiscardDialog() {
messageSender.send(
DialogMessage(

View file

@ -15,6 +15,8 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.ContactSelectionTrigger
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.SelectedContact
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListScreenOpened.Source
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import com.tangem.features.addressbook.list.state.AddressBookListStateController
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer
@ -42,6 +44,7 @@ internal class AddressBookListModel @Inject constructor(
private val stateController: AddressBookListStateController,
private val router: Router,
private val contactSelectionTrigger: ContactSelectionTrigger,
private val analyticsSender: AddressBookAnalyticsSender,
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
getWalletsUseCase: GetWalletsUseCase,
) : Model() {
@ -87,6 +90,14 @@ internal class AddressBookListModel @Inject constructor(
.onEach(::updateState)
.flowOn(dispatchers.default)
.launchIn(modelScope)
sendContactListScreenOpenedEvent()
}
fun deliverSelection(contact: SelectedContact) {
contactSelectionTrigger.trigger(contact)
selectorNavigation.dismiss()
router.pop()
}
private fun updateState(inputs: ListInputs) {
@ -134,6 +145,8 @@ internal class AddressBookListModel @Inject constructor(
}
private fun onPickContact(contact: MatchedContact) {
// Reported on tap, before the address is substituted; onPickContact is only invoked in selector (Send) mode.
analyticsSender.sendContactSelectedInSend(contactId = contact.contactId, scope = modelScope)
val singleEntry = contact.entries.singleOrNull()
if (singleEntry != null) {
deliverSelection(contact.toSelectedContact(singleEntry))
@ -142,10 +155,22 @@ internal class AddressBookListModel @Inject constructor(
}
}
fun deliverSelection(contact: SelectedContact) {
contactSelectionTrigger.trigger(contact)
selectorNavigation.dismiss()
router.pop()
private fun sendContactListScreenOpenedEvent() {
allContacts
.take(count = 1)
.onEach { contacts ->
analyticsSender.sendContactListScreenOpened(
source = params.mode.toAnalyticsSource(),
contactsCount = contacts.size,
scope = modelScope,
)
}
.launchIn(modelScope)
}
private fun AddressBookRoute.ListMode.toAnalyticsSource(): Source = when (this) {
AddressBookRoute.ListMode.Default -> Source.Settings
is AddressBookRoute.ListMode.Selector -> Source.SendFlow
}
private data class ListInputs(

View file

@ -12,21 +12,19 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.addressbook.usecase.CheckAddressDuplicateUseCase
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.common.AddressMemoValidator
import com.tangem.features.addressbook.common.SelectNetworksResultHolder
import com.tangem.features.addressbook.common.SupportedNetworksMatcher
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
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 io.mockk.*
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
@ -45,6 +43,7 @@ internal class AddAddressModelTest {
private val clipboardManager: ClipboardManager = mockk()
private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk()
private val checkAddressDuplicateUseCase: CheckAddressDuplicateUseCase = mockk()
private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true)
private val router: Router = mockk(relaxed = true)
private val selectNetworksResultHolder = SelectNetworksResultHolder()
@ -58,6 +57,7 @@ internal class AddAddressModelTest {
clipboardManager,
listenToQrScanningUseCase,
checkAddressDuplicateUseCase,
analyticsSender,
router,
)
selectNetworksResultHolder.clear()
@ -618,6 +618,81 @@ internal class AddAddressModelTest {
}
}
@Test
fun `WHEN model created THEN AddressScreenOpened sent`() = runTest {
// Act
createModel(testScope = this)
advanceUntilIdle()
// Assert — the add-address screen reports itself opened on creation, not the button that navigates to it.
verify(exactly = 1) { analyticsSender.sendAddressScreenOpened() }
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class AddressInvalidEvent {
@Test
fun `GIVEN edit mode WHEN address matches no network THEN AddressInvalid sent with contactId`() = runTest {
// Arrange
every { supportedNetworksMatcher.match(ADDRESS) } returns emptyList()
val model = createModel(testScope = this, params = params(walletId = "aa", excludeContactId = "c-1"))
advanceUntilIdle()
// Act
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Assert
verify(exactly = 1) { analyticsSender.sendAddressInvalid(walletId = UserWalletId("aa"), contactId = "c-1") }
}
@Test
fun `GIVEN create mode WHEN address matches no network THEN AddressInvalid sent with empty contactId`() =
runTest {
// Arrange
every { supportedNetworksMatcher.match(ADDRESS) } returns emptyList()
val model = createModel(testScope = this, params = params(walletId = "aa", excludeContactId = null))
advanceUntilIdle()
// Act
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Assert
verify(exactly = 1) { analyticsSender.sendAddressInvalid(walletId = UserWalletId("aa"), contactId = "") }
}
@Test
fun `GIVEN valid address WHEN typed THEN AddressInvalid not sent`() = runTest {
// Arrange
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum)
val model = createModel(testScope = this, params = params(walletId = "aa"))
advanceUntilIdle()
// Act
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Assert
verify(exactly = 0) { analyticsSender.sendAddressInvalid(any(), any()) }
}
@Test
fun `GIVEN empty field WHEN cleared THEN AddressInvalid not sent`() = runTest {
// Arrange
val model = createModel(testScope = this, params = params(walletId = "aa"))
advanceUntilIdle()
// Act
model.state.value.onAddressChange("")
advanceUntilIdle()
// Assert — a blank field is not a validation failure.
verify(exactly = 0) { analyticsSender.sendAddressInvalid(any(), any()) }
}
}
private fun params(
walletId: String? = null,
excludeContactId: String? = null,
@ -663,6 +738,7 @@ internal class AddAddressModelTest {
stateController = AddAddressStateController(),
selectNetworksResultHolder = selectNetworksResultHolder,
checkAddressDuplicateUseCase = checkAddressDuplicateUseCase,
analyticsSender = analyticsSender,
router = router,
).also { model = it }
}

View file

@ -0,0 +1,158 @@
package com.tangem.features.addressbook.block.model
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.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.AddressBookContactsBlockComponent
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.block.state.ContactsBlockStateController
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ContactsBlockModelTest {
private val getContactsUseCase: GetContactsUseCase = mockk()
private val getWalletsUseCase: GetWalletsUseCase = mockk()
private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true)
private val network: Network = mockk { every { rawId } returns ETHEREUM }
private var model: ContactsBlockModel? = null
@BeforeEach
fun resetMocks() {
clearMocks(getContactsUseCase, getWalletsUseCase, analyticsSender)
every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns
flowOf(linkedMapOf<UserWalletId, UserWallet>())
}
@AfterEach
fun tearDown() {
model?.onDestroy()
model = null
}
@Test
fun `GIVEN matching contacts WHEN block populated THEN SendFlowWidgetShown sent once`() = runTest {
// Arrange
every { getContactsUseCase(query = any(), userWalletId = null) } returns
flowOf(listOf(contact(id = "1"), contact(id = "2")))
// Act
createModel(testScope = this)
advanceUntilIdle()
// Assert — reported once even though two contacts populate the block.
verify(exactly = 1) { analyticsSender.sendSendFlowWidgetShown(scope = any()) }
}
@Test
fun `GIVEN no matching contacts WHEN block empty THEN SendFlowWidgetShown not sent`() = runTest {
// Arrange
every { getContactsUseCase(query = any(), userWalletId = null) } returns flowOf(emptyList())
// Act
val model = createModel(testScope = this)
advanceUntilIdle()
// Assert — the widget is hidden, so nothing is reported.
assertThat(model.state.value).isEqualTo(ContactsBlockUM.Hidden)
verify(exactly = 0) { analyticsSender.sendSendFlowWidgetShown(any()) }
}
@Test
fun `GIVEN populated block WHEN contact tapped THEN ContactSelectedInSend sent AND click propagated`() = runTest {
// Arrange
var clicked: MatchedContact? = null
every { getContactsUseCase(query = any(), userWalletId = null) } returns flowOf(listOf(contact(id = "42")))
val model = createModel(testScope = this, onContactClick = { clicked = it })
advanceUntilIdle()
// Act
(model.state.value as ContactsBlockUM.Content).contacts.first().onClick()
// Assert
verify(exactly = 1) { analyticsSender.sendContactSelectedInSend(contactId = "42", scope = any()) }
assertThat(clicked?.contactId).isEqualTo("42")
}
private fun contact(id: String): Contact = Contact(
id = ContactId(id),
walletId = UserWalletId("a"),
name = ContactName("Contact $id").getOrNull()!!,
icon = "",
iconColor = CryptoPortfolioIcon.Color.Azure.name,
createdAt = TIMESTAMP,
updatedAt = TIMESTAMP,
addresses = listOf(
AddressEntry(
id = AddressEntryId("e-$id"),
address = "0x$id",
networkId = Network.RawID(ETHEREUM),
networkName = "Ethereum",
memo = null,
signature = "sig",
),
),
)
private fun createModel(
testScope: TestScope,
onContactClick: (MatchedContact) -> Unit = {},
onSeeAllClick: () -> Unit = {},
): ContactsBlockModel {
val params = AddressBookContactsBlockComponent.Params(
network = network,
queryFlow = MutableStateFlow(""),
onContactClick = onContactClick,
onSeeAllClick = onSeeAllClick,
)
return ContactsBlockModel(
paramsContainer = MutableParamsContainer(value = params),
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
stateController = ContactsBlockStateController(),
analyticsSender = analyticsSender,
getContactsUseCase = getContactsUseCase,
getWalletsUseCase = getWalletsUseCase,
).also { model = it }
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
private companion object {
const val ETHEREUM = "ethereum"
const val TIMESTAMP = "2026-06-10T14:30:00.000Z"
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.features.addressbook.common
import com.tangem.common.routing.entity.AddressBookOpenMode
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.addressbook.error.AddressBookSyncError
import com.tangem.domain.addressbook.error.SaveContactError
@ -13,6 +12,7 @@ import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListSc
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactSaved
import com.tangem.features.addressbook.analytics.AddressBookEvents.SaveErrorShown.ErrorType
import com.tangem.test.core.ProvideTestModels
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
@ -37,6 +37,7 @@ internal class AddressBookAnalyticsSenderTest {
private val sender = AddressBookAnalyticsSender(
analyticsEventHandler = analyticsEventHandler,
userWalletsListRepository = userWalletsListRepository,
dispatcherProvider = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
@ -50,13 +51,14 @@ internal class AddressBookAnalyticsSenderTest {
@ProvideTestModels
fun sendContactListScreenOpened(model: ScreenOpenedModel) = runTest {
// Act
sender.sendContactListScreenOpened(mode = model.mode, scope = this)
sender.sendContactListScreenOpened(source = model.source, contactsCount = model.contactsCount, scope = this)
advanceUntilIdle()
// Assert
val expected = AddressBookEvents.ContactListScreenOpened(
walletId = EXPECTED_WALLET_ID,
source = model.expectedSource,
source = model.source,
contactsCount = model.contactsCount,
)
verify(exactly = 1) { analyticsEventHandler.send(expected) }
}
@ -69,7 +71,7 @@ internal class AddressBookAnalyticsSenderTest {
advanceUntilIdle()
// Assert
val expected = AddressBookEvents.AddContactTapped(
val expected = AddContactTapped(
walletId = EXPECTED_WALLET_ID,
source = model.expectedSource,
)
@ -83,7 +85,7 @@ internal class AddressBookAnalyticsSenderTest {
sender.sendContactSaved(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID, isEdit = model.isEdit)
// Assert
val expected = AddressBookEvents.ContactSaved(
val expected = ContactSaved(
walletId = EXPECTED_WALLET_ID,
contactId = CONTACT_ID,
mode = model.expectedMode,
@ -129,7 +131,92 @@ internal class AddressBookAnalyticsSenderTest {
verify(exactly = 1) { analyticsEventHandler.send(AddressBookEvents.AddressScreenOpened) }
}
internal data class ScreenOpenedModel(val mode: AddressBookOpenMode, val expectedSource: Source)
@Test
fun `WHEN sendContactScreenOpened THEN event sent with selected wallet`() = runTest {
// Act
sender.sendContactScreenOpened(contactId = CONTACT_ID, scope = this)
advanceUntilIdle()
// Assert
val expected = AddressBookEvents.ContactScreenOpened(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
verify(exactly = 1) { analyticsEventHandler.send(expected) }
}
@Test
fun `WHEN sendSendFlowWidgetShown THEN event sent with selected wallet`() = runTest {
// Act
sender.sendSendFlowWidgetShown(scope = this)
advanceUntilIdle()
// Assert
val expected = AddressBookEvents.SendFlowWidgetShown(walletId = EXPECTED_WALLET_ID)
verify(exactly = 1) { analyticsEventHandler.send(expected) }
}
@Test
fun `WHEN sendContactSelectedInSend THEN event sent with selected wallet`() = runTest {
// Act
sender.sendContactSelectedInSend(contactId = CONTACT_ID, scope = this)
advanceUntilIdle()
// Assert
val expected = AddressBookEvents.ContactSelectedInSend(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
verify(exactly = 1) { analyticsEventHandler.send(expected) }
}
@Test
fun `WHEN onAddressSubstitutedInSend THEN event sent`() {
// Act
sender.onAddressSubstitutedInSend(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
// Assert
val expected = AddressBookEvents.AddressSubstitutedInSend(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
verify(exactly = 1) { analyticsEventHandler.send(expected) }
}
@ParameterizedTest
@MethodSource("provideContactIdModels")
fun sendAddressInvalid(contactId: String) {
// Act
sender.sendAddressInvalid(walletId = EXPECTED_WALLET_ID, contactId = contactId)
// Assert
val expected = AddressBookEvents.AddressInvalid(walletId = EXPECTED_WALLET_ID, contactId = contactId)
verify(exactly = 1) { analyticsEventHandler.send(expected) }
}
@ParameterizedTest
@MethodSource("provideNullableContactIdModels")
fun sendDuplicateNameErrorShown(contactId: String?) {
// Act
sender.sendDuplicateNameErrorShown(walletId = EXPECTED_WALLET_ID, contactId = contactId)
// Assert
val expected = AddressBookEvents.DuplicateNameErrorShown(walletId = EXPECTED_WALLET_ID, contactId = contactId)
verify(exactly = 1) { analyticsEventHandler.send(expected) }
}
@Test
fun `WHEN sendAddressRemoved THEN event sent`() {
// Act
sender.sendAddressRemoved(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
// Assert
val expected = AddressBookEvents.AddressRemoved(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
verify(exactly = 1) { analyticsEventHandler.send(expected) }
}
@Test
fun `WHEN sendContactDeleted THEN event sent`() {
// Act
sender.sendContactDeleted(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
// Assert
val expected = AddressBookEvents.ContactDeleted(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID)
verify(exactly = 1) { analyticsEventHandler.send(expected) }
}
internal data class ScreenOpenedModel(val source: Source, val contactsCount: Int)
internal data class AddContactModel(val fromSendSuccess: Boolean, val expectedSource: AddContactTapped.Source)
@ -138,15 +225,8 @@ internal class AddressBookAnalyticsSenderTest {
internal data class SaveErrorModel(val error: SaveContactError, val expectedType: ErrorType?)
private fun provideTestModels() = listOf(
ScreenOpenedModel(mode = AddressBookOpenMode.Default, expectedSource = Source.Settings),
ScreenOpenedModel(
mode = AddressBookOpenMode.ContactSelection(networkId = "ethereum"),
expectedSource = Source.SendFlow,
),
ScreenOpenedModel(
mode = AddressBookOpenMode.WithContactCreation(address = "0xABC", networkId = "ethereum"),
expectedSource = Source.SendFlow,
),
ScreenOpenedModel(source = Source.Settings, contactsCount = 0),
ScreenOpenedModel(source = Source.SendFlow, contactsCount = 3),
)
private fun provideAddContactModels() = listOf(
@ -174,6 +254,12 @@ internal class AddressBookAnalyticsSenderTest {
SaveErrorModel(error = SaveContactError.Name(mockk()), expectedType = null),
)
// Create sends an empty contact id, edit sends the contact id.
private fun provideContactIdModels() = listOf("", CONTACT_ID)
// Duplicate-name allows a null contact id (create) as well as an edited contact's id.
private fun provideNullableContactIdModels() = listOf(null, CONTACT_ID)
private companion object {
val EXPECTED_WALLET_ID = UserWalletId("0011223344")
const val CONTACT_ID = "contact-42"

View file

@ -130,34 +130,9 @@ internal class EditContactModelTest {
}
@Test
fun `GIVEN new contact without predefined address WHEN created THEN AddContactTapped sent from settings`() =
runTest {
// Act
createModel(testScope = this, params = createParams(contactId = null, predefinedAddress = null))
advanceUntilIdle()
// Assert
verify(exactly = 1) { analyticsSender.sendAddContactTapped(fromSendSuccess = false, scope = any()) }
}
@Test
fun `GIVEN new contact with predefined address WHEN created THEN AddContactTapped sent from send success`() =
runTest {
// Arrange
val predefined = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))
// Act
createModel(testScope = this, params = createParams(predefinedAddress = predefined))
advanceUntilIdle()
// Assert
verify(exactly = 1) { analyticsSender.sendAddContactTapped(fromSendSuccess = true, scope = any()) }
}
@Test
fun `GIVEN existing contactId WHEN model created THEN AddContactTapped not sent`() = runTest {
// Act
createModel(testScope = this, params = createParams(contactId = ContactId(value = "contact-id")))
fun `GIVEN editor opened WHEN model created THEN AddContactTapped not sent from editor`() = runTest {
// The Add-Contact-Tapped funnel event belongs to the "Add contact" button handlers, not the editor screen.
createModel(testScope = this, params = createParams(contactId = null))
advanceUntilIdle()
// Assert
@ -283,7 +258,6 @@ internal class EditContactModelTest {
// Assert
assertThat(addClicked).isTrue()
verify(exactly = 0) { messageSender.send(any<DialogMessage>()) }
verify(exactly = 1) { analyticsSender.sendAddressScreenOpened() }
}
@Test
@ -311,7 +285,6 @@ internal class EditContactModelTest {
assertThat(model.state.value.isAddAddressEnabled).isFalse()
assertThat(addClicked).isFalse()
verify { messageSender.send(any<DialogMessage>()) }
verify(exactly = 0) { analyticsSender.sendAddressScreenOpened() }
}
@Test
@ -883,6 +856,153 @@ internal class EditContactModelTest {
verify(exactly = 0) { messageSender.send(any<DialogMessage>()) }
}
@Test
fun `GIVEN new contact WHEN created THEN ContactScreenOpened sent with empty contactId`() = runTest {
// Act
createModel(testScope = this, params = createParams(contactId = null))
advanceUntilIdle()
// Assert
verify(exactly = 1) { analyticsSender.sendContactScreenOpened(contactId = "", scope = any()) }
}
@Test
fun `GIVEN existing contact WHEN created THEN ContactScreenOpened sent with contactId`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
every { getContactByIdUseCase(ContactId("c-1")) } returns
MutableStateFlow(existingContact(walletId = "aa", name = "Alice", address = "0xABC"))
// Act
createModel(testScope = this, params = createParams(contactId = ContactId("c-1")))
advanceUntilIdle()
// Assert
verify(exactly = 1) { analyticsSender.sendContactScreenOpened(contactId = "c-1", scope = any()) }
}
@Test
fun `GIVEN duplicate name in selected wallet WHEN name entered THEN DuplicateNameErrorShown sent`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
coEvery { contactNameValidator.validate(any(), any()) } returns ContactNameValidationError.Duplicate.left()
val model = createModel(testScope = this, params = createParams(contactId = null))
advanceUntilIdle()
// Act
model.state.value.onNameChange("Satoshi")
advanceUntilIdle()
// Assert — create mode reports a null contact id.
verify(exactly = 1) {
analyticsSender.sendDuplicateNameErrorShown(walletId = walletA.walletId, contactId = null)
}
}
@Test
fun `GIVEN unique name WHEN name entered THEN DuplicateNameErrorShown not sent`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onNameChange("Satoshi")
advanceUntilIdle()
// Assert
verify(exactly = 0) { analyticsSender.sendDuplicateNameErrorShown(any(), any()) }
}
@Test
fun `GIVEN several addresses WHEN one is deleted THEN AddressRemoved sent AND ContactDeleted not sent`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
every { getContactByIdUseCase(ContactId("c-1")) } returns MutableStateFlow(twoAddressContact())
val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1")))
advanceUntilIdle()
// Act
model.createAddressInfoParams("0xAAA").onDeleteAddress()
advanceUntilIdle()
// Assert
verify(exactly = 1) { analyticsSender.sendAddressRemoved(walletId = walletA.walletId, contactId = "c-1") }
verify(exactly = 0) { analyticsSender.sendContactDeleted(any(), any()) }
}
@Test
fun `GIVEN last address WHEN deleted AND confirmed THEN AddressRemoved then ContactDeleted sent`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
every { getContactByIdUseCase(ContactId("c-1")) } returns
MutableStateFlow(existingContact(walletId = "aa", name = "Alice", address = "0xABC"))
coEvery { deleteContactUseCase(ContactId("c-1")) } returns Unit.right()
val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1")))
advanceUntilIdle()
// Act — deleting the only address prompts a contact deletion; confirming performs it.
model.createAddressInfoParams("0xABC").onDeleteAddress()
val dialog = slot<DialogMessage>()
verify { messageSender.send(capture(dialog)) }
dialog.captured.firstAction.onClick()
advanceUntilIdle()
// Assert — both events fire: the address removal, then the backend-confirmed deletion.
verify(exactly = 1) { analyticsSender.sendAddressRemoved(walletId = walletA.walletId, contactId = "c-1") }
verify(exactly = 1) { analyticsSender.sendContactDeleted(walletId = walletA.walletId, contactId = "c-1") }
}
@Test
fun `GIVEN existing contact WHEN explicitly deleted THEN ContactDeleted sent AND AddressRemoved not sent`() =
runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
every { getContactByIdUseCase(ContactId("c-1")) } returns
MutableStateFlow(existingContact(walletId = "aa", name = "Alice", address = "0xABC"))
coEvery { deleteContactUseCase(ContactId("c-1")) } returns Unit.right()
val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1")))
advanceUntilIdle()
// Act
model.state.value.onDeleteClick?.invoke()
val dialog = slot<DialogMessage>()
verify { messageSender.send(capture(dialog)) }
dialog.captured.firstAction.onClick()
advanceUntilIdle()
// Assert — explicit deletion is not an address removal.
verify(exactly = 1) { analyticsSender.sendContactDeleted(walletId = walletA.walletId, contactId = "c-1") }
verify(exactly = 0) { analyticsSender.sendAddressRemoved(any(), any()) }
}
private fun twoAddressContact(): Contact = existingContact(walletId = "aa", name = "Alice", address = "0xAAA").copy(
addresses = listOf(
AddressEntry(
id = AddressEntryId("e-1"),
address = "0xAAA",
networkId = Network.RawID("ethereum"),
networkName = "Ethereum",
memo = null,
signature = "sig",
),
AddressEntry(
id = AddressEntryId("e-2"),
address = "0xBBB",
networkId = Network.RawID("bsc"),
networkName = "BSC",
memo = null,
signature = "sig",
),
),
)
private fun existingContact(walletId: String, name: String, address: String): Contact = Contact(
id = ContactId("c-1"),
walletId = UserWalletId(walletId),

View file

@ -4,18 +4,15 @@ 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.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.addressbook.model.*
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
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListScreenOpened.Source
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import com.tangem.features.addressbook.list.state.AddressBookListStateController
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
@ -25,6 +22,7 @@ import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
@ -32,7 +30,10 @@ import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ -40,6 +41,7 @@ internal class AddressBookListModelTest {
private val router: Router = mockk(relaxed = true)
private val contactSelectionTrigger: ContactSelectionTrigger = mockk(relaxed = true)
private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true)
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor = mockk()
private val getWalletsUseCase: GetWalletsUseCase = mockk()
@ -47,7 +49,7 @@ internal class AddressBookListModelTest {
@BeforeEach
fun resetMocks() {
clearMocks(getVerifiedContactsInteractor, getWalletsUseCase)
clearMocks(getVerifiedContactsInteractor, getWalletsUseCase, analyticsSender, contactSelectionTrigger)
every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns
flowOf(linkedMapOf<UserWalletId, UserWallet>())
}
@ -119,6 +121,57 @@ internal class AddressBookListModelTest {
assertThat(clickedId).isEqualTo("42")
}
@Test
fun `GIVEN default mode WHEN created THEN ContactListScreenOpened sent with settings source and all-tab count`() =
runTest {
// Arrange
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
flowOf(listOf(verifiedContact(id = "1", name = "Alice"), verifiedContact(id = "2", name = "Bob")))
// Act
createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
advanceUntilIdle()
// Assert — count comes from the list's own contacts subscription.
verify(exactly = 1) {
analyticsSender.sendContactListScreenOpened(source = Source.Settings, contactsCount = 2, scope = any())
}
}
@Test
fun `GIVEN selector mode WHEN created THEN ContactListScreenOpened sent with send_flow source`() = runTest {
// Arrange
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns flowOf(emptyList())
// Act
createModel(testScope = this, mode = AddressBookRoute.ListMode.Selector(networkId = "ethereum"))
advanceUntilIdle()
// Assert — the "See all" list opened from Send reports send_flow, even with zero contacts.
verify(exactly = 1) {
analyticsSender.sendContactListScreenOpened(source = Source.SendFlow, contactsCount = 0, scope = any())
}
}
@Test
fun `GIVEN selector mode WHEN contact picked THEN ContactSelectedInSend sent`() = runTest {
// Arrange — a contact with a single ethereum address matches the selection network.
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
flowOf(listOf(verifiedContact(id = "42", name = "Alice")))
val model = createModel(
testScope = this,
mode = AddressBookRoute.ListMode.Selector(networkId = "ethereum"),
)
advanceUntilIdle()
// Act — tapping a contact in selector mode picks it.
(model.state.value as AddressBookListUM.Content).contacts.first().onClick()
advanceUntilIdle()
// Assert
verify(exactly = 1) { analyticsSender.sendContactSelectedInSend(contactId = "42", scope = any()) }
}
private fun verifiedContact(id: String, name: String): VerifiedContact = VerifiedContact(
contact = Contact(
id = ContactId(id),
@ -159,6 +212,7 @@ internal class AddressBookListModelTest {
stateController = AddressBookListStateController(),
router = router,
contactSelectionTrigger = contactSelectionTrigger,
analyticsSender = analyticsSender,
getVerifiedContactsInteractor = getVerifiedContactsInteractor,
getWalletsUseCase = getWalletsUseCase,
).also { model = it }