Updated on 2026-08-14
This commit is contained in:
parent
6bc50e8fe7
commit
657cc5bfd2
9 changed files with 507 additions and 1 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit f983c6defd0b2240eb6f134aefe30f7e93696795
|
||||
Subproject commit a51f37e5f339fd83357443f4a083f7af0155fcdb
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -17,4 +17,5 @@ internal enum class TesterScreen {
|
|||
ADDRESSES_INFO,
|
||||
STORY_BOOK,
|
||||
SURVEY_SPARROW,
|
||||
BACKEND_AUTH_STATUS,
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue