diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookSendAnalytics.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookSendAnalytics.kt new file mode 100644 index 0000000000..0a3441b1cc --- /dev/null +++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookSendAnalytics.kt @@ -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) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt index 18ae9ce50d..961c230e40 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt @@ -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, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/analytics/AddressBookEvents.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/analytics/AddressBookEvents.kt index d371791ca2..9a1448a028 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/analytics/AddressBookEvents.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/analytics/AddressBookEvents.kt @@ -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, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt index 3ad200dc0a..88648c1649 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt @@ -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() - val state: StateFlow 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) + } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookAnalyticsSender.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookAnalyticsSender.kt index c2978e7d08..591d502920 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookAnalyticsSender.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookAnalyticsSender.kt @@ -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 - } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt index 248a4fc71d..8849c3e4c0 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt @@ -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, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt index 30378a73a6..3ad2adeda7 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt @@ -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 } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt index b6ff6cdd5c..7f76faa746 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt @@ -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( diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt index 5271275291..80625ad2e7 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt @@ -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( diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt index 9d473ed8d8..adc273c7b7 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt @@ -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 } } diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModelTest.kt new file mode 100644 index 0000000000..18bb43c46d --- /dev/null +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModelTest.kt @@ -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()) + } + + @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" + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/AddressBookAnalyticsSenderTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/AddressBookAnalyticsSenderTest.kt index 0e6fc01cc1..1c2a4af1de 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/AddressBookAnalyticsSenderTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/AddressBookAnalyticsSenderTest.kt @@ -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" diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt index 0a5558d152..afd096d31c 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt @@ -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()) } - 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()) } - verify(exactly = 0) { analyticsSender.sendAddressScreenOpened() } } @Test @@ -883,6 +856,153 @@ internal class EditContactModelTest { verify(exactly = 0) { messageSender.send(any()) } } + @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() + 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() + 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), diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt index 198584da64..7b948abefa 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt @@ -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()) } @@ -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 } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt index 6c3340ec95..df4f3b2b1c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt @@ -37,6 +37,7 @@ import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.addressbook.AddressBookSendAnalytics import com.tangem.features.addressbook.ContactSelectionListener import com.tangem.features.addressbook.MatchedContact import com.tangem.features.addressbook.SelectedContact @@ -86,6 +87,7 @@ internal class SendDestinationModel @Inject constructor( private val getBackupProblematicWalletForAddressUseCase: GetBackupProblematicWalletForAddressUseCase, private val sendDestinationAlertFactory: SendDestinationAlertFactory, private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase, + private val addressBookSendAnalytics: AddressBookSendAnalytics, getContactsUseCase: GetContactsUseCase, contactSelectionListener: ContactSelectionListener, ) : Model(), SendDestinationClickIntents { @@ -182,6 +184,7 @@ internal class SendDestinationModel @Inject constructor( fun applySelectedContact(contact: SelectedContact) { addressSelectorNavigation.dismiss() + addressBookSendAnalytics.onAddressSubstitutedInSend(walletId = userWalletId, contactId = contact.contactId) _uiState.update(SendDestinationAddressTransformer(address = contact.address, isPasted = true)) _uiState.update(SendDestinationContactTransformer(contactName = contact.name, contactIcon = contact.icon)) diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt index e059bc5313..05255a8ce9 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt @@ -1,5 +1,8 @@ package com.tangem.features.send.subcomponents.destination.model +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import arrow.core.left import arrow.core.right import com.google.common.truth.Truth.assertThat @@ -30,6 +33,7 @@ import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.addressbook.AddressBookSendAnalytics import com.tangem.features.addressbook.ContactSelectionListener import com.tangem.features.addressbook.MatchedContact import com.tangem.features.addressbook.SelectedContact @@ -38,6 +42,7 @@ import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.subcomponents.destination.DestinationRoute import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFactory @@ -46,10 +51,6 @@ import com.tangem.features.send.subcomponents.destination.analytics.SendDestinat import com.tangem.features.send.testDispatcherProvider import com.tangem.test.core.ProvideTestModels import io.mockk.* -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -93,6 +94,7 @@ internal class SendDestinationModelTest { private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true) private val getContactsUseCase: GetContactsUseCase = 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) @BeforeEach @@ -320,6 +322,25 @@ internal class SendDestinationModelTest { assertThat(content(sut).addressTextField.contactName).isEqualTo("Bob") } + @Test + fun `GIVEN a selected contact WHEN applySelectedContact THEN AddressSubstitutedInSend reported`() = runTest { + // Arrange + coEvery { + validateWalletAddressUseCase(any(), any(), any(), any>(), any()) + } returns AddressValidation.Success.Valid.right() + val sut = buildModel() + advanceUntilIdle() + + // Act + sut.applySelectedContact(selectedContact(name = "Bob", address = "0xBob")) + advanceUntilIdle() + + // Assert + verify(exactly = 1) { + addressBookSendAnalytics.onAddressSubstitutedInSend(walletId = testUserWalletId, contactId = "c1") + } + } + @Test fun `GIVEN a contact is pre-set in state AND route is edit mode WHEN model initializes THEN the contact is reset`() = runTest { @@ -331,8 +352,8 @@ internal class SendDestinationModelTest { addressTextField = DestinationTextFieldUM.RecipientAddress( value = "0xDave", keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Text), - placeholder = com.tangem.core.ui.extensions.stringReference(""), - label = com.tangem.core.ui.extensions.stringReference(""), + placeholder = stringReference(""), + label = stringReference(""), isValuePasted = false, contactName = "Dave", ), @@ -549,6 +570,7 @@ internal class SendDestinationModelTest { getBackupProblematicWalletForAddressUseCase = getBackupProblematicWalletForAddressUseCase, sendDestinationAlertFactory = sendDestinationAlertFactory, sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase, + addressBookSendAnalytics = addressBookSendAnalytics, getContactsUseCase = getContactsUseCase, contactSelectionListener = contactSelectionListener, )