* feat(#443): Telegram onboarding, remove platform, credential expiry, gateway command, field validation - Add Telegram onboarding wizard: start (QR), poll status, cancel, apply with allowed-user-IDs flow. Full state machine in ChannelsViewModel. - Add remove platform button with confirmation dialog (per card). - Add credential expiry display in env var config fields. - Add gateway_start_command to the offline banner with monospace hint. - Add field validation for Slack token prefixes and member IDs. - Add admin section showing env path and gateway command. - Add DELETE, POST, GET API endpoints for onboarding + remove platform + action status polling. - Extend MessagingPlatform model with envVar expiresAt, homeChannel, updatedAt fields. * fix(#443): address review feedback + fix CI compilation & ktlint - Remove duplicate getActionStatus overload (compilation blocker) - Add missing Box import in ChannelsScreen (compilation blocker) - Remove unused gatewayCommand param from AdminSection - Centralize ISO timestamp parsing with UTC timezone - Clean up WAITING phase dialog (remove redundant confirmButton) - Use LocalHermesStatusColors for semantic surfaces - Fix all ktlint violations in changed files --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
b46e0ef46d
commit
9689d17be0
4 files changed
+847
-32
No files matched your search
@@ -14,6 +14,15 @@ data class MessagingPlatform(
|
||||
@SerializedName("error_code") val errorCode: String? = null,
|
||||
@SerializedName("error_message") val errorMessage: String? = null,
|
||||
@SerializedName("env_vars") val envVars: List<EnvVarField>? = null,
|
||||
@SerializedName("updated_at") val updatedAt: String? = null,
|
||||
@SerializedName("home_channel") val homeChannel: HomeChannelInfo? = null,
|
||||
)
|
||||
|
||||
data class HomeChannelInfo(
|
||||
val platform: String,
|
||||
@SerializedName("chat_id") val chatId: String,
|
||||
val name: String,
|
||||
@SerializedName("thread_id") val threadId: String? = null,
|
||||
)
|
||||
|
||||
data class EnvVarField(
|
||||
@@ -27,6 +36,7 @@ data class EnvVarField(
|
||||
@SerializedName("is_password") val isPassword: Boolean,
|
||||
val advanced: Boolean = false,
|
||||
val url: String? = null,
|
||||
@SerializedName("expires_at") val expiresAt: String? = null,
|
||||
)
|
||||
|
||||
data class MessagingPlatformResponse(
|
||||
@@ -47,3 +57,40 @@ data class MessagingPlatformTestResult(
|
||||
val state: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
// ── Telegram onboarding ─────────────────────────────────────────────────
|
||||
|
||||
data class TelegramOnboardingStartRequest(
|
||||
@SerializedName("bot_name") val botName: String = "Hermes Agent",
|
||||
)
|
||||
|
||||
data class TelegramOnboardingStartResponse(
|
||||
@SerializedName("pairing_id") val pairingId: String,
|
||||
@SerializedName("suggested_username") val suggestedUsername: String,
|
||||
@SerializedName("deep_link") val deepLink: String,
|
||||
@SerializedName("qr_payload") val qrPayload: String,
|
||||
@SerializedName("expires_at") val expiresAt: String,
|
||||
)
|
||||
|
||||
data class TelegramOnboardingStatusResponse(
|
||||
val status: String,
|
||||
@SerializedName("bot_username") val botUsername: String? = null,
|
||||
@SerializedName("owner_user_id") val ownerUserId: String? = null,
|
||||
@SerializedName("expires_at") val expiresAt: String? = null,
|
||||
)
|
||||
|
||||
data class TelegramOnboardingApplyRequest(
|
||||
@SerializedName("allowed_user_ids") val allowedUserIds: List<String>,
|
||||
val profile: String? = null,
|
||||
)
|
||||
|
||||
data class TelegramOnboardingApplyResponse(
|
||||
val ok: Boolean,
|
||||
val platform: String,
|
||||
@SerializedName("bot_username") val botUsername: String? = null,
|
||||
@SerializedName("needs_restart") val needsRestart: Boolean,
|
||||
@SerializedName("restart_started") val restartStarted: Boolean? = null,
|
||||
@SerializedName("restart_action") val restartAction: String? = null,
|
||||
@SerializedName("restart_pid") val restartPid: Int? = null,
|
||||
@SerializedName("restart_error") val restartError: String? = null,
|
||||
)
|
||||
@@ -73,6 +73,11 @@ import com.m57.hermescontrol.data.model.SkillHubUninstallRequest
|
||||
import com.m57.hermescontrol.data.model.SkillScanResponse
|
||||
import com.m57.hermescontrol.data.model.StatusResponse
|
||||
import com.m57.hermescontrol.data.model.SystemStatsResponse
|
||||
import com.m57.hermescontrol.data.model.TelegramOnboardingApplyRequest
|
||||
import com.m57.hermescontrol.data.model.TelegramOnboardingApplyResponse
|
||||
import com.m57.hermescontrol.data.model.TelegramOnboardingStartRequest
|
||||
import com.m57.hermescontrol.data.model.TelegramOnboardingStartResponse
|
||||
import com.m57.hermescontrol.data.model.TelegramOnboardingStatusResponse
|
||||
import com.m57.hermescontrol.data.model.ToggleSkillRequest
|
||||
import com.m57.hermescontrol.data.model.Toolset
|
||||
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
|
||||
@@ -499,6 +504,32 @@ interface HermesApiService {
|
||||
@Path("platform_id") platformId: String,
|
||||
): Response<MessagingPlatformTestResult>
|
||||
|
||||
@DELETE("api/messaging/platforms/{platform_id}")
|
||||
suspend fun removeMessagingPlatform(
|
||||
@Path("platform_id") platformId: String,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/messaging/telegram/onboarding/start")
|
||||
suspend fun startTelegramOnboarding(
|
||||
@Body body: TelegramOnboardingStartRequest,
|
||||
): Response<TelegramOnboardingStartResponse>
|
||||
|
||||
@GET("api/messaging/telegram/onboarding/{pairing_id}")
|
||||
suspend fun getTelegramOnboardingStatus(
|
||||
@Path("pairing_id") pairingId: String,
|
||||
): Response<TelegramOnboardingStatusResponse>
|
||||
|
||||
@POST("api/messaging/telegram/onboarding/{pairing_id}/apply")
|
||||
suspend fun applyTelegramOnboarding(
|
||||
@Path("pairing_id") pairingId: String,
|
||||
@Body body: TelegramOnboardingApplyRequest,
|
||||
): Response<TelegramOnboardingApplyResponse>
|
||||
|
||||
@HTTP(method = "DELETE", path = "api/messaging/telegram/onboarding/{pairing_id}", hasBody = false)
|
||||
suspend fun cancelTelegramOnboarding(
|
||||
@Path("pairing_id") pairingId: String,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/env")
|
||||
suspend fun getEnvVars(): Response<Map<String, EnvVarConfig>>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.ui.channels
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -14,20 +15,29 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.QrCode
|
||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -40,13 +50,16 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.EnvVarField
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatform
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
@@ -57,6 +70,35 @@ import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
|
||||
// ── Validation helpers ──────────────────────────────────────────────────
|
||||
|
||||
private val SLACK_MEMBER_ID_RE = Regex("^[UW][A-Z0-9]{2,}$")
|
||||
private val SLACK_TOKEN_PREFIXES =
|
||||
mapOf(
|
||||
"SLACK_BOT_TOKEN" to "xoxb-",
|
||||
"SLACK_APP_TOKEN" to "xapp-",
|
||||
)
|
||||
|
||||
private fun validateEnvField(
|
||||
field: EnvVarField,
|
||||
value: String,
|
||||
): String? {
|
||||
val trimmed = value.trim()
|
||||
if (!trimmed.isNotEmpty()) return null
|
||||
val expectedPrefix = SLACK_TOKEN_PREFIXES[field.key]
|
||||
if (expectedPrefix != null && !trimmed.startsWith(expectedPrefix)) {
|
||||
return "${field.prompt ?: field.key} must start with $expectedPrefix"
|
||||
}
|
||||
if (field.key == "SLACK_ALLOWED_USERS") {
|
||||
val parts = trimmed.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
val invalid = parts.firstOrNull { it != "*" && !SLACK_MEMBER_ID_RE.matches(it) }
|
||||
if (invalid != null) {
|
||||
return "$invalid does not look like a Slack member ID. Use IDs like U01ABC2DEF3."
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── State → badge colour / label ──────────────────────────────────────
|
||||
|
||||
private data class StateStyle(
|
||||
@@ -119,6 +161,18 @@ fun ChannelsScreen(
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
// ── Telegram onboarding dialog ────────────────────────────────
|
||||
if (state.onboardingPhase != OnboardingPhase.IDLE) {
|
||||
TelegramOnboardingDialog(
|
||||
state = state,
|
||||
onDismiss = { viewModel.cancelTelegramOnboarding() },
|
||||
onAddAllowedId = { viewModel.addOnboardingAllowedId() },
|
||||
onRemoveAllowedId = { viewModel.removeOnboardingAllowedId(it) },
|
||||
onNewAllowedIdChange = { viewModel.setOnboardingNewAllowedId(it) },
|
||||
onApply = { viewModel.applyTelegramOnboarding() },
|
||||
)
|
||||
}
|
||||
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.screen_channels)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
@@ -170,7 +224,7 @@ fun ChannelsScreen(
|
||||
// ── Gateway-not-running hint ──
|
||||
if (!gatewayRunning && !state.restartNeeded) {
|
||||
item(key = "gateway_offline") {
|
||||
GatewayOfflineBanner()
|
||||
GatewayOfflineBanner(gatewayCommand = state.gatewayStartCommand)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,9 +243,19 @@ fun ChannelsScreen(
|
||||
platform = platform,
|
||||
isToggling = state.togglingId == platform.id,
|
||||
isTesting = state.testingId == platform.id,
|
||||
isRemoving = state.removingId == platform.id,
|
||||
onToggle = { enabled -> viewModel.togglePlatform(platform.id, enabled) },
|
||||
onTest = { viewModel.testPlatform(platform.id) },
|
||||
onSave = { env -> viewModel.configurePlatform(platform.id, env) },
|
||||
onRemove = { viewModel.removePlatform(platform.id) },
|
||||
onStartOnboarding = { viewModel.startTelegramOnboarding() },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Admin section ──
|
||||
item(key = "admin_section") {
|
||||
AdminSection(
|
||||
envPath = state.envPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -249,30 +313,39 @@ private fun RestartBanner(
|
||||
// ── Gateway offline banner ───────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun GatewayOfflineBanner() {
|
||||
private fun GatewayOfflineBanner(gatewayCommand: String) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.channels_gateway_offline),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "The gateway is not running. Configure channels here, then start the gateway with:",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Surface(
|
||||
color = LocalHermesStatusColors.current.warningContainer,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = " $gatewayCommand",
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,11 +357,15 @@ private fun PlatformCard(
|
||||
platform: MessagingPlatform,
|
||||
isToggling: Boolean,
|
||||
isTesting: Boolean,
|
||||
isRemoving: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onTest: () -> Unit,
|
||||
onSave: (MessagingPlatformUpdate) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onStartOnboarding: () -> Unit,
|
||||
) {
|
||||
var showConfigureForm by remember { mutableStateOf(false) }
|
||||
var showRemoveConfirm by remember { mutableStateOf(false) }
|
||||
val style = platformStateStyle(platform.state)
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -318,14 +395,10 @@ private fun PlatformCard(
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
StateBadge(state = platform.state)
|
||||
if (platform.id == "telegram") {
|
||||
// reserved for onboarding badge later
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle switch
|
||||
if (isToggling) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
@@ -395,14 +468,81 @@ private fun PlatformCard(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
|
||||
// Remove button
|
||||
IconButton(
|
||||
onClick = { showRemoveConfirm = true },
|
||||
enabled = !isRemoving,
|
||||
) {
|
||||
if (isRemoving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = "Remove platform",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Telegram onboarding (only for unconfigured telegram) ──
|
||||
if (platform.id == "telegram" && !platform.configured) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = onStartOnboarding,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.QrCode,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Set up with QR",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Configure form ──
|
||||
if (showConfigureForm) {
|
||||
ConfigureForm(platform = platform, onSave = onSave, onClose = { showConfigureForm = false })
|
||||
ConfigureForm(
|
||||
platform = platform,
|
||||
onSave = onSave,
|
||||
onClose = { showConfigureForm = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove confirmation
|
||||
if (showRemoveConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRemoveConfirm = false },
|
||||
title = { Text("Remove ${platform.name}?") },
|
||||
text = { Text("This will permanently remove this platform's configuration.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showRemoveConfirm = false
|
||||
onRemove()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text("Remove")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRemoveConfirm = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Configure form ───────────────────────────────────────────────────
|
||||
@@ -415,6 +555,7 @@ private fun ConfigureForm(
|
||||
) {
|
||||
val envFields = platform.envVars.orEmpty()
|
||||
val inputValues = remember { mutableStateMapOf<String, String>() }
|
||||
val fieldErrors = remember { mutableStateMapOf<String, String>() }
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
@@ -426,13 +567,38 @@ private fun ConfigureForm(
|
||||
envFields.forEach { field ->
|
||||
OutlinedTextField(
|
||||
value = inputValues[field.key].orEmpty(),
|
||||
onValueChange = { inputValues[field.key] = it },
|
||||
onValueChange = {
|
||||
inputValues[field.key] = it
|
||||
fieldErrors.remove(field.key)
|
||||
},
|
||||
label = { Text(field.prompt ?: field.key) },
|
||||
placeholder = {
|
||||
if (field.isSet) {
|
||||
Text(field.redactedValue ?: "******** (Already Configured)")
|
||||
}
|
||||
},
|
||||
supportingText = {
|
||||
fieldErrors[field.key]?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
if (field.help != null && !fieldErrors.containsKey(field.key)) {
|
||||
Text(
|
||||
text = field.help,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
field.expiresAt?.let { expiry ->
|
||||
Text(
|
||||
text = "Expires: ${formatTimestamp(expiry)}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
if (field.isPassword) {
|
||||
@@ -441,6 +607,7 @@ private fun ConfigureForm(
|
||||
androidx.compose.ui.text.input.VisualTransformation.None
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = fieldErrors.containsKey(field.key),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
@@ -461,6 +628,16 @@ private fun ConfigureForm(
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val errors = mutableMapOf<String, String>()
|
||||
envFields.forEach { field ->
|
||||
val value = inputValues[field.key].orEmpty()
|
||||
val error = validateEnvField(field, value)
|
||||
if (error != null) errors[field.key] = error
|
||||
}
|
||||
if (errors.isNotEmpty()) {
|
||||
fieldErrors.putAll(errors)
|
||||
return@Button
|
||||
}
|
||||
val filledEnv = inputValues.filterValues { it.isNotBlank() }
|
||||
val update =
|
||||
MessagingPlatformUpdate(
|
||||
@@ -478,3 +655,226 @@ private fun ConfigureForm(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin section ────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun AdminSection(envPath: String) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Admin",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Info,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Credentials are written to $envPath",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Telegram Onboarding Dialog ────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun TelegramOnboardingDialog(
|
||||
state: ChannelsUiState,
|
||||
onDismiss: () -> Unit,
|
||||
onAddAllowedId: () -> Unit,
|
||||
onRemoveAllowedId: (String) -> Unit,
|
||||
onNewAllowedIdChange: (String) -> Unit,
|
||||
onApply: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text =
|
||||
when (state.onboardingPhase) {
|
||||
OnboardingPhase.STARTING -> "Starting…"
|
||||
OnboardingPhase.WAITING -> "Scan QR code"
|
||||
OnboardingPhase.READY -> "Telegram connected"
|
||||
OnboardingPhase.APPLYING -> "Saving…"
|
||||
OnboardingPhase.IDLE -> "Telegram Setup"
|
||||
},
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
state.onboardingError?.let { error ->
|
||||
Surface(
|
||||
color = LocalHermesStatusColors.current.errorContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (state.onboardingPhase) {
|
||||
OnboardingPhase.STARTING -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
OnboardingPhase.WAITING -> {
|
||||
Text(
|
||||
text = "Open Telegram and scan the QR code displayed in the app.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (state.onboardingExpiresIn.isNotEmpty()) {
|
||||
Text(
|
||||
text = "QR code expires in: ${state.onboardingExpiresIn}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OnboardingPhase.READY -> {
|
||||
state.onboardingBotUsername?.let { username ->
|
||||
Text(
|
||||
text = "Connected as @$username",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Allowed users:",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
state.onboardingDetectedOwnerId?.let { ownerId ->
|
||||
if (state.onboardingAllowedIds.contains(ownerId)) {
|
||||
Text(
|
||||
text = "$ownerId (owner detected)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
state.onboardingAllowedIds.forEach { id ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = id,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
IconButton(onClick = { onRemoveAllowedId(id) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Remove",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.onboardingNewAllowedId,
|
||||
onValueChange = onNewAllowedIdChange,
|
||||
placeholder = { Text("Telegram user ID") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onAddAllowedId) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Add user ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OnboardingPhase.APPLYING -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Saving configuration…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OnboardingPhase.IDLE -> { }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
when (state.onboardingPhase) {
|
||||
OnboardingPhase.READY -> {
|
||||
Button(
|
||||
onClick = onApply,
|
||||
enabled = state.onboardingAllowedIds.isNotEmpty(),
|
||||
) {
|
||||
Text("Save and restart")
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
when (state.onboardingPhase) {
|
||||
OnboardingPhase.READY -> {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
OnboardingPhase.WAITING -> {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
OnboardingPhase.IDLE, OnboardingPhase.STARTING, OnboardingPhase.APPLYING -> {}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val isoTimestampParser =
|
||||
java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
private val displayTimestampFormatter =
|
||||
java.text.SimpleDateFormat("MMM d, yyyy HH:mm", java.util.Locale.US)
|
||||
|
||||
private fun formatTimestamp(isoDate: String): String {
|
||||
return try {
|
||||
val parsed = isoTimestampParser.parse(isoDate) ?: return ""
|
||||
displayTimestampFormatter.format(parsed)
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,14 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatform
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
||||
import com.m57.hermescontrol.data.model.TelegramOnboardingApplyRequest
|
||||
import com.m57.hermescontrol.data.model.TelegramOnboardingStartRequest
|
||||
import com.m57.hermescontrol.data.model.TelegramOnboardingStartResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -15,6 +19,14 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class OnboardingPhase {
|
||||
IDLE,
|
||||
STARTING,
|
||||
WAITING,
|
||||
READY,
|
||||
APPLYING,
|
||||
}
|
||||
|
||||
data class ChannelsUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val platforms: List<MessagingPlatform> = emptyList(),
|
||||
@@ -24,6 +36,20 @@ data class ChannelsUiState(
|
||||
val isRestarting: Boolean = false,
|
||||
val togglingId: String? = null,
|
||||
val testingId: String? = null,
|
||||
val removingId: String? = null,
|
||||
val gatewayStartCommand: String = "hermes gateway start",
|
||||
// Telegram onboarding
|
||||
val onboardingPhase: OnboardingPhase = OnboardingPhase.IDLE,
|
||||
val onboardingSetup: TelegramOnboardingStartResponse? = null,
|
||||
val onboardingQrDataUrl: String? = null,
|
||||
val onboardingBotUsername: String? = null,
|
||||
val onboardingAllowedIds: List<String> = emptyList(),
|
||||
val onboardingDetectedOwnerId: String? = null,
|
||||
val onboardingNewAllowedId: String = "",
|
||||
val onboardingError: String? = null,
|
||||
val onboardingExpiresIn: String = "",
|
||||
// Admin section
|
||||
val envPath: String = "~/.hermes/.env",
|
||||
)
|
||||
|
||||
class ChannelsViewModel :
|
||||
@@ -32,6 +58,8 @@ class ChannelsViewModel :
|
||||
private val _uiState = MutableStateFlow(ChannelsUiState())
|
||||
val uiState: StateFlow<ChannelsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var launchJob: Job? = null
|
||||
|
||||
fun loadPlatforms() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getMessagingPlatforms() } },
|
||||
@@ -42,7 +70,12 @@ class ChannelsViewModel :
|
||||
},
|
||||
onSuccess = { data ->
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, platforms = data?.platforms.orEmpty())
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
platforms = data?.platforms.orEmpty(),
|
||||
gatewayStartCommand = data?.gatewayStartCommand ?: "hermes gateway start",
|
||||
envPath = data?.envPath ?: "~/.hermes/.env",
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
@@ -56,9 +89,7 @@ class ChannelsViewModel :
|
||||
)
|
||||
}
|
||||
|
||||
/** Toggle a platform's enabled state without a full reload.
|
||||
* On success the local state is updated optimistically and
|
||||
* restartNeeded is set. */
|
||||
/** Toggle a platform's enabled state without a full reload. */
|
||||
fun togglePlatform(
|
||||
platformId: String,
|
||||
enabled: Boolean,
|
||||
@@ -104,7 +135,37 @@ class ChannelsViewModel :
|
||||
)
|
||||
}
|
||||
|
||||
/** Configure a platform's env vars and/or settings. Reloads on success. */
|
||||
/** Remove an entire platform. */
|
||||
fun removePlatform(platformId: String) {
|
||||
_uiState.update { it.copy(removingId = platformId) }
|
||||
safeLaunchLoad(
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.removeMessagingPlatform(platformId)
|
||||
}
|
||||
},
|
||||
onStart = {},
|
||||
onSuccess = {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
platforms = state.platforms.filter { it.id != platformId },
|
||||
removingId = null,
|
||||
toastMessage = "Platform removed",
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { error ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
removingId = null,
|
||||
toastMessage = "Failed to remove: $error",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Configure a platform's env vars and/or settings. Reloads on success. */
|
||||
fun configurePlatform(
|
||||
platformId: String,
|
||||
update: MessagingPlatformUpdate,
|
||||
@@ -170,7 +231,7 @@ class ChannelsViewModel :
|
||||
)
|
||||
}
|
||||
|
||||
/** Restart the whole gateway. Reloads platforms after a 4s delay. */
|
||||
/** Restart the whole gateway. Reloads platforms after a 4s delay. */
|
||||
fun restartGateway() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.restartGateway() } },
|
||||
@@ -203,7 +264,283 @@ class ChannelsViewModel :
|
||||
_uiState.update { it.copy(restartNeeded = false) }
|
||||
}
|
||||
|
||||
// ── Telegram onboarding ────────────────────────────────────────────────
|
||||
|
||||
fun startTelegramOnboarding() {
|
||||
val state = _uiState.value
|
||||
if (state.onboardingPhase == OnboardingPhase.STARTING ||
|
||||
state.onboardingPhase == OnboardingPhase.WAITING
|
||||
) {
|
||||
return
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
onboardingPhase = OnboardingPhase.STARTING,
|
||||
onboardingError = null,
|
||||
onboardingBotUsername = null,
|
||||
onboardingAllowedIds = emptyList(),
|
||||
onboardingDetectedOwnerId = null,
|
||||
onboardingNewAllowedId = "",
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.startTelegramOnboarding(
|
||||
TelegramOnboardingStartRequest(botName = "Hermes Agent"),
|
||||
)
|
||||
}
|
||||
_uiState.update {
|
||||
when (result) {
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Success -> {
|
||||
val setup = result.data
|
||||
it.copy(
|
||||
onboardingPhase = OnboardingPhase.WAITING,
|
||||
onboardingSetup = setup,
|
||||
onboardingQrDataUrl = setup?.qrPayload,
|
||||
onboardingExpiresIn = formatExpiry(setup?.expiresAt),
|
||||
)
|
||||
}
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
||||
it.copy(
|
||||
onboardingPhase = OnboardingPhase.IDLE,
|
||||
onboardingError = result.error.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_uiState.value.onboardingPhase == OnboardingPhase.WAITING) {
|
||||
pollOnboardingStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pollOnboardingStatus() {
|
||||
launchJob?.cancel()
|
||||
launchJob =
|
||||
viewModelScope.launch {
|
||||
while (_uiState.value.onboardingPhase == OnboardingPhase.WAITING) {
|
||||
delay(2000)
|
||||
val pairingId = _uiState.value.onboardingSetup?.pairingId ?: break
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getTelegramOnboardingStatus(pairingId)
|
||||
}
|
||||
when (result) {
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Success -> {
|
||||
val status = result.data
|
||||
if (status?.status == "ready") {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
onboardingPhase = OnboardingPhase.READY,
|
||||
onboardingBotUsername = status.botUsername,
|
||||
onboardingError = null,
|
||||
)
|
||||
}
|
||||
val ownerId = status.ownerUserId
|
||||
if (ownerId != null && TELEGRAM_USER_ID_RE.matches(ownerId)) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
onboardingDetectedOwnerId = ownerId,
|
||||
onboardingAllowedIds = listOf(ownerId),
|
||||
)
|
||||
}
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
_uiState.update { it.copy(onboardingError = null) }
|
||||
}
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
||||
val expired = isOnboardingExpired(_uiState.value.onboardingSetup)
|
||||
if (expired) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
onboardingPhase = OnboardingPhase.IDLE,
|
||||
onboardingSetup = null,
|
||||
onboardingQrDataUrl = null,
|
||||
onboardingError =
|
||||
"Telegram pairing expired. " +
|
||||
"Start a new QR setup to try again.",
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
onboardingError = "Still waiting for Telegram. Retrying…",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update expiry countdown
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
onboardingExpiresIn = formatExpiry(it.onboardingSetup?.expiresAt),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelTelegramOnboarding() {
|
||||
val pairingId = _uiState.value.onboardingSetup?.pairingId
|
||||
launchJob?.cancel()
|
||||
if (pairingId != null) {
|
||||
viewModelScope.launch {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.cancelTelegramOnboarding(pairingId)
|
||||
}
|
||||
}
|
||||
}
|
||||
resetOnboarding()
|
||||
}
|
||||
|
||||
fun setOnboardingNewAllowedId(id: String) {
|
||||
_uiState.update { it.copy(onboardingNewAllowedId = id) }
|
||||
}
|
||||
|
||||
fun addOnboardingAllowedId() {
|
||||
val trimmed = _uiState.value.onboardingNewAllowedId.trim()
|
||||
if (!TELEGRAM_USER_ID_RE.matches(trimmed)) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
onboardingError = "Allowed Telegram user IDs must be numeric.",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
_uiState.update { state ->
|
||||
val ids = state.onboardingAllowedIds
|
||||
state.copy(
|
||||
onboardingError = null,
|
||||
onboardingNewAllowedId = "",
|
||||
onboardingAllowedIds =
|
||||
if (ids.contains(trimmed)) ids else ids + trimmed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeOnboardingAllowedId(id: String) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
onboardingAllowedIds = state.onboardingAllowedIds.filter { it != id },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun applyTelegramOnboarding() {
|
||||
val setup = _uiState.value.onboardingSetup ?: return
|
||||
val allowedIds = _uiState.value.onboardingAllowedIds
|
||||
if (allowedIds.isEmpty()) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
onboardingError = "Add at least one allowed Telegram user ID.",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
onboardingPhase = OnboardingPhase.APPLYING,
|
||||
onboardingError = null,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.applyTelegramOnboarding(
|
||||
setup.pairingId,
|
||||
TelegramOnboardingApplyRequest(
|
||||
allowedUserIds = allowedIds,
|
||||
),
|
||||
)
|
||||
}
|
||||
when (result) {
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Success -> {
|
||||
val applyResult = result.data
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage = "Telegram saved. Restarting gateway…",
|
||||
)
|
||||
}
|
||||
resetOnboarding()
|
||||
if (applyResult?.needsRestart == true) {
|
||||
restartGateway()
|
||||
} else {
|
||||
loadPlatforms()
|
||||
}
|
||||
}
|
||||
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
onboardingPhase = OnboardingPhase.READY,
|
||||
onboardingError = result.error.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetOnboarding() {
|
||||
launchJob?.cancel()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
onboardingPhase = OnboardingPhase.IDLE,
|
||||
onboardingSetup = null,
|
||||
onboardingQrDataUrl = null,
|
||||
onboardingBotUsername = null,
|
||||
onboardingAllowedIds = emptyList(),
|
||||
onboardingDetectedOwnerId = null,
|
||||
onboardingNewAllowedId = "",
|
||||
onboardingError = null,
|
||||
onboardingExpiresIn = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissOnboardingError() {
|
||||
_uiState.update { it.copy(onboardingError = null) }
|
||||
}
|
||||
|
||||
override fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
}
|
||||
|
||||
private val TELEGRAM_USER_ID_RE = Regex("^\\d+$")
|
||||
|
||||
private val isoTimestampParser =
|
||||
java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
private fun formatExpiry(expiresAt: String?): String {
|
||||
if (expiresAt == null) return ""
|
||||
val ms =
|
||||
try {
|
||||
isoTimestampParser
|
||||
.parse(expiresAt)?.time?.minus(System.currentTimeMillis())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
?: return ""
|
||||
if (ms <= 0) return "expired"
|
||||
val seconds = (ms / 1000).toInt()
|
||||
val mins = seconds / 60
|
||||
val secs = seconds % 60
|
||||
return "$mins:${secs.toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
private fun isOnboardingExpired(setup: com.m57.hermescontrol.data.model.TelegramOnboardingStartResponse?): Boolean {
|
||||
val expiresAt = setup?.expiresAt ?: return false
|
||||
val ms =
|
||||
try {
|
||||
isoTimestampParser
|
||||
.parse(expiresAt)?.time
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
?: return false
|
||||
return System.currentTimeMillis() >= ms
|
||||
}
|
||||
Reference in new issue
Block a user