Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-27 17:04:25 +03:00
parent bc24aeb9f3
commit c77fcafdc2
11 changed files with 380 additions and 1 deletions

View file

@ -75,6 +75,10 @@
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />

View file

@ -13,7 +13,7 @@ import com.tangem.domain.visa.model.VisaTxDetails
* *
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
*/ */
internal class EmailMessageBodyResolver( class EmailMessageBodyResolver(
private val feedbackRepository: FeedbackRepository, private val feedbackRepository: FeedbackRepository,
) { ) {

View file

@ -3,6 +3,7 @@ plugins {
alias(deps.plugins.kotlin.android) alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt) alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android) alias(deps.plugins.hilt.android)
alias(deps.plugins.kotlin.serialization)
id("configuration") id("configuration")
} }
@ -40,6 +41,7 @@ dependencies {
implementation(projects.domain.manageTokens) implementation(projects.domain.manageTokens)
implementation(projects.domain.wallets.models) implementation(projects.domain.wallets.models)
implementation(projects.domain.wallets) implementation(projects.domain.wallets)
implementation(projects.domain.feedback.models)
implementation(projects.data.common) implementation(projects.data.common)
@ -68,4 +70,5 @@ dependencies {
implementation(projects.common.ui) implementation(projects.common.ui)
implementation(projects.libs.blockchainSdk) implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto) implementation(projects.libs.crypto)
implementation(deps.kotlin.serialization)
} }

View file

@ -2,6 +2,7 @@ package com.tangem.feature.tester.presentation
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -20,6 +21,8 @@ import com.tangem.feature.tester.presentation.accounts.ui.AccountsScreen
import com.tangem.feature.tester.presentation.accounts.viewmodel.TesterAccountsViewModel import com.tangem.feature.tester.presentation.accounts.viewmodel.TesterAccountsViewModel
import com.tangem.feature.tester.presentation.actions.TesterActionsScreen import com.tangem.feature.tester.presentation.actions.TesterActionsScreen
import com.tangem.feature.tester.presentation.actions.TesterActionsViewModel import com.tangem.feature.tester.presentation.actions.TesterActionsViewModel
import com.tangem.feature.tester.presentation.addresses.ui.AddressesInfoScreen
import com.tangem.feature.tester.presentation.addresses.viewmodels.AddressesInfoViewModel
import com.tangem.feature.tester.presentation.environments.ui.EnvironmentTogglesScreen import com.tangem.feature.tester.presentation.environments.ui.EnvironmentTogglesScreen
import com.tangem.feature.tester.presentation.environments.viewmodels.EnvironmentsTogglesViewModel import com.tangem.feature.tester.presentation.environments.viewmodels.EnvironmentsTogglesViewModel
import com.tangem.feature.tester.presentation.excludedblockchains.ExcludedBlockchainsScreen import com.tangem.feature.tester.presentation.excludedblockchains.ExcludedBlockchainsScreen
@ -82,6 +85,7 @@ internal class TesterActivity : ComposeActivity() {
ButtonUM.TESTER_ACTIONS, ButtonUM.TESTER_ACTIONS,
ButtonUM.TEST_PUSHES, ButtonUM.TEST_PUSHES,
ButtonUM.ACCOUNTS, ButtonUM.ACCOUNTS,
ButtonUM.ADDRESSES_INFO,
), ),
onButtonClick = { buttonUM -> onButtonClick = { buttonUM ->
val route = when (buttonUM) { val route = when (buttonUM) {
@ -92,6 +96,7 @@ internal class TesterActivity : ComposeActivity() {
ButtonUM.TESTER_ACTIONS -> TesterScreen.TESTER_ACTIONS ButtonUM.TESTER_ACTIONS -> TesterScreen.TESTER_ACTIONS
ButtonUM.TEST_PUSHES -> TesterScreen.TEST_PUSHES ButtonUM.TEST_PUSHES -> TesterScreen.TEST_PUSHES
ButtonUM.ACCOUNTS -> TesterScreen.ACCOUNTS ButtonUM.ACCOUNTS -> TesterScreen.ACCOUNTS
ButtonUM.ADDRESSES_INFO -> TesterScreen.ADDRESSES_INFO
} }
innerTesterRouter.open(route) innerTesterRouter.open(route)
@ -162,6 +167,18 @@ internal class TesterActivity : ComposeActivity() {
AccountsScreen(state) AccountsScreen(state)
} }
composable(route = TesterScreen.ADDRESSES_INFO.name) {
val viewModel = hiltViewModel<AddressesInfoViewModel>()
LaunchedEffect(viewModel) {
viewModel.setupNavigation(innerTesterRouter)
}
val state by viewModel.uiState.collectAsStateWithLifecycle()
AddressesInfoScreen(state)
}
} }
} }
} }

View file

@ -0,0 +1,11 @@
package com.tangem.feature.tester.presentation.addresses.model
import kotlinx.serialization.Serializable
@Serializable
data class TesterAddressJson(
val addresses: List<String>,
val blockchain: String,
val derivationPath: String,
val token: String? = null,
)

View file

@ -0,0 +1,24 @@
package com.tangem.feature.tester.presentation.addresses.state
import androidx.annotation.StringRes
/**
* Content state of addresses info screen
*
* @property title title
* @property addressesText addresses in text format
* @property addressesJson addresses in JSON format
* @property onBackClick the lambda to be invoked when back button is pressed
*/
internal data class AddressesInfoScreenUM(
@StringRes val title: Int,
val addressesText: String,
val addressesJson: String,
val onBackClick: () -> Unit,
val onCopyClick: (CopyType) -> Unit,
)
internal enum class CopyType {
TEXT,
JSON,
}

View file

@ -0,0 +1,142 @@
package com.tangem.feature.tester.presentation.addresses.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.*
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tester.presentation.addresses.state.AddressesInfoScreenUM
import com.tangem.feature.tester.presentation.addresses.state.CopyType
import androidx.compose.foundation.text.selection.SelectionContainer
/**
* Screen with addresses info in text and JSON format
*
* @param uiModel screen state
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AddressesInfoScreen(uiModel: AddressesInfoScreenUM) {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf("Text", "JSON")
Scaffold(
topBar = {
AppBarWithBackButton(
onBackClick = uiModel.onBackClick,
text = stringResourceSafe(id = uiModel.title),
modifier = Modifier.statusBarsPadding(),
)
},
containerColor = TangemTheme.colors.background.secondary,
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
) {
TabRow(
selectedTabIndex = selectedTab,
containerColor = TangemTheme.colors.background.secondary,
contentColor = TangemTheme.colors.text.primary1,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTab]),
color = TangemTheme.colors.text.accent,
)
},
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = {
Text(
text = title,
style = TangemTheme.typography.subtitle2,
color = if (selectedTab == index) {
TangemTheme.colors.text.primary1
} else {
TangemTheme.colors.text.secondary
},
)
},
selectedContentColor = TangemTheme.colors.text.primary1,
unselectedContentColor = TangemTheme.colors.text.secondary,
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = {
uiModel.onCopyClick(
if (selectedTab == 0) CopyType.TEXT else CopyType.JSON,
)
},
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_copy_24),
contentDescription = "Copy",
tint = TangemTheme.colors.icon.primary1,
)
}
}
// Tab content
when (selectedTab) {
0 -> {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
SelectionContainer {
Text(
text = uiModel.addressesText,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
}
}
}
}
1 -> {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
SelectionContainer {
Text(
text = uiModel.addressesJson,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,175 @@
package com.tangem.feature.tester.presentation.addresses.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.domain.feedback.models.BlockchainInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.feedback.repository.FeedbackRepository
import com.tangem.domain.feedback.utils.EmailMessageBodyResolver
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.tester.impl.R
import com.tangem.feature.tester.presentation.addresses.model.TesterAddressJson
import com.tangem.feature.tester.presentation.addresses.state.AddressesInfoScreenUM
import com.tangem.feature.tester.presentation.addresses.state.CopyType
import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
/**
* Tester screen for displaying addresses and tokens.
* Uses the same data source as feedback emails to ensure identical output.
*/
@HiltViewModel
internal class AddressesInfoViewModel @Inject constructor(
private val feedbackRepository: FeedbackRepository,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val clipboardManager: ClipboardManager,
) : ViewModel() {
private val emailMessageBodyResolver = EmailMessageBodyResolver(feedbackRepository)
private val _uiState = MutableStateFlow(getInitialState())
val uiState: StateFlow<AddressesInfoScreenUM> = _uiState.asStateFlow()
fun setupNavigation(router: InnerTesterRouter) {
_uiState.update { state ->
state.copy(
onBackClick = router::back,
onCopyClick = ::onCopyClicked,
)
}
loadAddresses()
}
private fun getInitialState(): AddressesInfoScreenUM = AddressesInfoScreenUM(
title = R.string.addresses_info,
addressesText = "Loading...",
addressesJson = "[]",
onBackClick = {},
onCopyClick = {},
)
private fun loadAddresses() {
viewModelScope.launch {
try {
val wallet = getSelectedWalletSyncUseCase()
.getOrNull() ?: run {
setEmptyState()
return@launch
}
val walletMetaInfo = WalletMetaInfo(
userWalletId = wallet.walletId,
)
val emailType = FeedbackEmailType.DirectUserRequest(
walletMetaInfo = walletMetaInfo,
)
val emailBody = emailMessageBodyResolver.resolve(emailType)
val blockchainInfos = feedbackRepository.getBlockchainInfoList(wallet.walletId)
val json = formatAddressesJson(blockchainInfos)
_uiState.update { state ->
state.copy(
addressesText = emailBody,
addressesJson = json,
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
_uiState.update { state ->
state.copy(
addressesText = "Error loading addresses",
addressesJson = errorJson(e),
)
}
}
}
}
private fun setEmptyState() {
_uiState.update { state ->
state.copy(
addressesText = "No active addresses found",
addressesJson = "[]",
)
}
}
@Suppress("JSON_FORMAT_REDUNDANT")
private fun formatAddressesJson(list: List<BlockchainInfo>): String {
val jsonList = list
.flatMap { info ->
val addresses = info
.extractAddresses()
.sortedDescending()
val tokens = if (info.tokens.isNotEmpty()) {
info.tokens.map { it.name }
} else {
listOf(info.blockchain)
}
tokens.map { token ->
TesterAddressJson(
addresses = addresses,
blockchain = info.blockchain,
derivationPath = info.derivationPath,
token = token,
)
}
}
.sortedWith(
compareBy<TesterAddressJson> { it.blockchain }
.thenBy { it.derivationPath }
.thenBy { it.token },
)
return Json { prettyPrint = true }
.encodeToString(jsonList)
}
private fun BlockchainInfo.extractAddresses(): List<String> = when (val a = addresses) {
is BlockchainInfo.Addresses.Single ->
listOf(a.value)
is BlockchainInfo.Addresses.Multiple ->
a.values.map { it.value }
}
private fun errorJson(error: Throwable): String = Json.encodeToString(
mapOf("error" to (error.message ?: "Unknown error")),
)
private fun onCopyClicked(type: CopyType) {
val state = _uiState.value
val text = when (type) {
CopyType.TEXT -> state.addressesText
CopyType.JSON -> state.addressesJson
}
if (text.isNotBlank()) {
clipboardManager.setText(
text = text,
isSensitive = false,
label = type.name,
)
}
}
}

View file

@ -25,5 +25,6 @@ data class TesterMenuUM(
TESTER_ACTIONS(R.string.tester_actions), TESTER_ACTIONS(R.string.tester_actions),
TEST_PUSHES(R.string.test_push), TEST_PUSHES(R.string.test_push),
ACCOUNTS(R.string.accounts), ACCOUNTS(R.string.accounts),
ADDRESSES_INFO(R.string.addresses_info),
} }
} }

View file

@ -14,4 +14,5 @@ internal enum class TesterScreen {
BLOCKCHAIN_PROVIDERS, BLOCKCHAIN_PROVIDERS,
TEST_PUSHES, TEST_PUSHES,
ACCOUNTS, ACCOUNTS,
ADDRESSES_INFO,
} }

View file

@ -21,4 +21,5 @@
<string name="news" translatable="false">News</string> <string name="news" translatable="false">News</string>
<string name="news_details" translatable="false">News details</string> <string name="news_details" translatable="false">News details</string>
<string name="news_details_bottom_sheet" translatable="false">News details (Bottom Sheet)</string> <string name="news_details_bottom_sheet" translatable="false">News details (Bottom Sheet)</string>
<string name="addresses_info" translatable="false">Addresses info</string>
</resources> </resources>