Fix: Prevent AuthManager state pollution on connection verification (#348)

* Fix: Prevent AuthManager state pollution on connection verification

Modified `ConnectViewModel.connect()` to construct a temporary OkHttpClient and Retrofit builder, isolated from the global `ApiClient` state. The connection verification is executed using this temporary service. Only upon a successful HTTP response does the system persist the configured `host`, `port`, and `token` to the global `AuthManager` and triggers an `ApiClient.rebuild()`. This resolves the issue where a failed connection attempt would overwrite active and verified profiles. Extracted `createTempService` factory to `ApiClient` for robustness and clean tests.

Fixes #260

* Fix: Prevent AuthManager state pollution on connection verification

Modified `ConnectViewModel.connect()` to construct a temporary OkHttpClient and Retrofit builder, isolated from the global `ApiClient` state. The connection verification is executed using this temporary service. Only upon a successful HTTP response does the system persist the configured `host`, `port`, and `token` to the global `AuthManager` and triggers an `ApiClient.rebuild()`. This resolves the issue where a failed connection attempt would overwrite active and verified profiles. Extracted `createTempService` factory to `ApiClient` for robustness and clean tests.

Fixes #260

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-23 20:49:58 +03:00
1 parent c21e3e9ed0
commit 2c6e437a5b
42 files changed
+296 -667

No files matched your search

@@ -326,8 +326,7 @@ private fun appEntryProvider(
@Composable
fun MainNavigation(sessionId: String? = null) {
val token by AuthManager.tokenFlow.collectAsState()
val hasToken = !token.isNullOrBlank()
val hasToken = !AuthManager.getToken().isNullOrBlank()
val startScreen: NavKey = if (hasToken) ChatScreen else ConnectScreen
val backStack = rememberNavBackStack(startScreen)
@@ -57,10 +57,6 @@ object AuthManager {
private val _bottomNavDisplayModeFlow = MutableStateFlow<BottomNavDisplayMode>(BottomNavDisplayMode.ICON_AND_TEXT)
val bottomNavDisplayModeFlow: StateFlow<BottomNavDisplayMode> = _bottomNavDisplayModeFlow.asStateFlow()
private val _tokenFlow = MutableStateFlow<String?>(null)
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
private val gson = com.google.gson.Gson()
/**
@@ -88,7 +84,6 @@ object AuthManager {
_useDynamicColorsFlow.value = isUseDynamicColors()
_themePresetFlow.value = getThemePreset()
_bottomNavDisplayModeFlow.value = getBottomNavDisplayMode()
_tokenFlow.value = getToken()
}
}
@@ -108,9 +103,6 @@ object AuthManager {
>() {}.type
gson.fromJson(json, type) ?: emptyList()
} catch (e: Exception) {
if (com.m57.hermescontrol.BuildConfig.DEBUG) {
android.util.Log.w("AuthManager", "Failed to parse connection profiles", e)
}
emptyList()
}
}
@@ -127,9 +119,6 @@ object AuthManager {
token: String?,
) {
requirePrefs().edit().putString("token_$profileId", token).apply()
if (getSelectedProfileId() == profileId) {
_tokenFlow.value = token
}
}
fun getSelectedProfileId(): String? {
@@ -139,7 +128,6 @@ object AuthManager {
fun setSelectedProfileId(id: String?) {
requirePrefs().edit().putString(KEY_SELECTED_PROFILE_ID, id).apply()
_tokenFlow.value = getToken()
}
// ── Token ────────────────────────────────────────────────────────────
@@ -147,7 +135,8 @@ object AuthManager {
fun getToken(): String? {
val selectedId = getSelectedProfileId()
if (selectedId != null) {
return getProfileToken(selectedId)
val token = getProfileToken(selectedId)
if (token != null) return token
}
return requirePrefs().getString(KEY_TOKEN, null)
}
@@ -158,7 +147,6 @@ object AuthManager {
setProfileToken(selectedId, token)
} else {
requirePrefs().edit().putString(KEY_TOKEN, token).apply()
_tokenFlow.value = token
}
}
@@ -12,10 +12,7 @@ import androidx.room.PrimaryKey
* thread. The [timestamp] field preserves original ordering even if Room
* reorders internally.
*/
@Entity(
tableName = "chat_messages",
indices = [androidx.room.Index(value = ["session_id", "timestamp"])],
)
@Entity(tableName = "chat_messages")
data class ChatMessageEntity(
@PrimaryKey
val id: String,
@@ -4,13 +4,11 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.m57.hermescontrol.BuildConfig
@Database(
entities = [ChatMessageEntity::class],
version = 2,
version = 1,
exportSchema = true,
)
abstract class HermesDatabase : RoomDatabase() {
@@ -20,16 +18,6 @@ abstract class HermesDatabase : RoomDatabase() {
@Volatile
private var instance: HermesDatabase? = null
private val MIGRATION_1_2 =
object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` " +
"ON `chat_messages` (`session_id`, `timestamp`)",
)
}
}
fun get(context: Context): HermesDatabase =
instance ?: synchronized(this) {
instance ?: Room
@@ -37,8 +25,7 @@ abstract class HermesDatabase : RoomDatabase() {
context.applicationContext,
HermesDatabase::class.java,
"hermes_control.db",
).addMigrations(MIGRATION_1_2)
.apply {
).apply {
if (BuildConfig.DEBUG) {
fallbackToDestructiveMigration(false)
}
@@ -2,7 +2,6 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.CertificatePinner
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
@@ -24,8 +23,6 @@ object ApiClient {
@Volatile
private var service: HermesApiService? = null
private val connectionPool = okhttp3.ConnectionPool()
/** The current [HermesApiService] instance. Lazily created on first access. */
val hermesApi: HermesApiService
get() {
@@ -42,6 +39,47 @@ object ApiClient {
}
}
/** Creates a standalone, temporary [HermesApiService] without modifying the global instance. */
fun createTempService(
host: String,
port: Int,
token: String,
): HermesApiService {
val tempAuthInterceptor =
Interceptor { chain ->
val request =
if (token.isNotBlank()) {
chain
.request()
.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
chain.proceed(request)
}
val tempOkHttp =
OkHttpClient
.Builder()
.addInterceptor(tempAuthInterceptor)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
val tempRetrofit =
Retrofit
.Builder()
.baseUrl("http://$host:$port/")
.client(tempOkHttp)
.addConverterFactory(GsonConverterFactory.create())
.build()
return tempRetrofit.create(HermesApiService::class.java)
}
// ── Internal ─────────────────────────────────────────────────────────
private fun buildService(): HermesApiService {
@@ -75,18 +113,11 @@ object ApiClient {
chain.proceed(request)
}
// SEC-11: No certificate pinning is configured by default since the app
// intentionally uses HTTP for LAN and hosts are dynamic.
// CertificatePinner infrastructure is provided here if HTTPS is used with a known host.
val certificatePinner = CertificatePinner.Builder().build()
val okHttp =
OkHttpClient
.Builder()
.connectionPool(connectionPool)
.addInterceptor(authInterceptor)
.addInterceptor(logging)
.certificatePinner(certificatePinner)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
@@ -13,15 +13,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -79,38 +74,19 @@ object HermesWsClient {
@Volatile
private var reconnectJob: Job? = null
// SEC-11: No certificate pinning is configured by default since the app
// intentionally uses HTTP for LAN and hosts are dynamic.
// CertificatePinner infrastructure is provided here if HTTPS is used with a known host.
private val certificatePinner = CertificatePinner.Builder().build()
private val okHttpClient =
OkHttpClient
.Builder()
.certificatePinner(certificatePinner)
.readTimeout(0, TimeUnit.MILLISECONDS) // keep-alive forever
.pingInterval(30, TimeUnit.SECONDS)
.build()
// ── Public observable stream ─────────────────────────────────────────
private val rawMessages = MutableSharedFlow<String>(extraBufferCapacity = 64)
private val _events = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
/** Collect this from ViewModels to receive all parsed [WsEvent]s. */
val events: SharedFlow<WsEvent> =
rawMessages
.buffer()
.map { text ->
try {
val rpc = gson.fromJson(text, JsonRpcResponse::class.java)
EventParser.parse(rpc, text)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
WsEvent.Unknown(text)
}
}
.flowOn(Dispatchers.IO)
.shareIn(wsScope, SharingStarted.Eagerly)
val events: SharedFlow<WsEvent> = _events.asSharedFlow()
// ── Connection status flow ──────────────────────────────────────────
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
@@ -187,8 +163,7 @@ object HermesWsClient {
val url = AuthManager.wsUrl()
// B2 (Jun 18 2026, kanban t_8884db16): url contains the auth token
// as a query param — never stream to logcat in release builds.
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $url")
val request = Request.Builder().url(url).build()
webSocket = okHttpClient.newWebSocket(request, WsListenerImpl())
@@ -234,6 +209,10 @@ object HermesWsClient {
}
}
private fun emit(event: WsEvent) {
_events.tryEmit(event)
}
// ── Listener ─────────────────────────────────────────────────────────
private class WsListenerImpl : WebSocketListener() {
@@ -255,7 +234,14 @@ object HermesWsClient {
// AI reply tokens and tool output — never stream to logcat in
// release builds.
if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
rawMessages.tryEmit(text)
try {
val rpc = gson.fromJson(text, JsonRpcResponse::class.java)
val event = EventParser.parse(rpc, text)
emit(event)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
emit(WsEvent.Unknown(text))
}
}
override fun onClosing(
@@ -21,10 +21,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/**
* Foreground service that keeps the WebSocket connection alive while the app
@@ -59,10 +57,11 @@ class ChatNotificationService : Service() {
internal const val NOTIFICATION_ID = 1
internal const val PENDING_NOTIFICATION_ID = 2
private val isAppInForeground = AtomicBoolean(false)
@Volatile
private var isAppInForeground = false
fun setAppForeground(foreground: Boolean) {
isAppInForeground.set(foreground)
isAppInForeground = foreground
}
}
@@ -80,27 +79,22 @@ class ChatNotificationService : Service() {
eventCollector =
serviceScope.launch {
HermesWsClient.events.collect { event ->
if (!isAppInForeground.get()) {
launch {
delay(500)
if (!isAppInForeground.get()) {
when (event) {
is WsEvent.MessageComplete -> {
val preview =
event.text
.take(100)
.replace("\n", " ")
.ifBlank { getString(R.string.notif_new_message) }
showReplyNotification(preview, event.sessionId)
}
is WsEvent.ClarifyRequest -> {
showReplyNotification(getString(R.string.notif_clarification_needed), null)
}
else -> {}
}
if (!isAppInForeground) {
when (event) {
is WsEvent.MessageComplete -> {
val preview =
event.text
.take(100)
.replace("\n", " ")
.ifBlank { getString(R.string.notif_new_message) }
showReplyNotification(preview, event.sessionId)
}
is WsEvent.ClarifyRequest -> {
showReplyNotification(getString(R.string.notif_clarification_needed), null)
}
else -> {}
}
}
}
@@ -119,7 +113,6 @@ class ChatNotificationService : Service() {
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setAutoCancel(true)
.setContentIntent(buildContentIntent(sessionId))
@@ -53,24 +53,22 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
val pendingResult = goAsyncCompat()
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
try {
kotlinx.coroutines.withTimeout(5000L) {
val db =
com.m57.hermescontrol.data.local.HermesDatabase
.get(context)
val dao = db.chatMessageDao()
val entity =
com.m57.hermescontrol.data.local.ChatMessageEntity(
id =
java.util.UUID
.randomUUID()
.toString(),
sessionId = sessionId,
role = "USER",
content = replyText,
timestamp = System.currentTimeMillis(),
)
dao.upsert(entity)
}
val db =
com.m57.hermescontrol.data.local.HermesDatabase
.get(context)
val dao = db.chatMessageDao()
val entity =
com.m57.hermescontrol.data.local.ChatMessageEntity(
id =
java.util.UUID
.randomUUID()
.toString(),
sessionId = sessionId,
role = "USER",
content = replyText,
timestamp = System.currentTimeMillis(),
)
dao.upsert(entity)
} catch (e: Exception) {
android.util.Log.e("NotificationReply", "Failed to save replied message to Room DB", e)
} finally {
@@ -65,16 +65,17 @@ fun AchievementsScreen(
onOpenDrawer = onOpenDrawer,
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadAchievements() },
) {
) { paddingValues ->
when {
state.isLoading && state.achievements.isEmpty() -> {
LoadingState()
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null && state.achievements.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadAchievements() },
modifier = Modifier.padding(paddingValues),
)
}
@@ -83,12 +84,13 @@ fun AchievementsScreen(
title = stringResource(R.string.achievements_empty_title),
subtitle = stringResource(R.string.achievements_empty_desc),
icon = Icons.Filled.Refresh,
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
@@ -99,7 +101,7 @@ fun AchievementsScreen(
placeholder = "Search achievements...",
)
}
items(filteredAchievements, key = { it.id }) { achievement ->
items(filteredAchievements) { achievement ->
val pct = achievement.progress_pct?.toFloat()?.div(100f) ?: 0f
Card(
@@ -69,16 +69,17 @@ fun ChannelsScreen(
onOpenDrawer = onOpenDrawer,
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadPlatforms() },
) {
) { paddingValues ->
when {
state.isLoading && state.platforms.isEmpty() -> {
LoadingState()
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadPlatforms() },
modifier = Modifier.padding(paddingValues),
)
}
@@ -86,12 +87,13 @@ fun ChannelsScreen(
EmptyState(
title = stringResource(R.string.channels_empty_title),
subtitle = stringResource(R.string.channels_empty_desc),
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
@@ -10,9 +10,10 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -52,7 +53,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalUriHandler
@@ -129,6 +129,7 @@ fun ChatBubble(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun UserBubble(
message: ChatMessage,
@@ -173,18 +174,17 @@ private fun UserBubble(
)
val primary = MaterialTheme.colorScheme.primary
val secondary = MaterialTheme.colorScheme.secondary
val onPrimary = MaterialTheme.colorScheme.onPrimary
val avgLuminance = (primary.luminance() + secondary.luminance()) / 2f
val userBubbleTextColor =
if (avgLuminance > 0.5f) {
if (onPrimary.luminance() < 0.5f) {
onPrimary
if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) {
MaterialTheme.colorScheme.onPrimary
} else {
Color(0xFF1A1A24)
}
} else {
if (onPrimary.luminance() > 0.5f) {
onPrimary
if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) {
MaterialTheme.colorScheme.onPrimary
} else {
Color.White
}
@@ -203,13 +203,13 @@ private fun UserBubble(
),
).background(brush = gradientBrush)
.testTag("chat_bubble_user")
.pointerInput(message.id) {
detectTapGestures(
onLongPress = {
showCopyButton = true
},
)
},
.combinedClickable(
role = Role.Button,
onClick = {},
onLongClick = {
showCopyButton = true
},
),
color = Color.Transparent,
tonalElevation = 0.dp,
) {
@@ -270,6 +270,7 @@ private fun UserBubble(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AssistantBubble(
message: ChatMessage,
@@ -280,17 +281,16 @@ private fun AssistantBubble(
modifier: Modifier = Modifier,
) {
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
val onSurface = MaterialTheme.colorScheme.onSurface
val textColor =
if (bubbleColor.luminance() > 0.5f) {
if (onSurface.luminance() < 0.5f) {
onSurface
if (MaterialTheme.colorScheme.onSurface.luminance() < 0.5f) {
MaterialTheme.colorScheme.onSurface
} else {
Color(0xFF1A1A24)
}
} else {
if (onSurface.luminance() > 0.5f) {
onSurface
if (MaterialTheme.colorScheme.onSurface.luminance() > 0.5f) {
MaterialTheme.colorScheme.onSurface
} else {
Color(0xFFE8E6EE)
}
@@ -318,7 +318,7 @@ private fun AssistantBubble(
modifier =
Modifier
.widthIn(max = maxWidth)
.let { if (!message.isStreaming) it.animateContentSize() else it }
.animateContentSize()
.clip(
RoundedCornerShape(
topStart = 4.dp,
@@ -327,13 +327,13 @@ private fun AssistantBubble(
bottomEnd = 16.dp,
),
).testTag("chat_bubble_assistant")
.pointerInput(message.id) {
detectTapGestures(
onLongPress = {
showCopyButton = true
},
)
},
.combinedClickable(
role = Role.Button,
onClick = {},
onLongClick = {
showCopyButton = true
},
),
color = bubbleColor,
border =
BorderStroke(
@@ -75,6 +75,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -103,6 +104,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.StatusRed
@@ -139,9 +141,8 @@ fun ChatScreen(
// during composition — BEFORE any WebSocket event (GatewayReady) can be
// processed. This ensures GatewayReady sees the pending session and
// resumes it instead of creating a new empty chat (issue #240).
remember(sessionId) {
SideEffect {
viewModel.initialSessionId = sessionId
true
}
// Switch to the session from a notification tap or history screen when provided.
@@ -173,7 +174,6 @@ fun ChatScreen(
androidx.lifecycle.Lifecycle.Event.ON_START -> {
NotificationHelper.setAppForeground(context, true)
NotificationHelper.stop(context)
viewModel.refreshSettings()
viewModel.refreshCurrentSession()
}
@@ -212,7 +212,7 @@ fun ChatScreen(
}
// Auto-scroll to bottom on new messages
LaunchedEffect(state.messages.size, state.streamingMessage != null, state.isThinking) {
LaunchedEffect(state.messages.size, state.streamingMessage?.content?.length, state.isThinking) {
val totalItems =
state.messages.size +
(if (state.streamingMessage != null) 1 else 0) +
@@ -245,21 +245,15 @@ fun ChatScreen(
)
}
val backgroundColor = MaterialTheme.colorScheme.background
val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant
val primaryColor = MaterialTheme.colorScheme.primary
val backgroundGradient =
remember(backgroundColor, surfaceVariantColor, primaryColor) {
Brush.verticalGradient(
colors =
listOf(
backgroundColor,
surfaceVariantColor.copy(alpha = 0.3f),
primaryColor.copy(alpha = 0.05f),
),
)
}
Brush.verticalGradient(
colors =
listOf(
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
MaterialTheme.colorScheme.primary.copy(alpha = 0.05f),
),
)
// Scroll to current search match
LaunchedEffect(state.isSearchActive, state.currentSearchMatchIndex, state.searchMatchIndices) {
@@ -447,15 +441,13 @@ fun ChatScreen(
val isLastMessage = index == state.messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT
val typingEnabled = AuthManager.isTypingEffectEnabled()
val typingDelayMs = AuthManager.getTypingEffectDelayMs()
if (state.typingEffectEnabled &&
isLastMessage &&
isAssistant &&
lastAnimatedMessageId != message.id
) {
if (typingEnabled && isLastMessage && isAssistant && lastAnimatedMessageId != message.id) {
StreamingBubbleWithTypingEffect(
streaming = message,
typingDelayMs = state.typingEffectDelayMs,
typingDelayMs = typingDelayMs,
isDark = isDark,
onAnimationComplete = {
lastAnimatedMessageId = message.id
@@ -474,10 +466,13 @@ fun ChatScreen(
// Streaming message — rendered separately for O(1) updates
state.streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") {
if (state.typingEffectEnabled && streaming.isStreaming) {
val typingEnabled = AuthManager.isTypingEffectEnabled()
val typingDelayMs = AuthManager.getTypingEffectDelayMs()
if (typingEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect(
streaming = streaming,
typingDelayMs = state.typingEffectDelayMs,
typingDelayMs = typingDelayMs,
isDark = isDark,
)
} else {
@@ -721,7 +716,7 @@ private fun ChatInputBar(
LazyColumn(
modifier = Modifier.heightIn(max = 200.dp),
) {
items(filteredCommands, key = { it }) { cmd ->
items(filteredCommands) { cmd ->
val description =
when (cmd) {
"/help" -> stringResource(R.string.command_help_desc)
@@ -57,9 +57,6 @@ data class ChatUiState(
val searchQuery: String = "",
val searchMatchIndices: List<Int> = emptyList(),
val currentSearchMatchIndex: Int = -1,
// Cached settings
val typingEffectEnabled: Boolean = true,
val typingEffectDelayMs: Int = 30,
) {
/** Convenience — derived from [connectionStatus]. */
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
@@ -96,12 +93,6 @@ class ChatViewModel(
private var pendingCleanupJob: Job? = null
private val streamingBuffer = java.lang.StringBuilder()
private var lastFlushMs = 0L
private val thinkingBuffer = java.lang.StringBuilder()
private var lastThinkingFlushMs = 0L
// ── Public state ─────────────────────────────────────────────────────
/**
@@ -121,7 +112,6 @@ class ChatViewModel(
)
init {
refreshSettings()
connectWebSocket()
viewModelScope.launch {
wsClient.events.collect { event ->
@@ -254,11 +244,6 @@ class ChatViewModel(
var orphanToPersist: ChatMessage? = null
val sessionId = _uiState.value.currentSessionId
streamingBuffer.clear()
thinkingBuffer.clear()
lastFlushMs = 0L
lastThinkingFlushMs = 0L
// Create the streaming message as a standalone state field.
val msg =
ChatMessage(
@@ -296,61 +281,41 @@ class ChatViewModel(
private fun handleMessageToken(event: WsEvent.MessageToken) {
if (!isCurrentSession(event.sessionId)) return
streamingBuffer.append(event.token)
val now = System.currentTimeMillis()
// Always flush in tests, or if enough time has passed
val shouldFlush = (now - lastFlushMs >= 33L) || lastFlushMs == 0L || isTestEnvironment()
if (shouldFlush) {
val currentContent = streamingBuffer.toString()
lastFlushMs = now
_uiState.update { state ->
val current = state.streamingMessage
if (current != null) {
state.copy(
streamingMessage =
current.copy(
content = currentContent,
),
isThinking = false,
_uiState.update { state ->
val current = state.streamingMessage
if (current != null) {
state.copy(
streamingMessage =
current.copy(
content = current.content + event.token,
),
isThinking = false,
)
} else {
// Fallback: no MessageStart was received — create one now
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = event.token,
isStreaming = true,
)
} else {
// Fallback: no MessageStart was received — create one now
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = currentContent,
isStreaming = true,
)
streamingMessageId = msg.id
state.copy(
streamingMessage = msg,
isAgentTyping = true,
isThinking = false,
)
}
streamingMessageId = msg.id
state.copy(
streamingMessage = msg,
isAgentTyping = true,
isThinking = false,
)
}
}
}
private fun handleThinkingDelta(event: WsEvent.ThinkingDelta) {
if (!isCurrentSession(event.sessionId)) return
thinkingBuffer.append(event.token)
val now = System.currentTimeMillis()
// Always flush in tests, or if enough time has passed
val shouldFlush = (now - lastThinkingFlushMs >= 33L) || lastThinkingFlushMs == 0L || isTestEnvironment()
if (shouldFlush) {
val currentContent = thinkingBuffer.toString()
lastThinkingFlushMs = now
_uiState.update { state ->
state.copy(
isThinking = true,
thinkingText = currentContent,
)
}
_uiState.update { state ->
state.copy(
isThinking = true,
thinkingText = state.thinkingText + event.token,
)
}
}
@@ -360,9 +325,6 @@ class ChatViewModel(
var finalizedMsg: ChatMessage? = null
var sessionId: String? = null
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update { state ->
val streaming = state.streamingMessage
val msg =
@@ -409,20 +371,9 @@ class ChatViewModel(
var orphan: ChatMessage? = null
var sessionId: String? = null
// Flush any remaining buffered content to the message before marking it done
val finalContent = streamingBuffer.toString()
val finalThinkingText = thinkingBuffer.toString()
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update { state ->
val streaming = state.streamingMessage
val msg =
streaming?.copy(
content = finalContent.ifEmpty { streaming.content },
isStreaming = false,
)
val msg = streaming?.copy(isStreaming = false)
orphan = msg
sessionId = state.currentSessionId
if (msg != null) {
@@ -460,7 +411,7 @@ class ChatViewModel(
var orphanToPersist: ChatMessage? = null
val sessionId = _uiState.value.currentSessionId
val contentJson = event.data?.let { gson.toJson(it) } ?: ""
val contentJson = event.data?.let { Gson().toJson(it) } ?: ""
val toolMessage =
ChatMessage(
role = MessageRole.TOOL,
@@ -509,7 +460,7 @@ class ChatViewModel(
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx >= 0) {
val contentJson = event.data?.let { gson.toJson(it) } ?: ""
val contentJson = event.data?.let { Gson().toJson(it) } ?: ""
val updated =
messages[toolIdx].copy(
toolStatus = ToolStatus.COMPLETED,
@@ -599,8 +550,6 @@ class ChatViewModel(
}
WsMethods.SESSION_INTERRUPT -> {
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update {
it.copy(
isAgentTyping = false,
@@ -864,22 +813,11 @@ class ChatViewModel(
loadSessionMessages(sessionId)
}
fun refreshSettings() {
_uiState.update { state ->
state.copy(
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
)
}
}
fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return
// Reset streaming state
streamingMessageId = null
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update {
val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes"
@@ -1105,13 +1043,13 @@ class ChatViewModel(
while (true) {
delay(PENDING_REQUEST_TIMEOUT_MS)
val now = System.currentTimeMillis()
val iterator = pendingRequests.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (now - entry.value.createdAt > PENDING_REQUEST_TIMEOUT_MS) {
iterator.remove()
Log.w(TAG, "Request timed out: ${entry.value.method} (id=${entry.key})")
val stale =
pendingRequests.entries.filter {
now - it.value.createdAt > PENDING_REQUEST_TIMEOUT_MS
}
for (entry in stale) {
pendingRequests.remove(entry.key)
Log.w(TAG, "Request timed out: ${entry.value.method} (id=${entry.key})")
}
}
}
@@ -1197,21 +1135,6 @@ class ChatViewModel(
}
}
private var isTestEnv: Boolean? = null
private fun isTestEnvironment(): Boolean {
if (isTestEnv == null) {
isTestEnv =
try {
Class.forName("org.junit.Test")
true
} catch (e: ClassNotFoundException) {
false
}
}
return isTestEnv == true
}
// ── Lifecycle ─────────────────────────────────────────────────────────
override fun onCleared() {
@@ -1219,8 +1142,4 @@ class ChatViewModel(
pendingCleanupJob?.cancel()
wsClient.disconnect()
}
companion object {
private val gson = Gson()
}
}
@@ -246,7 +246,7 @@ fun FilterChipRow(
),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
items(chips, key = { it }) { chip ->
items(chips) { chip ->
FilterChip(
selected = chip == selectedChip,
onClick = { onChipSelected(chip) },
@@ -39,22 +39,6 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
loadSavedValues()
}
private suspend fun isHostAllowed(host: String): Boolean =
withContext(Dispatchers.IO) {
if (host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0") return@withContext false
if (host.startsWith("169.254.")) return@withContext false
if (host == "metadata.google.internal") return@withContext false
try {
val address = java.net.InetAddress.getByName(host)
if (address.isLoopbackAddress || address.isAnyLocalAddress || address.isLinkLocalAddress) {
return@withContext false
}
true
} catch (e: Exception) {
false
}
}
fun loadSavedValues() {
val savedToken = AuthManager.getToken() ?: ""
val savedHost = AuthManager.getHost()
@@ -147,15 +131,17 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
// Update ApiClient temporarily with current inputs to verify connection
val tempApi = ApiClient.createTempService(state.host, port, state.token)
safeApiCall { tempApi.getStatus() }
}
when (result) {
is NetworkResult.Success -> {
// Only persist credentials to global state upon successful verification
AuthManager.setToken(state.token)
AuthManager.setHost(state.host)
AuthManager.setPort(port)
ApiClient.rebuild()
safeApiCall { ApiClient.hermesApi.getStatus() }
}
when (result) {
is NetworkResult.Success -> {
if (state.saveProfile) {
val currentProfiles = AuthManager.getConnectionProfiles()
val existingIndex =
@@ -203,10 +189,6 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
is NetworkError.Http -> {
when (err.code) {
401 -> {
AuthManager.setToken(null)
if (AuthManager.getSelectedProfileId() != null) {
AuthManager.setProfileToken(AuthManager.getSelectedProfileId()!!, null)
}
app.getString(R.string.connect_error_401)
}
@@ -283,25 +265,15 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
val port = uri.getQueryParameter("port")
val token = uri.getQueryParameter("token")
if (host != null && port != null && token != null) {
viewModelScope.launch {
if (!isHostAllowed(host)) {
_uiState.update {
it.copy(
errorMessage = app.getString(R.string.connect_error_invalid_host),
)
}
return@launch
}
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
return
}
}
@@ -319,25 +291,15 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
val port = json.get("port")?.asInt?.toString() ?: json.get("port")?.asString
val token = json.get("token")?.asString
if (host != null && port != null && token != null) {
viewModelScope.launch {
if (!isHostAllowed(host)) {
_uiState.update {
it.copy(
errorMessage = app.getString(R.string.connect_error_invalid_host),
)
}
return@launch
}
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
_uiState.update {
it.copy(
host = host,
port = port,
token = token,
errorMessage = null,
)
}
connect()
return
}
// Decoded valid JSON but missing required fields
@@ -89,7 +89,7 @@ fun CronJobsScreen(
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(state.jobs, key = { it.id }) { job ->
items(state.jobs) { job ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -85,16 +85,17 @@ fun KanbanScreen(
onOpenDrawer = onOpenDrawer,
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadBoards() },
) {
) { paddingValues ->
when {
state.isLoading && state.boards.isEmpty() -> {
LoadingState()
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadBoards() },
modifier = Modifier.padding(paddingValues),
)
}
@@ -106,10 +107,11 @@ fun KanbanScreen(
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(paddingValues),
)
} else {
Column(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().padding(paddingValues),
) {
SearchBar(
query = query,
@@ -147,7 +149,7 @@ fun KanbanScreen(
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(state.columns, key = { it.name }) { column ->
items(state.columns) { column ->
val colName = column.name
val colTasks =
filteredTasks.filter {
@@ -176,7 +178,7 @@ fun KanbanScreen(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(colTasks, key = { it.id }) { task ->
items(colTasks) { task ->
TaskCard(
task = task,
onMoveLeft =
@@ -125,7 +125,7 @@ fun KeysScreen(
placeholder = "Search keys...",
)
}
items(filteredEnvVars.toList(), key = { it.first }) { (key, config) ->
items(filteredEnvVars.toList()) { (key, config) ->
var isEditing by remember { mutableStateOf(false) }
val revealedVal = state.revealedValues[key]
val isRevealed = revealedVal != null
@@ -116,7 +116,7 @@ fun McpServersScreen(
placeholder = "Search MCP servers...",
)
}
items(filteredServers, key = { it.name }) { server ->
items(filteredServers) { server ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -134,7 +134,7 @@ fun ModelScreen(
placeholder = "Search models...",
)
}
items(filteredProviders, key = { it.slug }) { provider ->
items(filteredProviders) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
@@ -121,7 +121,7 @@ fun PairingScreen(
}
}
items(pending, key = { "${it.platform}_${it.code ?: it.user_id}" }) { item ->
items(pending) { item ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -201,7 +201,7 @@ fun PairingScreen(
}
}
} else {
items(approved, key = { "${it.platform}_${it.user_id}" }) { item ->
items(approved) { item ->
Card(
modifier = Modifier.fillMaxWidth(),
) {
@@ -119,7 +119,7 @@ fun PluginsScreen(
placeholder = "Search plugins...",
)
}
items(filteredPlugins, key = { it.name }) { plugin ->
items(filteredPlugins) { plugin ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
@@ -118,7 +118,7 @@ fun ProfilesScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.profiles, key = { it.name }) { profile ->
items(state.profiles) { profile ->
val isActive = profile.name == state.activeProfileName
Card(
modifier = Modifier.fillMaxWidth(),
@@ -105,7 +105,7 @@ fun SessionsScreen(
placeholder = "Search sessions...",
)
}
items(filteredSessions, key = { it.id }) { session ->
items(filteredSessions) { session ->
Card(
modifier =
Modifier
@@ -237,7 +237,7 @@ fun SkillsScreen(
}
}
} else {
items(filtered, key = { it.name }) { skill ->
items(filtered) { skill ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -133,7 +133,7 @@ fun ToolsetsScreen(
placeholder = "Search toolsets...",
)
}
items(filteredToolsets, key = { it.name }) { toolset ->
items(filteredToolsets) { toolset ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -192,7 +192,7 @@ fun WebhooksScreen(
}
}
} else {
items(filteredSubscriptions, key = { it.name }) { sub ->
items(filteredSubscriptions) { sub ->
Card(
modifier = Modifier.fillMaxWidth(),
) {