Updated on 2026-08-14
This commit is contained in:
parent
4dec0b44cd
commit
f19c2a9fd5
14 changed files with 488 additions and 9 deletions
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue