diff --git a/CLAUDE.md b/CLAUDE.md index 0dc488bd3f..c091803199 100644 --- a/CLAUDE.md +++ b/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) - 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 + field = MutableStateFlow(FooUM()) +// inside the class, mutate via uiState.update { … }; callers see StateFlow +``` + +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:** - `childStack()` — stacked screen navigation (back stack) - `childSlot()` — optional overlays/bottom sheets (single or no child) diff --git a/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt b/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt index 7050229bd8..bce83f2ec7 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt @@ -10,9 +10,9 @@ import com.tangem.tap.data.converter.PendingOfframpEntryConverter import com.tangem.tap.data.model.PendingOfframpEntry import com.tangem.tap.network.exchangeServices.SellService import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import java.util.UUID -import java.util.concurrent.TimeUnit /** * Default implementation of [OfframpRepository]. @@ -27,7 +27,7 @@ internal class DefaultOfframpRepository( private val dispatchers: CoroutineDispatcherProvider, ) : OfframpRepository { - private val pendingOfframpConverter = PendingOfframpEntryConverter() + private val converter = PendingOfframpEntryConverter() override fun getOfframpUrl( cryptoCurrency: CryptoCurrency, @@ -71,19 +71,21 @@ internal class DefaultOfframpRepository( entry.requestId == requestId && entry.userWalletId == userWalletId.stringValue && 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 // matches but with a mismatched wallet/currency is left intact so a tampered redirect cannot burn it. stored.filter { it != matched }.filterNotExpired(now) } - matched?.let(pendingOfframpConverter::convert) + matched?.let(converter::convert) + } + + override suspend fun getAllStoredOfframps(): List = withContext(dispatchers.io) { + pendingOfframpStore.data.first().map(converter::convert) } private fun List.filterNotExpired(now: Long): List = - filter { now - it.createdAt < EXPIRY_MS } + filterNot { it.isExpired(now) } - private companion object { - val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1) - } + private fun PendingOfframpEntry.isExpired(now: Long): Boolean = converter.convert(this).isExpired(now) } \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt b/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt index 7c7f6f4c98..99151c5a99 100644 --- a/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt @@ -236,4 +236,44 @@ internal class DefaultOfframpRepositoryTest { // Assert 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() + } } \ No newline at end of file diff --git a/domain/offramp/src/main/java/com/tangem/domain/offramp/model/PendingOfframp.kt b/domain/offramp/src/main/java/com/tangem/domain/offramp/model/PendingOfframp.kt index c3fc9dee2a..c1d800e4c2 100644 --- a/domain/offramp/src/main/java/com/tangem/domain/offramp/model/PendingOfframp.kt +++ b/domain/offramp/src/main/java/com/tangem/domain/offramp/model/PendingOfframp.kt @@ -1,6 +1,7 @@ package com.tangem.domain.offramp.model 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. @@ -19,4 +20,13 @@ data class PendingOfframp( val userWalletId: UserWalletId, val currencyId: String, val createdAt: Long, -) \ No newline at end of file +) { + + /** 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) + } +} \ No newline at end of file diff --git a/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt b/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt index b93b8e77c0..0aeaa27839 100644 --- a/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt +++ b/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt @@ -42,4 +42,13 @@ interface OfframpRepository { userWalletId: UserWalletId, currencyId: String, ): 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 } \ No newline at end of file diff --git a/domain/offramp/src/test/kotlin/com/tangem/domain/offramp/model/PendingOfframpTest.kt b/domain/offramp/src/test/kotlin/com/tangem/domain/offramp/model/PendingOfframpTest.kt new file mode 100644 index 0000000000..ba55d0b721 --- /dev/null +++ b/domain/offramp/src/test/kotlin/com/tangem/domain/offramp/model/PendingOfframpTest.kt @@ -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, + ) +} \ No newline at end of file diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index 342d35d5f8..5f455e2701 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(projects.domain.manageTokens.models) implementation(projects.domain.markets.models) implementation(projects.domain.models) + implementation(projects.domain.offramp) implementation(projects.domain.walletManager) runtimeOnly(projects.domain.card) runtimeOnly(projects.domain.manageTokens) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt index 1d34a19cde..920cc29d1f 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt @@ -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().apply { + setupNavigation(innerTesterRouter) + } + val state by viewModel.uiState.collectAsStateWithLifecycle() + + SellRedirectGeneratorScreen(state) + } } } diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuUM.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuUM.kt index f73d03ea7b..9ca70f0a47 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuUM.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuUM.kt @@ -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), } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt index 19df253534..1a5fcfe9bd 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt @@ -18,4 +18,5 @@ internal enum class TesterScreen { STORY_BOOK, SURVEY_SPARROW, BACKEND_AUTH_STATUS, + SELL_REDIRECT_GENERATOR, } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/sellredirect/state/SellRedirectGeneratorUM.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/sellredirect/state/SellRedirectGeneratorUM.kt new file mode 100644 index 0000000000..38e96695c8 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/sellredirect/state/SellRedirectGeneratorUM.kt @@ -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 = 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, + ) +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/sellredirect/ui/SellRedirectGeneratorScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/sellredirect/ui/SellRedirectGeneratorScreen.kt new file mode 100644 index 0000000000..36ef5bb98c --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/sellredirect/ui/SellRedirectGeneratorScreen.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/sellredirect/viewmodels/SellRedirectGeneratorViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/sellredirect/viewmodels/SellRedirectGeneratorViewModel.kt new file mode 100644 index 0000000000..20a02c366f --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/sellredirect/viewmodels/SellRedirectGeneratorViewModel.kt @@ -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 + 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" + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/res/values/strings.xml b/features/tester/impl/src/main/res/values/strings.xml index a332ecc23d..42f60487b9 100644 --- a/features/tester/impl/src/main/res/values/strings.xml +++ b/features/tester/impl/src/main/res/values/strings.xml @@ -29,4 +29,5 @@ Story book Survey Sparrow Backend Auth + Sell Redirect DeepLink