From 21eee91053c23498bd4715ce8efdd9274c90fdbc Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 29 Jul 2026 13:45:09 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../routing/entity/AddressBookOpenMode.kt | 5 + .../common/AddressBookChildFactory.kt | 12 +- .../common/DefaultAddressBookComponent.kt | 1 + .../addressbook/route/AddressBookRoute.kt | 6 +- .../destination/model/SendDestinationModel.kt | 10 +- .../model/SendDestinationModelTest.kt | 123 +++++++++++++++++- 6 files changed, 149 insertions(+), 8 deletions(-) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt index e9af666c27..bee5e98366 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt @@ -9,10 +9,15 @@ sealed interface AddressBookOpenMode { @Serializable 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 data class WithContactCreation( val address: String, val networkId: String, + val memo: String? = null, ) : AddressBookOpenMode /** diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt index 1e1066a991..184fdd38d6 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt @@ -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? { val address = route.predefinedAddress ?: 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, + ) } } \ 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 8849c3e4c0..7e370ec8c4 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 @@ -139,6 +139,7 @@ internal class DefaultAddressBookComponent @AssistedInject constructor( AddressBookRoute.EditContact( predefinedAddress = mode.address, predefinedNetworkId = mode.networkId, + predefinedMemo = mode.memo, ), ) } diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt index 25e4c2edf5..82a51a24a7 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt @@ -16,15 +16,17 @@ internal sealed class AddressBookRoute { /** * 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 - * 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 data class EditContact( val contactId: String? = null, val predefinedAddress: String? = null, val predefinedNetworkId: String? = null, + val predefinedMemo: String? = null, ) : AddressBookRoute() /** 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 b502e8bbcb..055a95a067 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 @@ -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() { - val address = (uiState.value as? DestinationUM.Content)?.addressTextField?.actualAddress ?: return + val content = uiState.value as? DestinationUM.Content ?: return router.push( 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), + ), ), ) } 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 536a58aae2..acb56a5340 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 @@ -6,6 +6,8 @@ import androidx.compose.ui.text.input.KeyboardType import arrow.core.left import arrow.core.right 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.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.MutableParamsContainer @@ -105,7 +107,14 @@ internal class SendDestinationModelTest { fun setUp() { MockKAnnotations.init(this) // 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()) } returns emptyList() every { getWalletsUseCase() } 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 inner class SyncAddressBooks { @@ -741,8 +818,52 @@ internal class SendDestinationModelTest { private fun content(model: SendDestinationModel): 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 AddContactRouteModel( + val memoFieldValue: String?, + val blockchainAddress: String?, + val expectedAddress: String, + val expectedMemo: String?, + ) + data class AddContactModel( val isAddContactAvailable: Boolean, val savedAddresses: List,