Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-01 18:04:23 +01:00
parent 82b7c5afc6
commit e2a7b90658
6 changed files with 471 additions and 3 deletions

View file

@ -17,6 +17,10 @@ sealed class AddressBookEvents(
) : AnalyticsEvent(ADDRESS_BOOK_CATEGORY, event, params) {
// region Contact creation
data object SaveToButtonClicked : AddressBookEvents(event = "Button - Save To")
data object AddressScreenOpened : AddressBookEvents(event = "Address Screen Opened")
class ContactListScreenOpened(
walletId: UserWalletId,
source: Source,

View file

@ -0,0 +1,112 @@
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.analytics.AddressBookEvents
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListScreenOpened.Source
import com.tangem.features.addressbook.analytics.AddressBookEvents.SaveErrorShown.ErrorType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class AddressBookAnalyticsSender @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val userWalletsListRepository: UserWalletsListRepository,
) {
fun sendContactListScreenOpened(mode: AddressBookOpenMode, scope: CoroutineScope) {
scope.launch {
analyticsEventHandler.send(
AddressBookEvents.ContactListScreenOpened(
walletId = selectedWalletId(),
source = mode.toAnalyticsSource(),
),
)
}
}
fun sendAddContactTapped(fromSendSuccess: Boolean, scope: CoroutineScope) {
scope.launch {
analyticsEventHandler.send(
AddressBookEvents.AddContactTapped(
walletId = selectedWalletId(),
source = if (fromSendSuccess) {
AddressBookEvents.AddContactTapped.Source.SendSuccess
} else {
AddressBookEvents.AddContactTapped.Source.Settings
},
),
)
}
}
fun sendContactSaved(walletId: UserWalletId, contactId: String, isEdit: Boolean) {
analyticsEventHandler.send(
AddressBookEvents.ContactSaved(
walletId = walletId,
contactId = contactId,
mode = if (isEdit) {
AddressBookEvents.ContactSaved.Mode.Edit
} else {
AddressBookEvents.ContactSaved.Mode.Create
},
),
)
}
/**
* Fired when a save failure is surfaced to the user. [contactId] is set only in edit mode. Validation failures
* ([SaveContactError.Name]/[SaveContactError.Address]) are shown inline rather than as a save error, so they do
* not produce this event.
*/
fun sendSaveErrorShown(walletId: UserWalletId, contactId: String?, error: SaveContactError) {
val errorType = error.toErrorType() ?: return
analyticsEventHandler.send(
AddressBookEvents.SaveErrorShown(
walletId = walletId,
contactId = contactId,
errorType = errorType,
),
)
}
private fun SaveContactError.toErrorType(): ErrorType? = when (this) {
is SaveContactError.Signing -> ErrorType.Signing
// Network means the backend was unreachable; every other backend outcome (5xx, 412, other codes) is Server.
is SaveContactError.Backend -> when (error) {
AddressBookSyncError.Network -> ErrorType.Network
else -> ErrorType.Server
}
is SaveContactError.Name,
is SaveContactError.Address,
-> null
}
fun sendSaveToButtonClicked() {
analyticsEventHandler.send(AddressBookEvents.SaveToButtonClicked)
}
fun sendAddressScreenOpened() {
analyticsEventHandler.send(AddressBookEvents.AddressScreenOpened)
}
private suspend fun selectedWalletId(): UserWalletId = userWalletsListRepository.selectedUserWallet
.filterNotNull()
.first()
.walletId
private fun AddressBookOpenMode.toAnalyticsSource(): Source = when (this) {
AddressBookOpenMode.Default -> Source.Settings
is AddressBookOpenMode.ContactSelection,
is AddressBookOpenMode.WithContactCreation,
-> Source.SendFlow
}
}

View file

@ -30,6 +30,7 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
private val childFactory: AddressBookChildFactory,
private val resultHolder: AddressBookResultHolder,
private val selectNetworksResultHolder: SelectNetworksResultHolder,
private val analyticsSender: AddressBookAnalyticsSender,
) : AddressBookComponent, AppComponentContext by context {
private val navigation = StackNavigation<AddressBookRoute>()
@ -38,6 +39,8 @@ 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)
}
private val clickIntents = object : AddressBookClickIntents {

View file

@ -21,6 +21,7 @@ import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.common.AddressBookResultHolder
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
import com.tangem.features.addressbook.editcontact.state.EditContactStateController
@ -50,6 +51,7 @@ internal class EditContactModel @Inject constructor(
private val userWalletsListRepository: UserWalletsListRepository,
private val validateContactNameUseCase: ValidateContactNameUseCase,
private val saveContactInteractor: SaveContactInteractor,
private val analyticsSender: AddressBookAnalyticsSender,
val portfolioSelectorController: PortfolioSelectorController,
portfolioFetcherFactory: PortfolioFetcher.Factory,
) : Model() {
@ -86,6 +88,12 @@ internal class EditContactModel @Inject constructor(
observeWalletBlock()
observeNameValidation()
observeSaveButton()
sendAddContactTappedEvent()
}
private fun sendAddContactTappedEvent() {
if (params.contactId != null) return
analyticsSender.sendAddContactTapped(fromSendSuccess = params.predefinedAddress != null, scope = modelScope)
}
/** In WithContactCreation mode the contact opens with the already-known address attached. */
@ -151,6 +159,7 @@ internal class EditContactModel @Inject constructor(
private fun onWalletBlockClick() {
if (isWalletChangeable(userWalletsListRepository.userWallets.value)) {
analyticsSender.sendSaveToButtonClicked()
portfolioSelectorNavigation.activate(Unit)
}
}
@ -214,8 +223,22 @@ internal class EditContactModel @Inject constructor(
addressEntries = addressEntries,
)
result.fold(
ifLeft = ::handleSaveError,
ifRight = { params.onBackClick() },
ifLeft = { error ->
handleSaveError(error)
analyticsSender.sendSaveErrorShown(
walletId = userWallet.walletId,
contactId = params.contactId?.value,
error = error,
)
},
ifRight = { contact ->
analyticsSender.sendContactSaved(
walletId = userWallet.walletId,
contactId = contact.id.value,
isEdit = params.contactId != null,
)
params.onBackClick()
},
)
} finally {
refreshSaveButton()
@ -247,6 +270,7 @@ internal class EditContactModel @Inject constructor(
),
)
} else {
analyticsSender.sendAddressScreenOpened()
params.onAddAddressClick()
}
}

View file

@ -0,0 +1,182 @@
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.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.addressbook.analytics.AddressBookEvents
import com.tangem.features.addressbook.analytics.AddressBookEvents.AddContactTapped
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListScreenOpened.Source
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactSaved
import com.tangem.features.addressbook.analytics.AddressBookEvents.SaveErrorShown.ErrorType
import com.tangem.test.core.ProvideTestModels
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.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class AddressBookAnalyticsSenderTest {
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val sender = AddressBookAnalyticsSender(
analyticsEventHandler = analyticsEventHandler,
userWalletsListRepository = userWalletsListRepository,
)
@BeforeEach
fun setup() {
clearMocks(analyticsEventHandler)
val wallet = mockk<UserWallet> { every { walletId } returns EXPECTED_WALLET_ID }
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet)
}
@ParameterizedTest
@ProvideTestModels
fun sendContactListScreenOpened(model: ScreenOpenedModel) = runTest {
// Act
sender.sendContactListScreenOpened(mode = model.mode, scope = this)
advanceUntilIdle()
// Assert
val expected = AddressBookEvents.ContactListScreenOpened(
walletId = EXPECTED_WALLET_ID,
source = model.expectedSource,
)
verify(exactly = 1) { analyticsEventHandler.send(expected) }
}
@ParameterizedTest
@MethodSource("provideAddContactModels")
fun sendAddContactTapped(model: AddContactModel) = runTest {
// Act
sender.sendAddContactTapped(fromSendSuccess = model.fromSendSuccess, scope = this)
advanceUntilIdle()
// Assert
val expected = AddressBookEvents.AddContactTapped(
walletId = EXPECTED_WALLET_ID,
source = model.expectedSource,
)
verify(exactly = 1) { analyticsEventHandler.send(expected) }
}
@ParameterizedTest
@MethodSource("provideContactSavedModels")
fun sendContactSaved(model: ContactSavedModel) = runTest {
// Act
sender.sendContactSaved(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID, isEdit = model.isEdit)
// Assert
val expected = AddressBookEvents.ContactSaved(
walletId = EXPECTED_WALLET_ID,
contactId = CONTACT_ID,
mode = model.expectedMode,
)
verify(exactly = 1) { analyticsEventHandler.send(expected) }
}
@ParameterizedTest
@MethodSource("provideSaveErrorModels")
fun sendSaveErrorShown(model: SaveErrorModel) = runTest {
// Act
sender.sendSaveErrorShown(walletId = EXPECTED_WALLET_ID, contactId = CONTACT_ID, error = model.error)
// Assert
val expectedType = model.expectedType
if (expectedType == null) {
verify(exactly = 0) { analyticsEventHandler.send(any()) }
} else {
val expected = AddressBookEvents.SaveErrorShown(
walletId = EXPECTED_WALLET_ID,
contactId = CONTACT_ID,
errorType = expectedType,
)
verify(exactly = 1) { analyticsEventHandler.send(expected) }
}
}
@Test
fun `WHEN sendSaveToButtonClicked THEN event sent`() {
// Act
sender.sendSaveToButtonClicked()
// Assert
verify(exactly = 1) { analyticsEventHandler.send(AddressBookEvents.SaveToButtonClicked) }
}
@Test
fun `WHEN sendAddressScreenOpened THEN event sent`() {
// Act
sender.sendAddressScreenOpened()
// Assert
verify(exactly = 1) { analyticsEventHandler.send(AddressBookEvents.AddressScreenOpened) }
}
internal data class ScreenOpenedModel(val mode: AddressBookOpenMode, val expectedSource: Source)
internal data class AddContactModel(val fromSendSuccess: Boolean, val expectedSource: AddContactTapped.Source)
internal data class ContactSavedModel(val isEdit: Boolean, val expectedMode: ContactSaved.Mode)
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,
),
)
private fun provideAddContactModels() = listOf(
AddContactModel(fromSendSuccess = false, expectedSource = AddContactTapped.Source.Settings),
AddContactModel(fromSendSuccess = true, expectedSource = AddContactTapped.Source.SendSuccess),
)
private fun provideContactSavedModels() = listOf(
ContactSavedModel(isEdit = false, expectedMode = ContactSaved.Mode.Create),
ContactSavedModel(isEdit = true, expectedMode = ContactSaved.Mode.Edit),
)
private fun provideSaveErrorModels() = listOf(
SaveErrorModel(error = SaveContactError.Signing(mockk()), expectedType = ErrorType.Signing),
SaveErrorModel(error = SaveContactError.Backend(AddressBookSyncError.Network), expectedType = ErrorType.Network),
// 412
SaveErrorModel(error = SaveContactError.Backend(AddressBookSyncError.Conflict), expectedType = ErrorType.Server),
// 5xx / unmapped
SaveErrorModel(error = SaveContactError.Backend(AddressBookSyncError.Unknown), expectedType = ErrorType.Server),
SaveErrorModel(
error = SaveContactError.Backend(AddressBookSyncError.BadRequest),
expectedType = ErrorType.Server,
),
// Validation failures are shown inline, not as a save error.
SaveErrorModel(error = SaveContactError.Name(mockk()), expectedType = null),
SaveErrorModel(error = SaveContactError.Address(mockk()), expectedType = null),
)
private companion object {
val EXPECTED_WALLET_ID = UserWalletId("0011223344")
const val CONTACT_ID = "contact-42"
}
}

View file

@ -11,6 +11,7 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.addressbook.error.AddressBookSyncError
import com.tangem.domain.addressbook.error.ContactNameValidationError
import com.tangem.domain.addressbook.error.SaveContactError
import com.tangem.domain.addressbook.interactor.SaveContactInteractor
@ -23,6 +24,7 @@ import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.common.AddressBookResultHolder
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
import com.tangem.features.addressbook.editcontact.state.EditContactStateController
@ -60,6 +62,7 @@ internal class EditContactModelTest {
private val portfolioSelectorController: PortfolioSelectorController = mockk()
private val portfolioFetcher: PortfolioFetcher = mockk(relaxed = true)
private val portfolioFetcherFactory: PortfolioFetcher.Factory = mockk()
private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true)
// Drives the wallet picked in the reused portfolio selector; `first` of the pair is the chosen wallet.
private val selectedWalletData =
@ -121,6 +124,41 @@ internal class EditContactModelTest {
assertThat(state).isEqualTo(expected)
}
@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")))
advanceUntilIdle()
// Assert
verify(exactly = 0) { analyticsSender.sendAddContactTapped(any(), any()) }
}
@Test
fun `GIVEN existing contactId WHEN model created THEN title is contact`() = runTest {
// Arrange
@ -216,6 +254,7 @@ internal class EditContactModelTest {
// Assert
assertThat(addClicked).isTrue()
verify(exactly = 0) { messageSender.send(any<DialogMessage>()) }
verify(exactly = 1) { analyticsSender.sendAddressScreenOpened() }
}
@Test
@ -240,8 +279,40 @@ internal class EditContactModelTest {
assertThat(model.state.value.isAddAddressEnabled).isFalse()
assertThat(addClicked).isFalse()
verify { messageSender.send(any<DialogMessage>()) }
verify(exactly = 0) { analyticsSender.sendAddressScreenOpened() }
}
@Test
fun `GIVEN changeable wallet block WHEN clicked THEN SaveToButtonClicked sent`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
val walletB = createWallet(id = "bb", name = "Wallet B")
setupWallets(wallets = listOf(walletA, walletB), selected = walletA)
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.walletBlock.onClick()
// Assert
verify(exactly = 1) { analyticsSender.sendSaveToButtonClicked() }
}
@Test
fun `GIVEN non-changeable wallet block WHEN clicked THEN SaveToButtonClicked 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.walletBlock.onClick()
// Assert
verify(exactly = 0) { analyticsSender.sendSaveToButtonClicked() }
}
@Test
fun `GIVEN new contact AND multiple unlocked wallets WHEN created THEN wallet block changeable`() = runTest {
// Arrange
@ -391,7 +462,8 @@ internal class EditContactModelTest {
var navigatedBack = false
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
coEvery { saveContactInteractor.createContact(any(), any(), any(), any()) } returns mockk<Contact>().right()
val savedContact = mockk<Contact> { every { id } returns ContactId(value = "saved-1") }
coEvery { saveContactInteractor.createContact(any(), any(), any(), any()) } returns savedContact.right()
val model = createModel(testScope = this, params = createParams(onBackClick = { navigatedBack = true }))
advanceUntilIdle()
model.state.value.onNameChange("Satoshi")
@ -411,9 +483,79 @@ internal class EditContactModelTest {
addressEntries = any(),
)
}
verify(exactly = 1) {
analyticsSender.sendContactSaved(walletId = walletA.walletId, contactId = "saved-1", isEdit = false)
}
assertThat(navigatedBack).isTrue()
}
@Test
fun `GIVEN save fails WHEN save clicked THEN ContactSaved not sent`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
coEvery { saveContactInteractor.createContact(any(), any(), any(), any()) } returns
SaveContactError.Name(ContactNameValidationError.Duplicate).left()
val model = createModel(testScope = this)
advanceUntilIdle()
model.state.value.onNameChange("Satoshi")
resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")))
advanceUntilIdle()
// Act
model.state.value.saveButton.onClick()
advanceUntilIdle()
// Assert
verify(exactly = 0) { analyticsSender.sendContactSaved(any(), any(), any()) }
}
@Test
fun `GIVEN new contact AND save fails WHEN save clicked THEN SaveErrorShown sent with null contactId`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
val error = SaveContactError.Backend(AddressBookSyncError.Network)
coEvery { saveContactInteractor.createContact(any(), any(), any(), any()) } returns error.left()
val model = createModel(testScope = this, params = createParams(contactId = null))
advanceUntilIdle()
model.state.value.onNameChange("Satoshi")
resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")))
advanceUntilIdle()
// Act
model.state.value.saveButton.onClick()
advanceUntilIdle()
// Assert
verify(exactly = 1) {
analyticsSender.sendSaveErrorShown(walletId = walletA.walletId, contactId = null, error = error)
}
}
@Test
fun `GIVEN existing contact AND save fails WHEN save clicked THEN SaveErrorShown sent with contactId`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
val error = SaveContactError.Backend(AddressBookSyncError.Network)
coEvery { saveContactInteractor.createContact(any(), any(), any(), any()) } returns error.left()
val model = createModel(testScope = this, params = createParams(contactId = ContactId(value = "contact-id")))
advanceUntilIdle()
model.state.value.onNameChange("Satoshi")
resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")))
advanceUntilIdle()
// Act
model.state.value.saveButton.onClick()
advanceUntilIdle()
// Assert
verify(exactly = 1) {
analyticsSender.sendSaveErrorShown(walletId = walletA.walletId, contactId = "contact-id", error = error)
}
}
@Test
fun `GIVEN save returns name error WHEN save clicked THEN inline name error shown`() = runTest {
// Arrange
@ -462,6 +604,7 @@ internal class EditContactModelTest {
userWalletsListRepository = userWalletsListRepository,
validateContactNameUseCase = validateContactNameUseCase,
saveContactInteractor = saveContactInteractor,
analyticsSender = analyticsSender,
portfolioSelectorController = portfolioSelectorController,
portfolioFetcherFactory = portfolioFetcherFactory,
).also { model = it }