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

@ -78,6 +78,19 @@ The app uses [Decompose](https://github.com/arkivanov/Decompose) for lifecycle-a
- Exposes `StateFlow<{Name}UM>` (UM = UI Model, state class in `ui/state/` subpackage) - Exposes `StateFlow<{Name}UM>` (UM = UI Model, state class in `ui/state/` subpackage)
- Has `modelScope` (SupervisorJob + mainImmediate), auto-cancelled on destroy - Has `modelScope` (SupervisorJob + mainImmediate), auto-cancelled on destroy
**State exposure (preferred pattern):** expose a public read-only `StateFlow` backed by a Kotlin
**explicit backing field** rather than a separate `private val _state` + `asStateFlow()`. The project
enables the `ExplicitBackingFields` compiler feature, so write:
```kotlin
val uiState: StateFlow<FooUM>
field = MutableStateFlow(FooUM())
// inside the class, mutate via uiState.update { … }; callers see StateFlow<FooUM>
```
This applies to both Decompose `Model`s and Android `ViewModel`s. Avoid the `_uiState`/`asStateFlow()`
duplication for new code. References: `ScanFailsModel`, `AppSettingsModel`.
**Child navigation within features:** **Child navigation within features:**
- `childStack()` — stacked screen navigation (back stack) - `childStack()` — stacked screen navigation (back stack)
- `childSlot()` — optional overlays/bottom sheets (single or no child) - `childSlot()` — optional overlays/bottom sheets (single or no child)

View file

@ -10,9 +10,9 @@ import com.tangem.tap.data.converter.PendingOfframpEntryConverter
import com.tangem.tap.data.model.PendingOfframpEntry import com.tangem.tap.data.model.PendingOfframpEntry
import com.tangem.tap.network.exchangeServices.SellService import com.tangem.tap.network.exchangeServices.SellService
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit
/** /**
* Default implementation of [OfframpRepository]. * Default implementation of [OfframpRepository].
@ -27,7 +27,7 @@ internal class DefaultOfframpRepository(
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : OfframpRepository { ) : OfframpRepository {
private val pendingOfframpConverter = PendingOfframpEntryConverter() private val converter = PendingOfframpEntryConverter()
override fun getOfframpUrl( override fun getOfframpUrl(
cryptoCurrency: CryptoCurrency, cryptoCurrency: CryptoCurrency,
@ -71,19 +71,21 @@ internal class DefaultOfframpRepository(
entry.requestId == requestId && entry.requestId == requestId &&
entry.userWalletId == userWalletId.stringValue && entry.userWalletId == userWalletId.stringValue &&
entry.currencyId == currencyId && entry.currencyId == currencyId &&
now - entry.createdAt < EXPIRY_MS !entry.isExpired(now)
} }
// Remove only the fully-matched record (single-use); always prune expired ones. A request_id that // Remove only the fully-matched record (single-use); always prune expired ones. A request_id that
// matches but with a mismatched wallet/currency is left intact so a tampered redirect cannot burn it. // matches but with a mismatched wallet/currency is left intact so a tampered redirect cannot burn it.
stored.filter { it != matched }.filterNotExpired(now) stored.filter { it != matched }.filterNotExpired(now)
} }
matched?.let(pendingOfframpConverter::convert) matched?.let(converter::convert)
}
override suspend fun getAllStoredOfframps(): List<PendingOfframp> = withContext(dispatchers.io) {
pendingOfframpStore.data.first().map(converter::convert)
} }
private fun List<PendingOfframpEntry>.filterNotExpired(now: Long): List<PendingOfframpEntry> = private fun List<PendingOfframpEntry>.filterNotExpired(now: Long): List<PendingOfframpEntry> =
filter { now - it.createdAt < EXPIRY_MS } filterNot { it.isExpired(now) }
private companion object { private fun PendingOfframpEntry.isExpired(now: Long): Boolean = converter.convert(this).isExpired(now)
val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1)
}
} }

View file

@ -236,4 +236,44 @@ internal class DefaultOfframpRepositoryTest {
// Assert // Assert
assertThat(pending).isNull() assertThat(pending).isNull()
} }
@Test
fun `GIVEN registered pending offramps WHEN getAllStoredOfframps THEN returns them without consuming`() = runTest {
// Arrange
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
// Act
val stored = repository.getAllStoredOfframps()
// ...the record must survive the read so it can still be consumed afterwards
val consumed = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId)
// Assert
assertThat(stored).hasSize(1)
assertThat(stored.single().requestId).isEqualTo(storedRequestId)
assertThat(stored.single().userWalletId).isEqualTo(userWalletId)
assertThat(stored.single().currencyId).isEqualTo(currencyId)
assertThat(consumed).isNotNull()
}
@Test
fun `GIVEN expired pending offramp WHEN getAllStoredOfframps THEN it is still returned and flagged expired`() =
runTest {
// Arrange — seed a record created 2 hours ago (past the 1h expiry)
val now = System.currentTimeMillis()
pendingStoreState.value = listOf(
PendingOfframpEntry(
requestId = "expired-id",
userWalletId = userWalletId.stringValue,
currencyId = currencyId,
createdAt = now - TimeUnit.HOURS.toMillis(2),
),
)
// Act
val stored = repository.getAllStoredOfframps()
// Assert
assertThat(stored).hasSize(1)
assertThat(stored.single().isExpired(now)).isTrue()
}
} }

View file

@ -1,6 +1,7 @@
package com.tangem.domain.offramp.model package com.tangem.domain.offramp.model
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import java.util.concurrent.TimeUnit
/** /**
* A locally-recorded marker that the app itself initiated a sell (off-ramp) flow. * A locally-recorded marker that the app itself initiated a sell (off-ramp) flow.
@ -19,4 +20,13 @@ data class PendingOfframp(
val userWalletId: UserWalletId, val userWalletId: UserWalletId,
val currencyId: String, val currencyId: String,
val createdAt: Long, val createdAt: Long,
) ) {
/** Whether the record is past its expiry at [nowMillis] and can no longer authenticate a redirect. */
fun isExpired(nowMillis: Long): Boolean = nowMillis - createdAt >= EXPIRY_MS
companion object {
/** How long a pending sell stays valid after registration. */
val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1)
}
}

View file

@ -42,4 +42,13 @@ interface OfframpRepository {
userWalletId: UserWalletId, userWalletId: UserWalletId,
currencyId: String, currencyId: String,
): PendingOfframp? ): PendingOfframp?
/**
* Returns every stored app-initiated sell **including expired ones** that have not been pruned yet (read-only;
* does not mutate the store). Use [PendingOfframp.isExpired] to tell them apart.
*
* Intended for QA/tester tooling that needs to reproduce a returning `redirect_sell` deeplink from a real,
* app-registered sell and to inspect stale records.
*/
suspend fun getAllStoredOfframps(): List<PendingOfframp>
} }

View file

@ -0,0 +1,45 @@
package com.tangem.domain.offramp.model
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWalletId
import org.junit.jupiter.api.Test
internal class PendingOfframpTest {
@Test
fun `GIVEN record younger than expiry WHEN isExpired THEN returns false`() {
// Arrange
val now = 10_000_000L
val offramp = createOfframp(createdAt = now - PendingOfframp.EXPIRY_MS + 1)
// Act & Assert
assertThat(offramp.isExpired(now)).isFalse()
}
@Test
fun `GIVEN record exactly at expiry WHEN isExpired THEN returns true`() {
// Arrange
val now = 10_000_000L
val offramp = createOfframp(createdAt = now - PendingOfframp.EXPIRY_MS)
// Act & Assert
assertThat(offramp.isExpired(now)).isTrue()
}
@Test
fun `GIVEN record older than expiry WHEN isExpired THEN returns true`() {
// Arrange
val now = 10_000_000L
val offramp = createOfframp(createdAt = now - PendingOfframp.EXPIRY_MS - 1)
// Act & Assert
assertThat(offramp.isExpired(now)).isTrue()
}
private fun createOfframp(createdAt: Long) = PendingOfframp(
requestId = "request-id",
userWalletId = UserWalletId("0011223344556677"),
currencyId = "bitcoin",
createdAt = createdAt,
)
}

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.domain.manageTokens.models) implementation(projects.domain.manageTokens.models)
implementation(projects.domain.markets.models) implementation(projects.domain.markets.models)
implementation(projects.domain.models) implementation(projects.domain.models)
implementation(projects.domain.offramp)
implementation(projects.domain.walletManager) implementation(projects.domain.walletManager)
runtimeOnly(projects.domain.card) runtimeOnly(projects.domain.card)
runtimeOnly(projects.domain.manageTokens) runtimeOnly(projects.domain.manageTokens)

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.navigation.TesterScreen
import com.tangem.feature.tester.presentation.providers.ui.BlockchainProvidersScreen import com.tangem.feature.tester.presentation.providers.ui.BlockchainProvidersScreen
import com.tangem.feature.tester.presentation.providers.viewmodel.BlockchainProvidersViewModel 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.ui.StoryBookScreen
import com.tangem.feature.tester.presentation.storybook.viewmodel.StoryBookViewModel import com.tangem.feature.tester.presentation.storybook.viewmodel.StoryBookViewModel
import com.tangem.feature.tester.presentation.testpush.ui.TestPushScreen import com.tangem.feature.tester.presentation.testpush.ui.TestPushScreen
@ -102,6 +104,7 @@ internal class TesterActivity : ComposeActivity() {
ButtonUM.STORY_BOOK, ButtonUM.STORY_BOOK,
ButtonUM.SURVEY_SPARROW, ButtonUM.SURVEY_SPARROW,
ButtonUM.BACKEND_AUTH_STATUS, ButtonUM.BACKEND_AUTH_STATUS,
ButtonUM.SELL_REDIRECT_GENERATOR,
), ),
onButtonClick = { buttonUM -> onButtonClick = { buttonUM ->
val route = when (buttonUM) { val route = when (buttonUM) {
@ -116,6 +119,7 @@ internal class TesterActivity : ComposeActivity() {
ButtonUM.STORY_BOOK -> TesterScreen.STORY_BOOK ButtonUM.STORY_BOOK -> TesterScreen.STORY_BOOK
ButtonUM.SURVEY_SPARROW -> TesterScreen.SURVEY_SPARROW ButtonUM.SURVEY_SPARROW -> TesterScreen.SURVEY_SPARROW
ButtonUM.BACKEND_AUTH_STATUS -> TesterScreen.BACKEND_AUTH_STATUS ButtonUM.BACKEND_AUTH_STATUS -> TesterScreen.BACKEND_AUTH_STATUS
ButtonUM.SELL_REDIRECT_GENERATOR -> TesterScreen.SELL_REDIRECT_GENERATOR
} }
innerTesterRouter.open(route) innerTesterRouter.open(route)
@ -225,6 +229,15 @@ internal class TesterActivity : ComposeActivity() {
BackendAuthStatusScreen(state) 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), STORY_BOOK(R.string.story_book),
SURVEY_SPARROW(R.string.survey_sparrow), SURVEY_SPARROW(R.string.survey_sparrow),
BACKEND_AUTH_STATUS(R.string.backend_auth_status), 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, STORY_BOOK,
SURVEY_SPARROW, SURVEY_SPARROW,
BACKEND_AUTH_STATUS, 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="story_book" translatable="false">Story book</string>
<string name="survey_sparrow" translatable="false">Survey Sparrow</string> <string name="survey_sparrow" translatable="false">Survey Sparrow</string>
<string name="backend_auth_status" translatable="false">Backend Auth</string> <string name="backend_auth_status" translatable="false">Backend Auth</string>
<string name="sell_redirect_generator" translatable="false">Sell Redirect DeepLink</string>
</resources> </resources>