Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 17:00:20 +04:00
parent 4dec0b44cd
commit f19c2a9fd5
14 changed files with 488 additions and 9 deletions

View file

@ -38,6 +38,8 @@ import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
import com.tangem.feature.tester.presentation.navigation.TesterScreen
import com.tangem.feature.tester.presentation.providers.ui.BlockchainProvidersScreen
import com.tangem.feature.tester.presentation.providers.viewmodel.BlockchainProvidersViewModel
import com.tangem.feature.tester.presentation.sellredirect.ui.SellRedirectGeneratorScreen
import com.tangem.feature.tester.presentation.sellredirect.viewmodels.SellRedirectGeneratorViewModel
import com.tangem.feature.tester.presentation.storybook.ui.StoryBookScreen
import com.tangem.feature.tester.presentation.storybook.viewmodel.StoryBookViewModel
import com.tangem.feature.tester.presentation.testpush.ui.TestPushScreen
@ -102,6 +104,7 @@ internal class TesterActivity : ComposeActivity() {
ButtonUM.STORY_BOOK,
ButtonUM.SURVEY_SPARROW,
ButtonUM.BACKEND_AUTH_STATUS,
ButtonUM.SELL_REDIRECT_GENERATOR,
),
onButtonClick = { buttonUM ->
val route = when (buttonUM) {
@ -116,6 +119,7 @@ internal class TesterActivity : ComposeActivity() {
ButtonUM.STORY_BOOK -> TesterScreen.STORY_BOOK
ButtonUM.SURVEY_SPARROW -> TesterScreen.SURVEY_SPARROW
ButtonUM.BACKEND_AUTH_STATUS -> TesterScreen.BACKEND_AUTH_STATUS
ButtonUM.SELL_REDIRECT_GENERATOR -> TesterScreen.SELL_REDIRECT_GENERATOR
}
innerTesterRouter.open(route)
@ -225,6 +229,15 @@ internal class TesterActivity : ComposeActivity() {
BackendAuthStatusScreen(state)
}
composable(route = TesterScreen.SELL_REDIRECT_GENERATOR.name) {
val viewModel = hiltViewModel<SellRedirectGeneratorViewModel>().apply {
setupNavigation(innerTesterRouter)
}
val state by viewModel.uiState.collectAsStateWithLifecycle()
SellRedirectGeneratorScreen(state)
}
}
}

View file

@ -29,5 +29,6 @@ data class TesterMenuUM(
STORY_BOOK(R.string.story_book),
SURVEY_SPARROW(R.string.survey_sparrow),
BACKEND_AUTH_STATUS(R.string.backend_auth_status),
SELL_REDIRECT_GENERATOR(R.string.sell_redirect_generator),
}
}

View file

@ -18,4 +18,5 @@ internal enum class TesterScreen {
STORY_BOOK,
SURVEY_SPARROW,
BACKEND_AUTH_STATUS,
SELL_REDIRECT_GENERATOR,
}

View file

@ -0,0 +1,49 @@
package com.tangem.feature.tester.presentation.sellredirect.state
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* Content state of the Sell Redirect DeepLink generator screen.
*
* The screen reads every locally-stored app-initiated sell (pending off-ramps, including already-expired ones) and,
* for each one, builds a `redirect_sell` deeplink carrying its real `request_id` the only value that lets the
* deeplink pass the app's authenticity check. The remaining parameters (transaction id, amount, deposit address) are
* filled with test placeholders since they are not part of the stored record.
*
* @property onBackClick invoked when the back button is pressed
* @property onRefreshClick invoked to reload the stored sells
* @property items one generated deeplink per stored sell, newest first
* @property isEmpty `true` once loading finished and no stored sell was found
*/
internal data class SellRedirectGeneratorUM(
val onBackClick: () -> Unit = {},
val onRefreshClick: () -> Unit = {},
val items: ImmutableList<DeepLinkItemUM> = persistentListOf(),
val isEmpty: Boolean = false,
) {
/**
* A single generated deeplink built from one cached sell.
*
* @property currencyId currency id the sell was registered for
* @property walletId shortened id of the wallet that registered the sell (must be the selected wallet for the
* deeplink to be accepted)
* @property requestId shortened nonce embedded in the deeplink
* @property age human-readable age of the record (e.g. `5m ago`)
* @property deepLink full generated `tangem://redirect_sell?...` URL
* @property isExpired `true` when the record is past its expiry the deeplink will no longer be accepted
* @property onCopyClick copies [deepLink] to the clipboard
* @property onOpenClick fires [deepLink] as a VIEW intent so it routes through the app's deeplink handling
*/
data class DeepLinkItemUM(
val currencyId: String,
val walletId: String,
val requestId: String,
val age: String,
val deepLink: String,
val isExpired: Boolean,
val onCopyClick: () -> Unit,
val onOpenClick: () -> Unit,
)
}

View file

@ -0,0 +1,154 @@
package com.tangem.feature.tester.presentation.sellredirect.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.components.divider.DividerWithPadding
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tester.impl.R
import com.tangem.feature.tester.presentation.sellredirect.state.SellRedirectGeneratorUM
/**
* Screen listing `redirect_sell` deeplinks generated from the app's cached, app-initiated sells. Each item can be
* copied to the clipboard or opened directly to route through the app's deeplink handling.
*
* @param state screen state
*/
@Composable
internal fun SellRedirectGeneratorScreen(state: SellRedirectGeneratorUM) {
Scaffold(
topBar = {
AppBarWithBackButton(
onBackClick = state.onBackClick,
text = stringResourceSafe(id = R.string.sell_redirect_generator),
modifier = Modifier.statusBarsPadding(),
)
},
containerColor = TangemTheme.colors.background.secondary,
) { paddingValues ->
SelectionContainer {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(vertical = 8.dp),
) {
item(key = "refresh") {
SecondaryButton(
text = "Refresh cached sells",
onClick = state.onRefreshClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
if (state.isEmpty) {
item(key = "empty") { EmptyMessage() }
}
items(items = state.items, key = { it.requestId + it.deepLink }) { item ->
Column(modifier = Modifier.animateItem()) {
DeepLinkItem(item = item)
DividerWithPadding(start = 16.dp, end = 16.dp)
}
}
}
}
}
}
@Composable
private fun EmptyMessage() {
Text(
text = "No cached sells found. Start a Sell (off-ramp) in the app to register one, then refresh here. " +
"Records are single-use and expire after an hour.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
@Composable
private fun DeepLinkItem(item: SellRedirectGeneratorUM.DeepLinkItemUM) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Field(label = "Currency", value = item.currencyId)
Field(label = "Wallet", value = item.walletId)
Field(label = "Request id", value = item.requestId)
Field(label = "Age", value = item.age)
if (item.isExpired) {
Text(
text = "Expired — this deeplink is no longer valid",
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.warning,
modifier = Modifier.padding(top = 4.dp),
)
}
Text(
text = item.deepLink,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(top = 4.dp),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SecondaryButton(
text = "Copy",
onClick = item.onCopyClick,
modifier = Modifier.weight(1f),
)
SecondaryButton(
text = "Open",
onClick = item.onOpenClick,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun Field(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "$label:",
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.secondary,
)
Text(
text = value,
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}

View file

@ -0,0 +1,140 @@
package com.tangem.feature.tester.presentation.sellredirect.viewmodels
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.common.routing.DeepLinkRoute
import com.tangem.common.routing.DeepLinkScheme
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.domain.offramp.model.PendingOfframp
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
import com.tangem.feature.tester.presentation.sellredirect.state.SellRedirectGeneratorUM
import com.tangem.feature.tester.presentation.sellredirect.state.SellRedirectGeneratorUM.DeepLinkItemUM
import com.tangem.utils.coroutines.runSuspendCatching
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
import javax.inject.Inject
/**
* ViewModel for the Sell Redirect DeepLink generator screen.
*
* Reads every stored app-initiated sell (pending off-ramps, including expired ones) via [OfframpRepository] and turns
* each into a ready-to-use `redirect_sell` deeplink. The stored `request_id` is the crucial part: only a deeplink
* carrying a real, app-issued `request_id` (bound to the same wallet + currency) survives the handler's authenticity
* check. Expired records are still listed but flagged, since they will no longer be accepted.
*/
@HiltViewModel
internal class SellRedirectGeneratorViewModel @Inject constructor(
private val offrampRepository: OfframpRepository,
private val clipboardManager: ClipboardManager,
@ApplicationContext private val context: Context,
) : ViewModel() {
val uiState: StateFlow<SellRedirectGeneratorUM>
field = MutableStateFlow(SellRedirectGeneratorUM(onRefreshClick = ::load))
init {
load()
}
/** Setup navigation state property by router [router] */
fun setupNavigation(router: InnerTesterRouter) {
uiState.update { it.copy(onBackClick = router::back) }
}
private fun load() {
viewModelScope.launch {
val stored = runSuspendCatching { offrampRepository.getAllStoredOfframps() }
.onFailure {
Toast.makeText(context, "Failed to read stored sells: ${it.message}", Toast.LENGTH_SHORT).show()
}
.getOrDefault(emptyList())
val now = System.currentTimeMillis()
val items = stored
.sortedByDescending { it.createdAt }
.map { it.toItem(now) }
.toImmutableList()
uiState.update { it.copy(items = items, isEmpty = items.isEmpty()) }
}
}
private fun PendingOfframp.toItem(now: Long): DeepLinkItemUM {
val deepLink = buildDeepLink(this)
return DeepLinkItemUM(
currencyId = currencyId,
walletId = userWalletId.stringValue.shorten(),
requestId = requestId.shorten(),
age = formatAge(createdAt, now),
deepLink = deepLink,
isExpired = isExpired(now),
onCopyClick = { copyDeepLink(deepLink) },
onOpenClick = { openDeepLink(deepLink) },
)
}
/**
* Builds a `tangem://redirect_sell?...` URL. The real cached [PendingOfframp.currencyId] / [requestId] gate the
* handler's authenticity check; the other required params are non-empty test placeholders (the transaction id,
* amount and deposit address are not part of the cached record).
*/
private fun buildDeepLink(offramp: PendingOfframp): String = Uri.Builder()
.scheme(DeepLinkScheme.Tangem.scheme)
.authority(DeepLinkRoute.SellRedirect.host)
.appendQueryParameter(CURRENCY_ID_KEY, offramp.currencyId)
.appendQueryParameter(REQUEST_ID_KEY, offramp.requestId)
.appendQueryParameter(TRANSACTION_ID_KEY, "test-tx-${offramp.requestId.take(TX_ID_LENGTH)}")
.appendQueryParameter(AMOUNT_KEY, DEFAULT_AMOUNT)
.appendQueryParameter(DESTINATION_ADDRESS_KEY, DEFAULT_ADDRESS)
.build()
.toString()
private fun copyDeepLink(deepLink: String) {
clipboardManager.setText(text = deepLink, isSensitive = false, label = "Sell redirect deeplink")
Toast.makeText(context, "Deeplink copied", Toast.LENGTH_SHORT).show()
}
private fun openDeepLink(deepLink: String) {
val intent = Intent(Intent.ACTION_VIEW, deepLink.toUri()).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
.onFailure { Toast.makeText(context, "Can't open: ${it.message}", Toast.LENGTH_SHORT).show() }
}
private fun formatAge(createdAt: Long, now: Long): String {
val elapsedMinutes = TimeUnit.MILLISECONDS.toMinutes(now - createdAt)
return when {
elapsedMinutes <= 0 -> "just now"
else -> "${elapsedMinutes}m ago"
}
}
/** Shortens a long value to `prefix…suffix` for display; the full value stays inside the deeplink. */
private fun String.shorten(): String =
if (length <= SHORTEN_KEEP * 2 + 1) this else "${take(SHORTEN_KEEP)}${takeLast(SHORTEN_KEEP)}"
private companion object {
const val SHORTEN_KEEP = 6
const val TX_ID_LENGTH = 8
const val DEFAULT_AMOUNT = "1"
const val DEFAULT_ADDRESS = "test-deposit-address"
// Query keys expected by DefaultSellRedirectDeepLinkHandler; kept in sync with it.
const val TRANSACTION_ID_KEY = "transactionId"
const val CURRENCY_ID_KEY = "currency_id"
const val AMOUNT_KEY = "baseCurrencyAmount"
const val DESTINATION_ADDRESS_KEY = "depositWalletAddress"
const val REQUEST_ID_KEY = "request_id"
}
}

View file

@ -29,4 +29,5 @@
<string name="story_book" translatable="false">Story book</string>
<string name="survey_sparrow" translatable="false">Survey Sparrow</string>
<string name="backend_auth_status" translatable="false">Backend Auth</string>
<string name="sell_redirect_generator" translatable="false">Sell Redirect DeepLink</string>
</resources>