Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-10 16:20:49 +04:00
parent 6bc50e8fe7
commit 657cc5bfd2
9 changed files with 507 additions and 1 deletions

View file

@ -83,6 +83,7 @@ dependencies {
api(projects.libs.blockchainSdk)
implementation(projects.common)
implementation(projects.common.ui)
implementation(projects.libs.auth)
implementation(projects.libs.crypto)
implementation(projects.libs.tangemSdkApi)
}

View file

@ -23,6 +23,8 @@ import com.tangem.feature.tester.presentation.actions.TesterActionsScreen
import com.tangem.feature.tester.presentation.actions.TesterActionsViewModel
import com.tangem.feature.tester.presentation.addresses.ui.AddressesInfoScreen
import com.tangem.feature.tester.presentation.addresses.viewmodels.AddressesInfoViewModel
import com.tangem.feature.tester.presentation.backendauth.ui.BackendAuthStatusScreen
import com.tangem.feature.tester.presentation.backendauth.viewmodels.BackendAuthStatusViewModel
import com.tangem.feature.tester.presentation.environments.ui.EnvironmentTogglesScreen
import com.tangem.feature.tester.presentation.environments.viewmodels.EnvironmentsTogglesViewModel
import com.tangem.feature.tester.presentation.excludedblockchains.ExcludedBlockchainsScreen
@ -99,6 +101,7 @@ internal class TesterActivity : ComposeActivity() {
ButtonUM.ADDRESSES_INFO,
ButtonUM.STORY_BOOK,
ButtonUM.SURVEY_SPARROW,
ButtonUM.BACKEND_AUTH_STATUS,
),
onButtonClick = { buttonUM ->
val route = when (buttonUM) {
@ -112,6 +115,7 @@ internal class TesterActivity : ComposeActivity() {
ButtonUM.ADDRESSES_INFO -> TesterScreen.ADDRESSES_INFO
ButtonUM.STORY_BOOK -> TesterScreen.STORY_BOOK
ButtonUM.SURVEY_SPARROW -> TesterScreen.SURVEY_SPARROW
ButtonUM.BACKEND_AUTH_STATUS -> TesterScreen.BACKEND_AUTH_STATUS
}
innerTesterRouter.open(route)
@ -212,6 +216,15 @@ internal class TesterActivity : ComposeActivity() {
}
}
}
composable(route = TesterScreen.BACKEND_AUTH_STATUS.name) {
val viewModel = hiltViewModel<BackendAuthStatusViewModel>().apply {
setupNavigation(innerTesterRouter)
}
val state by viewModel.uiState.collectAsStateWithLifecycle()
BackendAuthStatusScreen(state)
}
}
}

View file

@ -0,0 +1,56 @@
package com.tangem.feature.tester.presentation.backendauth.state
import androidx.annotation.DrawableRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* Content state of the Backend Authentication status screen.
*
* Grouped into [Section]s: each section shows a set of status [StatusRow]s followed by the action
* buttons that operate on them.
*
* @property onBackClick invoked when back button is pressed
* @property sections status/action groups to display
* @property onCopyClick invoked with a row whose [StatusRow.copyValue] is non-null to copy the full value
* @property runningAction label of the action currently running (shows progress, disables others)
*/
internal data class BackendAuthStatusUM(
val onBackClick: () -> Unit = {},
val sections: ImmutableList<Section> = persistentListOf(),
val onCopyClick: (StatusRow) -> Unit = {},
val runningAction: String? = null,
) {
/** A group of related status rows and the actions that operate on them. */
data class Section(
val rows: ImmutableList<StatusRow>,
val actions: ImmutableList<Action> = persistentListOf(),
)
/**
* A single label value status row.
*
* @property value displayed value (may be shortened for keys/tokens)
* @property copyValue full value to copy; when non-null a copy action is shown
* @property subtitle optional extra line under the value (e.g. token expiry)
*/
data class StatusRow(
val label: String,
val value: String,
val copyValue: String? = null,
val subtitle: String? = null,
val iconActions: ImmutableList<IconAction> = persistentListOf(),
)
/** A full-width action button (mutates auth state, then refreshes the panel). */
data class Action(val label: String, val onClick: () -> Unit)
/** A compact icon action shown inline at the end of a row (like the copy icon). */
data class IconAction(
val label: String,
@DrawableRes val iconRes: Int,
val isProgressShown: Boolean = true,
val onClick: () -> Unit,
)
}

View file

@ -0,0 +1,182 @@
package com.tangem.feature.tester.presentation.backendauth.ui
import androidx.compose.animation.Crossfade
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.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.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.backendauth.state.BackendAuthStatusUM
import com.tangem.core.ui.R as CoreUiR
/**
* Read-only screen showing the current Backend Authentication state (toggle, environment,
* device key, registration flag, session tokens). Keys/tokens are shown shortened with a copy
* action; all values are also selectable.
*
* @param state screen state
*/
@Composable
internal fun BackendAuthStatusScreen(state: BackendAuthStatusUM) {
Scaffold(
topBar = {
AppBarWithBackButton(
onBackClick = state.onBackClick,
text = stringResourceSafe(id = R.string.backend_auth_status),
modifier = Modifier.statusBarsPadding(),
)
},
containerColor = TangemTheme.colors.background.secondary,
) { paddingValues ->
SelectionContainer {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(vertical = 8.dp),
) {
state.sections.forEachIndexed { sectionIndex, section ->
itemsIndexed(items = section.rows, key = { _, row -> "row_${row.label}" }) { index, row ->
Column(modifier = Modifier.animateItem()) {
StatusRow(
row = row,
runningAction = state.runningAction,
onCopyClick = { state.onCopyClick(row) },
)
if (index < section.rows.lastIndex) {
DividerWithPadding(start = 16.dp, end = 16.dp)
}
}
}
items(items = section.actions, key = { "act_${it.label}" }) { action ->
SecondaryButton(
text = action.label,
onClick = action.onClick,
showProgress = state.runningAction == action.label,
enabled = state.runningAction == null,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
if (sectionIndex < state.sections.lastIndex) {
item(key = "gap_$sectionIndex") {
DividerWithPadding(start = 16.dp, end = 16.dp)
}
}
}
}
}
}
}
@Composable
private fun StatusRow(row: BackendAuthStatusUM.StatusRow, runningAction: String?, onCopyClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Text(
text = row.label,
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.secondary,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Crossfade(
targetState = row.value,
modifier = Modifier.weight(1f),
label = "value",
) { value ->
Text(
text = value,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
}
RowTrailingIcons(row = row, runningAction = runningAction, onCopyClick = onCopyClick)
}
if (row.subtitle != null) {
Text(
text = row.subtitle,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Composable
private fun RowTrailingIcons(row: BackendAuthStatusUM.StatusRow, runningAction: String?, onCopyClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (row.copyValue != null) {
IconButton(
onClick = onCopyClick,
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = ImageVector.vectorResource(CoreUiR.drawable.ic_copy_24),
contentDescription = "Copy ${row.label}",
tint = TangemTheme.colors.icon.primary1,
)
}
}
row.iconActions.forEach { iconAction ->
if (runningAction == iconAction.label && iconAction.isProgressShown) {
CircularProgressIndicator(
modifier = Modifier
.size(32.dp)
.padding(6.dp),
strokeWidth = 2.dp,
color = TangemTheme.colors.icon.primary1,
)
} else {
IconButton(
onClick = iconAction.onClick,
enabled = runningAction == null,
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = ImageVector.vectorResource(iconAction.iconRes),
contentDescription = iconAction.label,
tint = TangemTheme.colors.icon.primary1,
)
}
}
}
}
}

View file

@ -0,0 +1,251 @@
package com.tangem.feature.tester.presentation.backendauth.viewmodels
import android.content.Context
import android.util.Base64
import android.widget.Toast
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.feature.tester.presentation.backendauth.state.BackendAuthStatusUM
import com.tangem.feature.tester.presentation.backendauth.state.BackendAuthStatusUM.Action
import com.tangem.feature.tester.presentation.backendauth.state.BackendAuthStatusUM.IconAction
import com.tangem.feature.tester.presentation.backendauth.state.BackendAuthStatusUM.Section
import com.tangem.feature.tester.presentation.backendauth.state.BackendAuthStatusUM.StatusRow
import com.tangem.core.ui.R as CoreUiR
import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.session.DeviceRegistrar
import com.tangem.lib.auth.session.SessionTokenRefresher
import com.tangem.lib.auth.session.SessionTokens
import com.tangem.lib.auth.session.SessionTokensStore
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.toJavaInstant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.hours
/**
* ViewModel for the Backend Authentication status screen.
*
* Aggregates the observable auth state (toggle, environment, device key, registration flag,
* session tokens) so QA doesn't have to read logcat, and exposes action buttons grouped by the
* state they operate on. Keys/tokens are shown shortened with a copy action for the full value.
*/
@Suppress("LongParameterList")
@HiltViewModel
internal class BackendAuthStatusViewModel @Inject constructor(
private val authFeatureToggles: AuthFeatureToggles,
private val deviceKeyManager: DeviceKeyManager,
private val sessionTokensStore: SessionTokensStore,
private val appPreferencesStore: AppPreferencesStore,
private val apiConfigsManager: ApiConfigsManager,
private val clipboardManager: ClipboardManager,
private val deviceRegistrar: DeviceRegistrar,
private val sessionTokenRefresher: SessionTokenRefresher,
@ApplicationContext private val context: Context,
) : ViewModel() {
private val _uiState = MutableStateFlow(BackendAuthStatusUM(onCopyClick = ::onCopy))
val uiState: StateFlow<BackendAuthStatusUM> = _uiState.asStateFlow()
init {
loadStatus()
}
/** Setup navigation state property by router [router] */
fun setupNavigation(router: InnerTesterRouter) {
_uiState.update { it.copy(onBackClick = router::back) }
}
private fun loadStatus() {
viewModelScope.launch {
_uiState.update { it.copy(sections = buildSections()) }
}
}
private suspend fun buildSections(): ImmutableList<Section> {
val publicKeyRow = deviceKeyManager.getPublicKeyEncoded().getOrNull()?.let { key ->
val spki = Base64.encodeToString(key, Base64.NO_WRAP)
StatusRow("Device public key (SPKI)", spki.shorten(), copyValue = spki)
} ?: StatusRow("Device public key (SPKI)", "absent")
val environmentValue = runCatching {
val authEnv = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.Auth)
"${authEnv.environment.name} (${authEnv.baseUrl})"
}.getOrDefault("unavailable")
val isRegistered = appPreferencesStore.getSyncOrDefault(
PreferencesKeys.IS_DEVICE_REGISTERED_KEY,
default = false,
)
val tokens = sessionTokensStore.get().getOrNull()
return persistentListOf(
Section(
rows = persistentListOf(
StatusRow("Feature toggle", if (authFeatureToggles.isBackendAuthenticationEnabled) "ON" else "OFF"),
StatusRow("Environment", environmentValue),
publicKeyRow,
),
),
Section(
rows = persistentListOf(
StatusRow(
label = "Registered",
value = if (isRegistered) "" else "",
iconActions = persistentListOf(
iconAction("Register now", CoreUiR.drawable.ic_plus_24) {
deviceRegistrar.register().fold({ "failed: $it" }, { "ok" })
},
iconAction("Reset registration", CoreUiR.drawable.ic_close_24, isProgressShown = false) {
resetRegistration()
},
),
),
),
),
Section(
rows = buildTokenRows(tokens),
actions = persistentListOf(
action("Force refresh") { sessionTokenRefresher.refresh().fold({ "failed: $it" }, { "ok" }) },
action("Corrupt access token") { corruptAccessToken() },
action("Expire access token") { expireAccessToken() },
action("Clear session tokens") { sessionTokensStore.clear(); "done" },
),
),
)
}
private fun buildTokenRows(tokens: SessionTokens?): ImmutableList<StatusRow> = buildList {
add(StatusRow("Session tokens", if (tokens != null) "" else ""))
if (tokens != null) {
val isAccessBlank = tokens.accessToken.isBlank()
add(
StatusRow(
label = "Access token",
value = if (isAccessBlank) "" else tokens.accessToken.shorten(),
copyValue = tokens.accessToken.ifBlank { null },
subtitle = if (isAccessBlank) {
null
} else {
"expires ${tokens.accessTokenExpiresAt.formatWithCountdown(hoursMinutes = true)}"
},
),
)
val refresh = tokens.refreshToken
add(
StatusRow(
label = "Refresh token",
value = refresh?.shorten() ?: "none",
copyValue = refresh,
subtitle = tokens.refreshTokenExpiresAt?.let { "expires ${it.formatWithCountdown()}" },
),
)
add(StatusRow("Wallet IDs", tokens.walletIds.joinToString().ifEmpty { "" }))
}
}.toImmutableList()
private fun onCopy(row: StatusRow) {
val value = row.copyValue ?: return
clipboardManager.setText(text = value, isSensitive = false, label = row.label)
Toast.makeText(context, "Copied ${row.label}", Toast.LENGTH_SHORT).show()
}
private fun action(label: String, block: suspend () -> String): Action = Action(label) { runAction(label, block) }
private fun iconAction(
label: String,
iconRes: Int,
isProgressShown: Boolean = true,
block: suspend () -> String,
): IconAction = IconAction(
label = label,
iconRes = iconRes,
isProgressShown = isProgressShown,
) { runAction(label, block) }
/** Shows a progress on the tapped button, runs [block], toasts its result, then refreshes the panel. */
private fun runAction(label: String, block: suspend () -> String) {
if (_uiState.value.runningAction != null) return
viewModelScope.launch {
_uiState.update { it.copy(runningAction = label) }
val result = try {
block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
"error: ${e.message}"
}
Toast.makeText(context, "$label: $result", Toast.LENGTH_SHORT).show()
_uiState.update { it.copy(sections = buildSections(), runningAction = null) }
}
}
/** Replaces the access token with a blank value (refresh token kept) so the next request 401s. */
private suspend fun corruptAccessToken(): String {
val tokens = sessionTokensStore.get().getOrNull() ?: return "no tokens"
sessionTokensStore.save(tokens.copy(accessToken = ""))
return "done"
}
/** Back-dates the access token expiry so it is treated as expired. */
private suspend fun expireAccessToken(): String {
val tokens = sessionTokensStore.get().getOrNull() ?: return "no tokens"
sessionTokensStore.save(tokens.copy(accessTokenExpiresAt = Clock.System.now() - 1.hours))
return "done"
}
/** Clears tokens and the registration flag so the next launch re-registers (no `pm clear`). */
private suspend fun resetRegistration(): String {
sessionTokensStore.clear()
appPreferencesStore.store(PreferencesKeys.IS_DEVICE_REGISTERED_KEY, value = false)
return "done"
}
/** Shortens a long value to `prefix…suffix` for display; the full value stays copyable. */
private fun String.shorten(): String =
if (length <= SHORTEN_KEEP * 2 + 1) this else "${take(SHORTEN_KEEP)}${takeLast(SHORTEN_KEEP)}"
/**
* Formats an [Instant] as `dd.MM.yyyy HH:mm` plus a remaining-time hint.
* @param hoursMinutes `true` ` (3h 25m left)` (short-lived access token);
* `false` ` (2d 5h left)` (long-lived refresh token).
*/
private fun Instant.formatWithCountdown(hoursMinutes: Boolean = false): String {
val readable = EXPIRY_FORMATTER.format(toJavaInstant())
val left = this - Clock.System.now()
val hint = when {
left.isNegative() -> "expired"
hoursMinutes -> "${left.inWholeHours}h ${left.inWholeMinutes % MINUTES_IN_HOUR}m left"
else -> "${left.inWholeDays}d ${left.inWholeHours % HOURS_IN_DAY}h left"
}
return "$readable ($hint)"
}
private companion object {
const val SHORTEN_KEEP = 6
const val HOURS_IN_DAY = 24
const val MINUTES_IN_HOUR = 60
val EXPIRY_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm").withZone(ZoneId.systemDefault())
}
}

View file

@ -28,5 +28,6 @@ data class TesterMenuUM(
ADDRESSES_INFO(R.string.addresses_info),
STORY_BOOK(R.string.story_book),
SURVEY_SPARROW(R.string.survey_sparrow),
BACKEND_AUTH_STATUS(R.string.backend_auth_status),
}
}

View file

@ -17,4 +17,5 @@ internal enum class TesterScreen {
ADDRESSES_INFO,
STORY_BOOK,
SURVEY_SPARROW,
BACKEND_AUTH_STATUS,
}

View file

@ -28,4 +28,5 @@
<string name="addresses_info" translatable="false">Addresses info</string>
<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>
</resources>