Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-29 13:45:09 +01:00
parent 6aec7efdd6
commit 21eee91053
6 changed files with 149 additions and 8 deletions

View file

@ -9,10 +9,15 @@ sealed interface AddressBookOpenMode {
@Serializable @Serializable
data object Default : AddressBookOpenMode data object Default : AddressBookOpenMode
/**
* Opened to create a contact for an already-known [address] on [networkId]. [memo] carries the destination tag /
* memo that went with it, when the network supports transaction extras and one was entered.
*/
@Serializable @Serializable
data class WithContactCreation( data class WithContactCreation(
val address: String, val address: String,
val networkId: String, val networkId: String,
val memo: String? = null,
) : AddressBookOpenMode ) : AddressBookOpenMode
/** /**

View file

@ -74,10 +74,18 @@ internal class AddressBookChildFactory @Inject constructor(
) )
} }
/** Builds the address attached up-front in WithContactCreation mode, when both the address and network are known. */ /**
* Builds the address attached up-front in WithContactCreation mode, when both the address and network are known.
* The memo rides along when one was provided; `ContactAddressEntriesConverter` drops it on save if the network has
* no transaction extras.
*/
private fun buildPredefinedAddress(route: AddressBookRoute.EditContact): ValidatedAddress? { private fun buildPredefinedAddress(route: AddressBookRoute.EditContact): ValidatedAddress? {
val address = route.predefinedAddress ?: return null val address = route.predefinedAddress ?: return null
val networkId = route.predefinedNetworkId ?: return null val networkId = route.predefinedNetworkId ?: return null
return ValidatedAddress(address = address, networkIds = persistentListOf(networkId)) return ValidatedAddress(
address = address,
networkIds = persistentListOf(networkId),
memo = route.predefinedMemo,
)
} }
} }

View file

@ -139,6 +139,7 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
AddressBookRoute.EditContact( AddressBookRoute.EditContact(
predefinedAddress = mode.address, predefinedAddress = mode.address,
predefinedNetworkId = mode.networkId, predefinedNetworkId = mode.networkId,
predefinedMemo = mode.memo,
), ),
) )
} }

View file

@ -16,15 +16,17 @@ internal sealed class AddressBookRoute {
/** /**
* if [contactId] is not null we should fetch existing contact. * if [contactId] is not null we should fetch existing contact.
* *
* [predefinedAddress] and [predefinedNetworkId] are set only when the feature is opened in * [predefinedAddress], [predefinedNetworkId] and [predefinedMemo] are set only when the feature is opened in
* [com.tangem.common.routing.entity.AddressBookOpenMode.WithContactCreation] mode the address and its * [com.tangem.common.routing.entity.AddressBookOpenMode.WithContactCreation] mode the address and its
* network are already known, so the new contact is opened with that address already attached. * network are already known, so the new contact is opened with that address already attached. [predefinedMemo]
* stays null when the network has no transaction extras or no memo was entered.
*/ */
@Serializable @Serializable
data class EditContact( data class EditContact(
val contactId: String? = null, val contactId: String? = null,
val predefinedAddress: String? = null, val predefinedAddress: String? = null,
val predefinedNetworkId: String? = null, val predefinedNetworkId: String? = null,
val predefinedMemo: String? = null,
) : AddressBookRoute() ) : AddressBookRoute()
/** /**

View file

@ -183,12 +183,16 @@ internal class SendDestinationModel @Inject constructor(
) )
} }
/** Opens the contact editor pre-filled with the sent address/network to save the recipient (success screen). */ /** Opens the contact editor pre-filled with the sent address/network/memo to save the recipient (success screen). */
fun onAddContactClick() { fun onAddContactClick() {
val address = (uiState.value as? DestinationUM.Content)?.addressTextField?.actualAddress ?: return val content = uiState.value as? DestinationUM.Content ?: return
router.push( router.push(
AppRoute.AddressBook( AppRoute.AddressBook(
AddressBookOpenMode.WithContactCreation(address = address, networkId = cryptoCurrency.network.rawId), AddressBookOpenMode.WithContactCreation(
address = content.addressTextField.actualAddress,
networkId = cryptoCurrency.network.rawId,
memo = content.memoTextField?.value?.takeIf(String::isNotBlank),
),
), ),
) )
} }

View file

@ -6,6 +6,8 @@ import androidx.compose.ui.text.input.KeyboardType
import arrow.core.left import arrow.core.left
import arrow.core.right import arrow.core.right
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.entity.AddressBookOpenMode
import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.MutableParamsContainer
@ -105,7 +107,14 @@ internal class SendDestinationModelTest {
fun setUp() { fun setUp() {
MockKAnnotations.init(this) MockKAnnotations.init(this)
// PER_CLASS parameterized nested classes reuse one instance — reset verified mocks between rows. // PER_CLASS parameterized nested classes reuse one instance — reset verified mocks between rows.
clearMocks(callback, validateWalletAddressUseCase, answers = false, recordedCalls = true, childMocks = false) clearMocks(
callback,
validateWalletAddressUseCase,
router,
answers = false,
recordedCalls = true,
childMocks = false,
)
coEvery { getNetworkAddressesUseCase.invokeSync(any(), any<Network.RawID>()) } returns emptyList() coEvery { getNetworkAddressesUseCase.invokeSync(any(), any<Network.RawID>()) } returns emptyList()
every { getWalletsUseCase() } returns flowOf(emptyList()) every { getWalletsUseCase() } returns flowOf(emptyList())
every { multiAccountStatusListSupplier() } returns flowOf(emptyList()) every { multiAccountStatusListSupplier() } returns flowOf(emptyList())
@ -536,6 +545,74 @@ internal class SendDestinationModelTest {
) )
} }
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class OnAddContactClick {
@ParameterizedTest
@ProvideTestModels
fun `WHEN onAddContactClick THEN address book opened with the sent address and memo`(
model: AddContactRouteModel,
) = runTest {
// Arrange
val sut = buildModel(
initialState = contentState(
address = "0xDave",
blockchainAddress = model.blockchainAddress,
memo = model.memoFieldValue,
),
)
advanceUntilIdle()
// Act
sut.onAddContactClick()
// Assert
verify(exactly = 1) {
router.push(
AppRoute.AddressBook(
AddressBookOpenMode.WithContactCreation(
address = model.expectedAddress,
networkId = networkRawId,
memo = model.expectedMemo,
),
),
)
}
}
private fun provideTestModels() = listOf(
// memo entered -> carried over to the new contact
AddContactRouteModel(
memoFieldValue = "12345",
blockchainAddress = null,
expectedAddress = "0xDave",
expectedMemo = "12345",
),
// memo field shown but left blank -> nothing to carry
AddContactRouteModel(
memoFieldValue = " ",
blockchainAddress = null,
expectedAddress = "0xDave",
expectedMemo = null,
),
// network without transaction extras -> no memo field at all
AddContactRouteModel(
memoFieldValue = null,
blockchainAddress = null,
expectedAddress = "0xDave",
expectedMemo = null,
),
// ENS-resolved recipient -> the canonical address is saved, not the typed name
AddContactRouteModel(
memoFieldValue = "42",
blockchainAddress = "0xCanonical",
expectedAddress = "0xCanonical",
expectedMemo = "42",
),
)
}
@Nested @Nested
inner class SyncAddressBooks { inner class SyncAddressBooks {
@ -741,8 +818,52 @@ internal class SendDestinationModelTest {
private fun content(model: SendDestinationModel): DestinationUM.Content = private fun content(model: SendDestinationModel): DestinationUM.Content =
model.uiState.value as DestinationUM.Content model.uiState.value as DestinationUM.Content
/**
* A ready-made [DestinationUM.Content] with `isInitialized = true`, so `SendDestinationInitialStateTransformer`
* leaves it alone and the test fully controls the address / memo fields. A null [memo] means the network has no
* transaction extras and therefore no memo field.
*/
private fun contentState(
address: String,
blockchainAddress: String? = null,
memo: String? = null,
): DestinationUM.Content = DestinationUM.Content(
isPrimaryButtonEnabled = false,
isInitialized = true,
addressTextField = DestinationTextFieldUM.RecipientAddress(
value = address,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Text),
placeholder = stringReference(""),
label = stringReference(""),
isValuePasted = false,
blockchainAddress = blockchainAddress,
),
memoTextField = memo?.let {
DestinationTextFieldUM.RecipientMemo(
value = it,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Text),
placeholder = stringReference(""),
label = stringReference(""),
disabledText = stringReference(""),
isEnabled = true,
isValuePasted = false,
)
},
recent = persistentListOf(),
wallets = persistentListOf(),
networkName = "Ethereum",
isRecentHidden = false,
)
data class AutoNextModel(val addressValidation: AddressValidationResult, val expectedNextClicks: Int) data class AutoNextModel(val addressValidation: AddressValidationResult, val expectedNextClicks: Int)
data class AddContactRouteModel(
val memoFieldValue: String?,
val blockchainAddress: String?,
val expectedAddress: String,
val expectedMemo: String?,
)
data class AddContactModel( data class AddContactModel(
val isAddContactAvailable: Boolean, val isAddContactAvailable: Boolean,
val savedAddresses: List<String>, val savedAddresses: List<String>,