Fix WebSocket events being silently dropped on buffer overflow (#313)
* Fix WebSocket events being silently dropped on buffer overflow Increases the MutableSharedFlow `extraBufferCapacity` from 64 to 256 and changes the `onBufferOverflow` policy to `BufferOverflow.DROP_OLDEST` in `HermesWsClient.kt`. This fixes an issue where the slow collection (e.g., due to DB writes) caused the buffer to fill up and silently drop incoming real-time WebSocket events. By setting `DROP_OLDEST`, `tryEmit()` remains functional without suspending, allowing real-time events to be retained and prioritized. Fixes #275 * Fix ktlint formatting issues in HermesWsClient.kt The formatting changes adhere to ktlint rules required by the CI pipeline: - Removed a trailing comma in MutableSharedFlow initialization to avoid multiline expression wrapping errors. - Sorted imports to be in lexicographical order. These formatting changes fix the failing CI check. Fixes #275 * Fix WebSocket events being silently dropped on buffer overflow Increases the MutableSharedFlow `extraBufferCapacity` from 64 to 256 and changes the `onBufferOverflow` policy to `BufferOverflow.DROP_OLDEST` in `HermesWsClient.kt`. This fixes an issue where the slow collection (e.g., due to DB writes) caused the buffer to fill up and silently drop incoming real-time WebSocket events. By setting `DROP_OLDEST`, `tryEmit()` remains functional without suspending, allowing real-time events to be retained and prioritized. Fixes #275 * Fix WebSocket events being silently dropped on buffer overflow Increases the MutableSharedFlow extraBufferCapacity from 64 to 256 and changes the onBufferOverflow policy to BufferOverflow.DROP_OLDEST in HermesWsClient.kt. This fixes an issue where the slow collection (e.g., due to DB writes) caused the buffer to fill up and silently drop incoming real-time WebSocket events. By setting DROP_OLDEST, tryEmit() remains functional without suspending, allowing real-time events to be retained and prioritized. Fixes #275 * Fix WebSocket events being silently dropped on buffer overflow Increases the MutableSharedFlow extraBufferCapacity from 64 to 256 and changes the onBufferOverflow policy to BufferOverflow.DROP_OLDEST in HermesWsClient.kt. This fixes an issue where the slow collection (e.g., due to DB writes) caused the buffer to fill up and silently drop incoming real-time WebSocket events. By setting DROP_OLDEST, tryEmit() remains functional without suspending, allowing real-time events to be retained and prioritized. Fixes #275 * Fix WebSocket events being silently dropped on buffer overflow Increases the MutableSharedFlow extraBufferCapacity from 64 to 256 and changes the onBufferOverflow policy to BufferOverflow.DROP_OLDEST in HermesWsClient.kt. This fixes an issue where the slow collection (e.g., due to DB writes) caused the buffer to fill up and silently drop incoming real-time WebSocket events. By setting DROP_OLDEST, tryEmit() remains functional without suspending, allowing real-time events to be retained and prioritized. Fixes #275 --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
2c6e437a5b
commit
eb71ae3a46
41 files changed
+666
-246
No files matched your search
@@ -326,7 +326,8 @@ private fun appEntryProvider(
|
||||
|
||||
@Composable
|
||||
fun MainNavigation(sessionId: String? = null) {
|
||||
val hasToken = !AuthManager.getToken().isNullOrBlank()
|
||||
val token by AuthManager.tokenFlow.collectAsState()
|
||||
val hasToken = !token.isNullOrBlank()
|
||||
val startScreen: NavKey = if (hasToken) ChatScreen else ConnectScreen
|
||||
|
||||
val backStack = rememberNavBackStack(startScreen)
|
||||
|
||||
@@ -57,6 +57,10 @@ 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()
|
||||
|
||||
/**
|
||||
@@ -84,6 +88,7 @@ object AuthManager {
|
||||
_useDynamicColorsFlow.value = isUseDynamicColors()
|
||||
_themePresetFlow.value = getThemePreset()
|
||||
_bottomNavDisplayModeFlow.value = getBottomNavDisplayMode()
|
||||
_tokenFlow.value = getToken()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +108,9 @@ 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()
|
||||
}
|
||||
}
|
||||
@@ -119,6 +127,9 @@ object AuthManager {
|
||||
token: String?,
|
||||
) {
|
||||
requirePrefs().edit().putString("token_$profileId", token).apply()
|
||||
if (getSelectedProfileId() == profileId) {
|
||||
_tokenFlow.value = token
|
||||
}
|
||||
}
|
||||
|
||||
fun getSelectedProfileId(): String? {
|
||||
@@ -128,6 +139,7 @@ object AuthManager {
|
||||
|
||||
fun setSelectedProfileId(id: String?) {
|
||||
requirePrefs().edit().putString(KEY_SELECTED_PROFILE_ID, id).apply()
|
||||
_tokenFlow.value = getToken()
|
||||
}
|
||||
|
||||
// ── Token ────────────────────────────────────────────────────────────
|
||||
@@ -135,8 +147,7 @@ object AuthManager {
|
||||
fun getToken(): String? {
|
||||
val selectedId = getSelectedProfileId()
|
||||
if (selectedId != null) {
|
||||
val token = getProfileToken(selectedId)
|
||||
if (token != null) return token
|
||||
return getProfileToken(selectedId)
|
||||
}
|
||||
return requirePrefs().getString(KEY_TOKEN, null)
|
||||
}
|
||||
@@ -147,6 +158,7 @@ object AuthManager {
|
||||
setProfileToken(selectedId, token)
|
||||
} else {
|
||||
requirePrefs().edit().putString(KEY_TOKEN, token).apply()
|
||||
_tokenFlow.value = token
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ import androidx.room.PrimaryKey
|
||||
* thread. The [timestamp] field preserves original ordering even if Room
|
||||
* reorders internally.
|
||||
*/
|
||||
@Entity(tableName = "chat_messages")
|
||||
@Entity(
|
||||
tableName = "chat_messages",
|
||||
indices = [androidx.room.Index(value = ["session_id", "timestamp"])],
|
||||
)
|
||||
data class ChatMessageEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
@@ -4,11 +4,13 @@ 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 = 1,
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class HermesDatabase : RoomDatabase() {
|
||||
@@ -18,6 +20,16 @@ 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
|
||||
@@ -25,7 +37,8 @@ abstract class HermesDatabase : RoomDatabase() {
|
||||
context.applicationContext,
|
||||
HermesDatabase::class.java,
|
||||
"hermes_control.db",
|
||||
).apply {
|
||||
).addMigrations(MIGRATION_1_2)
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
fallbackToDestructiveMigration(false)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -23,6 +24,8 @@ 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() {
|
||||
@@ -113,11 +116,18 @@ 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)
|
||||
|
||||
@@ -9,14 +9,20 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
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
|
||||
@@ -74,19 +80,42 @@ 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 _events = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
|
||||
private val rawMessages =
|
||||
MutableSharedFlow<String>(
|
||||
extraBufferCapacity = 256,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/** Collect this from ViewModels to receive all parsed [WsEvent]s. */
|
||||
val events: SharedFlow<WsEvent> = _events.asSharedFlow()
|
||||
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)
|
||||
|
||||
// ── Connection status flow ──────────────────────────────────────────
|
||||
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
||||
@@ -163,7 +192,8 @@ 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.
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $url")
|
||||
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
webSocket = okHttpClient.newWebSocket(request, WsListenerImpl())
|
||||
@@ -209,10 +239,6 @@ object HermesWsClient {
|
||||
}
|
||||
}
|
||||
|
||||
private fun emit(event: WsEvent) {
|
||||
_events.tryEmit(event)
|
||||
}
|
||||
|
||||
// ── Listener ─────────────────────────────────────────────────────────
|
||||
|
||||
private class WsListenerImpl : WebSocketListener() {
|
||||
@@ -234,14 +260,7 @@ object HermesWsClient {
|
||||
// AI reply tokens and tool output — never stream to logcat in
|
||||
// release builds.
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "← $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))
|
||||
}
|
||||
rawMessages.tryEmit(text)
|
||||
}
|
||||
|
||||
override fun onClosing(
|
||||
|
||||
@@ -21,8 +21,10 @@ 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
|
||||
@@ -57,11 +59,10 @@ class ChatNotificationService : Service() {
|
||||
internal const val NOTIFICATION_ID = 1
|
||||
internal const val PENDING_NOTIFICATION_ID = 2
|
||||
|
||||
@Volatile
|
||||
private var isAppInForeground = false
|
||||
private val isAppInForeground = AtomicBoolean(false)
|
||||
|
||||
fun setAppForeground(foreground: Boolean) {
|
||||
isAppInForeground = foreground
|
||||
isAppInForeground.set(foreground)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,22 +80,27 @@ class ChatNotificationService : Service() {
|
||||
eventCollector =
|
||||
serviceScope.launch {
|
||||
HermesWsClient.events.collect { event ->
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
is WsEvent.ClarifyRequest -> {
|
||||
showReplyNotification(getString(R.string.notif_clarification_needed), null)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +119,7 @@ class ChatNotificationService : Service() {
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(buildContentIntent(sessionId))
|
||||
|
||||
|
||||
@@ -53,22 +53,24 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val pendingResult = goAsyncCompat()
|
||||
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
|
||||
try {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NotificationReply", "Failed to save replied message to Room DB", e)
|
||||
} finally {
|
||||
|
||||
@@ -65,17 +65,16 @@ fun AchievementsScreen(
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadAchievements() },
|
||||
) { paddingValues ->
|
||||
) {
|
||||
when {
|
||||
state.isLoading && state.achievements.isEmpty() -> {
|
||||
LoadingState(modifier = Modifier.padding(paddingValues))
|
||||
LoadingState()
|
||||
}
|
||||
|
||||
state.errorMessage != null && state.achievements.isEmpty() -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadAchievements() },
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,13 +83,12 @@ 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().padding(paddingValues),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
@@ -101,7 +99,7 @@ fun AchievementsScreen(
|
||||
placeholder = "Search achievements...",
|
||||
)
|
||||
}
|
||||
items(filteredAchievements) { achievement ->
|
||||
items(filteredAchievements, key = { it.id }) { achievement ->
|
||||
val pct = achievement.progress_pct?.toFloat()?.div(100f) ?: 0f
|
||||
|
||||
Card(
|
||||
|
||||
@@ -69,17 +69,16 @@ fun ChannelsScreen(
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadPlatforms() },
|
||||
) { paddingValues ->
|
||||
) {
|
||||
when {
|
||||
state.isLoading && state.platforms.isEmpty() -> {
|
||||
LoadingState(modifier = Modifier.padding(paddingValues))
|
||||
LoadingState()
|
||||
}
|
||||
|
||||
state.errorMessage != null -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadPlatforms() },
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,13 +86,12 @@ 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().padding(paddingValues),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = listContentPadding,
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
|
||||
@@ -10,10 +10,9 @@ 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.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -53,6 +52,7 @@ 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,7 +129,6 @@ fun ChatBubble(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun UserBubble(
|
||||
message: ChatMessage,
|
||||
@@ -174,17 +173,18 @@ 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 (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
if (onPrimary.luminance() < 0.5f) {
|
||||
onPrimary
|
||||
} else {
|
||||
Color(0xFF1A1A24)
|
||||
}
|
||||
} else {
|
||||
if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
if (onPrimary.luminance() > 0.5f) {
|
||||
onPrimary
|
||||
} else {
|
||||
Color.White
|
||||
}
|
||||
@@ -203,13 +203,13 @@ private fun UserBubble(
|
||||
),
|
||||
).background(brush = gradientBrush)
|
||||
.testTag("chat_bubble_user")
|
||||
.combinedClickable(
|
||||
role = Role.Button,
|
||||
onClick = {},
|
||||
onLongClick = {
|
||||
showCopyButton = true
|
||||
},
|
||||
),
|
||||
.pointerInput(message.id) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
showCopyButton = true
|
||||
},
|
||||
)
|
||||
},
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
@@ -270,7 +270,6 @@ private fun UserBubble(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AssistantBubble(
|
||||
message: ChatMessage,
|
||||
@@ -281,16 +280,17 @@ 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 (MaterialTheme.colorScheme.onSurface.luminance() < 0.5f) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
if (onSurface.luminance() < 0.5f) {
|
||||
onSurface
|
||||
} else {
|
||||
Color(0xFF1A1A24)
|
||||
}
|
||||
} else {
|
||||
if (MaterialTheme.colorScheme.onSurface.luminance() > 0.5f) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
if (onSurface.luminance() > 0.5f) {
|
||||
onSurface
|
||||
} else {
|
||||
Color(0xFFE8E6EE)
|
||||
}
|
||||
@@ -318,7 +318,7 @@ private fun AssistantBubble(
|
||||
modifier =
|
||||
Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.animateContentSize()
|
||||
.let { if (!message.isStreaming) it.animateContentSize() else it }
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 4.dp,
|
||||
@@ -327,13 +327,13 @@ private fun AssistantBubble(
|
||||
bottomEnd = 16.dp,
|
||||
),
|
||||
).testTag("chat_bubble_assistant")
|
||||
.combinedClickable(
|
||||
role = Role.Button,
|
||||
onClick = {},
|
||||
onLongClick = {
|
||||
showCopyButton = true
|
||||
},
|
||||
),
|
||||
.pointerInput(message.id) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
showCopyButton = true
|
||||
},
|
||||
)
|
||||
},
|
||||
color = bubbleColor,
|
||||
border =
|
||||
BorderStroke(
|
||||
|
||||
@@ -75,7 +75,6 @@ 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
|
||||
@@ -104,7 +103,6 @@ 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
|
||||
@@ -141,8 +139,9 @@ 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).
|
||||
SideEffect {
|
||||
remember(sessionId) {
|
||||
viewModel.initialSessionId = sessionId
|
||||
true
|
||||
}
|
||||
|
||||
// Switch to the session from a notification tap or history screen when provided.
|
||||
@@ -174,6 +173,7 @@ 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?.content?.length, state.isThinking) {
|
||||
LaunchedEffect(state.messages.size, state.streamingMessage != null, state.isThinking) {
|
||||
val totalItems =
|
||||
state.messages.size +
|
||||
(if (state.streamingMessage != null) 1 else 0) +
|
||||
@@ -245,15 +245,21 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
|
||||
val backgroundColor = MaterialTheme.colorScheme.background
|
||||
val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
val backgroundGradient =
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.background,
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.05f),
|
||||
),
|
||||
)
|
||||
remember(backgroundColor, surfaceVariantColor, primaryColor) {
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
backgroundColor,
|
||||
surfaceVariantColor.copy(alpha = 0.3f),
|
||||
primaryColor.copy(alpha = 0.05f),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Scroll to current search match
|
||||
LaunchedEffect(state.isSearchActive, state.currentSearchMatchIndex, state.searchMatchIndices) {
|
||||
@@ -441,13 +447,15 @@ fun ChatScreen(
|
||||
|
||||
val isLastMessage = index == state.messages.lastIndex
|
||||
val isAssistant = message.role == MessageRole.ASSISTANT
|
||||
val typingEnabled = AuthManager.isTypingEffectEnabled()
|
||||
val typingDelayMs = AuthManager.getTypingEffectDelayMs()
|
||||
|
||||
if (typingEnabled && isLastMessage && isAssistant && lastAnimatedMessageId != message.id) {
|
||||
if (state.typingEffectEnabled &&
|
||||
isLastMessage &&
|
||||
isAssistant &&
|
||||
lastAnimatedMessageId != message.id
|
||||
) {
|
||||
StreamingBubbleWithTypingEffect(
|
||||
streaming = message,
|
||||
typingDelayMs = typingDelayMs,
|
||||
typingDelayMs = state.typingEffectDelayMs,
|
||||
isDark = isDark,
|
||||
onAnimationComplete = {
|
||||
lastAnimatedMessageId = message.id
|
||||
@@ -466,13 +474,10 @@ fun ChatScreen(
|
||||
// Streaming message — rendered separately for O(1) updates
|
||||
state.streamingMessage?.let { streaming ->
|
||||
item(key = "streaming-${streaming.id}") {
|
||||
val typingEnabled = AuthManager.isTypingEffectEnabled()
|
||||
val typingDelayMs = AuthManager.getTypingEffectDelayMs()
|
||||
|
||||
if (typingEnabled && streaming.isStreaming) {
|
||||
if (state.typingEffectEnabled && streaming.isStreaming) {
|
||||
StreamingBubbleWithTypingEffect(
|
||||
streaming = streaming,
|
||||
typingDelayMs = typingDelayMs,
|
||||
typingDelayMs = state.typingEffectDelayMs,
|
||||
isDark = isDark,
|
||||
)
|
||||
} else {
|
||||
@@ -716,7 +721,7 @@ private fun ChatInputBar(
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 200.dp),
|
||||
) {
|
||||
items(filteredCommands) { cmd ->
|
||||
items(filteredCommands, key = { it }) { cmd ->
|
||||
val description =
|
||||
when (cmd) {
|
||||
"/help" -> stringResource(R.string.command_help_desc)
|
||||
|
||||
@@ -57,6 +57,9 @@ 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
|
||||
@@ -93,6 +96,12 @@ 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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -112,6 +121,7 @@ class ChatViewModel(
|
||||
)
|
||||
|
||||
init {
|
||||
refreshSettings()
|
||||
connectWebSocket()
|
||||
viewModelScope.launch {
|
||||
wsClient.events.collect { event ->
|
||||
@@ -244,6 +254,11 @@ 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(
|
||||
@@ -281,41 +296,61 @@ class ChatViewModel(
|
||||
|
||||
private fun handleMessageToken(event: WsEvent.MessageToken) {
|
||||
if (!isCurrentSession(event.sessionId)) return
|
||||
_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,
|
||||
|
||||
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,
|
||||
)
|
||||
streamingMessageId = msg.id
|
||||
state.copy(
|
||||
streamingMessage = msg,
|
||||
isAgentTyping = true,
|
||||
isThinking = false,
|
||||
)
|
||||
} 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleThinkingDelta(event: WsEvent.ThinkingDelta) {
|
||||
if (!isCurrentSession(event.sessionId)) return
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isThinking = true,
|
||||
thinkingText = state.thinkingText + event.token,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +360,9 @@ class ChatViewModel(
|
||||
var finalizedMsg: ChatMessage? = null
|
||||
var sessionId: String? = null
|
||||
|
||||
streamingBuffer.clear()
|
||||
thinkingBuffer.clear()
|
||||
|
||||
_uiState.update { state ->
|
||||
val streaming = state.streamingMessage
|
||||
val msg =
|
||||
@@ -371,9 +409,20 @@ 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(isStreaming = false)
|
||||
val msg =
|
||||
streaming?.copy(
|
||||
content = finalContent.ifEmpty { streaming.content },
|
||||
isStreaming = false,
|
||||
)
|
||||
orphan = msg
|
||||
sessionId = state.currentSessionId
|
||||
if (msg != null) {
|
||||
@@ -411,7 +460,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,
|
||||
@@ -460,7 +509,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,
|
||||
@@ -550,6 +599,8 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
WsMethods.SESSION_INTERRUPT -> {
|
||||
streamingBuffer.clear()
|
||||
thinkingBuffer.clear()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isAgentTyping = false,
|
||||
@@ -813,11 +864,22 @@ 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"
|
||||
@@ -1043,13 +1105,13 @@ class ChatViewModel(
|
||||
while (true) {
|
||||
delay(PENDING_REQUEST_TIMEOUT_MS)
|
||||
val now = System.currentTimeMillis()
|
||||
val stale =
|
||||
pendingRequests.entries.filter {
|
||||
now - it.value.createdAt > PENDING_REQUEST_TIMEOUT_MS
|
||||
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})")
|
||||
}
|
||||
for (entry in stale) {
|
||||
pendingRequests.remove(entry.key)
|
||||
Log.w(TAG, "Request timed out: ${entry.value.method} (id=${entry.key})")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1135,6 +1197,21 @@ 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() {
|
||||
@@ -1142,4 +1219,8 @@ 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) { chip ->
|
||||
items(chips, key = { it }) { chip ->
|
||||
FilterChip(
|
||||
selected = chip == selectedChip,
|
||||
onClick = { onChipSelected(chip) },
|
||||
|
||||
@@ -39,6 +39,22 @@ 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()
|
||||
@@ -189,6 +205,10 @@ 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)
|
||||
}
|
||||
|
||||
@@ -265,15 +285,25 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
|
||||
val port = uri.getQueryParameter("port")
|
||||
val token = uri.getQueryParameter("token")
|
||||
if (host != null && port != null && token != null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
host = host,
|
||||
port = port,
|
||||
token = token,
|
||||
errorMessage = 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()
|
||||
}
|
||||
connect()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -291,15 +321,25 @@ 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) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
host = host,
|
||||
port = port,
|
||||
token = token,
|
||||
errorMessage = 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()
|
||||
}
|
||||
connect()
|
||||
return
|
||||
}
|
||||
// Decoded valid JSON but missing required fields
|
||||
|
||||
@@ -89,7 +89,7 @@ fun CronJobsScreen(
|
||||
contentPadding = listContentPadding,
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
items(state.jobs) { job ->
|
||||
items(state.jobs, key = { it.id }) { job ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
|
||||
@@ -85,17 +85,16 @@ fun KanbanScreen(
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadBoards() },
|
||||
) { paddingValues ->
|
||||
) {
|
||||
when {
|
||||
state.isLoading && state.boards.isEmpty() -> {
|
||||
LoadingState(modifier = Modifier.padding(paddingValues))
|
||||
LoadingState()
|
||||
}
|
||||
|
||||
state.errorMessage != null -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadBoards() },
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,11 +106,10 @@ fun KanbanScreen(
|
||||
Text(
|
||||
text = state.errorMessage ?: "",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
SearchBar(
|
||||
query = query,
|
||||
@@ -149,7 +147,7 @@ fun KanbanScreen(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(state.columns) { column ->
|
||||
items(state.columns, key = { it.name }) { column ->
|
||||
val colName = column.name
|
||||
val colTasks =
|
||||
filteredTasks.filter {
|
||||
@@ -178,7 +176,7 @@ fun KanbanScreen(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(colTasks) { task ->
|
||||
items(colTasks, key = { it.id }) { task ->
|
||||
TaskCard(
|
||||
task = task,
|
||||
onMoveLeft =
|
||||
|
||||
@@ -125,7 +125,7 @@ fun KeysScreen(
|
||||
placeholder = "Search keys...",
|
||||
)
|
||||
}
|
||||
items(filteredEnvVars.toList()) { (key, config) ->
|
||||
items(filteredEnvVars.toList(), key = { it.first }) { (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) { server ->
|
||||
items(filteredServers, key = { it.name }) { server ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
|
||||
@@ -134,7 +134,7 @@ fun ModelScreen(
|
||||
placeholder = "Search models...",
|
||||
)
|
||||
}
|
||||
items(filteredProviders) { provider ->
|
||||
items(filteredProviders, key = { it.slug }) { provider ->
|
||||
val isExpanded = expandedProviderSlug == provider.slug
|
||||
val isCurrent = provider.is_current == true
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ fun PairingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
items(pending) { item ->
|
||||
items(pending, key = { "${it.platform}_${it.code ?: it.user_id}" }) { item ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
@@ -201,7 +201,7 @@ fun PairingScreen(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(approved) { item ->
|
||||
items(approved, key = { "${it.platform}_${it.user_id}" }) { item ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
|
||||
@@ -119,7 +119,7 @@ fun PluginsScreen(
|
||||
placeholder = "Search plugins...",
|
||||
)
|
||||
}
|
||||
items(filteredPlugins) { plugin ->
|
||||
items(filteredPlugins, key = { it.name }) { 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) { profile ->
|
||||
items(state.profiles, key = { it.name }) { profile ->
|
||||
val isActive = profile.name == state.activeProfileName
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -105,7 +105,7 @@ fun SessionsScreen(
|
||||
placeholder = "Search sessions...",
|
||||
)
|
||||
}
|
||||
items(filteredSessions) { session ->
|
||||
items(filteredSessions, key = { it.id }) { session ->
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
|
||||
@@ -237,7 +237,7 @@ fun SkillsScreen(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(filtered) { skill ->
|
||||
items(filtered, key = { it.name }) { skill ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
|
||||
@@ -133,7 +133,7 @@ fun ToolsetsScreen(
|
||||
placeholder = "Search toolsets...",
|
||||
)
|
||||
}
|
||||
items(filteredToolsets) { toolset ->
|
||||
items(filteredToolsets, key = { it.name }) { toolset ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
|
||||
@@ -192,7 +192,7 @@ fun WebhooksScreen(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(filteredSubscriptions) { sub ->
|
||||
items(filteredSubscriptions, key = { it.name }) { sub ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user