From f25adffe547b43db5c1cd6f10aae3c0b5e144cfe Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 27 May 2026 21:04:28 +0500 Subject: [PATCH 1/7] Updated on 2026-08-14 --- .../api/GiveApprovalEntryComponent.kt | 40 ++++ .../api/SelectApprovalTypeComponent.kt | 39 ++++ .../impl/DefaultGiveApprovalEntryComponent.kt | 58 ++++++ .../DefaultSelectApprovalTypeComponent.kt | 80 ++++++++ .../impl/di/GiveApprovalBindsModule.kt | 22 +++ .../impl/model/SelectApprovalTypeModel.kt | 52 +++++ .../impl/model/SelectApprovalTypeUM.kt | 12 ++ .../impl/ui/ApprovalTypeSelectorRow.kt | 183 ++++++++++++++++++ .../impl/ui/SelectApprovalTypeContent.kt | 157 +++++++++++++++ .../swap/models/states/ProviderState.kt | 9 + .../tangem/feature/swap/ui/ProviderItem.kt | 47 ++++- .../feature/swap/ui/ProviderItemSimple.kt | 1 + 12 files changed, 692 insertions(+), 8 deletions(-) create mode 100644 features/approval/api/src/main/java/com/tangem/features/approval/api/GiveApprovalEntryComponent.kt create mode 100644 features/approval/api/src/main/java/com/tangem/features/approval/api/SelectApprovalTypeComponent.kt create mode 100644 features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultGiveApprovalEntryComponent.kt create mode 100644 features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultSelectApprovalTypeComponent.kt create mode 100644 features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/SelectApprovalTypeModel.kt create mode 100644 features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/SelectApprovalTypeUM.kt create mode 100644 features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/ApprovalTypeSelectorRow.kt create mode 100644 features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/SelectApprovalTypeContent.kt diff --git a/features/approval/api/src/main/java/com/tangem/features/approval/api/GiveApprovalEntryComponent.kt b/features/approval/api/src/main/java/com/tangem/features/approval/api/GiveApprovalEntryComponent.kt new file mode 100644 index 0000000000..f585d0ed4d --- /dev/null +++ b/features/approval/api/src/main/java/com/tangem/features/approval/api/GiveApprovalEntryComponent.kt @@ -0,0 +1,40 @@ +package com.tangem.features.approval.api + +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent + +/** + * Entry component that wraps the two approval-flow variants: + * + * - [Mode.FullApproval] — original [GiveApprovalComponent] which renders the approval-type + * selector together with the fee selector and submits the approval transaction. + * - [Mode.SelectOnly] — [SelectApprovalTypeComponent] which only collects the approval-type + * choice and returns it to the caller via its own [SelectApprovalTypeComponent.Callback]. + * + * Callers depend only on this single factory and pass the appropriate [Mode]; the entry + * component internally creates the corresponding child and delegates the bottom sheet + * rendering and dismissal to it. + */ +interface GiveApprovalEntryComponent : ComposableBottomSheetComponent { + + data class Params( + val mode: Mode, + ) + + sealed interface Mode { + + /** Full flow: approval-type selector + fee selector + transaction submission. */ + data class FullApproval( + val params: GiveApprovalComponent.Params, + ) : Mode + + /** Selection-only flow: returns the chosen approval type without sending anything. */ + data class SelectOnly( + val params: SelectApprovalTypeComponent.Params, + ) : Mode + } + + interface Factory { + fun create(context: AppComponentContext, params: Params): GiveApprovalEntryComponent + } +} \ No newline at end of file diff --git a/features/approval/api/src/main/java/com/tangem/features/approval/api/SelectApprovalTypeComponent.kt b/features/approval/api/src/main/java/com/tangem/features/approval/api/SelectApprovalTypeComponent.kt new file mode 100644 index 0000000000..193d11d9d8 --- /dev/null +++ b/features/approval/api/src/main/java/com/tangem/features/approval/api/SelectApprovalTypeComponent.kt @@ -0,0 +1,39 @@ +package com.tangem.features.approval.api + +import com.tangem.common.ui.bottomsheet.permission.state.ApproveType +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Selection-only variant of [GiveApprovalComponent]. + * + * Shows the same approval-type selector UI (LIMITED vs UNLIMITED) but does NOT submit the + * approval transaction. Instead, the chosen [ApproveType] is returned to the caller via + * [Callback.onApproveTypeSelected] when the user confirms. The caller is responsible for any + * downstream action (e.g. building the transaction, sending it, navigation). + * + * Intended for flows where the approval-type choice has to be collected separately from the + * actual fee selection / transaction submission step. + */ +interface SelectApprovalTypeComponent : ComposableBottomSheetComponent { + + data class Params( + val userWalletId: UserWalletId, + val cryptoCurrencyStatus: CryptoCurrencyStatus, + val amountFooter: TextReference, + val initialApproveType: ApproveType = ApproveType.LIMITED, + val callback: Callback, + ) + + interface Callback { + fun onApproveTypeSelected(approveType: ApproveType) + fun onCancelClick() + } + + interface Factory { + fun create(context: AppComponentContext, params: Params): SelectApprovalTypeComponent + } +} \ No newline at end of file diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultGiveApprovalEntryComponent.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultGiveApprovalEntryComponent.kt new file mode 100644 index 0000000000..fa2bd4c082 --- /dev/null +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultGiveApprovalEntryComponent.kt @@ -0,0 +1,58 @@ +package com.tangem.features.approval.impl + +import androidx.compose.runtime.Composable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.approval.api.GiveApprovalComponent +import com.tangem.features.approval.api.GiveApprovalEntryComponent +import com.tangem.features.approval.api.SelectApprovalTypeComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Default implementation of [GiveApprovalEntryComponent]. + * + * Picks the concrete child component (full [GiveApprovalComponent] or selection-only + * [SelectApprovalTypeComponent]) at construction time based on + * [GiveApprovalEntryComponent.Params.mode] and delegates [BottomSheet] and [dismiss] to it. + * + * Callers only need to depend on [GiveApprovalEntryComponent.Factory] regardless of the + * underlying mode. + */ +internal class DefaultGiveApprovalEntryComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: GiveApprovalEntryComponent.Params, + giveApprovalComponentFactory: GiveApprovalComponent.Factory, + selectApprovalTypeComponentFactory: SelectApprovalTypeComponent.Factory, +) : GiveApprovalEntryComponent, AppComponentContext by appComponentContext { + + private val delegate: ComposableBottomSheetComponent = when (val mode = params.mode) { + is GiveApprovalEntryComponent.Mode.FullApproval -> giveApprovalComponentFactory.create( + context = child("giveApprovalEntry_full"), + params = mode.params, + ) + is GiveApprovalEntryComponent.Mode.SelectOnly -> selectApprovalTypeComponentFactory.create( + context = child("giveApprovalEntry_select"), + params = mode.params, + ) + } + + override fun dismiss() { + delegate.dismiss() + } + + @Composable + override fun BottomSheet() { + delegate.BottomSheet() + } + + @AssistedFactory + interface Factory : GiveApprovalEntryComponent.Factory { + override fun create( + context: AppComponentContext, + params: GiveApprovalEntryComponent.Params, + ): DefaultGiveApprovalEntryComponent + } +} \ No newline at end of file diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultSelectApprovalTypeComponent.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultSelectApprovalTypeComponent.kt new file mode 100644 index 0000000000..dec40f2914 --- /dev/null +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultSelectApprovalTypeComponent.kt @@ -0,0 +1,80 @@ +package com.tangem.features.approval.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.R +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.approval.api.SelectApprovalTypeComponent +import com.tangem.features.approval.impl.model.SelectApprovalTypeModel +import com.tangem.features.approval.impl.ui.SelectApprovalTypeContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Default implementation of [SelectApprovalTypeComponent]. + * + * Renders the same selection UI as the full [com.tangem.features.approval.api.GiveApprovalComponent] + * but without the fee selector block and without dispatching the on-chain approval transaction. + * Dismissing the bottom sheet (close button or external dismiss) is treated as a cancel. + */ +internal class DefaultSelectApprovalTypeComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: SelectApprovalTypeComponent.Params, +) : SelectApprovalTypeComponent, AppComponentContext by appComponentContext { + + private val model: SelectApprovalTypeModel = getOrCreateModel(params = params) + + private val currency: String = params.cryptoCurrencyStatus.currency.symbol + + override fun dismiss() { + params.callback.onCancelClick() + } + + @Composable + override fun BottomSheet() { + val uiState by model.uiState.collectAsStateWithLifecycle() + + val config = remember { + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ) + } + + TangemBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + titleText = resourceReference(R.string.give_permission_title), + titleAction = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_close_new_20, + onClicked = model::onCancelClick, + ), + ) { + SelectApprovalTypeContent( + currency = currency, + uiState = uiState, + onChangeApproveType = model::onChangeApproveType, + onConfirmClick = model::onConfirmClick, + ) + } + } + + @AssistedFactory + interface Factory : SelectApprovalTypeComponent.Factory { + override fun create( + context: AppComponentContext, + params: SelectApprovalTypeComponent.Params, + ): DefaultSelectApprovalTypeComponent + } +} \ No newline at end of file diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/di/GiveApprovalBindsModule.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/di/GiveApprovalBindsModule.kt index 3f6fdce55a..dd4f46f896 100644 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/di/GiveApprovalBindsModule.kt +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/di/GiveApprovalBindsModule.kt @@ -3,10 +3,15 @@ package com.tangem.features.approval.impl.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.approval.api.GiveApprovalComponent +import com.tangem.features.approval.api.GiveApprovalEntryComponent import com.tangem.features.approval.api.GiveApprovalFeatureToggles +import com.tangem.features.approval.api.SelectApprovalTypeComponent import com.tangem.features.approval.impl.DefaultGiveApprovalComponent +import com.tangem.features.approval.impl.DefaultGiveApprovalEntryComponent import com.tangem.features.approval.impl.DefaultGiveApprovalFeatureToggles +import com.tangem.features.approval.impl.DefaultSelectApprovalTypeComponent import com.tangem.features.approval.impl.model.GiveApprovalModel +import com.tangem.features.approval.impl.model.SelectApprovalTypeModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -26,6 +31,18 @@ internal interface GiveApprovalFeatureModule { @Binds @Singleton fun bindComponentFactory(factory: DefaultGiveApprovalComponent.Factory): GiveApprovalComponent.Factory + + @Binds + @Singleton + fun bindSelectApprovalTypeComponentFactory( + factory: DefaultSelectApprovalTypeComponent.Factory, + ): SelectApprovalTypeComponent.Factory + + @Binds + @Singleton + fun bindGiveApprovalEntryComponentFactory( + factory: DefaultGiveApprovalEntryComponent.Factory, + ): GiveApprovalEntryComponent.Factory } @Module @@ -36,4 +53,9 @@ internal interface GiveApprovalModelModule { @IntoMap @ClassKey(GiveApprovalModel::class) fun bindModel(model: GiveApprovalModel): Model + + @Binds + @IntoMap + @ClassKey(SelectApprovalTypeModel::class) + fun bindSelectApprovalTypeModel(model: SelectApprovalTypeModel): Model } \ No newline at end of file diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/SelectApprovalTypeModel.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/SelectApprovalTypeModel.kt new file mode 100644 index 0000000000..e7e063601a --- /dev/null +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/SelectApprovalTypeModel.kt @@ -0,0 +1,52 @@ +package com.tangem.features.approval.impl.model + +import androidx.compose.runtime.Stable +import com.tangem.common.ui.bottomsheet.permission.state.ApproveType +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.approval.api.SelectApprovalTypeComponent +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +/** + * Model for [SelectApprovalTypeComponent]. + * + * Keeps the currently selected [ApproveType] and exposes intents to change it, open the + * learn-more URL, confirm the selection, and cancel. Unlike [GiveApprovalModel] this model + * does NOT load fees or submit any transaction — confirmation simply notifies the caller + * via the params callback with the selected [ApproveType]. + */ +@Stable +@ModelScoped +internal class SelectApprovalTypeModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, +) : Model() { + + private val params: SelectApprovalTypeComponent.Params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow( + SelectApprovalTypeUM( + approveType = params.initialApproveType, + subtitle = params.amountFooter, + ), + ) + + fun onChangeApproveType(approveType: ApproveType) { + if (uiState.value.approveType == approveType) return + uiState.update { it.copy(approveType = approveType) } + } + + fun onConfirmClick() { + params.callback.onApproveTypeSelected(uiState.value.approveType) + } + + fun onCancelClick() { + params.callback.onCancelClick() + } +} \ No newline at end of file diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/SelectApprovalTypeUM.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/SelectApprovalTypeUM.kt new file mode 100644 index 0000000000..47023909ea --- /dev/null +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/SelectApprovalTypeUM.kt @@ -0,0 +1,12 @@ +package com.tangem.features.approval.impl.model + +import com.tangem.common.ui.bottomsheet.permission.state.ApproveType +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal data class SelectApprovalTypeUM( + val subtitle: TextReference, + val approveType: ApproveType, + val approveItems: ImmutableList = ApproveType.entries.toImmutableList(), +) \ No newline at end of file diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/ApprovalTypeSelectorRow.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/ApprovalTypeSelectorRow.kt new file mode 100644 index 0000000000..e6d9ea6f97 --- /dev/null +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/ApprovalTypeSelectorRow.kt @@ -0,0 +1,183 @@ +package com.tangem.features.approval.impl.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import androidx.compose.material3.Text as M3Text +import com.tangem.common.ui.bottomsheet.permission.state.ApproveType +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import kotlinx.collections.immutable.ImmutableList + +/** + * Reusable row that shows "Amount for {currency}" on the left and the currently selected + * [ApproveType] on the right, with a dropdown to switch between the available types. + * + * Used by both [GiveApprovalContent] (full approval flow) and [SelectApprovalTypeContent] + * (selection-only flow). + */ +@Composable +internal fun ApprovalTypeSelectorRow( + currency: String, + approveType: ApproveType, + approveItems: ImmutableList, + onChangeApproveType: (ApproveType) -> Unit, + modifier: Modifier = Modifier, +) { + var isExpandSelector by remember { mutableStateOf(false) } + var amountSize by remember { mutableStateOf(IntSize.Zero) } + Box( + modifier = modifier + .fillMaxWidth() + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = { isExpandSelector = true }, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .onSizeChanged { amountSize = it } + .padding(vertical = 12.dp, horizontal = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + M3Text( + text = stringResourceSafe(id = R.string.give_permission_rows_amount, currency), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle1, + maxLines = 1, + ) + SpacerWMax() + M3Text( + text = approveType.text.resolveReference(), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.body1, + maxLines = 1, + ) + Icon( + painter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.ic_chevron_24)), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing2), + ) + } + ApprovalTypeDropdown( + isExpanded = isExpandSelector, + onDismiss = { isExpandSelector = false }, + onItemClick = { type -> + isExpandSelector = false + onChangeApproveType(type) + }, + items = approveItems, + selectedType = approveType, + amountSize = amountSize, + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun ApprovalTypeDropdown( + isExpanded: Boolean, + onDismiss: () -> Unit, + onItemClick: (ApproveType) -> Unit, + items: ImmutableList, + selectedType: ApproveType, + amountSize: IntSize, +) { + var dropDownWidth by remember { mutableStateOf(IntSize.Zero) } + val offsetY = amountSize.height.times(-1) + val offsetX = amountSize.width - dropDownWidth.width + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy(surface = TangemTheme.colors.background.action), + shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(TangemTheme.dimens.radius16)), + ) { + DropdownMenu( + expanded = isExpanded, + onDismissRequest = onDismiss, + properties = PopupProperties(clippingEnabled = false), + offset = with(LocalDensity.current) { + DpOffset(x = offsetX.toDp(), y = offsetY.toDp()) + }, + modifier = Modifier + .wrapContentSize() + .background(TangemTheme.colors.background.action) + .onSizeChanged { dropDownWidth = it }, + ) { + items.forEach { item -> + val color = if (item == selectedType) TangemTheme.colors.icon.accent else Color.Transparent + + DropdownMenuItem( + modifier = Modifier.fillMaxWidth(), + text = { + Row { + M3Text( + text = when (item) { + ApproveType.LIMITED -> stringResourceSafe( + id = R.string.give_permission_current_transaction, + ) + ApproveType.UNLIMITED -> stringResourceSafe( + id = R.string.give_permission_unlimited, + ) + }, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.body1, + maxLines = 1, + ) + SpacerWMax() + Icon( + painter = rememberVectorPainter( + image = ImageVector.vectorResource(id = R.drawable.ic_check_24), + ), + tint = color, + contentDescription = null, + modifier = Modifier.padding(start = TangemTheme.dimens.size20), + ) + } + }, + onClick = { + onItemClick.invoke(item) + }, + ) + } + } + } +} \ No newline at end of file diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/SelectApprovalTypeContent.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/SelectApprovalTypeContent.kt new file mode 100644 index 0000000000..d7f7363b9f --- /dev/null +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/SelectApprovalTypeContent.kt @@ -0,0 +1,157 @@ +package com.tangem.features.approval.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.bottomsheet.permission.state.ApproveType +import com.tangem.core.ui.R +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerH18 +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.approval.impl.model.SelectApprovalTypeUM +import kotlinx.collections.immutable.persistentListOf + +/** + * UI for the selection-only approval variant. Reuses [ApprovalTypeSelectorRow] for the + * approval-type picker. The primary button calls [onConfirmClick] which is wired to a + * callback that returns the chosen [ApproveType] + * to the caller (instead of submitting an on-chain transaction). + */ +@Composable +@Suppress("LongParameterList") +internal fun SelectApprovalTypeContent( + currency: String, + uiState: SelectApprovalTypeUM, + onChangeApproveType: (ApproveType) -> Unit, + onConfirmClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = uiState.subtitle.resolveAnnotatedReference(), + color = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.body2, + textAlign = TextAlign.Center, + modifier = Modifier.padding( + top = 2.dp, + start = 16.dp, + end = 16.dp, + ), + ) + + SpacerH18() + + ApprovalTypeSelectorRow( + currency = currency, + approveType = uiState.approveType, + approveItems = uiState.approveItems, + onChangeApproveType = onChangeApproveType, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + ) + + SpacerH(height = TangemTheme.dimens.spacing20) + + PrimaryButton( + text = stringResourceSafe(id = R.string.common_continue), + onClick = onConfirmClick, + enabled = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + ) + + SpacerH16() + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun SelectApprovalTypeContentPreview( + @PreviewParameter(SelectApprovalTypeContentPreviewProvider::class) params: SelectApprovalTypePreviewParams, +) { + TangemThemePreview { + SelectApprovalTypeContent( + currency = params.currency, + uiState = params.uiState, + onChangeApproveType = {}, + onConfirmClick = {}, + ) + } +} + +private data class SelectApprovalTypePreviewParams( + val currency: String, + val uiState: SelectApprovalTypeUM, +) + +private class SelectApprovalTypeContentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SelectApprovalTypePreviewParams( + currency = "USDT", + uiState = SelectApprovalTypeUM( + subtitle = combinedReference( + resourceReference( + id = R.string.give_permission_swap_subtitle_v2, + // Arg is only used in iOS + formatArgs = wrappedList(""), + ), + styledResourceReference( + id = R.string.common_learn_more, + spanStyleReference = { + TangemTheme.typography.caption2 + .copy(color = TangemTheme.colors.text.accent) + .toSpanStyle() + }, + onClick = { }, + ), + ), + approveType = ApproveType.LIMITED, + approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED), + ), + ), + SelectApprovalTypePreviewParams( + currency = "USDC", + uiState = SelectApprovalTypeUM( + subtitle = combinedReference( + resourceReference( + id = com.tangem.common.ui.R.string.give_permission_swap_subtitle_v2, + // Arg is only used in iOS + formatArgs = wrappedList(""), + ), + styledResourceReference( + id = com.tangem.common.ui.R.string.common_learn_more, + spanStyleReference = { + TangemTheme.typography.caption2 + .copy(color = TangemTheme.colors.text.accent) + .toSpanStyle() + }, + onClick = {}, + ), + ), + approveType = ApproveType.UNLIMITED, + approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED), + ), + ), + ) +} +// endregion \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt index 8ab97bb746..b36a3305f1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt @@ -29,6 +29,7 @@ sealed class ProviderState { val additionalBadge: AdditionalBadge, val percentLowerThenBest: PercentDifference = PercentDifference.Empty, val namePrefix: PrefixType, + val approvalSettings: ApprovalSettings = ApprovalSettings.Empty, override val onProviderClick: (String) -> Unit, ) : ProviderState() @@ -61,6 +62,14 @@ sealed class ProviderState { enum class PrefixType { NONE, PROVIDED_BY } + + @Immutable + sealed class ApprovalSettings { + data object Empty : ApprovalSettings() + data class Content( + val onApprovalSelectClick: () -> Unit, + ) : ApprovalSettings() + } } @Immutable diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt index 4c8aca99fe..c5ce3a57e2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt @@ -4,26 +4,32 @@ import android.content.res.Configuration import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.foundation.layout.Arrangement import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import com.tangem.core.ui.R import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe @@ -194,6 +200,23 @@ private fun ProviderContentState( } } } + + if (state.approvalSettings is ProviderState.ApprovalSettings.Content) { + SpacerWMax() + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_filter_default_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier + .padding(end = 14.dp) + .size(20.dp) + .clickable( + indication = ripple(false), + interactionSource = remember { MutableInteractionSource() }, + onClick = state.approvalSettings.onApprovalSelectClick, + ), + ) + } } ProviderChevron(selectionType = state.selectionType, isSelected = isSelected) @@ -442,10 +465,9 @@ private fun ProviderItemPreview( @PreviewParameter(ProviderItemParameterProvider::class) state: Pair, ) { TangemThemePreview { - ProviderItem( + ProviderItemBlock( modifier = Modifier.background(TangemTheme.colors.background.action), state = state.first, - isSelected = state.second, ) } } @@ -460,22 +482,28 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider subtitle = stringReference(value = "0,64554846 DAI ≈ 1 MATIC"), additionalBadge = ProviderState.AdditionalBadge.Empty, percentLowerThenBest = PercentDifference.Value(value = 12.0f), - selectionType = ProviderState.SelectionType.SELECT, + selectionType = ProviderState.SelectionType.NONE, namePrefix = ProviderState.PrefixType.PROVIDED_BY, + approvalSettings = ProviderState.ApprovalSettings.Empty, onProviderClick = {}, ) - val contentState2 = contentState.copy( + val contentStatePermissionRequired = contentState.copy( subtitle = stringReference(value = "1 132,46 MATIC"), additionalBadge = ProviderState.AdditionalBadge.PermissionRequired, percentLowerThenBest = PercentDifference.Value(value = 5f), ) + val contentStatePermissionIntegrated = contentState.copy( + subtitle = stringReference(value = "1 132,46 MATIC"), + percentLowerThenBest = PercentDifference.Value(value = 5f), + approvalSettings = ProviderState.ApprovalSettings.Content({}), + ) val unavailableState = ProviderState.Unavailable( id = "1", name = "1inch", type = "DEX", iconUrl = "", alertText = stringReference(value = "Not available"), - selectionType = ProviderState.SelectionType.SELECT, + selectionType = ProviderState.SelectionType.NONE, onProviderClick = {}, ) val loadingState = ProviderState.Loading() @@ -483,8 +511,11 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider add(contentState to true) add(contentState to false) - add(contentState2 to true) - add(contentState2 to false) + add(contentStatePermissionRequired to true) + add(contentStatePermissionRequired to false) + + add(contentStatePermissionIntegrated to true) + add(contentStatePermissionIntegrated to false) add(unavailableState to true) add(unavailableState to false) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItemSimple.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItemSimple.kt index 71cd53b67e..ac2315b41f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItemSimple.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItemSimple.kt @@ -140,6 +140,7 @@ private class SimpleProviderPreview : PreviewParameterProvider { additionalBadge = ProviderState.AdditionalBadge.Empty, percentLowerThenBest = PercentDifference.Empty, namePrefix = ProviderState.PrefixType.NONE, + approvalSettings = ProviderState.ApprovalSettings.Empty, onProviderClick = {}, ), ProviderState.Loading(), From b270b6980a29928f5e93c9ef50b6137fabe56863 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 28 May 2026 16:01:39 +0400 Subject: [PATCH 2/7] Updated on 2026-08-14 --- .../com/tangem/datasource/api/auth/AuthApi.kt | 45 ++++++++++++++ .../api/auth/RequiresSessionAuth.kt | 16 +++++ .../api/auth/models/request/AuthApiRequest.kt | 46 +++++++++++++++ .../auth/models/request/NonceApiRequest.kt | 11 ++++ .../auth/models/request/RefreshApiRequest.kt | 11 ++++ .../auth/models/response/NonceApiResponse.kt | 13 ++++ .../models/response/ProblemDetailResponse.kt | 26 ++++++++ .../auth/models/response/TokenApiResponse.kt | 19 ++++++ .../datasource/api/common/config/ApiConfig.kt | 2 + .../datasource/api/common/config/Auth.kt | 59 +++++++++++++++++++ .../tangem/datasource/di/ApiConfigsModule.kt | 6 ++ .../com/tangem/datasource/di/NetworkModule.kt | 10 ++++ .../api/common/config/ApiConfigTest.kt | 1 + .../managers/ProdApiConfigsManagerTest.kt | 24 ++++++++ 14 files changed, 289 insertions(+) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/RequiresSessionAuth.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/AuthApiRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/NonceApiRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RefreshApiRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/NonceApiResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/ProblemDetailResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/TokenApiResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/common/config/Auth.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt new file mode 100644 index 0000000000..870c0cc60a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt @@ -0,0 +1,45 @@ +package com.tangem.datasource.api.auth + +import com.tangem.datasource.api.auth.models.request.AuthApiRequest +import com.tangem.datasource.api.auth.models.request.NonceApiRequest +import com.tangem.datasource.api.auth.models.request.RefreshApiRequest +import com.tangem.datasource.api.auth.models.response.NonceApiResponse +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.datasource.api.common.response.ApiResponse +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Tangem Auth Service API (JWT session tokens / DPoP interceptor / refresh rotation) + */ +interface AuthApi { + + /** + * Request authentication nonce. + * + * Generates a nonce bound to the device public key for the authentication flow. + */ + @POST("api/v1/auth/nonce/auth") + suspend fun requestAuthNonce(@Body request: NonceApiRequest): ApiResponse + + /** + * Authenticate device. + * + * Authenticates a previously registered device using a device-key signature. Issues a new + * JWT access token with bound `walletIds[]` and risk tier. All subsequent auth after + * registration uses this endpoint. + */ + @POST("api/v1/auth/authenticate") + suspend fun authenticate(@Body request: AuthApiRequest): ApiResponse + + /** + * Refresh tokens. + * + * Rotates the refresh token and issues a new access token. Uses refresh-token rotation + * with family-based reuse detection — replaying a consumed token revokes the entire token + * family (SR-8). Sender-constraint is verified via the DPoP-proof header (`cnf.jkt`). + */ + @POST("api/v1/auth/refresh") + @RequiresSessionAuth + suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/RequiresSessionAuth.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/RequiresSessionAuth.kt new file mode 100644 index 0000000000..3ba07296b1 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/RequiresSessionAuth.kt @@ -0,0 +1,16 @@ +package com.tangem.datasource.api.auth + +/** + * Marks a Retrofit endpoint as requiring an authenticated session (DPoP, see + * [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)). + * + * Read at runtime by the session-auth interceptor: only methods + * carrying this annotation receive `Authorization: DPoP ` + `DPoP: ` + * headers; unannotated methods (e.g. public nonce endpoints) pass through unchanged. + * + * Mirrors the per-operation `security` blocks in the backend OpenAPI contract; follows the + * same on-method annotation pattern as `@ReadTimeout` / `@ConnectTimeout`. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class RequiresSessionAuth \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/AuthApiRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/AuthApiRequest.kt new file mode 100644 index 0000000000..4e9775755d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/AuthApiRequest.kt @@ -0,0 +1,46 @@ +package com.tangem.datasource.api.auth.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Authentication request — authenticates a previously registered device. */ +@JsonClass(generateAdapter = true) +data class AuthApiRequest( + /** Signed authentication payload. */ + @Json(name = "payload") val payload: AuthenticationPayload, + /** EC signature over the authentication payload, signed by the device private key (Base64). */ + @Json(name = "signature") val signature: String, +) + +/** Signed authentication payload — the data that is signed by the device private key. */ +@JsonClass(generateAdapter = true) +data class AuthenticationPayload( + /** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */ + @Json(name = "devicePublicKey") val devicePublicKey: String, + /** Deciphered nonce value from the nonce endpoint. */ + @Json(name = "nonce") val nonce: String, + /** Platform attestation token (Play Integrity / App Attest). */ + @Json(name = "attestationToken") val attestationToken: String?, + /** Client-reported device metadata. */ + @Json(name = "metadata") val metadata: DeviceMetadata, +) { + + /** Device metadata collection. */ + @JsonClass(generateAdapter = true) + data class DeviceMetadata( + /** Device hardware model (e.g. `iPhone 15 Pro`). */ + @Json(name = "deviceModel") val deviceModel: String?, + /** Operating system (`android` / `ios`). */ + @Json(name = "os") val os: String, + /** OS version string (e.g. `17.4.1`). */ + @Json(name = "osVersion") val osVersion: String?, + /** Application version (e.g. `5.8.0`). */ + @Json(name = "appVersion") val appVersion: String?, + /** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */ + @Json(name = "userAgent") val userAgent: String?, + /** Client locale (e.g. `en-US`). */ + @Json(name = "locale") val locale: String?, + /** Client timezone (e.g. `Europe/Moscow`). */ + @Json(name = "timezone") val timezone: String?, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/NonceApiRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/NonceApiRequest.kt new file mode 100644 index 0000000000..4d28fb97a9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/NonceApiRequest.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.auth.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Request body for nonce generation (auth, upgrade, wallet flows). */ +@JsonClass(generateAdapter = true) +data class NonceApiRequest( + /** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */ + @Json(name = "devicePublicKey") val devicePublicKey: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RefreshApiRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RefreshApiRequest.kt new file mode 100644 index 0000000000..716084e642 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RefreshApiRequest.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.auth.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Token refresh request. */ +@JsonClass(generateAdapter = true) +data class RefreshApiRequest( + /** Refresh token from a previous token response. */ + @Json(name = "refreshToken") val refreshToken: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/NonceApiResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/NonceApiResponse.kt new file mode 100644 index 0000000000..9e4acadecd --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/NonceApiResponse.kt @@ -0,0 +1,13 @@ +package com.tangem.datasource.api.auth.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Ciphered nonce response. */ +@JsonClass(generateAdapter = true) +data class NonceApiResponse( + /** RSA-OAEP ciphered nonce value (Base64). */ + @Json(name = "cipheredNonce") val cipheredNonce: String, + /** Nonce expiration timestamp (ISO-8601). */ + @Json(name = "expiresAt") val expiresAt: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/ProblemDetailResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/ProblemDetailResponse.kt new file mode 100644 index 0000000000..18d938b3bb --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/ProblemDetailResponse.kt @@ -0,0 +1,26 @@ +package com.tangem.datasource.api.auth.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * RFC 9457 / RFC 7807 Problem Details response. Returned by Tangem Auth Service with + * `Content-Type: application/problem+json` on every 4xx / 5xx response. + */ +@JsonClass(generateAdapter = true) +data class ProblemDetailResponse( + /** URI identifying the problem type. */ + @Json(name = "type") val type: String, + /** Short human-readable summary (e.g. `"Too Many Requests"`). */ + @Json(name = "title") val title: String, + /** HTTP status code. */ + @Json(name = "status") val status: Int, + /** Human-readable explanation. */ + @Json(name = "detail") val detail: String?, + /** URI reference to this occurrence (e.g. `"/api/v1/auth/refresh"`). */ + @Json(name = "instance") val instance: String?, + /** Application-specific error code. */ + @Json(name = "code") val code: String?, + /** Retry delay for rate limiting (`429`). */ + @Json(name = "retryAfterSeconds") val retryAfterSeconds: Int?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/TokenApiResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/TokenApiResponse.kt new file mode 100644 index 0000000000..4eaa4f3155 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/TokenApiResponse.kt @@ -0,0 +1,19 @@ +package com.tangem.datasource.api.auth.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Token response — contains JWT access token and optional refresh token. */ +@JsonClass(generateAdapter = true) +data class TokenApiResponse( + /** JWT access token (HMAC-SHA256 signed). */ + @Json(name = "accessToken") val accessToken: String, + /** Access token expiration timestamp (ISO-8601). */ + @Json(name = "accessTokenExpiresAt") val accessTokenExpiresAt: String, + /** Refresh token for token rotation. `null` for ORANGE tier (requires device challenge each time). */ + @Json(name = "refreshToken") val refreshToken: String?, + /** Refresh token expiration timestamp (ISO-8601). `null` iff [refreshToken] is `null`. */ + @Json(name = "refreshTokenExpiresAt") val refreshTokenExpiresAt: String?, + /** List of wallet IDs bound to this device. */ + @Json(name = "walletIds") val walletIds: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index c4f1f54238..38a2f2df19 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -33,6 +33,7 @@ sealed class ApiConfig { News, GaslessTxService, SurveySparrow, + Auth, } private fun initializeId(): ID { @@ -49,6 +50,7 @@ sealed class ApiConfig { is News -> ID.News is GaslessTxService -> ID.GaslessTxService is SurveySparrow -> ID.SurveySparrow + is Auth -> ID.Auth } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Auth.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Auth.kt new file mode 100644 index 0000000000..b84cc5c5a4 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Auth.kt @@ -0,0 +1,59 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig + +/** + * Tangem Auth Service [ApiConfig] — endpoints for device registration, authentication, + * nonce issuance, refresh token rotation, and JWKS publication. + */ +internal class Auth : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() + + override val environmentConfigs: List = listOf( + createDevEnvironment(), + createMockedEnvironment(), + createProdEnvironment(), + ) + + private fun getInitialEnvironment(): ApiEnvironment { + return when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK + DEBUG_BUILD_TYPE, + INTERNAL_BUILD_TYPE, + -> ApiEnvironment.DEV + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + } + + private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = DEV_BASE_URL, + headers = emptyMap(), + ) + + private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.MOCK, + baseUrl = MOCK_BASE_URL, + headers = emptyMap(), + ) + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = PROD_BASE_URL, + headers = emptyMap(), + ) + + private companion object { + + // TODO Replace with real Auth Service hosts once the backend team confirms deployment. + // Swagger currently only declares `http://localhost:8080` for local development. + // [REDACTED_JIRA] + private const val DEV_BASE_URL = "http://localhost:8080/" + private const val MOCK_BASE_URL = "http://localhost:8080/" + private const val PROD_BASE_URL = "http://localhost:8080/" + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index 1ed7b48d48..4a56663883 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -113,4 +113,10 @@ internal object ApiConfigsModule { fun provideSurveySparrowConfig(environmentConfig: EnvironmentConfig): ApiConfig { return SurveySparrow(environmentConfig) } + + @Provides + @IntoSet + fun provideAuthConfig(): ApiConfig { + return Auth() + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 06bcabc816..33ec143d5e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.di import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.auth.AuthApi import com.tangem.datasource.api.common.blockaid.BlockAidApi import com.tangem.datasource.api.surveysparrow.SurveySparrowApi import com.tangem.datasource.api.common.config.ApiConfig @@ -208,6 +209,15 @@ internal object NetworkModule { ) } + @Provides + @Singleton + fun provideAuthApi(retrofitApiBuilder: RetrofitApiBuilder): AuthApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.Auth, + applyTimeoutAnnotations = false, + ) + } + @Provides @Singleton fun provideGaslessTxServiceApi(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApi { diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt index 1b886a0447..573031630f 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt @@ -88,6 +88,7 @@ class ApiConfigTest { appInfoProvider = mockk(), ) ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig) + ApiConfig.ID.Auth -> Auth() } } } diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index 3ef468d8db..ac33d421ac 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -130,6 +130,7 @@ internal class ProdApiConfigsManagerTest { appInfoProvider = appInfoProvider, ) ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig) + ApiConfig.ID.Auth -> Auth() } } } @@ -148,9 +149,32 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.News -> createNewsModel() ApiConfig.ID.GaslessTxService -> createGaslessTxServiceModel() ApiConfig.ID.SurveySparrow -> createSurveySparrowModel() + ApiConfig.ID.Auth -> createAuthModel() } } + private fun createAuthModel(): TestModel { + val environment = when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK + DEBUG_BUILD_TYPE, + INTERNAL_BUILD_TYPE, + -> ApiEnvironment.DEV + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + + return TestModel( + id = ApiConfig.ID.Auth, + expected = ApiEnvironmentConfig( + environment = environment, + baseUrl = "http://localhost:8080/", + headers = emptyMap(), + ), + ) + } + private fun createExpressModel(): TestModel { val environment = when (BuildConfig.BUILD_TYPE) { DEBUG_BUILD_TYPE, From 5fda020fc396d50651bc359e51ede4e06d6d2393 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 16:28:48 +0300 Subject: [PATCH 3/7] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 1 + .../common/extensions/CustomAssertsExt.kt | 10 +- .../com/tangem/common/extensions/KNode.kt | 5 + .../tangem/common/extensions/UiDeviceExt.kt | 17 -- .../scenarios/CheckMainScreenScenarios.kt | 66 +------- .../com/tangem/scenarios/MarketsScenarios.kt | 20 +-- .../com/tangem/scenarios/SendScenarios.kt | 30 +++- .../com/tangem/scenarios/SwapScenarios.kt | 5 +- .../screens/AddFundsBottomSheetPageObject.kt | 41 +++++ .../screens/AddTokenBottomSheetPageObject.kt | 11 +- .../tangem/screens/MainScreenPageObject.kt | 38 ++++- .../screens/MarketsExchangesPageObject.kt | 7 +- .../com/tangem/screens/MarketsPageObject.kt | 23 ++- .../screens/MarketsTokenDetailsPageObject.kt | 12 +- .../screens/OrganizeTokensPageObject.kt | 15 +- .../tangem/screens/SendAddressPageObject.kt | 2 +- .../tangem/screens/TokenDetailsPageObject.kt | 76 ++------- .../screens/TransferBottomSheetPageObject.kt | 41 +++++ .../kotlin/com/tangem/tests/FeedbackTest.kt | 8 +- .../com/tangem/tests/OrganizeTokensTest.kt | 24 ++- .../kotlin/com/tangem/tests/ScanCardTest.kt | 48 ++---- .../kotlin/com/tangem/tests/StakingTest.kt | 16 +- .../kotlin/com/tangem/tests/WarningTest.kt | 4 - .../MainScreenActionButtonsTest.kt | 3 + .../TokenDetailsScreenActionButtonsTest.kt | 96 ++++++++--- .../tests/balance/TotalBalanceLongTapTest.kt | 42 ----- .../tests/balance/TotalBalanceUpdateTest.kt | 32 ++-- .../com/tangem/tests/main/MainScreenTest.kt | 21 ++- .../com/tangem/tests/main/WarningsTest.kt | 5 +- .../tests/markets/MarketsExchangesTest.kt | 7 +- .../send/addressScreen/RecentBlockTest.kt | 4 +- .../addressScreen/SendAddressScreenTest.kt | 7 +- .../confirmScreen/SendConfirmScreenTest.kt | 14 +- .../tests/send/feeScreen/SendFeeScreenTest.kt | 4 +- .../tests/send/warnings/KaspaWarningsTest.kt | 8 +- .../com/tangem/tests/swap/SwapStoriesTest.kt | 160 +++--------------- .../tangem/tests/swap/SwapTokenScreenTest.kt | 14 +- .../com/tangem/common/ui/earn/EarnBlock.kt | 12 +- .../ui/expressStatus/ExpressStatusItems.kt | 29 +++- .../common/ui/tokenaction/TokenActionRow.kt | 5 +- .../bottomsheets/TangemBottomSheet.kt | 5 +- .../modal/TangemModalBottomSheet.kt | 5 +- .../core/ui/ds/button/action/ActionButtons.kt | 9 +- .../core/ui/ds/message/TangemMessage.kt | 13 +- .../core/ui/test/BaseBottomSheetTestTags.kt | 1 + .../tangem/core/ui/test/MarketsTestTags.kt | 2 + .../ui/test/OrganizeTokensScreenTestTags.kt | 1 + .../detailed/MarketsTokenDetailsContent.kt | 3 + .../components/ExchangesBottomSheet.kt | 17 +- .../detailed/components/ListedOnBlock.kt | 17 +- .../tokendetails/ui/TokenDetailsScreen.kt | 17 +- .../tokendetails/ui/TokenDetailsTopBar.kt | 21 ++- .../ui/OrganizeTokensContent.kt | 16 +- .../child/tokenActions/TokenActionContent.kt | 7 +- .../wallet/ui/components/WalletItemBlocks.kt | 11 +- .../ui/components/common/WalletBalance.kt | 7 +- .../ui/components/common/WalletContent.kt | 4 +- .../ui/components/common/WalletTopBar.kt | 5 +- 58 files changed, 587 insertions(+), 557 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/TransferBottomSheetPageObject.kt delete mode 100644 app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 8b307f4571..15e6bfcafc 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -186,6 +186,7 @@ abstract class BaseTestCase : TestCase( "VISA_ONBOARDING_ENABLED" to true, "AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true, "AND_15310_ADD_FUNDS_STAGE1" to true, + "APP_REDESIGN_ENABLED" to true, ) ) } diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt index 87db4a23e4..309b7c2fa0 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt @@ -115,5 +115,13 @@ private fun extractText(node: SemanticsNode): String? { private fun parseVolume(node: SemanticsNode): Double? { val text = extractText(node) ?: return null - return text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull() + val multiplier = when { + text.contains('T', ignoreCase = true) -> 1_000_000_000_000.0 + text.contains('B', ignoreCase = true) -> 1_000_000_000.0 + text.contains('M', ignoreCase = true) -> 1_000_000.0 + text.contains('K', ignoreCase = true) -> 1_000.0 + else -> 1.0 + } + val number = text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull() ?: return null + return number * multiplier } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt index 3e5c61cf6b..ee98f114ff 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt @@ -11,6 +11,11 @@ fun KNode.clickWithAssertion() { performClick() } +fun KNode.clickWhenEnabled() { + assertIsEnabled() + performClick() +} + fun KNode.assertTextContainsSafe( text: String, substring: Boolean = false, diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt index 2ec823d1e8..b9121a87c6 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt @@ -4,8 +4,6 @@ import androidx.test.uiautomator.By import androidx.test.uiautomator.Until import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG -import com.tangem.wallet.R -import io.github.kakaocup.kakao.common.utilities.getResourceString fun BaseTestCase.swipeVertical( direction: SwipeDirection, @@ -31,21 +29,6 @@ fun BaseTestCase.pullToRefresh(steps: Int = 1000) { ) } -fun BaseTestCase.swipeMarketsBlock(direction: SwipeDirection) { - val searchBarText = device.uiDevice - .findObject(By.textContains(getResourceString(R.string.markets_search_header_title))) - val bounds = searchBarText.visibleBounds - - val centerX = bounds.centerX() - val startY = bounds.centerY() - val endY = when (direction) { - SwipeDirection.UP -> 50 - SwipeDirection.DOWN -> device.uiDevice.displayHeight - 100 - } - - device.uiDevice.swipe(centerX, startY, centerX, endY, 100) -} - fun BaseTestCase.openTheAppFromRecents() { device.uiDevice.waitForIdle() diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt index b8baf03580..cc456bd051 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt @@ -6,97 +6,33 @@ import com.tangem.common.extensions.swipeVertical import com.tangem.screens.onMainScreen import io.qameta.allure.kotlin.Allure.step -fun BaseTestCase.checkSingleCurrencyMainScreen( - cardBlockchain: String, - cardTitle: String, - withTransactions: Boolean = false, - withWalletImage: Boolean = true -) { +fun BaseTestCase.checkSingleCurrencyMainScreen(cardTitle: String) { step("Assert card title equal '$cardTitle'") { onMainScreen { walletNameText.assertTextEquals(cardTitle) } } - if (withWalletImage) { - step("Assert card image is displayed") { //TODO: create assertion method for checking images - onMainScreen { walletImage.assertIsDisplayed() } - } - } else { - step("Assert card image is not displayed") { - onMainScreen { walletImage.assertIsNotDisplayed() } - } - } - step("Assert 'Receive' button is displayed") { - onMainScreen { receiveButton.assertIsDisplayed() } - } step("Assert 'Buy' button is displayed") { onMainScreen { buyButton.assertIsDisplayed() } } - step("Assert 'Send' button is displayed") { - onMainScreen { sendButton.assertIsDisplayed() } - } step("Assert 'Sell' button is displayed") { onMainScreen { sellButton.assertIsDisplayed() } } step("Assert 'Swap' button is not displayed") { onMainScreen { swapButton.assertIsNotDisplayed() } } - step("Assert 'Market Price' on single card main screen is displayed") { - onMainScreen { marketPriceBlock().assertIsDisplayed() } - } - step("Assert 'Market Price' title equals $cardBlockchain Market Price") { - onMainScreen { marketPriceText.assertTextContains("$cardBlockchain Market Price") } - } step("Swipe up") { swipeVertical(SwipeDirection.UP) } - if (withTransactions) { - step("Assert 'Transactions' block is displayed") { - onMainScreen { transactionsExplorerText.assertIsDisplayed() } - } - step("Assert 'Transactions' title is displayed") { - onMainScreen { transactionsTitle.assertIsDisplayed() } - } - step("Assert 'Explorer' icon is displayed") { - onMainScreen { transactionsExplorerIcon.assertIsDisplayed() } - } - } else { - step("Assert empty 'Transactions' block is displayed") { - onMainScreen { emptyTransactionBlock.assertIsDisplayed() } - } - step("Assert empty 'Transactions' block icon is displayed") { - onMainScreen { emptyTransactionBlockIcon.assertIsDisplayed() } - } - step("Assert empty 'Transactions' block text is displayed") { - onMainScreen { emptyTransactionBlockText.assertIsDisplayed() } - } - step("Assert empty 'Transactions' block 'Explore' button is displayed") { - onMainScreen { emptyTransactionBlockExploreButton.assertIsDisplayed() } - } - } step("Assert 'Add & Manage' button is not displayed") { onMainScreen { addAndManageButtonWithoutLazySearch.assertIsNotDisplayed() } } } fun BaseTestCase.checkMultiCurrencyMainScreen( - devicesCount: String, cardTitle: String, - withWalletImage: Boolean = true ) { step("Assert card title equal '$cardTitle'") { onMainScreen { walletNameText.assertTextEquals(cardTitle) } } - if (withWalletImage) { - step("Assert card image is displayed") { - onMainScreen { walletImage.assertIsDisplayed() } - } - } else { - step("Assert card image is not displayed") { - onMainScreen { walletImage.assertIsNotDisplayed() } - } - } - step("Assert devices count equal to '$devicesCount'") { - onMainScreen { walletDevicesCount.assertTextContains(devicesCount) } - } step("Assert 'Buy' button is displayed") { onMainScreen { buyButton.assertIsDisplayed() } } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt index cda67fda49..ac596e4ede 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt @@ -1,28 +1,28 @@ package com.tangem.scenarios +import androidx.compose.ui.test.ExperimentalTestApi import com.tangem.common.BaseTestCase -import com.tangem.common.extensions.SwipeDirection import com.tangem.common.extensions.clickWithAssertion -import com.tangem.common.extensions.swipeVertical import com.tangem.screens.onMainScreen import com.tangem.screens.onMarketsExchangesScreen import com.tangem.screens.onMarketsScreen import com.tangem.screens.onMarketsTokenDetailsScreen import io.qameta.allure.kotlin.Allure.step -fun BaseTestCase.openMarketTokenDetailsScreen(blockchainName: String, tokenName: String) { +fun BaseTestCase.openTokenDetailsFromMarketsScreen(blockchainName: String, tokenName: String) { step("Open 'Markets' screen") { onMainScreen { searchThroughMarketPlaceholder.performClick() } waitForIdle() } - step("Click on 'Search' placeholder") { - onMarketsScreen { searchThroughMarketPlaceholder.performClick() } - } step("Click on $blockchainName blockchain") { waitForIdle() onMarketsScreen { tokenWithTitle(blockchainName).clickWithAssertion() } } - step("Click on $tokenName token") { + step("Click on 'In your portfolio' block") { + waitForIdle() + onMarketsTokenDetailsScreen { inYourPortfolioBlock.clickWithAssertion() } + } + step("Click on $tokenName token in 'Your portfolio' bottom sheet") { waitForIdle() onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() } } @@ -59,6 +59,7 @@ fun BaseTestCase.openMarketsScreen() { } } +@OptIn(ExperimentalTestApi::class) fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAllButton: Boolean = false) { openMarketsScreen() if (shouldClickSeeAllButton) @@ -69,9 +70,8 @@ fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAll onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() } waitForIdle() } - step("Scroll down") { - swipeVertical(SwipeDirection.UP) - swipeVertical(SwipeDirection.UP) + step("Scroll to 'Listed on exchanges' block") { + onMarketsScreen { scrollToListedOnBlock() } } step("Click on 'Listed on exchanges' block") { onMarketsScreen { listedOnBlockContainer.performClick() } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt index 5277c76ba8..591528e289 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt @@ -6,7 +6,6 @@ import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG -import com.tangem.common.extensions.assertIsDimmed import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.setWireMockScenarioState import com.tangem.screens.* @@ -34,8 +33,11 @@ fun BaseTestCase.openSendScreen( step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } - step("Click on 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } } @@ -91,11 +93,11 @@ fun BaseTestCase.openSendAddressScreen( step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } - step("Assert 'Send' button is not dimmed") { - onTokenDetailsScreen { sendButton().assertIsDimmed(false) } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } } - step("Click on 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } step("Type '$inputAmount' in input text field") { onSendScreen { @@ -109,6 +111,13 @@ fun BaseTestCase.openSendAddressScreen( step("Assert 'Send Address' container is displayed") { onSendAddressScreen { container.assertIsDisplayed() } } + step("Wait for recipient list to load") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { + onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() } + }.isSuccess + } + } } fun BaseTestCase.checkScanQrScreen(emptyClipboard: Boolean = true) { @@ -244,8 +253,11 @@ fun BaseTestCase.selectTokenToSendViaSwap( networkName: String, networkType: String? = null, ) { - step("Click on 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } step("Click on 'Swap to another token' button") { onSendScreen { swapToAnotherTokenButton.performClick() } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index d3933b0416..c036c80610 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -10,6 +10,7 @@ import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.extensions.assertVisibility +import com.tangem.common.extensions.clickWhenEnabled import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.isDisplayedSafely import com.tangem.core.ui.R as CoreUiR @@ -43,8 +44,8 @@ fun BaseTestCase.openSwapScreen( } SwapEntryPoint.TokenDetails -> step("Click on 'Swap' button on 'Token details' screen") { - onTokenDetailsScreen { swapButton().performClick() } - } + onTokenDetailsScreen { swapButton.clickWhenEnabled() } + } SwapEntryPoint.MarketsTokenDetails -> step("Click on 'Swap' button on 'Markets' token details screen") { onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt new file mode 100644 index 0000000000..5586e86757 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt @@ -0,0 +1,41 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseBottomSheetTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasText as withText + +class AddFundsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) }, + ) { + + val buyButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_buy))) + useUnmergedTree = true + } + + val swapButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_swap))) + useUnmergedTree = true + } + + val receiveButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_receive))) + useUnmergedTree = true + } + + val closeButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_close))) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onAddFundsBottomSheet(function: AddFundsBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AddTokenBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AddTokenBottomSheetPageObject.kt index fc864f40e2..e870bbb20e 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/AddTokenBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/AddTokenBottomSheetPageObject.kt @@ -11,7 +11,10 @@ import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen(semanticsProvider = semanticsProvider) { + ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) }, + ) { val title: KNode = child { hasTestTag(BaseBottomSheetTestTags.TITLE) @@ -23,6 +26,12 @@ class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractions hasText(getResourceString(R.string.common_add)) useUnmergedTree = true } + + val laterButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_later)) + useUnmergedTree = true + } } internal fun BaseTestCase.onAddTokenBottomSheet(function: AddTokenBottomSheetPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 82defdf7f0..ce7201883e 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.swipeUp import com.tangem.common.BaseTestCase import com.tangem.common.extensions.getQuantityString import com.tangem.common.extensions.hasLazyListItemPosition @@ -49,27 +50,32 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) val buyButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_buy)) + hasAnyDescendant(withText(getResourceString(R.string.common_buy))) + useUnmergedTree = true } val sendButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_send)) + hasAnyDescendant(withText(getResourceString(R.string.common_send))) + useUnmergedTree = true } val receiveButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_receive)) + hasAnyDescendant(withText(getResourceString(R.string.common_receive))) + useUnmergedTree = true } val sellButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_sell)) + hasAnyDescendant(withText(getResourceString(R.string.common_sell))) + useUnmergedTree = true } val swapButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_swap)) + hasAnyDescendant(withText(getResourceString(R.string.common_swap))) + useUnmergedTree = true } val walletNameText: KNode = child { @@ -82,13 +88,21 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) useUnmergedTree = true } - val walletDevicesCount: KNode = child { - hasTestTag(MainScreenTestTags.DEVICES_COUNT) - useUnmergedTree = true + /** + * Collapses the collapsing header via a touch-based swipe so that items near the bottom + * of the lazy list fall within screen bounds before programmatic childWith scroll. + * Required because TangemCollapsingTopBar places the body at y=collapsingHeight, which + * pushes lower list items off-screen when the header is expanded. + */ + private fun collapseHeader() { + screenContainer { + performTouchInput { swipeUp(startY = visibleSize.height * 0.6f, endY = visibleSize.height * 0.1f) } + } } @OptIn(ExperimentalTestApi::class) fun marketPriceBlock(): LazyListItemNode { + collapseHeader() return lazyList.childWith { hasTestTag(MarketPriceBlockTestTags.BLOCK) useUnmergedTree = true @@ -231,6 +245,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) */ @OptIn(ExperimentalTestApi::class) fun accountWithName(name: String): LazyListItemNode { + collapseHeader() return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasAnyDescendant(withText(name)) @@ -243,6 +258,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) */ @OptIn(ExperimentalTestApi::class) fun tokenWithTitleAndAddress(tokenTitle: String): KNode { + collapseHeader() return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasText(tokenTitle) @@ -255,6 +271,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) @OptIn(ExperimentalTestApi::class) fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode { + collapseHeader() return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasText(tokenTitle) @@ -267,6 +284,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) @OptIn(ExperimentalTestApi::class) fun addAndManageButton(): KNode { + collapseHeader() return lazyList.childWith { hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON) }.child { @@ -282,11 +300,12 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } val searchThroughMarketPlaceholder: KNode = child { - hasText(getResourceString(R.string.markets_search_header_title)) + hasText(getResourceString(R.string.markets_search_title_placeholder)) useUnmergedTree = true } fun tokenNetworkGroupTitle(tokenNetwork: String): KNode { + collapseHeader() return lazyList.child { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasAnyChild(withText(tokenNetwork)) @@ -296,6 +315,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) @OptIn(ExperimentalTestApi::class) fun tokenWithTitleAndPosition(tokenTitle: String, index: Int): KNode { + collapseHeader() return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasText(tokenTitle) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MarketsExchangesPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MarketsExchangesPageObject.kt index 00d7f17067..2ae6458a3b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MarketsExchangesPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MarketsExchangesPageObject.kt @@ -2,11 +2,9 @@ package com.tangem.screens import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.test.SemanticsNodeInteractionsProvider -import androidx.compose.ui.test.hasParent import androidx.compose.ui.test.hasTestTag import com.tangem.common.BaseTestCase import com.tangem.core.ui.test.TokenElementsTestTags -import com.tangem.core.ui.test.TopAppBarTestTags import com.tangem.features.onramp.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen @@ -23,16 +21,15 @@ class MarketsExchangesPageObject(private val provider: SemanticsNodeInteractions fun allExchangeTypeNodes(): List = provider - .onAllNodes(hasParent(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_PRICE)))) + .onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_PRICE)) .fetchSemanticsNodes() fun allTrustScoreNodes(): List = provider - .onAllNodes(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT))) + .onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT)) .fetchSemanticsNodes() val exchangesTitle: KNode = child { - hasTestTag(TopAppBarTestTags.TITLE) hasText(getResourceString(R.string.markets_token_details_exchanges_title)) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt index 1eefd12486..dd24c4d3ca 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt @@ -1,6 +1,8 @@ package com.tangem.screens +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.hasTestTag import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.MARKETS_MAIN_NETWORK_SUFFIX import com.tangem.core.ui.test.BaseButtonTestTags @@ -15,9 +17,9 @@ import io.github.kakaocup.kakao.common.utilities.getResourceString class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { - val addToPortfolioButton: KNode = child { + val addButton: KNode = child { hasTestTag(BaseButtonTestTags.TEXT) - hasText(getResourceString(R.string.common_add_to_portfolio)) + hasText(getResourceString(R.string.common_add)) useUnmergedTree = true } @@ -31,7 +33,12 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : } val searchThroughMarketPlaceholder: KNode = child { - hasText(getResourceString(R.string.markets_search_header_title)) + hasText(getResourceString(R.string.markets_search_title_placeholder)) + useUnmergedTree = true + } + + val tokenDetailsContent: KNode = child { + hasTestTag(MarketsTestTags.TOKEN_DETAILS_CONTENT) useUnmergedTree = true } @@ -41,7 +48,8 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : } val listedOnBlockContainer: KNode = child { - hasText(getResourceString(R.string.markets_token_details_listed_on), substring = true) + hasTestTag(MarketsTestTags.LISTED_ON_BLOCK) + useUnmergedTree = true } val listedOnEmptyText: KNode = child { @@ -60,6 +68,13 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasText(title) } } + + @ExperimentalTestApi + fun scrollToListedOnBlock() { + tokenDetailsContent { + performScrollToNode(hasTestTag(MarketsTestTags.LISTED_ON_BLOCK)) + } + } } internal fun BaseTestCase.onMarketsScreen(function: MarketsPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MarketsTokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MarketsTokenDetailsPageObject.kt index 237a0f183b..cdd4f4843c 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MarketsTokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MarketsTokenDetailsPageObject.kt @@ -3,14 +3,13 @@ package com.tangem.screens import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags -import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.features.onramp.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString -import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText +import com.tangem.core.ui.R as CoreUiR class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -20,11 +19,14 @@ class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractions hasText(getResourceString(R.string.common_swap), substring = true) } + val inYourPortfolioBlock: KNode = child { + hasText(getResourceString(CoreUiR.string.markets_portfolio_block_subtitle), substring = true) + useUnmergedTree = true + } + fun tokenWithTitle(title: String): KNode = child { - hasAnyAncestor(withTestTag(MarketTokenDetailsBottomSheetTestTags.PORTFOLIO_TOKEN_ITEM)) - hasTestTag(TokenElementsTestTags.TOKEN_TITLE) - hasAnySibling(withTestTag(TokenElementsTestTags.TOKEN_ICON)) hasAnyChild(withText(title)) + hasClickAction() useUnmergedTree = true } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt index 1d5bd3418b..2ea02089e6 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt @@ -28,23 +28,18 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi useUnmergedTree = true } - private val topBarGroupButton: KNode = child { - hasTestTag(OrganizeTokensScreenTestTags.GROUP_BUTTON) + val organizeMenuButton: KNode = child { + hasTestTag(OrganizeTokensScreenTestTags.MENU_BUTTON) useUnmergedTree = true } - val groupButton: KNode = topBarGroupButton.child { + val groupButton: KNode = child { hasText(getResourceString(R.string.organize_tokens_group)) useUnmergedTree = true } - val ungroupButton: KNode = topBarGroupButton.child { - hasText(getResourceString(R.string.organize_tokens_ungroup)) - useUnmergedTree = true - } - val sortByBalanceButton: KNode = child { - hasTestTag(OrganizeTokensScreenTestTags.SORT_BY_BALANCE_BUTTON) + hasText(getResourceString(R.string.organize_tokens_sort_by_balance)) useUnmergedTree = true } // endregion TopBar @@ -84,7 +79,7 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi fun tokenNetworkGroupTitle(tokenNetwork: String): KNode { return lazyList.child { hasTestTag(OrganizeTokensScreenTestTags.GROUP_TITLE_ITEM) - hasAnyChild(withText(tokenNetwork)) + hasAnyDescendant(withText(tokenNetwork)) useUnmergedTree = true } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt index 85d6640b23..be240a7c5a 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt @@ -97,7 +97,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider ): KNode = child { hasTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ITEM) hasAnyChild(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ICON)) - hasAnyDescendant(withText(recipientAddress)) + hasAnyDescendant(withText(recipientAddress, substring = true)) hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TEXT)) useUnmergedTree = true if (description != null) { diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index 4996f7db80..9f4b705579 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -1,20 +1,15 @@ package com.tangem.screens -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase -import com.tangem.common.utils.LazyListItemNode import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.NotificationTestTags import com.tangem.core.ui.test.TokenDetailsScreenTestTags -import com.tangem.core.ui.utils.LazyListItemPositionSemantics import com.tangem.features.tokendetails.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode -import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText @@ -36,18 +31,8 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide useUnmergedTree = true } - val availableStakingBlockTitle: KNode = child { - hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TITLE) - useUnmergedTree = true - } - - val availableStakingBlockText: KNode = child { - hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TEXT) - useUnmergedTree = true - } - - val availableStakingBlockCurrencyIcon: KNode = child { - hasTestTag(TokenDetailsScreenTestTags.STAKING_CURRENCY_ICON) + fun availableStakingBlockText(apy: String): KNode = child { + hasText(getResourceString(R.string.token_details_earn_staking_subtitle, apy)) useUnmergedTree = true } @@ -62,69 +47,39 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide useUnmergedTree = true } - val stakingDot: KNode = child { - hasTestTag(TokenDetailsScreenTestTags.STAKING_DOT) - useUnmergedTree = true - } - val stakingTokenAmount: KNode = child { hasTestTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT) useUnmergedTree = true } - val stakingChevronIcon: KNode = child { - hasTestTag(TokenDetailsScreenTestTags.STAKING_CHEVRON_ICON) - useUnmergedTree = true + val stakingTitle: KNode = child { + hasText(getResourceString(R.string.common_staking)) } - val stakingTitle: KNode = child { - hasText(getResourceString(R.string.staking_native)) + val stakingEnabledTitle: KNode = child { + hasText(getResourceString(R.string.staking_enabled)) } val title: KNode = child { hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE) } - private val horizontalActionChips = KLazyListNode( - semanticsProvider = semanticsProvider, - viewBuilderAction = { hasTestTag(BaseActionButtonsBlockTestTags.HORIZONTAL_ACTION_CHIPS) }, - itemTypeBuilder = { itemType(::LazyListItemNode) }, - positionMatcher = { position -> - SemanticsMatcher.expectValue( - LazyListItemPositionSemantics, - position - ) - } - ) - - @OptIn(ExperimentalTestApi::class) - fun receiveButton(): LazyListItemNode = horizontalActionChips.childWith { + val addFundsButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_receive)) + hasAnyDescendant(withText(getResourceString(R.string.tangempay_card_details_add_funds))) + useUnmergedTree = true } - @OptIn(ExperimentalTestApi::class) - fun swapButton(): LazyListItemNode = horizontalActionChips.childWith { + val swapButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_swap)) + hasAnyDescendant(withText(getResourceString(R.string.common_swap))) + useUnmergedTree = true } - @OptIn(ExperimentalTestApi::class) - fun sellButton(): LazyListItemNode = horizontalActionChips.childWith { + val transferButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_sell)) - } - - @OptIn(ExperimentalTestApi::class) - fun buyButton(): LazyListItemNode = horizontalActionChips.childWith { - hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_buy)) - } - - @OptIn(ExperimentalTestApi::class) - fun sendButton(): LazyListItemNode = horizontalActionChips.childWith { - hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_send)) + hasAnyDescendant(withText(getResourceString(R.string.common_transfer))) + useUnmergedTree = true } fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child { @@ -204,7 +159,6 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON)) hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON)) hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT)) - hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_CHEVRON_ICON)) useUnmergedTree = true } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TransferBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TransferBottomSheetPageObject.kt new file mode 100644 index 0000000000..f8dd42807f --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TransferBottomSheetPageObject.kt @@ -0,0 +1,41 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseBottomSheetTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasText as withText + +class TransferBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) }, + ) { + + val sendButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_send))) + useUnmergedTree = true + } + + val swapButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_swap))) + useUnmergedTree = true + } + + val sellButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_sell))) + useUnmergedTree = true + } + + val closeButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_close))) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onTransferBottomSheet(function: TransferBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt index b7fa3d9c5b..821cf2ca7b 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -27,6 +27,7 @@ import com.tangem.screens.onSendScreen import com.tangem.screens.onStoriesScreen import com.tangem.screens.onTokenDetailsScreen import com.tangem.screens.onMainScreenTopBar +import com.tangem.screens.onTransferBottomSheet import com.tangem.tap.domain.sdk.mocks.MockProvider import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId @@ -94,8 +95,11 @@ class FeedbackTest : BaseTestCase() { step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } - step("Click 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } step("Type '$sendAmount' in input text field") { onSendScreen { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt index 81fe762f61..23ae843dbc 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt @@ -22,7 +22,8 @@ class OrganizeTokensTest : BaseTestCase() { fun groupTokensTest() { setupHooks().run { val tokenTitle = "Ethereum" - val tokenNetwork = "Ethereum network" + val networkTitleOrganize = "Ethereum" + val networkTitleMain = "Ethereum network" step("Open 'Main Screen'") { openMainScreen() @@ -39,17 +40,20 @@ class OrganizeTokensTest : BaseTestCase() { tokenWithTitle(tokenTitle).assertIsDisplayed() } } + step("Open organize menu") { + onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() } + } step("Click 'Group' button") { onOrganizeTokensScreen { groupButton.clickWithAssertion() } } step("Assert tokens were grouped on 'Organize tokens' screen") { - onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() } + onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsDisplayed() } } step("Click 'Apply' button") { onOrganizeTokensScreen { applyButton.clickWithAssertion() } } step("Assert tokens were grouped on 'Main screen'") { - onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() } + onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsDisplayed() } } step("Open 'Organize tokens' screen") { openOrganizeTokensScreen() @@ -60,17 +64,20 @@ class OrganizeTokensTest : BaseTestCase() { tokenWithTitle(tokenTitle).assertIsDisplayed() } } - step("Click 'Ungroup' button") { - onOrganizeTokensScreen { ungroupButton.clickWithAssertion() } + step("Open organize menu") { + onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() } + } + step("Click 'Group' checkbox again to ungroup") { + onOrganizeTokensScreen { groupButton.clickWithAssertion() } } step("Assert tokens were ungrouped on 'Organize tokens' screen") { - onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() } + onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsNotDisplayed() } } step("Click 'Apply' button") { onOrganizeTokensScreen { applyButton.clickWithAssertion() } } step("Assert tokens were ungrouped on 'Main screen'") { - onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() } + onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsNotDisplayed() } } } } @@ -185,6 +192,9 @@ class OrganizeTokensTest : BaseTestCase() { tokenWithTitleAndPosition(polExMaticTitle, 3).assertIsDisplayed() } } + step("Open organize menu") { + onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() } + } step("Click 'By Balance' button") { onOrganizeTokensScreen { sortByBalanceButton.clickWithAssertion() diff --git a/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt index 1b83533fcb..d3511f52da 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt @@ -35,11 +35,7 @@ class ScanCardTest : BaseTestCase() { openMainScreen(cardType) } step("Check 'Main' screen for '${cardType.name}' $cardBlockchain card") { - checkSingleCurrencyMainScreen( - cardBlockchain = cardBlockchain, - cardTitle = cardType.name, - withTransactions = true - ) + checkSingleCurrencyMainScreen(cardTitle = cardType.name) } } } @@ -57,7 +53,7 @@ class ScanCardTest : BaseTestCase() { openMainScreen(mockContent = cardType, isTwinsCard = true) } step("Check 'Main' screen for '$cardName' $cardBlockchain card") { - checkSingleCurrencyMainScreen(cardBlockchain = cardBlockchain, cardTitle = cardName) + checkSingleCurrencyMainScreen(cardTitle = cardName) } } } @@ -66,7 +62,6 @@ class ScanCardTest : BaseTestCase() { @DisplayName("Scan: Card with Secp256k1 curve") @Test fun secpk1CurveCardScanTest() { - val devicesCount = "1 device" val cardType: MockContent = Secpk1CurveMockContent val cardName = "Wallet" val card = "card with Secp256k1 curve" @@ -75,12 +70,8 @@ class ScanCardTest : BaseTestCase() { step("Open 'Main Screen' on $card") { openMainScreen(mockContent = cardType) } - step("Check 'Main' screen for $card curve with devices count = '$devicesCount'") { - checkMultiCurrencyMainScreen( - devicesCount = devicesCount, - cardTitle = cardName, - withWalletImage = false - ) + step("Check 'Main' screen for $card curve") { + checkMultiCurrencyMainScreen(cardTitle = cardName) } } } @@ -99,11 +90,7 @@ class ScanCardTest : BaseTestCase() { openMainScreen(mockContent = cardType) } step("Check 'Main' screen for $card with blockchain: '$cardBlockchain'") { - checkSingleCurrencyMainScreen( - cardBlockchain = cardBlockchain, - cardTitle = cardName, - withWalletImage = false - ) + checkSingleCurrencyMainScreen(cardTitle = cardName) } } } @@ -112,7 +99,6 @@ class ScanCardTest : BaseTestCase() { @DisplayName("Scan: 'Shiba' card") @Test fun shibaCardScanTest() { - val devicesCount = "2 devices" val cardType: MockContent = ShibaMockContent val cardName = "Wallet" val card = "Shiba" @@ -121,8 +107,8 @@ class ScanCardTest : BaseTestCase() { step("Open 'Main Screen' on '$card' card") { openMainScreen(mockContent = cardType) } - step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") { - checkMultiCurrencyMainScreen(devicesCount, cardName) + step("Check 'Main' screen for '$card' card") { + checkMultiCurrencyMainScreen(cardName) } } } @@ -131,7 +117,6 @@ class ScanCardTest : BaseTestCase() { @DisplayName("Scan: 'Ring'") @Test fun ringScanTest() { - val devicesCount = "3 devices" val cardType: ProductType = ProductType.Ring val cardName = "Wallet" val ring = "Ring" @@ -140,8 +125,8 @@ class ScanCardTest : BaseTestCase() { step("Open 'Main Screen' on '$ring'") { openMainScreen(productType = cardType) } - step("Check 'Main' screen for '$ring' with devices count = '$devicesCount'") { - checkMultiCurrencyMainScreen(devicesCount, cardName) + step("Check 'Main' screen for '$ring'") { + checkMultiCurrencyMainScreen(cardName) } } } @@ -150,7 +135,6 @@ class ScanCardTest : BaseTestCase() { @DisplayName("Scan: 'Wallet' card") @Test fun walletCardScanTest() { - val devicesCount = "1 device" val cardType: ProductType = ProductType.Wallet val cardName = "Wallet" @@ -158,8 +142,8 @@ class ScanCardTest : BaseTestCase() { step("Open 'Main Screen' on '$cardName' card") { openMainScreen(productType = cardType) } - step("Check 'Main' screen for '$cardName' card with devices count = '$devicesCount'") { - checkMultiCurrencyMainScreen(devicesCount, cardName) + step("Check 'Main' screen for '$cardName' card") { + checkMultiCurrencyMainScreen(cardName) } } } @@ -168,7 +152,6 @@ class ScanCardTest : BaseTestCase() { @DisplayName("Scan: 'Wallet 2' card") @Test fun wallet2ScanTest() { - val devicesCount = "2 devices" val cardType: MockContent = Wallet2MockContent val cardName = "Wallet" val card = "Wallet 2" @@ -177,8 +160,8 @@ class ScanCardTest : BaseTestCase() { step("Open 'Main Screen' on '$card' card") { openMainScreen(mockContent = cardType) } - step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") { - checkMultiCurrencyMainScreen(devicesCount, cardName) + step("Check 'Main' screen for '$card' card") { + checkMultiCurrencyMainScreen(cardName) } } } @@ -187,7 +170,6 @@ class ScanCardTest : BaseTestCase() { @DisplayName("Scan: Card with 4.12 firmware") @Test fun firmware412CardScanTest() { - val devicesCount = "1 device" val cardType: MockContent = Firmware412MockContent val cardName = "Tangem card" val card = "card with 4.12 firmware" @@ -196,8 +178,8 @@ class ScanCardTest : BaseTestCase() { step("Open 'Main Screen' on '$card'") { openMainScreen(mockContent = cardType) } - step("Check 'Main' screen for '$card' with devices count = '$devicesCount'") { - checkMultiCurrencyMainScreen(devicesCount, cardName) + step("Check 'Main' screen for '$card'") { + checkMultiCurrencyMainScreen(cardName) } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt index d02ce135ee..013753f460 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt @@ -56,20 +56,14 @@ class StakingTest : BaseTestCase() { onTokenDetailsScreen { stakingBlock.assertIsDisplayed() } } step("Assert 'Staking title' is displayed") { - onTokenDetailsScreen { stakingTitle.assertIsDisplayed() } + onTokenDetailsScreen { stakingEnabledTitle.assertIsDisplayed() } } step("Assert 'Staking fiat amount' is displayed") { onTokenDetailsScreen { stakingFiatAmount.assertIsDisplayed() } } - step("Assert 'Staking dot' is displayed") { - onTokenDetailsScreen { stakingDot.assertIsDisplayed() } - } step("Assert 'Staking token amount' is displayed") { onTokenDetailsScreen { stakingTokenAmount.assertIsDisplayed() } } - step("Assert 'Staking block chevron icon' is displayed") { - onTokenDetailsScreen { stakingChevronIcon.assertIsDisplayed() } - } } } @@ -139,6 +133,7 @@ class StakingTest : BaseTestCase() { val scenarioName = "staking_eth_pol_balances_android" val scenarioState = "Started" val stakingAmount = "1" + val stakingApy = "2.84%" setupHooks( additionalAfterSection = { @@ -172,13 +167,10 @@ class StakingTest : BaseTestCase() { onTokenDetailsScreen { availableStakingBlock.assertIsDisplayed() } } step("Assert 'Available staking block' title is displayed") { - onTokenDetailsScreen { availableStakingBlockTitle.assertIsDisplayed() } + onTokenDetailsScreen { stakingTitle.assertIsDisplayed() } } step("Assert 'Available staking block' text is displayed") { - onTokenDetailsScreen { availableStakingBlockText.assertIsDisplayed() } - } - step("Assert 'Available staking block' currency icon is displayed") { - onTokenDetailsScreen { availableStakingBlockCurrencyIcon.assertIsDisplayed() } + onTokenDetailsScreen { availableStakingBlockText(stakingApy).assertIsDisplayed() } } step("Click on 'Stake' button") { onTokenDetailsScreen { stakeButton.clickWithAssertion() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt index 51bc278976..e3c3937047 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt @@ -1,14 +1,10 @@ package com.tangem.tests import com.tangem.common.BaseTestCase -import com.tangem.common.utils.resetWireMockScenarioState -import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.openMainScreen import com.tangem.screens.onMainScreen import com.tangem.tap.domain.sdk.mocks.content.DevWalletMockContent -import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithSeedPhraseMockContent import dagger.hilt.android.testing.HiltAndroidTest -import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName import org.junit.Test diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt index 1f795cadce..418774cd8e 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -389,6 +389,9 @@ class MainScreenActionButtonsTest : BaseTestCase() { step("Click on 'Buy' button") { onMainScreen { buyButton.performClick() } } + step("Click on token: '$tokenTitle'") { + onBuyTokenScreen { tokenWithTitleAndFiatAmount(tokenTitle).performClick() } + } step("Click on 'Confirm' button in 'Dialog'") { waitForIdle() onDialog { confirmButton.clickWithAssertion() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt index f634fe21c0..99e629f6e9 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt @@ -2,16 +2,17 @@ package com.tangem.tests.actionButtons import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT -import com.tangem.common.extensions.assertIsDimmed import com.tangem.common.extensions.clickWithAssertion import com.tangem.scenarios.checkQrCodeBottomSheetScenario import com.tangem.scenarios.goToQrCodeBottomSheet import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onAddFundsBottomSheet import com.tangem.screens.onMainScreen import com.tangem.screens.onSwapStoriesScreen import com.tangem.screens.onSwapTokenScreen import com.tangem.screens.onTokenDetailsScreen +import com.tangem.screens.onTransferBottomSheet import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -37,20 +38,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { waitForIdle() onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } } - step("Assert 'Receive' button is displayed") { - onTokenDetailsScreen { receiveButton().assertIsDisplayed() } - } - step("Assert 'Buy' button is displayed") { - onTokenDetailsScreen { buyButton().assertIsDisplayed() } - } - step("Assert 'Send' button is displayed") { - onTokenDetailsScreen { sendButton().assertIsDisplayed() } + step("Assert 'Add funds' button is displayed") { + onTokenDetailsScreen { addFundsButton.assertIsDisplayed() } } step("Assert 'Swap' button is displayed") { - onTokenDetailsScreen { swapButton().assertIsDisplayed() } + onTokenDetailsScreen { swapButton.assertIsDisplayed() } } - step("Assert 'Sell' button is displayed") { - onTokenDetailsScreen { sellButton().assertIsDisplayed() } + step("Assert 'Transfer' button is displayed") { + onTokenDetailsScreen { transferButton.assertIsDisplayed() } + } + step("Click on 'Add funds' button") { + onTokenDetailsScreen { addFundsButton.clickWithAssertion() } + } + step("Assert 'Buy' button in bottom sheet is displayed") { + onAddFundsBottomSheet { buyButton.assertIsDisplayed() } + } + step("Assert 'Swap' button in bottom sheet is displayed") { + onAddFundsBottomSheet { swapButton.assertIsDisplayed() } + } + step("Assert 'Receive' button in bottom sheet is displayed") { + onAddFundsBottomSheet { receiveButton.assertIsDisplayed() } + } + step("Click on 'Close' button in bottom sheet") { + onAddFundsBottomSheet { closeButton.clickWithAssertion() } + } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Assert 'Send' button in bottom sheet is displayed") { + onTransferBottomSheet { sendButton.assertIsDisplayed() } + } + step("Assert 'Swap' button in bottom sheet is displayed") { + onTransferBottomSheet { swapButton.assertIsDisplayed() } + } + step("Assert 'Sell' button in bottom sheet is displayed") { + onTransferBottomSheet { sellButton.assertIsDisplayed() } } } } @@ -72,20 +94,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { waitForIdle() onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } } - step("Assert 'Receive' button is not dimmed") { - onTokenDetailsScreen { receiveButton().assertIsDimmed(false) } + step("Assert 'Add funds' button is enabled") { + onTokenDetailsScreen { addFundsButton.assertIsEnabled() } } - step("Assert 'Buy' button is not dimmed") { - onTokenDetailsScreen { buyButton().assertIsDimmed(false) } + step("Assert 'Swap' button is disabled") { + onTokenDetailsScreen { swapButton.assertIsNotEnabled() } } - step("Assert 'Send' button is not dimmed") { - onTokenDetailsScreen { sendButton().assertIsDimmed(false) } + step("Assert 'Transfer' button is enabled") { + onTokenDetailsScreen { transferButton.assertIsEnabled() } } - step("Assert 'Swap' button is dimmed") { - onTokenDetailsScreen { swapButton().assertIsDimmed() } + step("Click on 'Add funds' button") { + onTokenDetailsScreen { addFundsButton.clickWithAssertion() } } - step("Assert 'Sell' button is dimmed") { - onTokenDetailsScreen { sellButton().assertIsDimmed() } + step("Assert 'Buy' button in bottom sheet is enabled") { + onAddFundsBottomSheet { buyButton.assertIsEnabled() } + } + step("Assert 'Swap' button in bottom sheet is disabled") { + onAddFundsBottomSheet { swapButton.assertIsNotEnabled() } + } + step("Assert 'Receive' button in bottom sheet is enabled") { + onAddFundsBottomSheet { receiveButton.assertIsEnabled() } + } + step("Click on 'Close' button in bottom sheet") { + onAddFundsBottomSheet { closeButton.clickWithAssertion() } + } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Assert 'Send' button in bottom sheet is enabled") { + onTransferBottomSheet { sendButton.assertIsEnabled() } + } + step("Assert 'Swap' button in bottom sheet is disabled") { + onTransferBottomSheet { swapButton.assertIsNotEnabled() } + } + step("Assert 'Sell' button in bottom sheet is disabled") { + onTransferBottomSheet { sellButton.assertIsNotEnabled() } } } } @@ -109,7 +152,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton().performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -140,8 +183,11 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { waitForIdle() onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } } - step("Click on 'Receive' button") { - onTokenDetailsScreen { receiveButton().performClick() } + step("Click on 'Add funds' button") { + onTokenDetailsScreen { addFundsButton.clickWithAssertion() } + } + step("Click on 'Receive' button in bottom sheet") { + onAddFundsBottomSheet { receiveButton.clickWithAssertion() } } step("Go to QR code bottom sheet") { flakySafely(WAIT_UNTIL_TIMEOUT) { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt deleted file mode 100644 index bae6abd799..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.tangem.tests.balance - -import androidx.compose.ui.test.longClick -import com.tangem.common.BaseTestCase -import com.tangem.scenarios.openMainScreen -import com.tangem.scenarios.synchronizeAddresses -import com.tangem.screens.onMainScreen -import dagger.hilt.android.testing.HiltAndroidTest -import io.qameta.allure.kotlin.AllureId -import io.qameta.allure.kotlin.junit4.DisplayName -import org.junit.Test - -@HiltAndroidTest -class TotalBalanceLongTapTest : BaseTestCase() { - - @Test - @AllureId("3965") - @DisplayName("Total balance: check long tap on block without biometry") - fun whenBiometryIsOffTest() { - setupHooks().run { - step("Open 'Main Screen'") { - openMainScreen() - } - step("Synchronize addresses") { - synchronizeAddresses() - } - step("Long tap on total balance block") { - onMainScreen { - totalBalanceContainer.performTouchInput { - longClick() - } - } - } - step("Assert 'Rename' button is displayed") { - onMainScreen { totalBalanceMenuRenameWallet.assertIsDisplayed() } - } - step("Assert 'Delete' button is not displayed") { - onMainScreen { totalBalanceMenuDeleteWallet.assertIsNotDisplayed() } - } - } - } -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt index b8a7826147..61318f6706 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt @@ -3,6 +3,7 @@ package com.tangem.tests.balance import androidx.compose.ui.test.longClick import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.extensions.* import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState @@ -82,28 +83,27 @@ class TotalBalanceUpdateTest : BaseTestCase() { step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) } - step("Click on 'Add to portfolio' button") { - onMarketsScreen { addToPortfolioButton.clickWithAssertion() } + step("Click on 'Add' button in 'Markets' bottom sheet") { + onMarketsScreen { addButton.clickWithAssertion() } } - step("Click on main network") { - onMarketsScreen { mainNetworkSuffix.performClick() } - } - step("Click on 'Add' button") { - onDialog { addButton.clickWithAssertion() } - } - step("Assert 'Continue' is not displayed") { - onDialog { addButton.assertIsNotDisplayed() } + step("Click on 'Add' button in 'Add token' bottom sheet") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + onAddTokenBottomSheet { + addButton.performClick() + } + onAddTokenBottomSheet { laterButton.assertIsDisplayed() } + } } step("Click on 'Later' button") { - onDialog { laterButton.clickWithAssertion() } + onAddTokenBottomSheet { laterButton.performClick() } } - step("Go back to 'Markets: tokens list'") { + step("Press 'Back' button") { waitForIdle() - onMarketsScreen { topBarBackButton.clickWithAssertion() } + device.uiDevice.pressBack() } - step("Close 'Markets screen'") { - onSearchBar { searchField.assertIsDisplayed() } - swipeMarketsBlock(SwipeDirection.DOWN) + step("Press 'Back' button") { + waitForIdle() + device.uiDevice.pressBack() } step("Assert $updatedBalance is displayed in total balance") { onMainScreen { totalBalanceText.assertTextContains(updatedBalance) } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt index ac99d2e42e..0822905804 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt @@ -2,6 +2,8 @@ package com.tangem.tests.main import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.SwipeDirection +import com.tangem.common.extensions.swipeVertical import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.openMainScreen @@ -35,7 +37,7 @@ class MainScreenTest : BaseTestCase() { } @AllureId("8748") - @DisplayName("Main: check 'Organize tokens' button with single token no accounts") + @DisplayName("Main: check 'Add & Manage' button with single token no accounts") @Test fun checkOrganizeTokensButtonWithSingleTokenNoAccountsTest() { val scenarioState = "Cardano" @@ -56,14 +58,14 @@ class MainScreenTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Assert 'Add & Manage' button is not displayed") { - onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()} + step("Assert 'Add & Manage' button is displayed") { + onMainScreen { addAndManageButtonNode.assertIsDisplayed() } } } } @AllureId("8749") - @DisplayName("Main: check 'Organize tokens' button with single token two accounts") + @DisplayName("Main: check 'Add & Manage' button with single token two accounts") @Test fun checkOrganizeTokensButtonWithSingleTokenMultiAccountsTest() { val scenarioState = "TwoAccountsSingleTokenEach" @@ -81,14 +83,14 @@ class MainScreenTest : BaseTestCase() { step("Open 'Main Screen'") { openMainScreen() } - step("Assert 'Add & Manage' button is not displayed") { - onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()} + step("Assert 'Add & Manage' button is displayed") { + onMainScreen { addAndManageButtonNode.assertIsDisplayed() } } } } @AllureId("8750") - @DisplayName("Main: check 'Organize tokens' button with multiple tokens two accounts") + @DisplayName("Main: check 'Add & Manage' button with multiple tokens two accounts") @Test fun checkOrganizeTokensButtonWithMultipleTokensMultiAccountsTest() { val scenarioState = "TwoAccountsMixed" @@ -106,8 +108,11 @@ class MainScreenTest : BaseTestCase() { step("Open 'Main Screen'") { openMainScreen() } + step("Swipe up") { + swipeVertical(SwipeDirection.UP, startHeightRatio = 0.6f, endHeightRatio = 0.1f) + } step("Assert 'Add & Manage' button is displayed") { - onMainScreen { addAndManageButtonNode.assertIsDisplayed()} + onMainScreen { addAndManageButtonNode.assertIsDisplayed() } } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/main/WarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/main/WarningsTest.kt index 689da371e0..8510d1e1c6 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/main/WarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/main/WarningsTest.kt @@ -16,7 +16,7 @@ import org.junit.Test class WarningsTest : BaseTestCase() { @AllureId("184") - @DisplayName("Token list: hide token by long tap") + @DisplayName("Warnings: missing address warning") @Test fun checkUnavailableNetworksWarningTest() { val scenarioState = "MissingDerivation" @@ -38,9 +38,6 @@ class WarningsTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses(isBalanceAvailable = false) } - step("Assert 'Missing addresses' notification icon is displayed") { - onMainScreen { missingAddressNotificationIcon.assertIsDisplayed() } - } step("Assert 'Missing addresses' notification title is displayed") { onMainScreen { missingAddressNotificationTitle.assertIsDisplayed() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt index fc17e54653..cef6601e65 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt @@ -1,5 +1,6 @@ package com.tangem.tests.markets +import androidx.compose.ui.test.ExperimentalTestApi import com.tangem.common.BaseTestCase import com.tangem.common.annotations.ApiEnv import com.tangem.common.annotations.ApiEnvConfig @@ -39,6 +40,7 @@ class MarketsExchangesTest : BaseTestCase() { } } + @OptIn(ExperimentalTestApi::class) @Test @AllureId("56") @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) @@ -60,9 +62,8 @@ class MarketsExchangesTest : BaseTestCase() { onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() } waitForIdle() } - step("Scroll down") { - swipeVertical(SwipeDirection.UP) - swipeVertical(SwipeDirection.UP) + step("Scroll to 'Listed on exchanges' block") { + onMarketsScreen { scrollToListedOnBlock() } } step("Assert 'Listed on exchanges' block has title") { onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/RecentBlockTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/RecentBlockTest.kt index 37950eb1a3..ff84ea465b 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/RecentBlockTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/RecentBlockTest.kt @@ -227,7 +227,7 @@ class RecentBlockTest : BaseTestCase() { val sendAmount = "1" val txHistoryScenarioState = "11OutgoingTransactions" val recipientAddressBase = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaq" - val shortenedRecipientAddress = "DJ2TaZ5vvp3mBLugU...Li4uYaq123456789b" + val longRecipientAddress = recipientAddressBase + "123456789b" setupHooks( additionalAfterSection = { @@ -261,7 +261,7 @@ class RecentBlockTest : BaseTestCase() { checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = recentTransactionAmount1) } step("Check recent address item №2") { - checkRecentAddressItem(address = shortenedRecipientAddress, description = recentTransactionAmount2) + checkRecentAddressItem(address = longRecipientAddress, description = recentTransactionAmount2) } step("Check recent address item №3") { checkRecentAddressItem(address = recipientAddressBase + "k", description = recentTransactionAmount2) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt index 6f0000086b..49d9682e4e 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt @@ -246,8 +246,11 @@ class SendAddressScreenTest : BaseTestCase() { step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } } - step("Click on 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt index a9746ca3c1..4a8f00b331 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt @@ -46,8 +46,11 @@ class SendConfirmScreenTest : BaseTestCase() { step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } - step("Click on 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } step("Type '$inputAmount' in input text field") { onSendScreen { @@ -123,8 +126,11 @@ class SendConfirmScreenTest : BaseTestCase() { step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } - step("Click on 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } step("Type '$inputAmount' in input text field") { onSendScreen { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt index b9f6635d09..d67314ba1c 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt @@ -281,13 +281,13 @@ class SendFeeScreenTest : BaseTestCase() { fun checkNetworkFeeBottomSheetForBitcoinTest() { val tokenName = "Bitcoin" val tokenAmount = "0.00000001" - val feeAmount = "$2.86" + val feeAmount = "$0.48" val fiatFeeAmount = "$0.24" val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market) val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast) val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow) val feeUpTo = getResourceString(R.string.send_max_fee) - val feeUpToValue = "0.0000264 BTC" + val feeUpToValue = "0.0000044 BTC" val newFeeUpToValue = "0.0000022 BTC" val satoshi = getResourceString(R.string.send_satoshi_per_byte_title) val satoshiValue = "2" diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaWarningsTest.kt index 98b623c535..18d4af9a0a 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaWarningsTest.kt @@ -4,10 +4,12 @@ import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.KASPA_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendConfirmScreenViaNextButton import com.tangem.scenarios.openSendScreen import com.tangem.screens.onSendAddressScreen import com.tangem.screens.onSendScreen @@ -145,8 +147,10 @@ class KaspaWarningsTest : BaseTestCase() { step("Type address in input text field") { onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) } } - step("Click on 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } + step("Click 'Next' button until 'Send Confirm' screen opens") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { openSendConfirmScreenViaNextButton() }.isSuccess + } } step("Assert 'UTXO limit warning' is displayed") { checkSendWarning( diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt index 13bc7aba06..e5f34ef64d 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt @@ -3,8 +3,6 @@ package com.tangem.tests.swap import androidx.compose.ui.test.longClick import androidx.test.InstrumentationRegistry.getTargetContext import com.tangem.common.BaseTestCase -import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT -import com.tangem.common.extensions.assertHasBadge import com.tangem.common.extensions.restartApp import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState @@ -18,101 +16,6 @@ import org.junit.Test @HiltAndroidTest class SwapStoriesTest : BaseTestCase() { - @AllureId("5453") - @DisplayName("Check 'Swap' button badge on 'Main' screen") - @Test - fun checkMainScreenSwapButtonBadgeTest() { - - setupHooks().run { - - step("Open 'Main Screen'") { - openMainScreen() - } - step("Synchronize addresses") { - synchronizeAddresses() - } - step("Assert 'Swap' button has badge") { - onMainScreen { swapButton.assertHasBadge() } - } - step("Open 'Swap' screen") { - openSwapScreen(from = SwapEntryPoint.MainScreen) - } - step("Click on 'Close' button") { - onSwapTokenScreen { closeButton.performClick() } - } - step("Assert 'Swap' button has not badge") { - onMainScreen { swapButton.assertHasBadge(false) } - } - } - } - - @AllureId("5454") - @DisplayName("Check 'Swap' button badge on token details screen") - @Test - fun checkTokenDetailsScreenSwapButtonTest() { - val tokenName = "Ethereum" - - setupHooks().run { - - step("Open 'Main Screen'") { - openMainScreen() - } - step("Synchronize addresses") { - synchronizeAddresses() - } - step("Click on token with name: '$tokenName'") { - onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } - } - step("Assert 'Swap' button has badge") { - onTokenDetailsScreen { swapButton().assertHasBadge() } - } - step("Open 'Swap' screen") { - openSwapScreen(from = SwapEntryPoint.TokenDetails) - } - step("Click on 'Close' button") { - onSwapTokenScreen { closeButton.performClick() } - } - step("Assert 'Swap' button has not badge") { - onTokenDetailsScreen { swapButton().assertHasBadge(false) } - } - } - } - - @AllureId("5455") - @DisplayName("Check 'Swap' button badge on token details in 'Market' screen") - @Test - fun checkMarketTokenDetailsScreenSwapButtonTest() { - val tokenName = "Ethereum" - val badgeShown = "Badge shown" - val badgeHidden = "Badge hidden" - - setupHooks().run { - - step("Open 'Main Screen'") { - openMainScreen() - } - step("Synchronize addresses") { - synchronizeAddresses() - } - step("Open 'Markets' token details screen for token '$tokenName'") { - openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName) - } - step("Assert 'Swap' button has badge") { - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() } - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) } - } - step("Open 'Swap' screen") { - openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails) - } - step("Click on 'Close' button") { - onSwapTokenScreen { closeButton.performClick() } - } - step("Assert 'Swap' button has not badge") { - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) } - } - } - } - @AllureId("5469") @DisplayName("Check unavailable swap stories on 'Main' screen") @Test @@ -136,9 +39,6 @@ class SwapStoriesTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Assert 'Swap' button has not badge") { - onMainScreen { swapButton.assertHasBadge(false) } - } step("Open 'Swap' screen") { openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false) } @@ -155,9 +55,6 @@ class SwapStoriesTest : BaseTestCase() { waitForIdle() onMainScreen { swapButton.assertIsDisplayed() } } - step("Assert 'Swap' button has badge") { - onMainScreen { swapButton.assertHasBadge() } - } step("Open 'Swap' screen") { openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = true) } @@ -192,9 +89,6 @@ class SwapStoriesTest : BaseTestCase() { step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } } - step("Assert 'Swap' button has not badge") { - onTokenDetailsScreen { swapButton().assertHasBadge(false) } - } step("Open 'Swap' screen") { openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) } @@ -207,13 +101,6 @@ class SwapStoriesTest : BaseTestCase() { step("Restart app") { restartApp(packageName) } - step("Assert 'Swap' button has badge") { - waitForIdle() - flakySafely(WAIT_UNTIL_TIMEOUT) { - composeTestRule.mainClock.advanceTimeBy(500) - onMainScreen { swapButton.assertHasBadge() } - } - } step("Open 'Swap' screen") { openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true) } @@ -228,8 +115,6 @@ class SwapStoriesTest : BaseTestCase() { val scenarioErrorState = "Error" val packageName = getTargetContext().packageName val tokenName = "Ethereum" - val badgeShown = "Badge shown" - val badgeHidden = "Badge hidden" setupHooks( additionalBeforeAppLaunchSection = { @@ -246,16 +131,15 @@ class SwapStoriesTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Open 'Markets' token details screen for token '$tokenName'") { - openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName) + step("Open 'Token details' from 'Markets' screen for token '$tokenName'") { + openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName) } - step("Assert 'Swap' button has not badge") { + step("Assert 'Swap' button is displayed") { waitForIdle() - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() } - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) } + onTokenDetailsScreen { swapButton.assertIsDisplayed() } } step("Open 'Swap' screen") { - openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false) + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) } step("Click on 'Close' button") { onSwapTokenScreen { closeButton.performClick() } @@ -266,16 +150,12 @@ class SwapStoriesTest : BaseTestCase() { step("Restart app") { restartApp(packageName) } - step("Open 'Markets' token details screen for token '$tokenName'") { - openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName) - } - step("Assert 'Swap' button has badge") { + step("Assert 'Swap' button is displayed") { waitForIdle() - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() } - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) } + onTokenDetailsScreen { swapButton.assertIsDisplayed() } } step("Open 'Swap' screen") { - openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = true) + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true) } } } @@ -331,11 +211,8 @@ class SwapStoriesTest : BaseTestCase() { step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } } - step("Assert 'Swap' button has badge") { - onTokenDetailsScreen { swapButton().assertHasBadge() } - } step("Click on 'Swap' button on 'Token details' screen") { - onTokenDetailsScreen { swapButton().performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Check stories changes") { checkStoriesChanges() @@ -369,11 +246,11 @@ class SwapStoriesTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Open 'Markets' token details screen for token '$tokenName'") { - openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName) + step("Open 'Token details' from 'Markets' screen for token '$tokenName'") { + openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName) } step("Click on 'Swap' button on 'Markets' token details screen") { - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Check stories changes") { checkStoriesChanges() @@ -388,7 +265,7 @@ class SwapStoriesTest : BaseTestCase() { onSwapTokenScreen { closeButton.performClick() } } step("Open 'Swap' screen without stories") { - openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false) + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) } } } @@ -433,6 +310,17 @@ class SwapStoriesTest : BaseTestCase() { step("Click on 'Close' button") { onSwapTokenScreen { closeButton.performClick() } } + step("Long click on token with name: '$tokenName' again to reopen actions menu") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenName).performTouchInput { + longClick( + position = center, + durationMillis = 1000L, + ) + } + } + } step("Open 'Swap' screen without stories") { openSwapScreen(from = SwapEntryPoint.TokenActionsBottomSheet, storiesExist = false) } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt index 94092b1e2f..7137918e72 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt @@ -50,7 +50,7 @@ class SwapTokenScreenTest : BaseTestCase() { onTokenDetailsScreen { title.assertIsDisplayed() } } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton().performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -147,7 +147,7 @@ class SwapTokenScreenTest : BaseTestCase() { disableMobileData() } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton().performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -201,7 +201,7 @@ class SwapTokenScreenTest : BaseTestCase() { onTokenDetailsScreen { title.assertIsDisplayed() } } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton().performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -304,7 +304,7 @@ class SwapTokenScreenTest : BaseTestCase() { onTokenDetailsScreen { title.assertIsDisplayed() } } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton().performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -510,7 +510,7 @@ class SwapTokenScreenTest : BaseTestCase() { onMainScreen { tokenWithTitleAndAddress(polygon).clickWithAssertion() } } step("Assert 'Swap' button is not dimmed. Swap available") { - onTokenDetailsScreen { swapButton().assertIsDimmed(false) } + onTokenDetailsScreen { swapButton.assertIsEnabled() } } step("Press 'Back' button") { device.uiDevice.pressBack() @@ -519,7 +519,7 @@ class SwapTokenScreenTest : BaseTestCase() { onMainScreen { tokenWithTitleAndAddress(bitcoin).clickWithAssertion() } } step("Assert 'Swap' button is dimmed") { - onTokenDetailsScreen { swapButton().assertIsDimmed(true) } + onTokenDetailsScreen { swapButton.assertIsNotEnabled() } } step("Press 'Back' button") { device.uiDevice.pressBack() @@ -528,7 +528,7 @@ class SwapTokenScreenTest : BaseTestCase() { onMainScreen { tokenWithTitleAndAddress(salam).clickWithAssertion() } } step("Assert 'Swap' button is dimmed") { - onTokenDetailsScreen { swapButton().assertIsDimmed(true) } + onTokenDetailsScreen { swapButton.assertIsNotEnabled() } } } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt index da370bebe6..fbd260125a 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.innerShadow +import androidx.compose.ui.platform.testTag import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.shadow.Shadow @@ -38,6 +39,7 @@ import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.TokenDetailsScreenTestTags import com.tangem.core.res.R as CoreResR private const val TINTED_BACKGROUND_ALPHA = 0.1f @@ -179,17 +181,23 @@ private fun EarnBlockTrailing(type: Type, trailingUM: EarnBlockUM.TrailingUM?, o } is EarnBlockUM.TrailingUM.Balance -> { if (!trailingUM.isBalanceHidden) { + val fiatModifier = Modifier.layoutId(TangemRowLayoutId.END_TOP).let { + if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_FIAT_AMOUNT) else it + } + val cryptoModifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM).let { + if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT) else it + } Text( text = trailingUM.fiatValue.resolveAnnotatedReference(), style = TangemTheme.typography2.bodySemibold16, color = TangemTheme.colors2.text.neutral.primary, - modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), + modifier = fiatModifier, ) Text( text = trailingUM.cryptoValue.resolveReference(), style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.secondary, - modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM), + modifier = cryptoModifier, ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt index 368bf293c7..875515992a 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import com.tangem.common.ui.R @@ -35,6 +36,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.TokenDetailsScreenTestTags import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf @@ -78,7 +80,8 @@ private fun ExpressTransactionItem( .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors2.surface.level3) .clickable(onClick = info.onClick) - .padding(TangemTheme.dimens2.x4), + .padding(TangemTheme.dimens2.x4) + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM), ) { TitleRow( title = info.title.resolveReference(), @@ -104,7 +107,9 @@ private fun TitleRow(title: String, infoIconRes: Int?, infoIconTint: Color?) { text = title, style = TangemTheme.typography2.bodyMedium16, color = TangemTheme.colors3.text.primary, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TITLE), ) if (infoIconRes != null && infoIconTint != null) { Icon( @@ -126,32 +131,42 @@ private fun AmountsRow(info: ExpressTransactionStateInfoUM) { CurrencyIcon( state = info.fromCurrencyIcon, shouldDisplayNetwork = false, - modifier = Modifier.size(TangemTheme.dimens.size18), + modifier = Modifier + .size(TangemTheme.dimens.size18) + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_ICON), ) EllipsisText( text = info.fromAmount.resolveReference(), style = TangemTheme.typography2.bodyMedium16, color = TangemTheme.colors3.text.primary, ellipsis = TextEllipsis.OffsetEnd(info.fromAmountSymbol.length), - modifier = Modifier.weight(weight = 1f, fill = false), + modifier = Modifier + .weight(weight = 1f, fill = false) + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_AMOUNT), ) Icon( painter = painterResource(R.drawable.ic_forward_24), contentDescription = null, tint = TangemTheme.colors3.icon.tertiary, - modifier = Modifier.size(TangemTheme.dimens.size18), + modifier = Modifier + .size(TangemTheme.dimens.size18) + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON), ) CurrencyIcon( state = info.toCurrencyIcon, shouldDisplayNetwork = false, - modifier = Modifier.size(TangemTheme.dimens.size18), + modifier = Modifier + .size(TangemTheme.dimens.size18) + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON), ) EllipsisText( text = info.toAmount.resolveReference(), style = TangemTheme.typography2.bodyMedium16, color = TangemTheme.colors3.text.primary, ellipsis = TextEllipsis.OffsetEnd(info.toAmountSymbol.length), - modifier = Modifier.weight(weight = 1f, fill = false), + modifier = Modifier + .weight(weight = 1f, fill = false) + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT), ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokenaction/TokenActionRow.kt b/common/ui/src/main/java/com/tangem/common/ui/tokenaction/TokenActionRow.kt index ebfad19e9c..9941a44e01 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokenaction/TokenActionRow.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokenaction/TokenActionRow.kt @@ -19,6 +19,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.layoutId import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.ds.row.TangemRowContainer @@ -68,7 +70,8 @@ fun TokenActionRow( onClick = onClick, onLongClick = onLongClick, hapticManager = hapticManager, - ), + ) + .semantics { if (!isEnabled) disabled() }, ) { LeadingIcon(iconRes = iconRes, accentColor = accentColor) Text( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt index a286a52db4..b597df325e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -39,6 +40,7 @@ import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.BaseBottomSheetTestTags import com.tangem.core.ui.utils.WindowInsetsZero /** @@ -253,7 +255,8 @@ inline fun BasicBottomSheet( Column( modifier = contentModifier .background(containerColor) - .heightIn(max = maxHeight), + .heightIn(max = maxHeight) + .testTag(BaseBottomSheetTestTags.CONTAINER), ) { Box(modifier = Modifier.fillMaxWidth()) { title(model) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt index 23b9f3979b..988177e57c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -40,6 +41,7 @@ import com.tangem.core.ui.components.sheetscaffold.TangemSheetState import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue import com.tangem.core.ui.components.sheetscaffold.rememberSheetState import com.tangem.core.ui.res.* +import com.tangem.core.ui.test.BaseBottomSheetTestTags import com.tangem.core.ui.utils.WindowInsetsZero const val MODAL_SHEET_MAX_HEIGHT = 0.8f @@ -205,7 +207,8 @@ inline fun BsContent( .background(containerColor) .heightIn(max = maxHeight.dp) .fillMaxWidth() - .nestedScroll(nestedScrollConnection), + .nestedScroll(nestedScrollConnection) + .testTag(BaseBottomSheetTestTags.CONTAINER), ) { Box(modifier = Modifier.fillMaxWidth()) { title(model) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/action/ActionButtons.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/action/ActionButtons.kt index fc7c6c2af8..94c0d8aaa8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/action/ActionButtons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/action/ActionButtons.kt @@ -10,6 +10,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -25,6 +28,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -51,7 +55,10 @@ fun ActionButtons(buttons: ImmutableList, modifier: Modifier = M TangemTheme.colors2.text.status.disabled } Column( - modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2_5), + modifier = Modifier + .padding(horizontal = TangemTheme.dimens2.x2_5) + .testTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) + .semantics { if (!button.isEnabled) disabled() }, verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt index 8495036ca7..282be7cef8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt @@ -19,9 +19,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import com.tangem.core.ui.R +import com.tangem.core.ui.test.NotificationTestTags import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState import com.tangem.core.ui.ds.button.* @@ -66,7 +68,8 @@ fun TangemMessage( Alignment.Top }, ) - .size(messageUM.iconSize), + .size(messageUM.iconSize) + .testTag(NotificationTestTags.ICON), ) } }, @@ -115,14 +118,14 @@ fun TangemMessage( Image( painter = painterResource(config.iconResId), contentDescription = null, - modifier = Modifier.size(config.iconSize), + modifier = Modifier.size(config.iconSize).testTag(NotificationTestTags.ICON), ) } else { Icon( imageVector = ImageVector.vectorResource(config.iconResId), contentDescription = null, tint = iconTint, - modifier = Modifier.size(config.iconSize), + modifier = Modifier.size(config.iconSize).testTag(NotificationTestTags.ICON), ) } }, @@ -166,7 +169,7 @@ fun TangemMessage( } else { Alignment.Start } - Box(modifier = modifier) { + Box(modifier = modifier.testTag(NotificationTestTags.CONTAINER)) { Box( modifier = Modifier .matchParentSize() @@ -244,6 +247,7 @@ private fun TangemMessageContent( color = TangemTheme.colors2.text.neutral.primary, maxLines = 1, textAlign = textAlign, + modifier = Modifier.testTag(NotificationTestTags.TITLE), ) } if (subtitle != null) { @@ -252,6 +256,7 @@ private fun TangemMessageContent( text = subtitle.resolveAnnotatedReference(), style = TangemTheme.typography2.captionSemibold12, color = TangemTheme.colors2.text.neutral.secondary, + modifier = Modifier.testTag(NotificationTestTags.MESSAGE), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt index ccf4a1033e..f1cdb64dcb 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt @@ -1,6 +1,7 @@ package com.tangem.core.ui.test object BaseBottomSheetTestTags { + const val CONTAINER = "BASE_BOTTOM_SHEET_CONTAINER" const val ACTION_BUTTON = "BASE_BOTTOM_SHEET_ACTION_BUTTON" const val ACTION_ICON = "BASE_BOTTOM_SHEET_ACTION_ICON" const val TITLE = "BASE_BOTTOM_SHEET_TITLE" diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/MarketsTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/MarketsTestTags.kt index 9569e50d89..49829a1eda 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/MarketsTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/MarketsTestTags.kt @@ -4,4 +4,6 @@ object MarketsTestTags { const val TOKENS_LIST = "MARKETS_TOKENS_LIST" const val TOKENS_LIST_ITEM = "MARKETS_TOKENS_LIST_ITEM" const val LISTED_ON_EXCHANGES_COUNT = "MARKETS_LISTED_ON_EXCHANGES_COUNT" + const val LISTED_ON_BLOCK = "MARKETS_LISTED_ON_BLOCK" + const val TOKEN_DETAILS_CONTENT = "MARKETS_TOKEN_DETAILS_CONTENT" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/OrganizeTokensScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/OrganizeTokensScreenTestTags.kt index 1bcf552c56..e351290af8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/OrganizeTokensScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/OrganizeTokensScreenTestTags.kt @@ -2,6 +2,7 @@ package com.tangem.core.ui.test object OrganizeTokensScreenTestTags { // region OrganizeTokensTopBar + const val MENU_BUTTON = "ORGANIZE_TOKENS_MENU_BUTTON" const val GROUP_BUTTON = "ORGANIZE_TOKENS_GROUP_BUTTON" const val SORT_BY_BALANCE_BUTTON = "SORT_BY_BALANCE_BUTTON" // endregion OrganizeTokensTopBar diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt index d9dd9222f6..09ac1d2352 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt @@ -17,6 +17,8 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import com.tangem.core.ui.test.MarketsTestTags import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -116,6 +118,7 @@ private fun Content( LazyColumn( state = lazyListState, contentPadding = PaddingValues(bottom = bottomBarHeight, top = contentPadding.calculateTopPadding()), + modifier = Modifier.testTag(MarketsTestTags.TOKEN_DETAILS_CONTENT), ) { item("header") { Header(state = state) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ExchangesBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ExchangesBottomSheet.kt index d613f5e228..e41fb40418 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ExchangesBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ExchangesBottomSheet.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -48,6 +49,7 @@ import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds.topbar.TangemTopBarType import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreviewRedesign @@ -314,13 +316,15 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif tangemIconUM = exchangeItemUM.icon, modifier = Modifier .layoutId(layoutId = TangemRowLayoutId.HEAD) - .size(TangemTheme.dimens2.x10), + .size(TangemTheme.dimens2.x10) + .testTag(TokenElementsTestTags.TOKEN_ICON), ) Text( modifier = Modifier .padding(start = TangemTheme.dimens2.x2) - .layoutId(TangemRowLayoutId.START_TOP), + .layoutId(TangemRowLayoutId.START_TOP) + .testTag(TokenElementsTestTags.TOKEN_TITLE), text = exchangeItemUM.title.resolveReference(), style = TangemTheme.typography2.bodySemibold16, color = TangemTheme.colors2.text.neutral.primary, @@ -329,7 +333,8 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif Text( modifier = Modifier .padding(start = TangemTheme.dimens2.x2) - .layoutId(TangemRowLayoutId.START_BOTTOM), + .layoutId(TangemRowLayoutId.START_BOTTOM) + .testTag(TokenElementsTestTags.TOKEN_PRICE), text = exchangeItemUM.subTitle.resolveReference(), style = TangemTheme.typography2.captionSemibold12, color = TangemTheme.colors2.text.neutral.secondary, @@ -338,7 +343,8 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif Text( modifier = Modifier .padding(start = TangemTheme.dimens2.x2) - .layoutId(TangemRowLayoutId.END_TOP), + .layoutId(TangemRowLayoutId.END_TOP) + .testTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT_TEXT), text = exchangeItemUM.volumeInUsd.resolveReference(), style = TangemTheme.typography2.bodySemibold16, color = TangemTheme.colors2.text.neutral.primary, @@ -351,7 +357,8 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif shape = CircleShape, ) .padding(vertical = 2.dp, horizontal = 6.dp) - .layoutId(TangemRowLayoutId.END_BOTTOM), + .layoutId(TangemRowLayoutId.END_BOTTOM) + .testTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT), text = exchangeItemUM.auditLabel.text.resolveReference(), style = TangemTheme.typography2.captionSemibold11, color = getColorByTrustValue(exchangeItemUM.auditLabel.type), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt index a8f1472bd7..5691e27dcb 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt @@ -10,10 +10,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -65,7 +64,8 @@ private fun ListedOnBlockV1(state: ListedOnUM, modifier: Modifier = Modifier) { .clip(shape = TangemTheme.shapes.roundedCornersXMedium) .clickable(enabled = state is ListedOnUM.Content) { (state as? ListedOnUM.Content)?.onClick?.invoke() - }, + } + .testTag(MarketsTestTags.LISTED_ON_BLOCK), ) { Description( state = state, @@ -89,9 +89,11 @@ private fun ListedOnBlockV1(state: ListedOnUM, modifier: Modifier = Modifier) { @Composable private fun ListedOnBlockV2(state: ListedOnUM, modifier: Modifier = Modifier) { TokenMarketInformationBlock( - modifier = modifier.clickable(enabled = state is ListedOnUM.Content) { - (state as? ListedOnUM.Content)?.onClick?.invoke() - }, + modifier = modifier + .clickable(enabled = state is ListedOnUM.Content) { + (state as? ListedOnUM.Content)?.onClick?.invoke() + } + .testTag(MarketsTestTags.LISTED_ON_BLOCK), title = { Row(verticalAlignment = Alignment.CenterVertically) { Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) { @@ -103,6 +105,7 @@ private fun ListedOnBlockV2(state: ListedOnUM, modifier: Modifier = Modifier) { overflow = TextOverflow.Ellipsis, ) Text( + modifier = Modifier.testTag(MarketsTestTags.LISTED_ON_EXCHANGES_COUNT), text = state.description.resolveReference(), style = TangemTheme.typography2.headingSemibold20, color = TangemTheme.colors2.text.neutral.primary, @@ -178,7 +181,7 @@ internal fun ListedOnBlockPlaceholderV2(modifier: Modifier = Modifier) { private fun Description(state: ListedOnUM, modifier: Modifier = Modifier) { Text( text = state.description.resolveReference(), - modifier = modifier.semantics { testTag = MarketsTestTags.LISTED_ON_EXCHANGES_COUNT }, + modifier = modifier.testTag(MarketsTestTags.LISTED_ON_EXCHANGES_COUNT), color = TangemTheme.colors.text.tertiary, overflow = TextOverflow.Ellipsis, maxLines = 1, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index ba581e7537..31eaefbf5e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -13,12 +13,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.earn.EarnBlock +import com.tangem.common.ui.earn.EarnBlockUM import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState import com.tangem.common.ui.notifications.notifications @@ -35,6 +37,7 @@ import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.LocalHazeState +import com.tangem.core.ui.test.TokenDetailsScreenTestTags import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.domain.models.account.CryptoPortfolioIcon @@ -140,7 +143,7 @@ private fun BoxScope.TokenDetailsMarketBlockOverlay( ) } -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LongMethod") @Composable private fun TokenDetailsBody( tokenDetailsUM: TokenDetailsUM, @@ -165,6 +168,7 @@ private fun TokenDetailsBody( LazyColumn( modifier = modifier + .testTag(TokenDetailsScreenTestTags.SCREEN_CONTAINER) .hazeSourceTangem(state = LocalHazeState.current) .topFade( height = topContentPadding, @@ -190,9 +194,18 @@ private fun TokenDetailsBody( ) tokenDetailsUM.earnBlockState?.let { earnBlock -> item(key = "staking_block") { + val stakingTag = when { + earnBlock is EarnBlockUM.Content && + earnBlock.type == EarnBlockUM.Type.Staking && + earnBlock.trailingUM is EarnBlockUM.TrailingUM.Button -> + TokenDetailsScreenTestTags.STAKING_AVAILABLE_BLOCK + else -> TokenDetailsScreenTestTags.STAKING_BLOCK + } EarnBlock( state = earnBlock, - modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x3), + modifier = itemModifier + .padding(vertical = TangemTheme.dimens2.x3) + .testTag(stakingTag), ) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt index a25b282c16..a79ceb5399 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt @@ -16,9 +16,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.* +import androidx.compose.ui.platform.testTag +import com.tangem.core.ui.test.TokenDetailsScreenTestTags +import com.tangem.core.ui.test.TokenDetailsTopBarTestTags +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -58,7 +67,9 @@ internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier: .clip(CircleShape) .hazeEffectTangem { blurRadius = ACTION_BLUR_RADIUS } TangemTopBar( - modifier = modifier.statusBarsPadding(), + modifier = modifier + .statusBarsPadding() + .testTag(TokenDetailsTopBarTestTags.BACK_BUTTON), startContent = { TangemTopBarActionContent( modifier = actionModifier, @@ -73,7 +84,7 @@ internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier: var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) } Box { TangemTopBarActionContent( - modifier = actionModifier, + modifier = actionModifier.testTag(TokenDetailsTopBarTestTags.MORE_BUTTON), actionUM = TangemTopBarActionUM( iconRes = CoreUiR.drawable.ic_more_default_24, onClick = { isDropdownMenuShown = true }, @@ -105,7 +116,9 @@ internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier: horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5), ) { - TokenDetailsTitle(titleState = topAppBarUM.titleState) + Box(modifier = Modifier.testTag(TokenDetailsScreenTestTags.TOKEN_TITLE)) { + TokenDetailsTitle(titleState = topAppBarUM.titleState) + } Text( text = topAppBarUM.subtitle.resolveAnnotatedReference(), color = TangemTheme.colors2.text.neutral.tertiary, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt index f1c3b50e57..0c08233a68 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/ui/OrganizeTokensContent.kt @@ -96,6 +96,7 @@ internal fun OrganizeTokensContent( onClick = { isShowDropdownMenu = true }, ghostModeProgress = 0f, ), + modifier = Modifier.testTag(OrganizeTokensScreenTestTags.MENU_BUTTON), type = TangemTopBarType.BottomSheet, ) OrganizeDropDownMenu( @@ -161,8 +162,7 @@ private fun TokenList( modifier = Modifier .align(Alignment.TopCenter) .reorderable(reorderableListState) - .testTag(OrganizeTokensScreenTestTags.TOKENS_LAZY_LIST) - .hazeSourceTangem(zIndex = 1f), + .testTag(OrganizeTokensScreenTestTags.TOKENS_LAZY_LIST), state = reorderableListState.listState, contentPadding = listContentPadding, ) { @@ -221,7 +221,7 @@ private fun LazyItemScope.DraggableItem( when (item) { is OrganizeRowItemUM.Network -> TangemHeaderRow( - modifier = modifierWithBackground, + modifier = modifierWithBackground.testTag(OrganizeTokensScreenTestTags.GROUP_TITLE_ITEM), reorderableState = reorderableState, headerRowUM = item.headerRowUM, ) @@ -231,7 +231,7 @@ private fun LazyItemScope.DraggableItem( isBalanceHidden = isBalanceHidden, ) is OrganizeRowItemUM.Token -> OrganizeTokenRow( - modifier = modifierWithBackground, + modifier = modifierWithBackground.testTag(OrganizeTokensScreenTestTags.TOKEN_LIST_ITEM), tokenRowUM = item.tokenRowUM, reorderableState = reorderableState, isBalanceHidden = isBalanceHidden, @@ -262,11 +262,15 @@ private fun BoxScope.BottomButtons(organizeTokensUM: OrganizeTokensUM) { ) { TangemButton( buttonUM = organizeTokensUM.cancelButton, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .testTag(OrganizeTokensScreenTestTags.CANCEL_BUTTON), ) TangemButton( buttonUM = organizeTokensUM.applyButton, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .testTag(OrganizeTokensScreenTestTags.APPLY_BUTTON), ) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/tokenActions/TokenActionContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/tokenActions/TokenActionContent.kt index aebf8404a9..449d0a9eb0 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/tokenActions/TokenActionContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/tokenActions/TokenActionContent.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import com.tangem.core.ui.test.BaseBottomSheetTestTags import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpOffset @@ -101,6 +103,7 @@ private fun TokenActionContextMenuContent(actions: ImmutableList - TangemTopBarActionContent(action) + TangemTopBarActionContent( + action, + modifier = Modifier.testTag(MainScreenTestTags.MORE_BUTTON), + ) } } }, From 7f2e5650e23957a9db1a6a742d74b00832d374e5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 15:38:14 +0200 Subject: [PATCH 4/7] Updated on 2026-08-14 --- .../WalletTokenCurrencyItemConverter.kt | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt index 06c31c47b7..7f37f1b1e8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt @@ -193,20 +193,22 @@ internal class WalletTokenCurrencyItemConverter( is CryptoCurrencyStatus.MissedDerivation -> TangemTokenRowUM.EndContentUM.Content( text = stringReference(StringsSigns.DASH_SIGN), ) - is CryptoCurrencyStatus.Unreachable -> TangemTokenRowUM.EndContentUM.Content( - text = styledResourceReference( - id = R.string.common_unreachable, - spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.status.attention) }, - ), - endIcons = persistentListOf( - TangemIconUM.Icon( - iconRes = R.drawable.ic_attention_default_24, - tintReference = { TangemTheme.colors2.graphic.status.attention }, - ), - ), - ) + is CryptoCurrencyStatus.Unreachable, is CryptoCurrencyStatus.NoAmount, - -> TangemTokenRowUM.EndContentUM.Empty + -> { + TangemTokenRowUM.EndContentUM.Content( + text = styledResourceReference( + id = R.string.common_unreachable, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.status.attention) }, + ), + endIcons = persistentListOf( + TangemIconUM.Icon( + iconRes = R.drawable.ic_attention_default_24, + tintReference = { TangemTheme.colors2.graphic.status.attention }, + ), + ), + ) + } } } From 16c7de90546cd73b40f464c55ca61efb602025af Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 17:48:29 +0400 Subject: [PATCH 5/7] Updated on 2026-08-14 --- .../tap/di/domain/YieldSupplyDomainModule.kt | 10 + .../configs/feature_toggles_config.json | 4 + data/swap/build.gradle.kts | 3 + .../data/swap/DefaultSwapRepositoryV2.kt | 24 +- .../com/tangem/data/swap/di/SwapDataModule.kt | 3 + .../data/swap/DefaultSwapRepositoryV2Test.kt | 59 ++++- .../DefaultYieldModuleAddressProvider.kt | 54 +++++ ...DefaultYieldSupplyTransactionRepository.kt | 28 ++- .../yield/supply/di/YieldSupplyDataModule.kt | 14 ++ .../domain/swap/models/SwapCurrencyStatus.kt | 2 + .../usecase/GetEthSpecificFeeUseCase.kt | 6 +- .../supply/YieldModuleAddressProvider.kt | 25 +++ .../YieldSupplyTransactionRepository.kt | 12 + ...WrapYieldSwapCallDataWithUpgradeUseCase.kt | 25 +++ .../features/swap/SwapFeatureToggles.kt | 1 + features/swap/domain/build.gradle.kts | 2 + .../feature/swap/domain/SwapInteractorImpl.kt | 191 ++++++++++++---- .../swap/domain/di/SwapDomainModule.kt | 3 + .../swap/domain/fee/DexSwapFeeCalculator.kt | 136 ++++++++++++ .../models/domain/ExpressTransactionModel.kt | 2 +- .../SwapInteractorImplFindBestQuoteTest.kt | 205 ++++++++++++++++++ .../swap/domain/SwapInteractorImplTestBase.kt | 8 + .../domain/fee/DexSwapFeeCalculatorTest.kt | 3 + .../feature/swap/DefaultSwapFeatureToggles.kt | 4 + gradle/tangem_dependencies.toml | 2 +- .../WalletManagerFactoryCreator.kt | 19 +- .../di/BlockchainSDKFactoryModule.kt | 21 +- 27 files changed, 796 insertions(+), 70 deletions(-) create mode 100644 data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProvider.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldModuleAddressProvider.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/WrapYieldSwapCallDataWithUpgradeUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt index e16015f499..1340cbbf2e 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -78,6 +78,16 @@ internal object YieldSupplyDomainModule { ) } + @Provides + @Singleton + fun provideWrapYieldSwapCallDataWithUpgradeUseCase( + yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, + ): WrapYieldSwapCallDataWithUpgradeUseCase { + return WrapYieldSwapCallDataWithUpgradeUseCase( + yieldSupplyTransactionRepository = yieldSupplyTransactionRepository, + ) + } + @Provides @Singleton fun provideYieldSupplyGetProtocolBalanceUseCase( diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 597f1553c9..8eed9a1b0a 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -55,6 +55,10 @@ "name": "WALLET_CONNECT_BITCOIN_ENABLED", "version": "undefined" }, + { + "name": "TWI_1326_YIELD_MODE_SWAP_ENABLED", + "version": "undefined" + }, { "name": "ADDRESS_SYNC_ENABLED", "version": "undefined" diff --git a/data/swap/build.gradle.kts b/data/swap/build.gradle.kts index 6541bb22cd..0297e4d3ea 100644 --- a/data/swap/build.gradle.kts +++ b/data/swap/build.gradle.kts @@ -46,6 +46,9 @@ dependencies { exclude(module = "joda-time") } + /** Core */ + implementation(projects.core.configToggles) + /** Libs */ implementation(projects.libs.blockchainSdk) implementation(projects.libs.crypto) diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt index a888483d9c..8aec84f31e 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt @@ -6,6 +6,8 @@ import arrow.core.toOption import com.squareup.moshi.Moshi import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.data.common.api.safeApiCall import com.tangem.data.swap.converter.SwapDataConverter import com.tangem.data.swap.converter.SwapStatusConverter @@ -57,6 +59,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( private val dataSignatureVerifier: DataSignatureVerifier, private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher, + private val featureTogglesManager: FeatureTogglesManager, @NetworkMoshi moshi: Moshi, ) : SwapRepositoryV2 { @@ -544,16 +547,21 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString() } - private fun List.filterYieldSupplyProvider(cryptoCurrencyStatus: CryptoCurrencyStatus?) = - filter { provider -> - // !!!WARNING!!! Filter out dex provider if yield supply is active - val yieldSupplyStatus = cryptoCurrencyStatus?.value?.yieldSupplyStatus - if (yieldSupplyStatus != null && yieldSupplyStatus.isActive) { - provider.type == ExpressProviderType.CEX - } else { - true + private fun List.filterYieldSupplyProvider( + cryptoCurrencyStatus: CryptoCurrencyStatus?, + ): List { + return if (featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED)) { + this + } else { + filter { provider -> + if (cryptoCurrencyStatus?.value?.yieldSupplyStatus?.isActive == true) { + provider.type == ExpressProviderType.CEX + } else { + true + } } } + } } private val MEMO_RESTRICTED_NETWORKS = setOf( diff --git a/data/swap/src/main/java/com/tangem/data/swap/di/SwapDataModule.kt b/data/swap/src/main/java/com/tangem/data/swap/di/SwapDataModule.kt index 6fee629eea..b3ea0a86bb 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/di/SwapDataModule.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/di/SwapDataModule.kt @@ -18,6 +18,7 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.swap.SwapErrorResolver import com.tangem.domain.swap.SwapRepositoryV2 +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.domain.swap.SwapTransactionRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -49,6 +50,7 @@ internal object SwapDataModule { dataSignatureVerifier: DataSignatureVerifier, singleQuoteStatusSupplier: SingleQuoteStatusSupplier, singleQuoteStatusFetcher: SingleQuoteStatusFetcher, + featureTogglesManager: FeatureTogglesManager, @NetworkMoshi moshi: Moshi, ): SwapRepositoryV2 { return DefaultSwapRepositoryV2( @@ -60,6 +62,7 @@ internal object SwapDataModule { moshi = moshi, singleQuoteStatusSupplier = singleQuoteStatusSupplier, singleQuoteStatusFetcher = singleQuoteStatusFetcher, + featureTogglesManager = featureTogglesManager, ) } diff --git a/data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt b/data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt index 92343937bb..8fd1a4d3a3 100644 --- a/data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt +++ b/data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt @@ -24,6 +24,8 @@ import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.swap.models.SwapStatus import com.tangem.domain.swap.models.SwapTxType +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.test.runTest @@ -43,6 +45,9 @@ internal class DefaultSwapRepositoryV2Test { private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk() private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher = mockk() private val moshi: Moshi = Moshi.Builder().build() + private val featureTogglesManager: FeatureTogglesManager = mockk { + every { isFeatureEnabled(any()) } returns false + } private val repository = DefaultSwapRepositoryV2( tangemExpressApi = tangemExpressApi, @@ -52,6 +57,7 @@ internal class DefaultSwapRepositoryV2Test { dataSignatureVerifier = dataSignatureVerifier, singleQuoteStatusSupplier = singleQuoteStatusSupplier, singleQuoteStatusFetcher = singleQuoteStatusFetcher, + featureTogglesManager = featureTogglesManager, moshi = moshi, ) @@ -64,7 +70,9 @@ internal class DefaultSwapRepositoryV2Test { dataSignatureVerifier, singleQuoteStatusSupplier, singleQuoteStatusFetcher, + featureTogglesManager, ) + every { featureTogglesManager.isFeatureEnabled(any()) } returns false } // region getPairs(SwapCurrencyStatus, SwapCurrencyStatus) @@ -511,8 +519,9 @@ internal class DefaultSwapRepositoryV2Test { // region filterYieldSupplyProvider @Test - fun `getPairs filters out DEX providers when yield supply is active`() = runTest { + fun `getPairs filters out DEX providers when yield supply is active and flag is off`() = runTest { // Arrange + every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED) } returns false val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin) val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) val primarySwapCurrencyStatus = SwapCurrencyStatus( @@ -558,6 +567,54 @@ internal class DefaultSwapRepositoryV2Test { assertThat(providers.first().type).isEqualTo(ExpressProviderType.CEX) } + @Test + fun `getPairs keeps DEX providers when yield supply is active and flag is on`() = runTest { + // Arrange + every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED) } returns true + val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + val primarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = primaryStatus, + account = mockk(), + ) + val secondarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = secondaryStatus, + account = mockk(), + ) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf( + SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)), + SwapPairProvider(providerId = CEX_PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)), + ), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(dexProvider, cexProvider) + + // Act + val result = repository.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert — both providers should remain + assertThat(result).hasSize(2) + val providers = result.first().providers + assertThat(providers).hasSize(2) + } + // endregion // region getSwapData diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProvider.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProvider.kt new file mode 100644 index 0000000000..bf80a11396 --- /dev/null +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProvider.kt @@ -0,0 +1,54 @@ +package com.tangem.data.yield.supply + +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.YieldModuleAddressProvider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap + +internal class DefaultYieldModuleAddressProvider( + private val walletManagersFacade: WalletManagersFacade, + private val dispatchers: CoroutineDispatcherProvider, +) : YieldModuleAddressProvider { + + private data class Key(val userWalletId: UserWalletId, val networkRawId: String) + + private val cache = ConcurrentHashMap() + private val mutex = Mutex() + + override suspend fun getOrFetch(userWalletId: UserWalletId, network: Network): String? { + val key = Key(userWalletId, network.rawId) + cache[key]?.let { return it } + return withContext(dispatchers.io) { + mutex.withLock { + cache[key]?.let { return@withLock it } + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = network.toBlockchain(), + derivationPath = network.derivationPath.value, + ) ?: error("Wallet manager not found for $network") + // SDK returns ZERO_ADDRESS on internal failure (e.g. RPC error). Treat that as + // "unavailable" so callers are forced by the type system to fall back instead + // of using it as a destination. + val address = walletManager.getYieldModuleAddress() + .takeIf { it != EthereumUtils.ZERO_ADDRESS } + if (address != null) cache[key] = address + address + } + } + } + + override fun invalidate(userWalletId: UserWalletId?) { + if (userWalletId == null) { + cache.clear() + } else { + cache.keys.removeAll { it.userWalletId == userWalletId } + } + } +} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt index 19f06ecd0f..ac1c2517a6 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt @@ -11,6 +11,7 @@ import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFact import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.yield.supply.YieldSupplyStatus import com.tangem.domain.utils.convertToSdkAmount @@ -127,6 +128,11 @@ internal class DefaultYieldSupplyTransactionRepository( val amount = getEnterAmount(cryptoCurrency, yieldSupplyStatus) val emptyContractAddress = existingYieldAddress == null || existingYieldAddress == EthereumUtils.ZERO_ADDRESS + val activeYieldContractAddress = if (emptyContractAddress) { + calculatedYieldContractAddress + } else { + existingYieldAddress + } when { yieldSupplyStatus == null || emptyContractAddress -> { @@ -143,7 +149,7 @@ internal class DefaultYieldSupplyTransactionRepository( createInitTokenTransaction( walletManager = walletManager, cryptoCurrency = cryptoCurrency, - yieldContractAddress = calculatedYieldContractAddress, + yieldContractAddress = activeYieldContractAddress, amount = amount, maxNetworkFee = maxNetworkFee, ), @@ -152,7 +158,7 @@ internal class DefaultYieldSupplyTransactionRepository( createReactivateTokenTransaction( walletManager = walletManager, cryptoCurrency = cryptoCurrency, - yieldContractAddress = calculatedYieldContractAddress, + yieldContractAddress = activeYieldContractAddress, amount = amount, maxNetworkFee = maxNetworkFee, ), @@ -166,7 +172,7 @@ internal class DefaultYieldSupplyTransactionRepository( walletManager = walletManager, cryptoCurrency = cryptoCurrency, callData = ApprovalERC20TokenCallData( - spenderAddress = calculatedYieldContractAddress, + spenderAddress = activeYieldContractAddress, amount = null, ), destinationAddress = cryptoCurrency.contractAddress, @@ -182,7 +188,7 @@ internal class DefaultYieldSupplyTransactionRepository( walletManager = walletManager, cryptoCurrency = cryptoCurrency, amount = amount, - yieldContractAddress = calculatedYieldContractAddress, + yieldContractAddress = activeYieldContractAddress, ), ) } @@ -218,6 +224,20 @@ internal class DefaultYieldSupplyTransactionRepository( }.onFailure { TangemLogger.e("Error", it) }.getOrThrow() } + override suspend fun wrapYieldSwapCallDataWithUpgradeIfNeeded( + userWalletId: UserWalletId, + network: Network, + callData: SmartContractCallData, + ): SmartContractCallData = withContext(dispatchers.io) { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = network.toBlockchain(), + derivationPath = network.derivationPath.value, + ) ?: error("Wallet manager not found for $network") + val versionStatus = walletManager.checkModuleVersionStatus() + YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, callData) + } + private suspend fun getYieldTokenStatus( walletManager: WalletManager, cryptoCurrency: CryptoCurrency.Token, diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt index c432bde85a..38ff5bba43 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt @@ -1,6 +1,7 @@ package com.tangem.data.yield.supply.di import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.data.yield.supply.DefaultYieldModuleAddressProvider import com.tangem.data.yield.supply.DefaultYieldSupplyRepository import com.tangem.data.yield.supply.DefaultYieldSupplyErrorResolver import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository @@ -12,6 +13,7 @@ import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.YieldModuleAddressProvider import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.YieldSupplyErrorResolver import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository @@ -65,6 +67,18 @@ internal object YieldSupplyDataModule { return DefaultYieldSupplyErrorResolver } + @Provides + @Singleton + fun provideYieldModuleAddressProvider( + walletManagersFacade: WalletManagersFacade, + dispatchers: CoroutineDispatcherProvider, + ): YieldModuleAddressProvider { + return DefaultYieldModuleAddressProvider( + walletManagersFacade = walletManagersFacade, + dispatchers = dispatchers, + ) + } + @Provides @Singleton fun provideYieldPromoRepository( diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt index 4a0067251f..eb6af3b4ec 100644 --- a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt @@ -30,4 +30,6 @@ data class SwapCurrencyStatus( get() = status.currency val userWalletId: UserWalletId get() = userWallet.walletId + val isYieldSupplyActive: Boolean + get() = status.value.yieldSupplyStatus?.isActive == true } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt index ebb7a3bf0e..62bebaf8ba 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt @@ -46,7 +46,7 @@ class GetEthSpecificFeeUseCase( val minimalFee = getEthLegacyFee( gasPrice = gasPriceResult, gasLimit = gasLimit, - decimals = cryptoCurrency.decimals, + decimals = blockchain.decimals(), blockchain = blockchain, ) @@ -54,7 +54,7 @@ class GetEthSpecificFeeUseCase( val normalFee = getEthLegacyFee( gasPrice = normalGasPrice, gasLimit = gasLimit, - decimals = cryptoCurrency.decimals, + decimals = blockchain.decimals(), blockchain = blockchain, ) @@ -64,7 +64,7 @@ class GetEthSpecificFeeUseCase( val priorityFee = getEthLegacyFee( gasPrice = priorityGasPrice, gasLimit = gasLimit, - decimals = cryptoCurrency.decimals, + decimals = blockchain.decimals(), blockchain = blockchain, ) diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldModuleAddressProvider.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldModuleAddressProvider.kt new file mode 100644 index 0000000000..f5ace8ca05 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldModuleAddressProvider.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.yield.supply + +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Resolves the yield-module proxy address for a `(wallet, network)` pair and caches the result. + * + * The address is derived from on-chain state (factory contract + user's wallet) and is stable + * for the lifetime of the wallet, so caching the result avoids redundant blockchain calls. + * + * Call [invalidate] when the wallet's yield-module state may have changed (e.g. after a + * successful upgrade or removal of yield-supply). + */ +interface YieldModuleAddressProvider { + + /** + * Returns the yield-module proxy address, or `null` if the address is currently unavailable + * (e.g. RPC failure inside the SDK). + */ + suspend fun getOrFetch(userWalletId: UserWalletId, network: Network): String? + + /** Drops cached entries for [userWalletId], or the entire cache when [userWalletId] is `null`. */ + fun invalidate(userWalletId: UserWalletId? = null) +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt index dccf5c9e1d..cfb76be389 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt @@ -1,9 +1,11 @@ package com.tangem.domain.yield.supply import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.smartcontract.SmartContractCallData import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import java.math.BigDecimal @@ -24,4 +26,14 @@ interface YieldSupplyTransactionRepository { suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String? suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal? + + /** + * Checks the version status of the user's yield-module contract and wraps [callData] with an + * upgrade transaction if the deployed version is out of date. + */ + suspend fun wrapYieldSwapCallDataWithUpgradeIfNeeded( + userWalletId: UserWalletId, + network: Network, + callData: SmartContractCallData, + ): SmartContractCallData } \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/WrapYieldSwapCallDataWithUpgradeUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/WrapYieldSwapCallDataWithUpgradeUseCase.kt new file mode 100644 index 0000000000..f70f62e664 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/WrapYieldSwapCallDataWithUpgradeUseCase.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.yield.supply.usecase + +import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository + +/** + * Wraps a yield-swap call data with a yield-module upgrade transaction when the user's deployed + * yield-module contract version is out of date. + */ +class WrapYieldSwapCallDataWithUpgradeUseCase( + private val yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + network: Network, + callData: SmartContractCallData, + ): SmartContractCallData = yieldSupplyTransactionRepository.wrapYieldSwapCallDataWithUpgradeIfNeeded( + userWalletId = userWalletId, + network = network, + callData = callData, + ) +} \ No newline at end of file diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt index 0a5875e227..2b975af32f 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt @@ -1,6 +1,7 @@ package com.tangem.features.swap interface SwapFeatureToggles { + val isYieldSwapEnabled: Boolean val isSwapSwitchToTransferEnabled: Boolean val isSwapIntegratedApproveEnabled: Boolean val isSwapAbEnabled: Boolean diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 63505328ce..00bc4cb0a8 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -51,8 +51,10 @@ dependencies { implementation(projects.domain.visa) implementation(projects.domain.visa.models) implementation(projects.domain.balanceHiding) + implementation(projects.domain.yieldSupply) /** Core modules */ + implementation(projects.core.configToggles) implementation(projects.core.utils) implementation(projects.core.ui) implementation(projects.core.datasource) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index f902272fa7..059dd0647c 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -51,6 +51,7 @@ import com.tangem.domain.transaction.usecase.* import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.YieldModuleAddressProvider import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator @@ -61,6 +62,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.toStringWithRightOffset import com.tangem.feature.swap.domain.models.ui.* +import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.extensions.orZero import com.tangem.utils.logging.TangemLogger @@ -99,12 +101,17 @@ internal class SwapInteractorImpl @Inject constructor( private val getSwapPairUseCase: GetSwapPairUseCase, private val dexSwapFeeCalculator: DexSwapFeeCalculator, private val cexSwapFeeCalculator: CexSwapFeeCalculator, + private val swapFeatureToggles: SwapFeatureToggles, + private val yieldModuleAddressProvider: YieldModuleAddressProvider, ) : SwapInteractor { private val getSelectedAppCurrencyUseCase by lazy(LazyThreadSafetyMode.NONE) { GetSelectedAppCurrencyUseCase(appCurrencyRepository) } + private val SwapCurrencyStatus.isYieldSwapActive: Boolean + get() = swapFeatureToggles.isYieldSwapEnabled && isYieldSupplyActive + override suspend fun getPair( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, @@ -254,7 +261,9 @@ internal class SwapInteractorImpl @Inject constructor( reduceBalanceBy: BigDecimal, expressOperationType: ExpressOperationType, ): Pair { - if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true) { + if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true && + !swapFeatureToggles.isYieldSwapEnabled + ) { return provider to produceDexSwapDataError( error = ExpressDataError.DexActiveSupplyError(), fromSwapCurrencyStatus = fromSwapCurrencyStatus, @@ -286,19 +295,26 @@ internal class SwapInteractorImpl @Inject constructor( } val fromTokenAddress = getTokenAddress(fromSwapCurrencyStatus.currency) - val isAllowedToSpend = maybeQuotes.fold( - ifRight = { quotes -> - quotes.allowanceContract?.let { allowanceContract -> - getAllowanceInfoUseCase( - userWalletId = fromSwapCurrencyStatus.userWalletId, - cryptoCurrency = fromSwapCurrencyStatus.currency, - spenderAddress = allowanceContract, - requiredAmount = amount.value, - ).getOrNull() is AllowanceInfo.Enough - } != false - }, - ifLeft = { false }, - ) + val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive && + fromSwapCurrencyStatus.currency is CryptoCurrency.Token + val isAllowedToSpend = if (isYieldSwap) { + maybeQuotes.isRight() && + fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isAllowedToSpend == true + } else { + maybeQuotes.fold( + ifRight = { quotes -> + quotes.allowanceContract?.let { allowanceContract -> + getAllowanceInfoUseCase( + userWalletId = fromSwapCurrencyStatus.userWalletId, + cryptoCurrency = fromSwapCurrencyStatus.currency, + spenderAddress = allowanceContract, + requiredAmount = amount.value, + ).getOrNull() is AllowanceInfo.Enough + } != false + }, + ifLeft = { false }, + ) + } if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) { allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress) @@ -308,6 +324,7 @@ internal class SwapInteractorImpl @Inject constructor( ) } val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null) + val quoteAllowanceContract = maybeQuotes.getOrNull()?.allowanceContract return if (isAllowedToSpend && isBalanceWithoutFeeEnough) { provider to loadDexSwapDataNoFee( provider = provider, @@ -315,6 +332,7 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, expressOperationType = expressOperationType, + quoteAllowanceContract = quoteAllowanceContract, ) } else { val quoteBalanceStatus = if (isBalanceWithoutFeeEnough) { @@ -377,6 +395,7 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, expressOperationType = expressOperationType, + quoteAllowanceContract = maybeQuotes.getOrNull()?.allowanceContract, ) } else { provider to getQuotesState( @@ -635,28 +654,54 @@ internal class SwapInteractorImpl @Inject constructor( swapFee: SwapFee, ): SwapTransactionState { val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } - val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" } val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX val dataToSign = dexTransaction.txData - val amountToSend = createNativeAmountForDex(txValue, fromSwapCurrencyStatus.currency.network) - val txData = createTransactionUseCase( - amount = amountToSend, - fee = swapFee.fee, - memo = null, - destination = swapData.transaction.txTo, - userWalletId = fromSwapCurrencyStatus.userWalletId, - network = toSwapCurrencyStatus.currency.network, - txExtras = createDexTxExtras( - dataToSign, - fromSwapCurrencyStatus.currency.network, - swapFee.fee.getGasLimit(), - ), - ).getOrElse { error -> + val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive + val fromCurrency = fromSwapCurrencyStatus.currency + + val txDataResult = if (isYieldSwap && fromCurrency is CryptoCurrency.Token) { + val spenderAddress = dexTransaction.allowanceContract + ?: return SwapTransactionState.Error.UnknownError + createYieldSwapDexTransaction( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + swapData = swapData, + dexCallData = dataToSign, + amount = amountDecimal, + fee = swapFee.fee, + spenderAddress = spenderAddress, + ) + } else { + val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" } + val amountToSend = createNativeAmountForDex(txValue, fromCurrency.network) + createTransactionUseCase( + amount = amountToSend, + fee = swapFee.fee, + memo = null, + destination = swapData.transaction.txTo, + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = fromCurrency.network, + txExtras = createDexTxExtras( + dataToSign, + fromCurrency.network, + swapFee.fee.getGasLimit(), + ), + ) + } + + val txData = txDataResult.getOrElse { error -> TangemLogger.e("Failed to create swap dex tx data", error) return SwapTransactionState.Error.UnknownError } + val payInAddress = if (isYieldSwap && fromCurrency is CryptoCurrency.Token) { + swapData.transaction.txTo + } else if (txData is TransactionData.Uncompiled) { + getPayoutAddress(txData) + } else { + swapData.transaction.txTo + } + return handleSwapResult( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, @@ -664,7 +709,7 @@ internal class SwapInteractorImpl @Inject constructor( swapData = swapData, amount = amount, txData = txData, - payInAddress = getPayoutAddress(txData), + payInAddress = payInAddress, ) } @@ -1001,11 +1046,23 @@ internal class SwapInteractorImpl @Inject constructor( val transaction = swapData?.transaction as? ExpressTransactionModel.DEX ?: return GetFeeError.UnknownError.left() - return dexSwapFeeCalculator.calculate( - fromSwapCurrencyStatus = fromStatus, - transaction = transaction, - selectedToken = selectedFeeToken, - ).fold( + val dexFeeResultEither = if (fromStatus.isYieldSwapActive && fromStatus.currency is CryptoCurrency.Token) { + val network = (fromStatus.currency as CryptoCurrency.Token).network + val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromStatus.userWalletId, network) + dexSwapFeeCalculator.calculateYield( + fromSwapCurrencyStatus = fromStatus, + transaction = transaction, + yieldModuleAddress = yieldModuleAddress, + ) + } else { + dexSwapFeeCalculator.calculate( + fromSwapCurrencyStatus = fromStatus, + transaction = transaction, + selectedToken = selectedFeeToken, + ) + } + + return dexFeeResultEither.fold( ifLeft = { error -> GetFeeError.DataError(error).left() }, ifRight = { dexFeeResult -> val feeToken = selectedFeeToken @@ -1021,6 +1078,42 @@ internal class SwapInteractorImpl @Inject constructor( ) } + private suspend fun createYieldSwapDexTransaction( + fromSwapCurrencyStatus: SwapCurrencyStatus, + swapData: SwapDataModel, + dexCallData: String, + amount: BigDecimal, + fee: Fee, + spenderAddress: String, + ): Either { + val fromCurrency = fromSwapCurrencyStatus.currency as CryptoCurrency.Token + val network = fromCurrency.network + val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromSwapCurrencyStatus.userWalletId, network) + ?: return Either.Left(IllegalStateException("Yield module address is not available for ${network.id}")) + val wrappedCallData = dexSwapFeeCalculator.buildYieldSwapCallData( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + txTo = swapData.transaction.txTo, + dexCallData = dexCallData, + amount = amount, + spenderAddress = spenderAddress, + ) + val txExtras = createTransactionExtrasUseCase( + callData = wrappedCallData, + network = network, + gasLimit = fee.getGasLimit()?.toBigInteger(), + ).getOrNull() ?: error("Failed to create yield swap extras") + + return createTransactionUseCase( + amount = createNativeAmountForDex("0", network), + fee = fee, + memo = null, + destination = yieldModuleAddress, + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = network, + txExtras = txExtras, + ) + } + /** * [REDACTED_TASK_KEY] — CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when * [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator]) @@ -1501,6 +1594,7 @@ internal class SwapInteractorImpl @Inject constructor( toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, expressOperationType: ExpressOperationType, + quoteAllowanceContract: String? = null, ): SwapState { val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() @@ -1521,7 +1615,14 @@ internal class SwapInteractorImpl @Inject constructor( toAddress = dexToAddress, refundAddress = fromNetworkAddress?.defaultAddress?.value, expressOperationType = expressOperationType, - ).fold( + ).map { swapData -> + val dexTx = swapData.transaction as? ExpressTransactionModel.DEX + if (dexTx != null && quoteAllowanceContract != null && dexTx.allowanceContract == null) { + swapData.copy(transaction = dexTx.copy(allowanceContract = quoteAllowanceContract)) + } else { + swapData + } + }.fold( ifRight = { swapData -> val preparedSwapConfigState = PreparedSwapConfigState( balanceStatus = SwapBalanceStatus.Pending, @@ -1640,17 +1741,31 @@ internal class SwapInteractorImpl @Inject constructor( ) } + val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive && fromToken is CryptoCurrency.Token + val spenderAddress = if (isYieldSwap) { + yieldModuleAddressProvider.getOrFetch(fromSwapCurrencyStatus.userWalletId, fromToken.network) + ?: run { + TangemLogger.e( + "Yield-swap approval skipped: yield-module address unresolved for " + + "walletId=${fromSwapCurrencyStatus.userWalletId} network=${fromToken.network.rawId}", + ) + return quotesLoadedState.copy(permissionState = PermissionDataState.Empty) + } + } else { + requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" } + } + val allowanceInfo = getAllowanceInfoUseCase( userWalletId = fromSwapCurrencyStatus.userWalletId, cryptoCurrency = fromToken, - spenderAddress = requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" }, + spenderAddress = spenderAddress, requiredAmount = swapAmount.value, ).getOrNull() return quotesLoadedState.copy( permissionState = PermissionDataState.PermissionRequired( isResetApproval = allowanceInfo is AllowanceInfo.ResetNeeded, - spenderAddress = quoteModel.allowanceContract, + spenderAddress = spenderAddress, ), ) } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt index d4c16b5b71..3bcd2fdcb4 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -9,6 +9,7 @@ import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseC import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase import com.tangem.feature.swap.domain.* import com.tangem.feature.swap.domain.api.SwapFeedbackRepository import com.tangem.feature.swap.domain.api.SwapRepository @@ -75,6 +76,7 @@ internal class SwapDomainModule { createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase, walletManagersFacade: WalletManagersFacade, @SwapDexGasLimit patchEthGasLimitForSwap: PatchEthGasLimitForSwap, + wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase, ): DexSwapFeeCalculator = DexSwapFeeCalculator( getFeeUseCase = getFeeUseCase, getEthSpecificFeeUseCase = getEthSpecificFeeUseCase, @@ -82,6 +84,7 @@ internal class SwapDomainModule { createTransactionExtrasUseCase = createTransactionExtrasUseCase, walletManagersFacade = walletManagersFacade, patchEthGasLimitForSwap = patchEthGasLimitForSwap, + wrapYieldSwapCallDataWithUpgradeUseCase = wrapYieldSwapCallDataWithUpgradeUseCase, ) @Provides diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt index 8f711b7b88..183303e1e4 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt @@ -7,8 +7,13 @@ import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.smartcontract.SmartContractCallData import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException +import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException +import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySwapCallData import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.common.extensions.hexToBytes import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -19,12 +24,14 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal +import java.math.BigInteger /** * Calculates the on-chain transaction fee for a DEX swap. @@ -52,6 +59,7 @@ class DexSwapFeeCalculator( private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase, private val walletManagersFacade: WalletManagersFacade, private val patchEthGasLimitForSwap: PatchEthGasLimitForSwap, + private val wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase, ) { suspend fun calculate( @@ -115,6 +123,134 @@ class DexSwapFeeCalculator( } } + /** + * Yield-mode DEX fee path: routes the swap through the user's yield module proxy. + * + * Native fee is computed for a [TransactionData.Uncompiled] addressed to [yieldModuleAddress], + * carrying the wrapped call data produced by [buildYieldSwapCallData]. The 12% gas-limit bump + * is applied to match the non-yield DEX flow. + * + * Fallback to [GetEthSpecificFeeUseCase] (with the gas limit carried by the Express transaction + * model) is applied in two cases: + * - [yieldModuleAddress] is `null` — yield module address could not be resolved upstream; + * - the fee estimation call throws `IllegalStateException` (e.g. payload too large). + * + * Yield-module errors ([YieldModuleUpgradeUnavailableException], + * [YieldModuleVersionIndeterminateException]) are mapped to [ExpressDataError.UnknownError] + * to keep the unified error surface a single type. + */ + suspend fun calculateYield( + fromSwapCurrencyStatus: SwapCurrencyStatus, + transaction: ExpressTransactionModel.DEX, + yieldModuleAddress: String?, + ): Either = either { + val fromCurrency = fromSwapCurrencyStatus.currency as? CryptoCurrency.Token + ?: raise(ExpressDataError.UnknownError()) + val network = fromCurrency.network + + val nativeBalance = walletManagersFacade.getNativeTokenBalance( + userWalletId = fromSwapCurrencyStatus.userWalletId, + networkId = network.rawId, + derivationPath = network.derivationPath.value, + ) + if (nativeBalance.signum() == 0) raise(ExpressDataError.UnknownError()) + + if (yieldModuleAddress == null) { + val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError()) + return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind() + } + + val spenderAddress = transaction.allowanceContract + ?: raise(ExpressDataError.UnknownError()) + + val rawFee = try { + val wrappedCallData = buildYieldSwapCallData( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + txTo = transaction.txTo, + dexCallData = transaction.txData, + amount = transaction.fromAmount.value, + spenderAddress = spenderAddress, + ) + val extras = createTransactionExtrasUseCase( + callData = wrappedCallData, + network = network, + ).getOrNull() ?: raise(ExpressDataError.UnknownError()) + + val transactionData = TransactionData.Uncompiled( + amount = createNativeAmountForDex("0", network), + destinationAddress = yieldModuleAddress, + fee = null, + sourceAddress = transaction.txFrom, + extras = extras, + ) + getFeeUseCase( + transactionData = transactionData, + network = network, + userWallet = fromSwapCurrencyStatus.userWallet, + ).getOrNull() ?: raise(ExpressDataError.UnknownError()) + } catch (_: YieldModuleUpgradeUnavailableException) { + raise(ExpressDataError.UnknownError()) + } catch (_: YieldModuleVersionIndeterminateException) { + raise(ExpressDataError.UnknownError()) + } catch (_: IllegalStateException) { + val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError()) + return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind() + } + + val patched = patchEthGasLimitForSwap(rawFee) + DexFeeResult( + transactionFee = TransactionFeeResult.Loaded(patched), + otherNativeFee = BigDecimal.ZERO, + gas = transaction.gas, + ) + } + + /** + * Wraps a DEX call data into a yield-supply swap call data, ready to be sent through the + * user's yield module. Shared with [SwapInteractorImpl.createYieldSwapDexTransaction], which + * is why this helper is exposed at the calculator level rather than kept private. + */ + suspend fun buildYieldSwapCallData( + fromSwapCurrencyStatus: SwapCurrencyStatus, + txTo: String, + dexCallData: String, + amount: BigDecimal, + spenderAddress: String, + ): SmartContractCallData { + val fromCurrency = fromSwapCurrencyStatus.currency as CryptoCurrency.Token + val amountInWei = amount.movePointRight(fromCurrency.decimals).toBigInteger() + val dexCallDataBytes = dexCallData.removePrefix("0x").hexToBytes() + val swapCallData = EthereumYieldSupplySwapCallData( + tokenIn = fromCurrency.contractAddress, + amountIn = amountInWei, + target = txTo, + spender = spenderAddress, + swapData = dexCallDataBytes, + ) + return wrapYieldSwapCallDataWithUpgradeUseCase( + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = fromCurrency.network, + callData = swapCallData, + ) + } + + private suspend fun ethSpecificFeeFallback( + fromSwapCurrencyStatus: SwapCurrencyStatus, + gasLimit: BigInteger, + ): Either = either { + val fee = getEthSpecificFeeUseCase( + userWallet = fromSwapCurrencyStatus.userWallet, + cryptoCurrency = fromSwapCurrencyStatus.currency, + gasLimit = gasLimit, + ).getOrNull() ?: raise(ExpressDataError.UnknownError()) + val patched = patchEthGasLimitForSwap(fee) + DexFeeResult( + transactionFee = TransactionFeeResult.Loaded(patched), + otherNativeFee = BigDecimal.ZERO, + gas = gasLimit, + ) + } + @Suppress("CyclomaticComplexMethod") private suspend fun getFeeDataForDexSwap( fromSwapCurrencyStatus: SwapCurrencyStatus, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt index 0986144ef2..7d01a26907 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt @@ -30,7 +30,7 @@ sealed class ExpressTransactionModel { val txData: String, val otherNativeFeeWei: BigDecimal?, val gas: BigInteger?, - val allowanceContract: String?, + val allowanceContract: String? = null, ) : ExpressTransactionModel() data class CEX( diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt index 5e5d2ab44b..29811e9c8c 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -21,6 +21,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.ui.PermissionDataState import com.tangem.feature.swap.domain.models.ui.SwapState import io.mockk.coEvery import io.mockk.every @@ -872,6 +873,210 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase( assertThat(result[cexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java) } } + + @Nested + inner class YieldSwapApprovalPath { + + private val yieldProxyAddress = "0xYieldModuleProxy" + private val yieldTokenContract = "0xTokenContract" + + @BeforeEach + fun enableYieldSwap() { + every { swapFeatureToggles.isYieldSwapEnabled } returns true + coEvery { + yieldModuleAddressProvider.getOrFetch(any(), any()) + } returns yieldProxyAddress + } + + @Test + fun `should proceed to QuotesLoadedState when yield-supply is active and isAllowedToSpend is true`() = runTest { + // Given — yield active, approve to proxy in place → swap proceeds via loadDexSwapDataNoFee + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = yieldTokenContract, + isCoin = false, + amount = BigDecimal("10"), + yieldSupplyActive = true, + yieldSupplyAllowedToSpend = true, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex() + + coEvery { + repository.findBestQuote( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), toNetwork = any(), fromAmount = any(), + fromDecimals = any(), toDecimals = any(), + providerId = dexProvider.providerId, rateType = any(), + ) + } returns quoteModel.right() + coEvery { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = dexProvider.providerId, rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } returns swapData.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — proceeds (no PermissionRequired), permissionState is Empty + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = state as SwapState.QuotesLoadedState + assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty) + } + + @Test + fun `should request approval to yield-module proxy when isAllowedToSpend is false`() = runTest { + // Given — yield active, approve to proxy revoked → flow must surface PermissionRequired + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = yieldTokenContract, + isCoin = false, + amount = BigDecimal("10"), + yieldSupplyActive = true, + yieldSupplyAllowedToSpend = false, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouterShouldNotBeUsed") + + coEvery { + repository.findBestQuote( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), toNetwork = any(), fromAmount = any(), + fromDecimals = any(), toDecimals = any(), + providerId = dexProvider.providerId, rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — PermissionRequired with spender = yield-module proxy (not DEX router) + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = state as SwapState.QuotesLoadedState + assertThat(loaded.permissionState).isInstanceOf(PermissionDataState.PermissionRequired::class.java) + val required = loaded.permissionState as PermissionDataState.PermissionRequired + assertThat(required.spenderAddress).isEqualTo(yieldProxyAddress) + } + + @Test + fun `should set isResetApproval=true when yield-token allowance requires reset before re-approval`() = runTest { + // Given — Tether-like token: any non-zero allowance must be reset to zero before re-approve. + // Yield approve to proxy was revoked → onchain allowance is partial → ResetNeeded. + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = yieldTokenContract, + isCoin = false, + amount = BigDecimal("10"), + yieldSupplyActive = true, + yieldSupplyAllowedToSpend = false, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouterIgnoredForYield") + coEvery { + repository.findBestQuote( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), toNetwork = any(), fromAmount = any(), + fromDecimals = any(), toDecimals = any(), + providerId = dexProvider.providerId, rateType = any(), + ) + } returns quoteModel.right() + // Override default Enough stub: simulate partial-allowance state for yield-proxy spender. + coEvery { + getAllowanceInfoUseCase.invoke( + userWalletId = any(), + cryptoCurrency = any(), + spenderAddress = yieldProxyAddress, + requiredAmount = any(), + ) + } returns ( + AllowanceInfo.ResetNeeded( + allowance = BigDecimal("0.5"), + requiredAmount = BigDecimal("1"), + ) as AllowanceInfo + ).right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — PermissionRequired with isResetApproval=true and spender = yield-module proxy + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = state as SwapState.QuotesLoadedState + assertThat(loaded.permissionState).isInstanceOf(PermissionDataState.PermissionRequired::class.java) + val required = loaded.permissionState as PermissionDataState.PermissionRequired + assertThat(required.spenderAddress).isEqualTo(yieldProxyAddress) + assertThat(required.isResetApproval).isTrue() + } + + @Test + fun `should fallback to no-permission state when yield-module proxy address is unresolvable`() = runTest { + // Given — yield store returns null (e.g. network unreachable on first resolve) + coEvery { yieldModuleAddressProvider.getOrFetch(any(), any()) } returns null + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = yieldTokenContract, + isCoin = false, + amount = BigDecimal("10"), + yieldSupplyActive = true, + yieldSupplyAllowedToSpend = false, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouter") + coEvery { + repository.findBestQuote( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), toNetwork = any(), fromAmount = any(), + fromDecimals = any(), toDecimals = any(), + providerId = dexProvider.providerId, rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + ) + + // Then — falls back to PermissionDataState.Empty (no approval UI shown to avoid bogus DEX-router approve) + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = state as SwapState.QuotesLoadedState + assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty) + } + } } // region — test-local helpers diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt index e6d4aaf1cb..80dbaf4494 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt @@ -33,6 +33,7 @@ import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.usecase.* import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.YieldModuleAddressProvider import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator @@ -41,6 +42,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.AmountFormatter import com.tangem.feature.swap.domain.models.ui.SwapFee +import com.tangem.features.swap.SwapFeatureToggles import io.mockk.clearAllMocks import io.mockk.every import io.mockk.mockk @@ -84,6 +86,8 @@ internal open class SwapInteractorImplTestBase { protected val getSwapPairUseCase: GetSwapPairUseCase = mockk(relaxed = true) protected val dexSwapFeeCalculator: DexSwapFeeCalculator = mockk(relaxed = true) protected val cexSwapFeeCalculator: CexSwapFeeCalculator = mockk(relaxed = true) + protected val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true) + protected val yieldModuleAddressProvider: YieldModuleAddressProvider = mockk(relaxed = true) // endregion @@ -115,6 +119,8 @@ internal open class SwapInteractorImplTestBase { getSwapPairUseCase = getSwapPairUseCase, dexSwapFeeCalculator = dexSwapFeeCalculator, cexSwapFeeCalculator = cexSwapFeeCalculator, + swapFeatureToggles = swapFeatureToggles, + yieldModuleAddressProvider = yieldModuleAddressProvider, ) } @@ -158,6 +164,7 @@ internal fun buildSwapCurrencyStatus( decimals: Int = 18, userWalletId: UserWalletId = UserWalletId(stringValue = "deadbeef"), yieldSupplyActive: Boolean = false, + yieldSupplyAllowedToSpend: Boolean = true, ): SwapCurrencyStatus { val networkId = mockk(relaxed = true) { every { rawId } returns Network.RawID(networkRawId) @@ -196,6 +203,7 @@ internal fun buildSwapCurrencyStatus( val maybeYield: YieldSupplyStatus? = if (yieldSupplyActive) { mockk(relaxed = true) { every { isActive } returns true + every { isAllowedToSpend } returns yieldSupplyAllowedToSpend } } else { null diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt index 9d57af4369..3f5b481b94 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt @@ -20,6 +20,7 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase import com.tangem.feature.swap.domain.buildSwapCurrencyStatus import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount @@ -59,6 +60,7 @@ internal class DexSwapFeeCalculatorTest { private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true) private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true) private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true) + private val wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase = mockk(relaxed = true) private val dexBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.DEX_PERCENTAGE) @@ -70,6 +72,7 @@ internal class DexSwapFeeCalculatorTest { createTransactionExtrasUseCase = createTransactionExtrasUseCase, walletManagersFacade = walletManagersFacade, patchEthGasLimitForSwap = dexBump, + wrapYieldSwapCallDataWithUpgradeUseCase = wrapYieldSwapCallDataWithUpgradeUseCase, ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index 39e01a84c8..a98c00d50b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -9,6 +9,10 @@ internal class DefaultSwapFeatureToggles @Inject constructor( featureTogglesManager: FeatureTogglesManager, ) : SwapFeatureToggles { + override val isYieldSwapEnabled: Boolean = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED, + ) + override val isSwapSwitchToTransferEnabled: Boolean = featureTogglesManager.isFeatureEnabled( toggle = FeatureToggles.AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED, ) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index b745566b76..ad6ebb8cb7 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1527" +tangemBlockchainSdk = "develop-1532" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-620" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt index 2213a287ff..58af77dd60 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt @@ -16,6 +16,7 @@ import javax.inject.Inject * @property accountCreator account creator * @property blockchainDataStorage blockchain data storage * @property blockchainSDKLogger blockchain SDK logger + * @property featureToggleValues blockchain feature toggle values * [REDACTED_AUTHOR] */ @@ -23,9 +24,7 @@ internal class WalletManagerFactoryCreator @Inject constructor( private val accountCreator: AccountCreator, private val blockchainDataStorage: BlockchainDataStorage, private val blockchainSDKLogger: BlockchainSDKLogger, - private val isSolanaTxHistoryEnabled: Boolean, - private val isSolanaScaledUiAmountEnabled: Boolean, - private val isHederaErc20Enabled: Boolean, + private val featureToggleValues: FeatureToggleValues, ) { fun create(config: BlockchainSdkConfig, blockchainProviderTypes: BlockchainProviderTypes): WalletManagerFactory { @@ -37,13 +36,21 @@ internal class WalletManagerFactoryCreator @Inject constructor( accountCreator = accountCreator, featureToggles = BlockchainFeatureToggles( isYieldSupplyEnabled = true, + isYieldModeSwapEnabled = featureToggleValues.isYieldModeSwapEnabled, isPendingTransactionsEnabled = true, - isSolanaTxHistoryEnabled = isSolanaTxHistoryEnabled, - isSolanaScaledUiAmountEnabled = isSolanaScaledUiAmountEnabled, - isHederaErc20Enabled = isHederaErc20Enabled, + isSolanaTxHistoryEnabled = featureToggleValues.isSolanaTxHistoryEnabled, + isSolanaScaledUiAmountEnabled = featureToggleValues.isSolanaScaledUiAmountEnabled, + isHederaErc20Enabled = featureToggleValues.isHederaErc20Enabled, ), blockchainDataStorage = blockchainDataStorage, loggers = listOf(blockchainSDKLogger), ) } + + data class FeatureToggleValues( + val isSolanaTxHistoryEnabled: Boolean, + val isSolanaScaledUiAmountEnabled: Boolean, + val isYieldModeSwapEnabled: Boolean, + val isHederaErc20Enabled: Boolean, + ) } \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt index cd52d01c17..3ff97082fb 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt @@ -97,14 +97,19 @@ internal object BlockchainSDKFactoryModule { accountCreator = DefaultAccountCreator(tangemTechApi), blockchainDataStorage = DefaultBlockchainDataStorage(appPreferencesStore), blockchainSDKLogger = blockchainSDKLogger, - isSolanaTxHistoryEnabled = featureTogglesManager.isFeatureEnabled( - FeatureToggles.SOLANA_TX_HISTORY_ENABLED, - ), - isSolanaScaledUiAmountEnabled = featureTogglesManager.isFeatureEnabled( - FeatureToggles.SOLANA_SCALED_UI_AMOUNT_ENABLED, - ), - isHederaErc20Enabled = featureTogglesManager.isFeatureEnabled( - FeatureToggles.HEDERA_ERC20_ENABLED, + featureToggleValues = WalletManagerFactoryCreator.FeatureToggleValues( + isSolanaTxHistoryEnabled = featureTogglesManager.isFeatureEnabled( + FeatureToggles.SOLANA_TX_HISTORY_ENABLED, + ), + isSolanaScaledUiAmountEnabled = featureTogglesManager.isFeatureEnabled( + FeatureToggles.SOLANA_SCALED_UI_AMOUNT_ENABLED, + ), + isYieldModeSwapEnabled = featureTogglesManager.isFeatureEnabled( + FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED, + ), + isHederaErc20Enabled = featureTogglesManager.isFeatureEnabled( + FeatureToggles.HEDERA_ERC20_ENABLED, + ), ), ) } From fdc9bdbdae873b29eba58dd7188f697ce999f251 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 17:10:48 +0300 Subject: [PATCH 6/7] Updated on 2026-08-14 --- .../pay/repository/MockAwareTangemPayCardDetailsRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt index 17a322804b..f455fe0128 100644 --- a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt @@ -7,13 +7,13 @@ import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.domain.models.account.CardDisplayName +import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayCardDetails import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository -import com.tangem.domain.visa.model.TangemPayCardFrozenState import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton From b3d51d6805118af76463da143656d51d6037c043 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 18:15:17 +0400 Subject: [PATCH 7/7] Updated on 2026-08-14 --- .../data/yield/supply/DefaultYieldModuleAddressProvider.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProvider.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProvider.kt index bf80a11396..6dff6c1604 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProvider.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProvider.kt @@ -17,13 +17,13 @@ internal class DefaultYieldModuleAddressProvider( private val dispatchers: CoroutineDispatcherProvider, ) : YieldModuleAddressProvider { - private data class Key(val userWalletId: UserWalletId, val networkRawId: String) + private data class Key(val userWalletId: UserWalletId, val networkId: Network.ID) private val cache = ConcurrentHashMap() private val mutex = Mutex() override suspend fun getOrFetch(userWalletId: UserWalletId, network: Network): String? { - val key = Key(userWalletId, network.rawId) + val key = Key(userWalletId, network.id) cache[key]?.let { return it } return withContext(dispatchers.io) { mutex.withLock {