Implement Multi-Hermes Connection Manager and Unified Sessions with Context Sync and Room DB

This commit is contained in:
Ochenstarik 2026-08-23 23:24:48 +07:00
parent 4fbb95c6c4
commit 879b834e51
32 changed files with 4270 additions and 242 deletions

142
README.md
View file

@ -1,57 +1,81 @@
# Hermes Android Native Remote Client
A production-grade, native Android client application for **Hermes**, implementing Protocol & Architecture Contract v1.
A production-grade, native Android client application for **Hermes**, implementing Protocol & Architecture Contract v1 with **Multi-Hermes Connection Manager** and **Unified Sessions**.
Built with **Kotlin**, **Jetpack Compose (Material 3)**, **Coroutines**, **OkHttp**, and **Android Keystore (EncryptedSharedPreferences)**.
Built with **Kotlin**, **Jetpack Compose (Material 3)**, **Coroutines**, **Room Database**, **OkHttp**, and **Android Keystore (EncryptedSharedPreferences)**.
---
## 🌟 Architecture Overview
```
┌─────────────────────────────────────────────────────────┐
│ Hermes Android Client │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Jetpack Compose UI (M3) │ │
│ │ • Connections • Sessions • Chat & Approvals │ │
│ └─────────────────────────┬─────────────────────────┘ │
│ │ StateFlow / Actions │
│ ┌─────────────────────────▼─────────────────────────┐ │
│ │ Hermes Gateway Layer │ │
│ │ • Reconnection Loop with Exponential Backoff │ │
│ │ • Session State Reconciliation │ │
│ │ • Event Stream Dispatcher │ │
│ └──────────┬──────────────────────────┬─────────────┘ │
│ │ JSON-RPC / Ticket │ PKCE Auth │
│ ┌──────────▼──────────┐ ┌──────────▼──────────────┐ │
│ │ OkHttp WebSocket │ │ Loopback Auth Server │ │
│ │ (Single-Use Auth) │ │ (127.0.0.1:<port>) │ │
│ └──────────┬──────────┘ └──────────┬──────────────┘ │
└─────────────┼──────────────────────────┼─────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Hermes Host │
│ (`hermes serve`) │
│ │
│ • `GET /api/status``GET /auth/native/...`
│ • `POST /api/auth/ws-ticket``WS /ws?ticket=...`
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Hermes Android Client │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Jetpack Compose UI (M3) │ │
│ │ • Multi-Host Switcher • Unified Sessions • Attributed Chat │ │
│ └─────────────────────────────────┬─────────────────────────────────┘ │
│ │ StateFlow / Actions │
│ ┌─────────────────────────────────▼─────────────────────────────────┐ │
│ │ Unified Session Repository │ │
│ │ • Logical Unified Sessions • Context Synchronization Delta │ │
│ │ • Host-Tagged Event Routing • Local Persistence (Room DB) │ │
│ └──────────────────┬──────────────────────────────┬─────────────────┘ │
│ │ │ │
│ ┌──────────────────▼──────────────────┐ ┌────────▼─────────────────┐ │
│ │ Hermes Connection Manager │ │ Encrypted Token Vault │ │
│ │ • Map<HostId, HostRuntime> │ │ (Host-Scoped Keystore) │ │
│ └───────┬─────────────────────────┬───┘ └──────────────────────────┘ │
│ │ │ │
│ ┌───────▼─────────────┐ ┌───────▼─────────────┐ │
│ │ Host #1 Runtime │ │ Host #2 Runtime │ │
│ │ (OkHttp WS + REST) │ │ (OkHttp WS + REST) │ │
│ └───────┬─────────────┘ └───────┬─────────────┘ │
└──────────┼─────────────────────────┼────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Hermes Host #1 │ │ Hermes Host #2
│ (Windows Office) │ │ (Linux Server) │
`hermes serve` │ │ `hermes serve`
└──────────────────────┘ └──────────────────────┘
```
---
## 🚀 Getting Started & Host Setup
## 🚀 Key Multi-Host Features
### 1. Windows Host Setup
1. **Multi-Hermes Connection Manager**:
- Save and manage multiple independent Hermes installations (e.g. Workstation, Linux Server, Cloud VM).
- Independent WebSocket connections, concurrent state management, and isolated reconnect loops.
- Individual host health badges: `Online`, `Connecting`, `Offline`, `Auth Expired`.
Run the Hermes server binding to all interfaces (or your LAN / Tailscale IP):
2. **Unified Sessions & Context Synchronization**:
- Create one logical conversation (`UnifiedSession`) that spans multiple physical Hermes hosts.
- Seamlessly switch active execution hosts mid-conversation via the top-bar dropdown.
- **Delta Context Sync**: Injects conversation history and task context to newly attached hosts automatically without full-history re-transmission or secret leakage.
- **Host Attribution**: Every response bubble, tool card, and thinking trace displays its originating host badge (e.g. `[Office PC]`, `[Linux Server]`).
- **Non-Blocking Host Switching**: If Host #1 is executing a long tool or computation and you switch to Host #2, Host #1 completes its work in the background and commits results into the shared timeline.
3. **Isolated Host Security & Approvals**:
- Host-scoped credentials stored securely in Android Keystore (`hostId -> tokens`).
- **Host-Targeted Approvals & Clarifications**: Dangerous command approvals (`approval.request`) and sudo prompts route back strictly to the exact host runtime and native session that emitted them.
4. **Local Persistence (Room DB)**:
- Full offline caching for `UnifiedSession`, `HostSessionBinding`, and `UnifiedMessage`.
- Raw native session browser for inspecting individual host histories.
---
## 🖥️ Hermes Host Setup
### Windows Host
```powershell
hermes serve --host 0.0.0.0 --port 9119
```
To configure GitHub authentication:
With OAuth / GitHub Auth:
```powershell
$env:HERMES_AUTH_REQUIRED="true"
$env:HERMES_AUTH_PROVIDERS="github"
@ -60,7 +84,7 @@ $env:HERMES_AUTH_GITHUB_CLIENT_SECRET="<your_client_secret>"
hermes serve --host 0.0.0.0 --port 9119
```
### 2. Linux Host Setup
### Linux Host
```bash
export HERMES_AUTH_REQUIRED="true"
@ -73,50 +97,22 @@ hermes serve --host 0.0.0.0 --port 9119
---
## 📱 Android Client Features
1. **Host Connection Manager**:
- Save multiple Hermes host endpoints.
- Live endpoint verification (`GET /api/status`).
- Cleartext HTTP toggling with explicit security warning badges for local development.
2. **Native PKCE Authentication**:
- RFC 7636 & RFC 8252 compliant PKCE loopback authentication on `127.0.0.1:<ephemeral_port>`.
- Single-use WebSocket tickets with 30s TTL.
- Credentials securely stored via Android Keystore & `EncryptedSharedPreferences`. Zero token logging.
3. **Session Management**:
- Resume durable sessions (`DurableSessionId`) or create new sessions.
- Dynamic reconciliation across network disconnects.
4. **Real-time Chat Experience**:
- Streaming token deltas (`message.delta`).
- Collapsible reasoning & chain-of-thought section (`thinking.delta`).
- Real-time tool execution tracking cards (`tool.start`, `tool.progress`, `tool.complete`).
- **Interactive Approvals**: Immediate in-stream approval card for dangerous commands (`Allow Once`, `Allow Always`, `Deny`).
- **Clarifications & Sudo**: Masked dialogs for `sudo.request`, `secret.request`, and `clarify.request`.
- Interrupt / Stop execution control.
---
## 🔒 Security Best Practices for Remote Access
- **Do NOT expose cleartext HTTP directly to the public internet.**
- **Recommended**: Connect via **Tailscale**, **WireGuard**, or a TLS Reverse Proxy (Caddy / Nginx) with HTTPS & WSS.
- The Android client strictly enforces `usesCleartextTraffic="false"` at the manifest level by default.
---
## 🧪 Testing & Verification
Run all unit tests via Gradle:
Run the full automated test suite:
```powershell
.\gradlew testDebugUnitTest
.\gradlew.bat testDebugUnitTest
```
Build the release or debug APK:
Run Android Lint:
```powershell
.\gradlew assembleDebug
.\gradlew.bat lintDebug
```
Assemble Debug APK:
```powershell
.\gradlew.bat assembleDebug
```

View file

@ -3,6 +3,7 @@ plugins {
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp")
}
android {
@ -86,6 +87,12 @@ dependencies {
implementation("androidx.datastore:datastore-preferences:1.1.2")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Room Database
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")

View file

@ -10,25 +10,32 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import app.hermes.mobile.core.auth.PkceLoopbackAuthManager
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.UnifiedSessionId
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.network.JsonRpcGatewayClient
import app.hermes.mobile.core.repository.ConnectionRepository
import app.hermes.mobile.core.repository.HermesGatewayRepository
import app.hermes.mobile.core.repository.UnifiedSessionRepository
import app.hermes.mobile.core.runtime.HermesConnectionManager
import app.hermes.mobile.core.security.EncryptedTokenVault
import app.hermes.mobile.core.storage.HermesDatabase
import app.hermes.mobile.core.storage.MigrationHelper
import app.hermes.mobile.feature.chat.ChatScreen
import app.hermes.mobile.feature.chat.ChatViewModel
import app.hermes.mobile.feature.connections.ConnectionsScreen
import app.hermes.mobile.feature.connections.ConnectionsViewModel
import app.hermes.mobile.feature.sessions.SessionsScreen
import app.hermes.mobile.feature.sessions.SessionsViewModel
import app.hermes.mobile.feature.hosts.HostsScreen
import app.hermes.mobile.feature.hosts.HostsViewModel
import app.hermes.mobile.feature.native_sessions.NativeSessionsScreen
import app.hermes.mobile.feature.native_sessions.NativeSessionsViewModel
import app.hermes.mobile.feature.settings.SettingsScreen
import app.hermes.mobile.feature.unified_sessions.UnifiedSessionsScreen
import app.hermes.mobile.feature.unified_sessions.UnifiedSessionsViewModel
import app.hermes.mobile.ui.theme.HermesAndroidTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
@ -36,12 +43,28 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val db = HermesDatabase.getInstance(applicationContext)
val hostDao = db.hostDao()
val sessionDao = db.unifiedSessionDao()
val tokenVault = EncryptedTokenVault(applicationContext)
val restClient = HermesRestClient()
val gatewayClient = JsonRpcGatewayClient()
val pkceAuthManager = PkceLoopbackAuthManager(restClient, tokenVault)
val connectionRepo = ConnectionRepository(applicationContext)
val gatewayRepo = HermesGatewayRepository(restClient, gatewayClient, tokenVault)
// Migrate legacy connections from DataStore if present
lifecycleScope.launch {
MigrationHelper.migrateLegacyConnections(applicationContext, hostDao)
}
val connectionManager = HermesConnectionManager(
hostDao = hostDao,
tokenVault = tokenVault,
restClient = restClient
)
val unifiedSessionRepo = UnifiedSessionRepository(
connectionManager = connectionManager,
sessionDao = sessionDao
)
setContent {
HermesAndroidTheme {
@ -49,9 +72,9 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
HermesAppNavigation(
connectionRepo = connectionRepo,
gatewayRepo = gatewayRepo,
HermesUnifiedAppNavigation(
connectionManager = connectionManager,
sessionRepo = unifiedSessionRepo,
tokenVault = tokenVault,
pkceAuthManager = pkceAuthManager
)
@ -62,66 +85,77 @@ class MainActivity : ComponentActivity() {
}
@Composable
fun HermesAppNavigation(
connectionRepo: ConnectionRepository,
gatewayRepo: HermesGatewayRepository,
fun HermesUnifiedAppNavigation(
connectionManager: HermesConnectionManager,
sessionRepo: UnifiedSessionRepository,
tokenVault: EncryptedTokenVault,
pkceAuthManager: PkceLoopbackAuthManager
) {
val navController = rememberNavController()
val connectionsViewModel = remember {
ConnectionsViewModel(connectionRepo, gatewayRepo, tokenVault, pkceAuthManager)
val unifiedSessionsViewModel = remember {
UnifiedSessionsViewModel(sessionRepo, connectionManager)
}
val sessionsViewModel = remember {
SessionsViewModel(gatewayRepo)
}
val chatViewModel = remember {
ChatViewModel(gatewayRepo)
val hostsViewModel = remember {
HostsViewModel(connectionManager, tokenVault, pkceAuthManager = pkceAuthManager)
}
NavHost(
navController = navController,
startDestination = "connections"
startDestination = "unified_sessions"
) {
composable("connections") {
ConnectionsScreen(
viewModel = connectionsViewModel,
onNavigateToSessions = { connId ->
sessionsViewModel.loadSessions()
navController.navigate("sessions/$connId")
composable("unified_sessions") {
UnifiedSessionsScreen(
viewModel = unifiedSessionsViewModel,
onNavigateToChat = { sessionId ->
navController.navigate("chat/${sessionId.value}")
},
onNavigateToHosts = {
navController.navigate("hosts")
}
)
}
composable(
route = "sessions/{connectionId}",
arguments = listOf(navArgument("connectionId") { type = NavType.StringType })
route = "chat/{unifiedSessionId}",
arguments = listOf(navArgument("unifiedSessionId") { type = NavType.StringType })
) { backStackEntry ->
val connId = backStackEntry.arguments?.getString("connectionId") ?: ""
SessionsScreen(
viewModel = sessionsViewModel,
connectionId = connId,
val sessionIdStr = backStackEntry.arguments?.getString("unifiedSessionId") ?: ""
val sessionId = UnifiedSessionId(sessionIdStr)
val chatViewModel = remember(sessionIdStr) {
ChatViewModel(sessionRepo, connectionManager, sessionId)
}
ChatScreen(
viewModel = chatViewModel,
onNavigateBack = {
navController.popBackStack()
}
)
}
composable("hosts") {
HostsScreen(
viewModel = hostsViewModel,
onNavigateBack = {
navController.popBackStack()
},
onNavigateToChat = { durableSessionId ->
navController.navigate("chat/$connId/$durableSessionId")
onNavigateToNativeSessions = { hostId ->
navController.navigate("native_sessions/${hostId.value}")
}
)
}
composable(
route = "chat/{connectionId}/{durableSessionId}",
arguments = listOf(
navArgument("connectionId") { type = NavType.StringType },
navArgument("durableSessionId") { type = NavType.StringType }
)
route = "native_sessions/{hostId}",
arguments = listOf(navArgument("hostId") { type = NavType.StringType })
) { backStackEntry ->
val durableSessionId = backStackEntry.arguments?.getString("durableSessionId") ?: ""
ChatScreen(
viewModel = chatViewModel,
durableSessionId = durableSessionId,
val hostIdStr = backStackEntry.arguments?.getString("hostId") ?: ""
val hostId = HermesHostId(hostIdStr)
val nativeSessionsViewModel = remember(hostIdStr) {
NativeSessionsViewModel(connectionManager, hostId)
}
NativeSessionsScreen(
viewModel = nativeSessionsViewModel,
onNavigateBack = {
navController.popBackStack()
}

View file

@ -0,0 +1,108 @@
package app.hermes.mobile.core.model
import kotlinx.serialization.Serializable
@Serializable
data class HermesHostId(val value: String)
@Serializable
data class UnifiedSessionId(val value: String)
enum class HostStatus {
ONLINE,
OFFLINE,
CONNECTING,
AUTH_REQUIRED,
AUTH_EXPIRED,
ERROR
}
@Serializable
data class HermesHost(
val id: HermesHostId,
val displayName: String,
val baseUrl: String,
val allowCleartext: Boolean = false,
val enabled: Boolean = true,
val lastSeenAt: Long = 0L,
val lastKnownStatus: HostStatus = HostStatus.OFFLINE
)
enum class BindingState {
NOT_CREATED,
READY,
CONNECTING,
RUNNING,
OFFLINE,
ERROR
}
@Serializable
data class HostSessionBinding(
val hostId: HermesHostId,
val durableSessionId: DurableSessionId,
val runtimeSessionId: RuntimeSessionId,
val lastAttachedAt: Long = System.currentTimeMillis(),
val state: BindingState = BindingState.NOT_CREATED,
val syncedThroughMessageId: String? = null,
val syncedAt: Long? = null
)
enum class UnifiedMessageSource {
USER,
HERMES,
SYSTEM,
TRANSFER,
A2A
}
@Serializable
data class UnifiedMessage(
val id: String,
val role: MessageRole,
val content: String,
val hostId: HermesHostId? = null,
val source: UnifiedMessageSource = UnifiedMessageSource.HERMES,
val createdAt: Long = System.currentTimeMillis(),
val nativeMessageId: String? = null,
val thinking: String? = null,
val tools: List<ToolActivity> = emptyList(),
val isStreaming: Boolean = false
)
@Serializable
data class UnifiedSession(
val id: UnifiedSessionId,
val title: String,
val activeHostId: HermesHostId,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val bindings: Map<HermesHostId, HostSessionBinding> = emptyMap(),
val timeline: List<UnifiedMessage> = emptyList()
)
@Serializable
data class A2AContextBinding(
val sourceHostId: HermesHostId,
val targetHostId: HermesHostId,
val contextId: String
)
data class HostGatewayEvent(
val hostId: HermesHostId,
val event: GatewayEvent
)
@Serializable
data class HostAttributedApproval(
val hostId: HermesHostId,
val hostDisplayName: String,
val approval: HermesApproval
)
@Serializable
data class HostAttributedClarify(
val hostId: HermesHostId,
val hostDisplayName: String,
val request: HermesClarifyRequest
)

View file

@ -58,7 +58,7 @@ class JsonRpcGatewayClient(
.readTimeout(0, TimeUnit.MILLISECONDS) // infinite for websockets
.pingInterval(30, TimeUnit.SECONDS)
.build(),
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
) {
private val json = Json {
ignoreUnknownKeys = true
@ -208,8 +208,10 @@ class JsonRpcGatewayClient(
gatewayReadyDeferred.complete(Unit)
}
}
scope.launch {
_events.emit(event)
if (!_events.tryEmit(event)) {
scope.launch {
_events.emit(event)
}
}
} catch (e: Exception) {
// Ignore corrupted frames gracefully or log if debug

View file

@ -0,0 +1,717 @@
package app.hermes.mobile.core.repository
import app.hermes.mobile.core.model.*
import app.hermes.mobile.core.network.ConnectionState
import app.hermes.mobile.core.runtime.HermesConnectionManager
import app.hermes.mobile.core.storage.*
import app.hermes.mobile.core.sync.UnifiedContextBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.IOException
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class UnifiedSessionRepository(
val connectionManager: HermesConnectionManager,
val sessionDao: UnifiedSessionDao,
val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
) {
private val json = Json { ignoreUnknownKeys = true }
val sessions: StateFlow<List<UnifiedSession>> = sessionDao.getSessionsFlow()
.map { list ->
list.map { entity ->
val details = sessionDao.getSessionWithDetails(entity.id)
details?.toDomain() ?: entity.toDomainPlaceholder()
}
}
.stateIn(scope, SharingStarted.Eagerly, emptyList())
private val _activeApprovals = MutableStateFlow<List<HostAttributedApproval>>(emptyList())
val activeApprovals: StateFlow<List<HostAttributedApproval>> = _activeApprovals.asStateFlow()
private val _activeClarify = MutableStateFlow<HostAttributedClarify?>(null)
val activeClarify: StateFlow<HostAttributedClarify?> = _activeClarify.asStateFlow()
// Mapping from runtimeSessionId to (sessionId, hostId)
private val runtimeToSessionMap = ConcurrentHashMap<String, Pair<UnifiedSessionId, HermesHostId>>()
// In-memory active session messages cache for reactive streaming updates
private val sessionMessagesState = ConcurrentHashMap<UnifiedSessionId, MutableStateFlow<List<UnifiedMessage>>>()
private val sessionExecutingState = ConcurrentHashMap<UnifiedSessionId, MutableStateFlow<Boolean>>()
init {
scope.launch {
connectionManager.allEvents.collect { hostEvent ->
handleHostGatewayEvent(hostEvent)
}
}
}
fun getSessionMessages(sessionId: UnifiedSessionId): StateFlow<List<UnifiedMessage>> {
return sessionMessagesState.computeIfAbsent(sessionId) {
val flow = MutableStateFlow<List<UnifiedMessage>>(emptyList())
scope.launch {
val details = sessionDao.getSessionWithDetails(sessionId.value)
if (details != null) {
flow.value = details.messages.map { it.toDomain() }
}
}
flow
}.asStateFlow()
}
fun getSessionExecuting(sessionId: UnifiedSessionId): StateFlow<Boolean> {
return sessionExecutingState.computeIfAbsent(sessionId) {
MutableStateFlow(false)
}.asStateFlow()
}
suspend fun createUnifiedSession(
title: String = "New Session",
initialHostId: HermesHostId? = null
): UnifiedSession {
val hostId = initialHostId ?: connectionManager.activeHostId.value
?: connectionManager.hosts.value.firstOrNull()?.id
?: HermesHostId("default")
val sessionId = UnifiedSessionId(UUID.randomUUID().toString())
val sessionEntity = UnifiedSessionEntity(
id = sessionId.value,
title = title,
activeHostId = hostId.value,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
sessionDao.insertSession(sessionEntity)
val session = UnifiedSession(
id = sessionId,
title = title,
activeHostId = hostId,
createdAt = sessionEntity.createdAt,
updatedAt = sessionEntity.updatedAt,
bindings = emptyMap(),
timeline = emptyList()
)
sessionMessagesState[sessionId] = MutableStateFlow(emptyList())
sessionExecutingState[sessionId] = MutableStateFlow(false)
return session
}
suspend fun getUnifiedSession(sessionId: UnifiedSessionId): UnifiedSession? {
val details = sessionDao.getSessionWithDetails(sessionId.value) ?: return null
return details.toDomain()
}
suspend fun deleteUnifiedSession(sessionId: UnifiedSessionId) {
sessionDao.deleteSession(sessionId.value)
sessionMessagesState.remove(sessionId)
sessionExecutingState.remove(sessionId)
}
suspend fun switchSessionActiveHost(sessionId: UnifiedSessionId, targetHostId: HermesHostId) {
sessionDao.updateActiveHost(sessionId.value, targetHostId.value, System.currentTimeMillis())
}
suspend fun sendPrompt(sessionId: UnifiedSessionId, text: String): String {
val details = sessionDao.getSessionWithDetails(sessionId.value)
?: throw IllegalArgumentException("Session not found: ${sessionId.value}")
val currentSession = details.toDomain()
val targetHostId = currentSession.activeHostId
val host = connectionManager.hosts.value.find { it.id == targetHostId }
?: throw IllegalStateException("Active host ${targetHostId.value} is not configured")
val runtime = connectionManager.getRuntime(targetHostId)
?: throw IllegalStateException("Runtime not available for host ${targetHostId.value}")
// Ensure host is connected
if (runtime.connectionState.value !is ConnectionState.Connected) {
val connectRes = runtime.connect()
if (connectRes.isFailure) {
throw IOException("Failed to connect to ${host.displayName}: ${connectRes.exceptionOrNull()?.message}")
}
runtime.gatewayClient.awaitGatewayReady(10_000)
}
// Get or create native session binding for this host
var binding = currentSession.bindings[targetHostId]
if (binding == null || binding.runtimeSessionId.value.isEmpty()) {
val createRes = runtime.gatewayClient.createSession(source = "android")
binding = HostSessionBinding(
hostId = targetHostId,
durableSessionId = createRes.durableId,
runtimeSessionId = createRes.runtimeId,
lastAttachedAt = System.currentTimeMillis(),
state = BindingState.READY,
syncedThroughMessageId = null,
syncedAt = null
)
sessionDao.insertOrUpdateBinding(binding.toEntity(sessionId.value))
}
runtimeToSessionMap[binding.runtimeSessionId.value] = Pair(sessionId, targetHostId)
// Context Synchronization
val hostsMap = connectionManager.hosts.value.associateBy { it.id }
val syncResult = UnifiedContextBuilder.buildContextSyncPayload(
session = currentSession,
targetHost = host,
allHosts = hostsMap,
syncedThroughMessageId = binding.syncedThroughMessageId
)
val promptToSend = if (syncResult.hasNewContext && currentSession.timeline.isNotEmpty()) {
// Include context transfer message in timeline as a visual marker
val transferMsg = UnifiedMessage(
id = UUID.randomUUID().toString(),
role = MessageRole.SYSTEM,
content = "Context synchronized with ${host.displayName}",
hostId = targetHostId,
source = UnifiedMessageSource.TRANSFER,
createdAt = System.currentTimeMillis()
)
insertMessageToSession(sessionId, transferMsg)
UnifiedContextBuilder.mergeContextWithPrompt(syncResult.contextPrompt, text)
} else {
text
}
// Update binding sync status
sessionDao.updateBindingSync(
sessionId = sessionId.value,
hostId = targetHostId.value,
syncedThroughMessageId = syncResult.latestSyncedMessageId,
syncedAt = System.currentTimeMillis(),
state = BindingState.RUNNING.name
)
// Insert user message to timeline
val userMessage = UnifiedMessage(
id = UUID.randomUUID().toString(),
role = MessageRole.USER,
content = text,
hostId = null,
source = UnifiedMessageSource.USER,
createdAt = System.currentTimeMillis()
)
insertMessageToSession(sessionId, userMessage)
setExecuting(sessionId, true)
return try {
val result = runtime.gatewayClient.submitPrompt(binding.runtimeSessionId, promptToSend)
result.turnId ?: userMessage.id
} catch (e: Exception) {
setExecuting(sessionId, false)
sessionDao.updateBindingState(sessionId.value, targetHostId.value, BindingState.ERROR.name)
throw e
}
}
suspend fun interruptSession(sessionId: UnifiedSessionId) {
val details = sessionDao.getSessionWithDetails(sessionId.value) ?: return
for (binding in details.bindings) {
val runtime = connectionManager.getRuntime(HermesHostId(binding.hostId))
if (runtime != null && binding.runtimeSessionId.isNotEmpty()) {
try {
runtime.gatewayClient.interruptSession(RuntimeSessionId(binding.runtimeSessionId))
} catch (_: Exception) {
}
}
}
setExecuting(sessionId, false)
}
suspend fun respondApproval(
hostId: HermesHostId,
requestId: String,
choice: String,
all: Boolean = false
): Boolean {
val runtime = connectionManager.getRuntime(hostId) ?: return false
val approval = _activeApprovals.value.find { it.hostId == hostId && it.approval.requestId == requestId }
val sessionKey = "" // Gateway client handles request_id
val success = try {
runtime.gatewayClient.respondApproval(sessionKey, requestId, choice, all)
} catch (_: Exception) {
true
}
if (success) {
_activeApprovals.value = _activeApprovals.value.filterNot {
it.hostId == hostId && it.approval.requestId == requestId
}
}
return success
}
suspend fun respondClarify(
hostId: HermesHostId,
requestId: String,
answer: String,
questionId: String? = null
): Boolean {
val runtime = connectionManager.getRuntime(hostId) ?: return false
val success = runtime.gatewayClient.respondClarify(requestId, answer, questionId)
if (success) {
if (_activeClarify.value?.hostId == hostId && _activeClarify.value?.request?.requestId == requestId) {
_activeClarify.value = null
}
}
return success
}
suspend fun respondSudo(
hostId: HermesHostId,
requestId: String,
password: String
): Boolean {
val runtime = connectionManager.getRuntime(hostId) ?: return false
val success = runtime.gatewayClient.respondSudo(requestId, password)
if (success) {
if (_activeClarify.value?.hostId == hostId && _activeClarify.value?.request?.requestId == requestId) {
_activeClarify.value = null
}
}
return success
}
suspend fun respondSecret(
hostId: HermesHostId,
requestId: String,
secret: String
): Boolean {
val runtime = connectionManager.getRuntime(hostId) ?: return false
val success = runtime.gatewayClient.respondSecret(requestId, secret)
if (success) {
if (_activeClarify.value?.hostId == hostId && _activeClarify.value?.request?.requestId == requestId) {
_activeClarify.value = null
}
}
return success
}
private fun insertMessageToSession(sessionId: UnifiedSessionId, message: UnifiedMessage) {
val flow = sessionMessagesState.computeIfAbsent(sessionId) {
MutableStateFlow(emptyList())
}
flow.value = flow.value + message
scope.launch {
sessionDao.insertOrUpdateMessage(message.toEntity(sessionId.value))
}
}
private fun updateMessageInSession(
sessionId: UnifiedSessionId,
messageId: String,
transform: (UnifiedMessage) -> UnifiedMessage
) {
val flow = sessionMessagesState.computeIfAbsent(sessionId) {
MutableStateFlow(emptyList())
}
val list = flow.value.toMutableList()
val idx = list.indexOfFirst { it.id == messageId }
if (idx >= 0) {
val updated = transform(list[idx])
list[idx] = updated
flow.value = list
scope.launch {
val toolsJson = if (updated.tools.isNotEmpty()) json.encodeToString(updated.tools) else null
sessionDao.updateMessageContent(
messageId = updated.id,
content = updated.content,
isStreaming = updated.isStreaming,
thinking = updated.thinking,
toolsJson = toolsJson
)
}
}
}
private fun setExecuting(sessionId: UnifiedSessionId, executing: Boolean) {
val flow = sessionExecutingState.computeIfAbsent(sessionId) {
MutableStateFlow(false)
}
flow.value = executing
}
private fun findSessionForHost(hostId: HermesHostId): UnifiedSessionId? {
for ((sessionId, flow) in sessionExecutingState) {
if (flow.value) {
return sessionId
}
}
// Fall back to active session, open session cache, or first known session
return sessions.value.find { it.activeHostId == hostId }?.id
?: sessionMessagesState.keys.firstOrNull()
?: sessions.value.firstOrNull()?.id
}
private fun findSessionForMessage(messageId: String, hostId: HermesHostId): UnifiedSessionId? {
for ((sessionId, flow) in sessionMessagesState) {
if (flow.value.any { it.id == messageId }) {
return sessionId
}
}
return findSessionForHost(hostId)
}
private fun handleHostGatewayEvent(hostEvent: HostGatewayEvent) {
val hostId = hostEvent.hostId
val event = hostEvent.event
val hostName = connectionManager.hosts.value.find { it.id == hostId }?.displayName ?: hostId.value
when (event) {
is GatewayEvent.MessageStartEvent -> {
val sessionId = findSessionForMessage(event.messageId, hostId) ?: return
setExecuting(sessionId, true)
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
val existing = flow.value.find { it.id == event.messageId }
if (existing == null) {
val role = if (event.role.equals("user", ignoreCase = true)) MessageRole.USER else MessageRole.ASSISTANT
val newMsg = UnifiedMessage(
id = event.messageId,
role = role,
content = "",
hostId = hostId,
source = UnifiedMessageSource.HERMES,
isStreaming = true
)
insertMessageToSession(sessionId, newMsg)
}
}
is GatewayEvent.MessageDeltaEvent -> {
val sessionId = findSessionForMessage(event.messageId, hostId) ?: return
setExecuting(sessionId, true)
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
val idx = flow.value.indexOfFirst { it.id == event.messageId }
if (idx >= 0) {
updateMessageInSession(sessionId, event.messageId) {
it.copy(content = it.content + event.delta, isStreaming = true)
}
} else {
val newMsg = UnifiedMessage(
id = event.messageId,
role = MessageRole.ASSISTANT,
content = event.delta,
hostId = hostId,
source = UnifiedMessageSource.HERMES,
isStreaming = true
)
insertMessageToSession(sessionId, newMsg)
}
}
is GatewayEvent.MessageInterimEvent -> {
val sessionId = findSessionForMessage(event.messageId, hostId) ?: return
updateMessageInSession(sessionId, event.messageId) {
it.copy(content = event.content, isStreaming = true)
}
}
is GatewayEvent.MessageCompleteEvent -> {
val sessionId = findSessionForMessage(event.messageId, hostId) ?: return
setExecuting(sessionId, false)
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
val idx = flow.value.indexOfFirst { it.id == event.messageId }
if (idx >= 0) {
updateMessageInSession(sessionId, event.messageId) {
it.copy(
content = if (event.content.isNotEmpty()) event.content else it.content,
isStreaming = false
)
}
} else if (event.content.isNotEmpty()) {
val newMsg = UnifiedMessage(
id = event.messageId,
role = MessageRole.ASSISTANT,
content = event.content,
hostId = hostId,
source = UnifiedMessageSource.HERMES,
isStreaming = false
)
insertMessageToSession(sessionId, newMsg)
}
}
is GatewayEvent.ThinkingDeltaEvent -> {
val sessionId = findSessionForHost(hostId) ?: return
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
val lastAssistant = flow.value.lastOrNull { it.role == MessageRole.ASSISTANT && it.hostId == hostId }
if (lastAssistant != null) {
updateMessageInSession(sessionId, lastAssistant.id) {
it.copy(thinking = (it.thinking ?: "") + event.delta)
}
}
}
is GatewayEvent.ReasoningDeltaEvent -> {
val sessionId = findSessionForHost(hostId) ?: return
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
val lastAssistant = flow.value.lastOrNull { it.role == MessageRole.ASSISTANT && it.hostId == hostId }
if (lastAssistant != null) {
updateMessageInSession(sessionId, lastAssistant.id) {
it.copy(thinking = (it.thinking ?: "") + event.delta)
}
}
}
is GatewayEvent.ReasoningAvailableEvent -> {
val sessionId = findSessionForHost(hostId) ?: return
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
val lastAssistant = flow.value.lastOrNull { it.role == MessageRole.ASSISTANT && it.hostId == hostId }
if (lastAssistant != null) {
updateMessageInSession(sessionId, lastAssistant.id) {
it.copy(thinking = event.reasoning)
}
}
}
is GatewayEvent.ToolStartEvent -> {
val sessionId = findSessionForHost(hostId) ?: return
val tool = ToolActivity(id = event.toolId, name = event.name, status = "running")
attachToolToSessionMessage(sessionId, hostId, tool)
}
is GatewayEvent.ToolProgressEvent -> {
val sessionId = findSessionForHost(hostId) ?: return
updateToolInSessionMessage(sessionId, event.toolId) { it.copy(progress = event.progress) }
}
is GatewayEvent.ToolGeneratingEvent -> {
val sessionId = findSessionForHost(hostId) ?: return
updateToolInSessionMessage(sessionId, event.toolId) { it.copy(status = "generating") }
}
is GatewayEvent.ToolCompleteEvent -> {
val sessionId = findSessionForHost(hostId) ?: return
updateToolInSessionMessage(sessionId, event.toolId) {
it.copy(
status = if (event.isError) "failed" else "completed",
result = event.result,
isError = event.isError
)
}
}
is GatewayEvent.ApprovalRequestEvent -> {
val approval = HermesApproval(
requestId = event.requestId,
command = event.command,
description = event.description,
choices = event.choices
)
val attributed = HostAttributedApproval(
hostId = hostId,
hostDisplayName = hostName,
approval = approval
)
_activeApprovals.value = _activeApprovals.value.filterNot {
it.hostId == hostId && it.approval.requestId == event.requestId
} + attributed
}
is GatewayEvent.ClarifyRequestEvent -> {
val req = HermesClarifyRequest(
requestId = event.requestId,
questionId = event.questionId,
question = event.question,
promptType = ClarifyType.CLARIFY
)
_activeClarify.value = HostAttributedClarify(hostId, hostName, req)
}
is GatewayEvent.SudoRequestEvent -> {
val req = HermesClarifyRequest(
requestId = event.requestId,
question = event.question,
promptType = ClarifyType.SUDO
)
_activeClarify.value = HostAttributedClarify(hostId, hostName, req)
}
is GatewayEvent.SecretRequestEvent -> {
val req = HermesClarifyRequest(
requestId = event.requestId,
question = event.question,
promptType = ClarifyType.SECRET
)
_activeClarify.value = HostAttributedClarify(hostId, hostName, req)
}
is GatewayEvent.ErrorEvent -> {
val sessionId = findSessionForHost(hostId) ?: return
setExecuting(sessionId, false)
}
else -> {}
}
}
private fun attachToolToSessionMessage(sessionId: UnifiedSessionId, hostId: HermesHostId, tool: ToolActivity) {
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
val lastAssistant = flow.value.lastOrNull { it.role == MessageRole.ASSISTANT && it.hostId == hostId }
if (lastAssistant != null) {
updateMessageInSession(sessionId, lastAssistant.id) {
val updatedTools = it.tools.filterNot { t -> t.id == tool.id } + tool
it.copy(tools = updatedTools)
}
} else {
val newMsg = UnifiedMessage(
id = UUID.randomUUID().toString(),
role = MessageRole.ASSISTANT,
content = "",
hostId = hostId,
tools = listOf(tool),
isStreaming = true
)
insertMessageToSession(sessionId, newMsg)
}
}
private fun updateToolInSessionMessage(
sessionId: UnifiedSessionId?,
toolId: String,
transform: (ToolActivity) -> ToolActivity
) {
val targetSessionId = sessionId?.takeIf { sId ->
sessionMessagesState[sId]?.value?.any { msg -> msg.tools.any { it.id == toolId } } == true
} ?: sessionMessagesState.entries.firstOrNull { (_, flow) ->
flow.value.any { msg -> msg.tools.any { it.id == toolId } }
}?.key ?: sessionId ?: return
val flow = sessionMessagesState.computeIfAbsent(targetSessionId) { MutableStateFlow(emptyList()) }
val targetMsg = flow.value.lastOrNull { msg -> msg.tools.any { it.id == toolId } }
if (targetMsg != null) {
updateMessageInSession(targetSessionId, targetMsg.id) { msg ->
val updatedTools = msg.tools.map { if (it.id == toolId) transform(it) else it }
msg.copy(tools = updatedTools)
}
}
}
private fun UnifiedSessionWithDetails.toDomain(): UnifiedSession {
val bindingsMap = bindings.associate {
HermesHostId(it.hostId) to it.toDomain()
}
val timelineList = messages.map { it.toDomain() }
return UnifiedSession(
id = UnifiedSessionId(session.id),
title = session.title,
activeHostId = HermesHostId(session.activeHostId),
createdAt = session.createdAt,
updatedAt = session.updatedAt,
bindings = bindingsMap,
timeline = timelineList
)
}
private fun UnifiedSessionEntity.toDomainPlaceholder(): UnifiedSession {
return UnifiedSession(
id = UnifiedSessionId(id),
title = title,
activeHostId = HermesHostId(activeHostId),
createdAt = createdAt,
updatedAt = updatedAt,
bindings = emptyMap(),
timeline = emptyList()
)
}
private fun HostBindingEntity.toDomain(): HostSessionBinding {
val bState = try {
BindingState.valueOf(state)
} catch (_: Exception) {
BindingState.NOT_CREATED
}
return HostSessionBinding(
hostId = HermesHostId(hostId),
durableSessionId = DurableSessionId(durableSessionId),
runtimeSessionId = RuntimeSessionId(runtimeSessionId),
lastAttachedAt = lastAttachedAt,
state = bState,
syncedThroughMessageId = syncedThroughMessageId,
syncedAt = syncedAt
)
}
private fun HostSessionBinding.toEntity(sessionId: String): HostBindingEntity {
return HostBindingEntity(
sessionId = sessionId,
hostId = hostId.value,
durableSessionId = durableSessionId.value,
runtimeSessionId = runtimeSessionId.value,
lastAttachedAt = lastAttachedAt,
state = state.name,
syncedThroughMessageId = syncedThroughMessageId,
syncedAt = syncedAt
)
}
private fun UnifiedMessageEntity.toDomain(): UnifiedMessage {
val mRole = try {
MessageRole.valueOf(role)
} catch (_: Exception) {
MessageRole.ASSISTANT
}
val mSource = try {
UnifiedMessageSource.valueOf(source)
} catch (_: Exception) {
UnifiedMessageSource.HERMES
}
val toolList = if (!toolsJson.isNullOrBlank()) {
try {
json.decodeFromString<List<ToolActivity>>(toolsJson)
} catch (_: Exception) {
emptyList()
}
} else {
emptyList()
}
return UnifiedMessage(
id = id,
role = mRole,
content = content,
hostId = hostId?.let { HermesHostId(it) },
source = mSource,
createdAt = createdAt,
nativeMessageId = nativeMessageId,
thinking = thinking,
tools = toolList,
isStreaming = isStreaming
)
}
private fun UnifiedMessage.toEntity(sessionId: String): UnifiedMessageEntity {
val toolsString = if (tools.isNotEmpty()) json.encodeToString(tools) else null
return UnifiedMessageEntity(
id = id,
sessionId = sessionId,
role = role.name,
content = content,
hostId = hostId?.value,
source = source.name,
createdAt = createdAt,
nativeMessageId = nativeMessageId,
thinking = thinking,
toolsJson = toolsString,
isStreaming = isStreaming
)
}
}

View file

@ -0,0 +1,182 @@
package app.hermes.mobile.core.runtime
import app.hermes.mobile.core.model.HermesHost
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.HostGatewayEvent
import app.hermes.mobile.core.model.HostStatus
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.network.JsonRpcGatewayClient
import app.hermes.mobile.core.security.TokenVault
import app.hermes.mobile.core.storage.HostDao
import app.hermes.mobile.core.storage.HostEntity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
class HermesConnectionManager(
val hostDao: HostDao,
val tokenVault: TokenVault,
val restClient: HermesRestClient = HermesRestClient(),
val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
val runtimeFactory: (HermesHost) -> HermesHostRuntime = { host ->
HermesHostRuntime(
initialHost = host,
restClient = restClient,
gatewayClient = JsonRpcGatewayClient(scope = scope),
tokenVault = tokenVault,
scope = scope
)
}
) {
private val runtimes = ConcurrentHashMap<HermesHostId, HermesHostRuntime>()
private val _hosts = MutableStateFlow<List<HermesHost>>(emptyList())
val hosts: StateFlow<List<HermesHost>> = _hosts.asStateFlow()
private val _activeHostId = MutableStateFlow<HermesHostId?>(null)
val activeHostId: StateFlow<HermesHostId?> = _activeHostId.asStateFlow()
private val _allEvents = MutableSharedFlow<HostGatewayEvent>(extraBufferCapacity = 128)
val allEvents: SharedFlow<HostGatewayEvent> = _allEvents.asSharedFlow()
init {
scope.launch {
hostDao.getHostsFlow().collect { entities ->
val list = entities.map { it.toDomain() }
_hosts.value = list
// Auto-sync runtimes with database hosts
val validIds = list.map { it.id }.toSet()
for ((id, rt) in runtimes) {
if (id !in validIds) {
rt.close()
runtimes.remove(id)
}
}
for (h in list) {
val existingRt = runtimes[h.id]
if (existingRt != null) {
existingRt.updateHost(h)
} else {
getOrCreateRuntime(h)
}
}
if (_activeHostId.value == null && list.isNotEmpty()) {
_activeHostId.value = list.first().id
} else if (_activeHostId.value != null && list.none { it.id == _activeHostId.value }) {
_activeHostId.value = list.firstOrNull()?.id
}
}
}
}
fun getRuntime(hostId: HermesHostId): HermesHostRuntime? {
val existing = runtimes[hostId]
if (existing != null) return existing
val host = _hosts.value.find { it.id == hostId } ?: return null
return getOrCreateRuntime(host)
}
fun getOrCreateRuntime(host: HermesHost): HermesHostRuntime {
return runtimes.computeIfAbsent(host.id) {
val rt = runtimeFactory(host)
// Forward events
scope.launch {
rt.events.collect { event ->
if (!_allEvents.tryEmit(event)) {
_allEvents.emit(event)
}
}
}
// Update host status in DB on change
scope.launch {
rt.status.collect { st ->
hostDao.updateHostStatus(host.id.value, st.name, System.currentTimeMillis())
}
}
rt
}
}
suspend fun addHost(host: HermesHost) {
hostDao.insertOrUpdateHost(host.toEntity())
getOrCreateRuntime(host)
}
suspend fun updateHost(host: HermesHost) {
hostDao.insertOrUpdateHost(host.toEntity())
val rt = runtimes[host.id]
rt?.updateHost(host)
}
suspend fun removeHost(hostId: HermesHostId) {
val rt = runtimes.remove(hostId)
rt?.close()
tokenVault.clearTokens(hostId.value)
hostDao.deleteHost(hostId.value)
if (_activeHostId.value == hostId) {
_activeHostId.value = _hosts.value.firstOrNull { it.id != hostId }?.id
}
}
suspend fun connectHost(hostId: HermesHostId): Result<Unit> {
val host = _hosts.value.find { it.id == hostId }
?: return Result.failure(IllegalArgumentException("Host not found: ${hostId.value}"))
val rt = getOrCreateRuntime(host)
return rt.connect()
}
fun disconnectHost(hostId: HermesHostId) {
runtimes[hostId]?.disconnect()
}
fun switchActiveHost(hostId: HermesHostId) {
if (_hosts.value.any { it.id == hostId }) {
_activeHostId.value = hostId
}
}
suspend fun refreshAllHosts() {
val currentHosts = hostDao.getHosts()
_hosts.value = currentHosts.map { it.toDomain() }
}
private fun HostEntity.toDomain(): HermesHost {
val status = try {
HostStatus.valueOf(lastKnownStatus)
} catch (_: Exception) {
HostStatus.OFFLINE
}
return HermesHost(
id = HermesHostId(id),
displayName = displayName,
baseUrl = baseUrl,
allowCleartext = allowCleartext,
enabled = enabled,
lastSeenAt = lastSeenAt,
lastKnownStatus = status
)
}
private fun HermesHost.toEntity(): HostEntity {
return HostEntity(
id = id.value,
displayName = displayName,
baseUrl = baseUrl,
allowCleartext = allowCleartext,
enabled = enabled,
lastSeenAt = lastSeenAt,
lastKnownStatus = lastKnownStatus.name
)
}
}

View file

@ -0,0 +1,273 @@
package app.hermes.mobile.core.runtime
import app.hermes.mobile.core.model.HermesHost
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.HermesServerStatus
import app.hermes.mobile.core.model.HostGatewayEvent
import app.hermes.mobile.core.model.HostStatus
import app.hermes.mobile.core.network.ConnectionState
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.network.JsonRpcGatewayClient
import app.hermes.mobile.core.security.TokenVault
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.IOException
import kotlin.math.min
import kotlin.random.Random
class HermesHostRuntime(
initialHost: HermesHost,
val restClient: HermesRestClient = HermesRestClient(),
val gatewayClient: JsonRpcGatewayClient = JsonRpcGatewayClient(),
val tokenVault: TokenVault,
val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
) {
private val _host = MutableStateFlow(initialHost)
val host: StateFlow<HermesHost> = _host.asStateFlow()
val hostId: HermesHostId get() = _host.value.id
private val _status = MutableStateFlow(initialHost.lastKnownStatus)
val status: StateFlow<HostStatus> = _status.asStateFlow()
private val _serverStatus = MutableStateFlow<HermesServerStatus?>(null)
val serverStatus: StateFlow<HermesServerStatus?> = _serverStatus.asStateFlow()
val connectionState: StateFlow<ConnectionState> = gatewayClient.connectionState
private val _events = MutableSharedFlow<HostGatewayEvent>(extraBufferCapacity = 64)
val events: SharedFlow<HostGatewayEvent> = _events.asSharedFlow()
private var reconnectJob: Job? = null
private var autoReconnectEnabled = false
private var reconnectAttempt = 0
init {
scope.launch {
gatewayClient.events.collect { event ->
val hostEvent = HostGatewayEvent(hostId, event)
if (!_events.tryEmit(hostEvent)) {
_events.emit(hostEvent)
}
}
}
scope.launch {
gatewayClient.connectionState.collect { state ->
when (state) {
is ConnectionState.Connected -> {
reconnectAttempt = 0
reconnectJob?.cancel()
_status.value = HostStatus.ONLINE
updateLastSeen()
}
is ConnectionState.Connecting, is ConnectionState.Reconnecting -> {
_status.value = HostStatus.CONNECTING
}
is ConnectionState.AuthExpired -> {
autoReconnectEnabled = false
reconnectJob?.cancel()
_status.value = HostStatus.AUTH_EXPIRED
}
is ConnectionState.Failed -> {
_status.value = HostStatus.ERROR
if (autoReconnectEnabled) {
scheduleReconnect()
}
}
is ConnectionState.Disconnected -> {
if (_status.value != HostStatus.AUTH_EXPIRED && _status.value != HostStatus.AUTH_REQUIRED) {
_status.value = HostStatus.OFFLINE
}
if (autoReconnectEnabled) {
scheduleReconnect()
}
}
}
}
}
}
fun updateHost(newHost: HermesHost) {
_host.value = newHost
}
private fun updateLastSeen() {
val updated = _host.value.copy(
lastSeenAt = System.currentTimeMillis(),
lastKnownStatus = _status.value
)
_host.value = updated
}
suspend fun checkStatus(): Result<HermesServerStatus> {
val currentHost = _host.value
val result = restClient.getStatus(currentHost.baseUrl, currentHost.allowCleartext)
if (result.isSuccess) {
_serverStatus.value = result.getOrNull()
updateLastSeen()
}
return result
}
suspend fun connect(): Result<Unit> {
autoReconnectEnabled = true
return connectInternal()
}
private suspend fun connectInternal(): Result<Unit> {
val currentHost = _host.value
_status.value = HostStatus.CONNECTING
return try {
val statusResult = restClient.getStatus(currentHost.baseUrl, currentHost.allowCleartext)
val sStatus = statusResult.getOrNull() ?: HermesServerStatus()
_serverStatus.value = sStatus
var ticket: String? = null
if (sStatus.authRequired) {
var tokens = tokenVault.getTokens(currentHost.id.value)
if (tokens == null) {
_status.value = HostStatus.AUTH_REQUIRED
return Result.failure(IllegalStateException("Authentication required for ${currentHost.displayName}"))
}
val nowSeconds = System.currentTimeMillis() / 1000
val isExpiring = tokens.expiresAt > 0 && nowSeconds >= (tokens.expiresAt - 60)
if (isExpiring && tokens.refreshToken.isNotEmpty()) {
val refreshRes = restClient.refreshNativeToken(
baseUrl = currentHost.baseUrl,
refreshToken = tokens.refreshToken,
provider = tokens.provider,
allowCleartext = currentHost.allowCleartext
)
if (refreshRes.isSuccess) {
val newTokens = refreshRes.getOrThrow()
tokenVault.saveTokens(currentHost.id.value, newTokens)
tokens = newTokens
} else {
val errMsg = refreshRes.exceptionOrNull()?.message ?: ""
if (errMsg.contains("401") || errMsg.contains("session_expired") || errMsg.contains("invalid_grant")) {
tokenVault.clearTokens(currentHost.id.value)
_status.value = HostStatus.AUTH_EXPIRED
gatewayClient.setAuthExpired("Session expired for ${currentHost.displayName}")
return Result.failure(IllegalStateException("Session expired. Please sign in again."))
}
}
}
var ticketResult = restClient.mintWsTicket(
baseUrl = currentHost.baseUrl,
accessToken = tokens.accessToken,
allowCleartext = currentHost.allowCleartext
)
if (ticketResult.isFailure) {
val errMsg = ticketResult.exceptionOrNull()?.message ?: ""
if (errMsg.contains("401") && tokens.refreshToken.isNotEmpty()) {
val refreshRes = restClient.refreshNativeToken(
baseUrl = currentHost.baseUrl,
refreshToken = tokens.refreshToken,
provider = tokens.provider,
allowCleartext = currentHost.allowCleartext
)
if (refreshRes.isSuccess) {
val newTokens = refreshRes.getOrThrow()
tokenVault.saveTokens(currentHost.id.value, newTokens)
tokens = newTokens
ticketResult = restClient.mintWsTicket(
baseUrl = currentHost.baseUrl,
accessToken = tokens.accessToken,
allowCleartext = currentHost.allowCleartext
)
} else {
tokenVault.clearTokens(currentHost.id.value)
_status.value = HostStatus.AUTH_EXPIRED
gatewayClient.setAuthExpired("Session expired for ${currentHost.displayName}")
return Result.failure(IllegalStateException("Session expired. Please sign in again."))
}
}
if (ticketResult.isFailure) {
val finalErr = ticketResult.exceptionOrNull()
if (finalErr?.message?.contains("401") == true) {
tokenVault.clearTokens(currentHost.id.value)
_status.value = HostStatus.AUTH_EXPIRED
gatewayClient.setAuthExpired("Session expired for ${currentHost.displayName}")
return Result.failure(IllegalStateException("Session expired. Please sign in again."))
}
_status.value = HostStatus.ERROR
return Result.failure(finalErr ?: IOException("Failed to mint WebSocket ticket"))
}
}
ticket = ticketResult.getOrNull()
}
val wsUrl = convertHttpToWsUrl(currentHost.baseUrl)
gatewayClient.connect(
wsUrl = wsUrl,
ticket = ticket,
allowCleartext = currentHost.allowCleartext
)
Result.success(Unit)
} catch (e: Exception) {
_status.value = HostStatus.ERROR
Result.failure(e)
}
}
private fun scheduleReconnect() {
if (reconnectJob?.isActive == true) return
reconnectJob = scope.launch {
val baseDelay = min(30_000L, (1000L * (1 shl min(reconnectAttempt, 5))))
val jitter = Random.nextLong(0, 1000)
val totalDelay = baseDelay + jitter
reconnectAttempt++
delay(totalDelay)
try {
connectInternal()
} catch (_: Exception) {
}
}
}
fun disconnect() {
autoReconnectEnabled = false
reconnectJob?.cancel()
gatewayClient.disconnect()
_status.value = HostStatus.OFFLINE
}
fun close() {
disconnect()
scope.cancel()
}
private fun convertHttpToWsUrl(baseUrl: String): String {
val trimmed = baseUrl.trim().trimEnd('/')
val wsBase = when {
trimmed.startsWith("https://", ignoreCase = true) -> "wss://" + trimmed.substring(8)
trimmed.startsWith("http://", ignoreCase = true) -> "ws://" + trimmed.substring(7)
trimmed.startsWith("wss://", ignoreCase = true) || trimmed.startsWith("ws://", ignoreCase = true) -> trimmed
else -> "ws://$trimmed"
}
return when {
wsBase.endsWith("/api/ws") -> wsBase
wsBase.endsWith("/ws") -> wsBase.removeSuffix("/ws") + "/api/ws"
else -> "$wsBase/api/ws"
}
}
}

View file

@ -10,10 +10,13 @@ import kotlinx.serialization.json.Json
import java.util.concurrent.ConcurrentHashMap
interface TokenVault {
fun saveTokens(connectionId: String, tokens: NativeAuthTokens)
fun getTokens(connectionId: String): NativeAuthTokens?
fun clearTokens(connectionId: String)
fun getAllConnectionIds(): Set<String>
fun saveTokens(hostId: String, tokens: NativeAuthTokens)
fun getTokens(hostId: String): NativeAuthTokens?
fun clearTokens(hostId: String)
fun getAllHostIds(): Set<String>
// Backwards-compatible aliases
fun getAllConnectionIds(): Set<String> = getAllHostIds()
}
class EncryptedTokenVault(context: Context) : TokenVault {
@ -34,13 +37,13 @@ class EncryptedTokenVault(context: Context) : TokenVault {
throw SecurityException("Keystore encryption required for token storage", e)
}
override fun saveTokens(connectionId: String, tokens: NativeAuthTokens) {
override fun saveTokens(hostId: String, tokens: NativeAuthTokens) {
val serialized = json.encodeToString(tokens)
prefs.edit().putString("conn_$connectionId", serialized).apply()
prefs.edit().putString("conn_$hostId", serialized).apply()
}
override fun getTokens(connectionId: String): NativeAuthTokens? {
val raw = prefs.getString("conn_$connectionId", null) ?: return null
override fun getTokens(hostId: String): NativeAuthTokens? {
val raw = prefs.getString("conn_$hostId", null) ?: return null
return try {
json.decodeFromString<NativeAuthTokens>(raw)
} catch (e: Exception) {
@ -48,11 +51,11 @@ class EncryptedTokenVault(context: Context) : TokenVault {
}
}
override fun clearTokens(connectionId: String) {
prefs.edit().remove("conn_$connectionId").apply()
override fun clearTokens(hostId: String) {
prefs.edit().remove("conn_$hostId").apply()
}
override fun getAllConnectionIds(): Set<String> {
override fun getAllHostIds(): Set<String> {
return prefs.all.keys
.filter { it.startsWith("conn_") }
.map { it.removePrefix("conn_") }
@ -63,19 +66,19 @@ class EncryptedTokenVault(context: Context) : TokenVault {
class InMemoryTokenVault : TokenVault {
private val storage = ConcurrentHashMap<String, NativeAuthTokens>()
override fun saveTokens(connectionId: String, tokens: NativeAuthTokens) {
storage[connectionId] = tokens
override fun saveTokens(hostId: String, tokens: NativeAuthTokens) {
storage[hostId] = tokens
}
override fun getTokens(connectionId: String): NativeAuthTokens? {
return storage[connectionId]
override fun getTokens(hostId: String): NativeAuthTokens? {
return storage[hostId]
}
override fun clearTokens(connectionId: String) {
storage.remove(connectionId)
override fun clearTokens(hostId: String) {
storage.remove(hostId)
}
override fun getAllConnectionIds(): Set<String> {
override fun getAllHostIds(): Set<String> {
return storage.keys.toSet()
}
}

View file

@ -0,0 +1,93 @@
package app.hermes.mobile.core.storage
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface HostDao {
@Query("SELECT * FROM hosts ORDER BY displayName ASC")
fun getHostsFlow(): Flow<List<HostEntity>>
@Query("SELECT * FROM hosts ORDER BY displayName ASC")
suspend fun getHosts(): List<HostEntity>
@Query("SELECT * FROM hosts WHERE id = :hostId LIMIT 1")
suspend fun getHost(hostId: String): HostEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdateHost(host: HostEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertHosts(hosts: List<HostEntity>)
@Query("DELETE FROM hosts WHERE id = :hostId")
suspend fun deleteHost(hostId: String)
@Query("UPDATE hosts SET lastKnownStatus = :status, lastSeenAt = :lastSeenAt WHERE id = :hostId")
suspend fun updateHostStatus(hostId: String, status: String, lastSeenAt: Long)
}
@Dao
interface UnifiedSessionDao {
@Query("SELECT * FROM unified_sessions ORDER BY updatedAt DESC")
fun getSessionsFlow(): Flow<List<UnifiedSessionEntity>>
@Query("SELECT * FROM unified_sessions ORDER BY updatedAt DESC")
suspend fun getSessions(): List<UnifiedSessionEntity>
@Transaction
@Query("SELECT * FROM unified_sessions WHERE id = :sessionId LIMIT 1")
fun getSessionWithDetailsFlow(sessionId: String): Flow<UnifiedSessionWithDetails?>
@Transaction
@Query("SELECT * FROM unified_sessions WHERE id = :sessionId LIMIT 1")
suspend fun getSessionWithDetails(sessionId: String): UnifiedSessionWithDetails?
@Query("SELECT * FROM unified_messages WHERE sessionId = :sessionId ORDER BY createdAt ASC")
suspend fun getMessagesForSession(sessionId: String): List<UnifiedMessageEntity>
@Query("SELECT * FROM host_bindings WHERE sessionId = :sessionId")
suspend fun getBindingsForSession(sessionId: String): List<HostBindingEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSession(session: UnifiedSessionEntity)
@Update
suspend fun updateSession(session: UnifiedSessionEntity)
@Query("DELETE FROM unified_sessions WHERE id = :sessionId")
suspend fun deleteSession(sessionId: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdateBinding(binding: HostBindingEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdateBindings(bindings: List<HostBindingEntity>)
@Query("DELETE FROM host_bindings WHERE sessionId = :sessionId AND hostId = :hostId")
suspend fun deleteBinding(sessionId: String, hostId: String)
@Query("DELETE FROM host_bindings WHERE sessionId = :sessionId")
suspend fun deleteBindingsForSession(sessionId: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdateMessage(message: UnifiedMessageEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(messages: List<UnifiedMessageEntity>)
@Query("DELETE FROM unified_messages WHERE sessionId = :sessionId")
suspend fun deleteMessagesForSession(sessionId: String)
@Query("UPDATE unified_messages SET content = :content, isStreaming = :isStreaming, thinking = :thinking, toolsJson = :toolsJson WHERE id = :messageId")
suspend fun updateMessageContent(messageId: String, content: String, isStreaming: Boolean, thinking: String?, toolsJson: String?)
@Query("UPDATE unified_sessions SET activeHostId = :hostId, updatedAt = :updatedAt WHERE id = :sessionId")
suspend fun updateActiveHost(sessionId: String, hostId: String, updatedAt: Long)
@Query("UPDATE host_bindings SET syncedThroughMessageId = :syncedThroughMessageId, syncedAt = :syncedAt, state = :state WHERE sessionId = :sessionId AND hostId = :hostId")
suspend fun updateBindingSync(sessionId: String, hostId: String, syncedThroughMessageId: String?, syncedAt: Long, state: String)
@Query("UPDATE host_bindings SET state = :state WHERE sessionId = :sessionId AND hostId = :hostId")
suspend fun updateBindingState(sessionId: String, hostId: String, state: String)
}

View file

@ -0,0 +1,92 @@
package app.hermes.mobile.core.storage
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
@Entity(tableName = "hosts")
data class HostEntity(
@PrimaryKey val id: String,
val displayName: String,
val baseUrl: String,
val allowCleartext: Boolean = false,
val enabled: Boolean = true,
val lastSeenAt: Long = 0L,
val lastKnownStatus: String = "OFFLINE"
)
@Entity(tableName = "unified_sessions")
data class UnifiedSessionEntity(
@PrimaryKey val id: String,
val title: String,
val activeHostId: String,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis()
)
@Entity(
tableName = "host_bindings",
primaryKeys = ["sessionId", "hostId"],
foreignKeys = [
ForeignKey(
entity = UnifiedSessionEntity::class,
parentColumns = ["id"],
childColumns = ["sessionId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("sessionId"), Index("hostId")]
)
data class HostBindingEntity(
val sessionId: String,
val hostId: String,
val durableSessionId: String,
val runtimeSessionId: String,
val lastAttachedAt: Long = System.currentTimeMillis(),
val state: String = "NOT_CREATED",
val syncedThroughMessageId: String? = null,
val syncedAt: Long? = null
)
@Entity(
tableName = "unified_messages",
foreignKeys = [
ForeignKey(
entity = UnifiedSessionEntity::class,
parentColumns = ["id"],
childColumns = ["sessionId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("sessionId"), Index("createdAt")]
)
data class UnifiedMessageEntity(
@PrimaryKey val id: String,
val sessionId: String,
val role: String,
val content: String,
val hostId: String? = null,
val source: String = "HERMES",
val createdAt: Long = System.currentTimeMillis(),
val nativeMessageId: String? = null,
val thinking: String? = null,
val toolsJson: String? = null,
val isStreaming: Boolean = false
)
data class UnifiedSessionWithDetails(
@Embedded val session: UnifiedSessionEntity,
@Relation(
parentColumn = "id",
entityColumn = "sessionId"
)
val bindings: List<HostBindingEntity>,
@Relation(
parentColumn = "id",
entityColumn = "sessionId"
)
val messages: List<UnifiedMessageEntity>
)

View file

@ -0,0 +1,49 @@
package app.hermes.mobile.core.storage
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [
HostEntity::class,
UnifiedSessionEntity::class,
HostBindingEntity::class,
UnifiedMessageEntity::class
],
version = 1,
exportSchema = false
)
abstract class HermesDatabase : RoomDatabase() {
abstract fun hostDao(): HostDao
abstract fun unifiedSessionDao(): UnifiedSessionDao
companion object {
@Volatile
private var INSTANCE: HermesDatabase? = null
fun getInstance(context: Context): HermesDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
HermesDatabase::class.java,
"hermes_unified.db"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
fun createInMemory(context: Context): HermesDatabase {
return Room.inMemoryDatabaseBuilder(
context.applicationContext,
HermesDatabase::class.java
)
.allowMainThreadQueries()
.build()
}
}
}

View file

@ -0,0 +1,42 @@
package app.hermes.mobile.core.storage
import android.content.Context
import androidx.datastore.preferences.core.stringPreferencesKey
import app.hermes.mobile.core.model.HermesConnection
import app.hermes.mobile.core.model.HostStatus
import app.hermes.mobile.core.repository.dataStore
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.serialization.json.Json
object MigrationHelper {
private val json = Json { ignoreUnknownKeys = true }
private val connectionsKey = stringPreferencesKey("saved_connections")
suspend fun migrateLegacyConnections(context: Context, hostDao: HostDao) {
try {
val preferences = context.dataStore.data.firstOrNull() ?: return
val raw = preferences[connectionsKey] ?: return
if (raw.isBlank()) return
val legacyList = json.decodeFromString<List<HermesConnection>>(raw)
for (legacy in legacyList) {
val existing = hostDao.getHost(legacy.id)
if (existing == null) {
hostDao.insertOrUpdateHost(
HostEntity(
id = legacy.id,
displayName = legacy.name,
baseUrl = legacy.baseUrl,
allowCleartext = legacy.allowCleartext,
enabled = true,
lastSeenAt = legacy.createdAt,
lastKnownStatus = HostStatus.OFFLINE.name
)
)
}
}
} catch (_: Exception) {
// Ignore migration failure gracefully
}
}
}

View file

@ -0,0 +1,109 @@
package app.hermes.mobile.core.sync
import app.hermes.mobile.core.model.HermesHost
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.MessageRole
import app.hermes.mobile.core.model.UnifiedMessage
import app.hermes.mobile.core.model.UnifiedSession
data class SyncContextResult(
val contextPrompt: String,
val latestSyncedMessageId: String?,
val hasNewContext: Boolean
)
object UnifiedContextBuilder {
private val bearerTokenRegex = Regex("Bearer\\s+[a-zA-Z0-9_\\-\\.]+", RegexOption.IGNORE_CASE)
private val genericSecretRegex = Regex("(?i)(password|secret|api[_-]?key|token|auth_token)\\s*[:=]\\s*[\"']?([^\\s,\"';]+)[\"']?")
private val openAiKeyRegex = Regex("sk-[a-zA-Z0-9]{20,}")
private val githubTokenRegex = Regex("gh[pousr]_[a-zA-Z0-9]{20,}")
private val jwtTokenRegex = Regex("ey[A-Za-z0-9-_=]{10,}\\.[A-Za-z0-9-_=]{10,}\\.[A-Za-z0-9-_.+/=]{10,}")
fun sanitizeContent(content: String): String {
var sanitized = content
sanitized = bearerTokenRegex.replace(sanitized, "Bearer [REDACTED_TOKEN]")
sanitized = openAiKeyRegex.replace(sanitized, "[REDACTED_API_KEY]")
sanitized = githubTokenRegex.replace(sanitized, "[REDACTED_TOKEN]")
sanitized = jwtTokenRegex.replace(sanitized, "[REDACTED_JWT]")
sanitized = genericSecretRegex.replace(sanitized) { matchResult ->
"${matchResult.groupValues[1]}: [REDACTED_SECRET]"
}
return sanitized
}
fun buildContextSyncPayload(
session: UnifiedSession,
targetHost: HermesHost,
allHosts: Map<HermesHostId, HermesHost> = emptyMap(),
syncedThroughMessageId: String? = null
): SyncContextResult {
val timeline = session.timeline
if (timeline.isEmpty()) {
return SyncContextResult(contextPrompt = "", latestSyncedMessageId = null, hasNewContext = false)
}
val startIndex = if (syncedThroughMessageId != null) {
val idx = timeline.indexOfFirst { it.id == syncedThroughMessageId }
if (idx >= 0) idx + 1 else 0
} else {
0
}
val messagesToSync = timeline.subList(startIndex, timeline.size)
if (messagesToSync.isEmpty()) {
return SyncContextResult(
contextPrompt = "",
latestSyncedMessageId = syncedThroughMessageId,
hasNewContext = false
)
}
val latestMessageId = messagesToSync.last().id
val sb = StringBuilder()
sb.appendLine("[Unified Hermes Session Context Transfer]")
sb.appendLine("You are continuing a unified conversation that previously ran across Hermes host instances.")
sb.appendLine("Target Host: ${targetHost.displayName} (${targetHost.baseUrl})")
sb.appendLine("Session Title: ${session.title}")
sb.appendLine("--- Prior Conversation Turns ---")
for (msg in messagesToSync) {
val sanitized = sanitizeContent(msg.content)
when (msg.role) {
MessageRole.USER -> {
sb.appendLine("User: $sanitized")
}
MessageRole.ASSISTANT -> {
val hostLabel = if (msg.hostId != null) {
allHosts[msg.hostId]?.displayName ?: msg.hostId.value.take(8)
} else {
"Hermes"
}
sb.appendLine("[$hostLabel]: $sanitized")
if (msg.tools.isNotEmpty()) {
val toolNames = msg.tools.joinToString(", ") { "${it.name} (${it.status})" }
sb.appendLine("[$hostLabel Tool Activities: $toolNames]")
}
}
MessageRole.SYSTEM -> {
sb.appendLine("System: $sanitized")
}
}
}
sb.appendLine("--- End Prior Conversation ---")
sb.appendLine("Please continue assisting the user seamlessly using the above context.")
return SyncContextResult(
contextPrompt = sb.toString().trim(),
latestSyncedMessageId = latestMessageId,
hasNewContext = true
)
}
fun mergeContextWithPrompt(contextPrompt: String, userPrompt: String): String {
if (contextPrompt.isBlank()) return userPrompt
return "$contextPrompt\n\nUser request: $userPrompt"
}
}

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.Button
@ -33,13 +34,16 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.hermes.mobile.core.model.HermesApproval
import app.hermes.mobile.core.model.HostAttributedApproval
@Composable
fun ApprovalCard(
approval: HermesApproval,
attributedApproval: HostAttributedApproval,
onRespond: (choice: String, all: Boolean) -> Unit
) {
val approval = attributedApproval.approval
val hostDisplayName = attributedApproval.hostDisplayName
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
@ -50,20 +54,49 @@ fun ApprovalCard(
.border(1.5.dp, Color(0xFFF59E0B), RoundedCornerShape(16.dp))
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Shield,
contentDescription = null,
tint = Color(0xFFF59E0B),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Action Authorization Required",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = Color(0xFFF59E0B)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Shield,
contentDescription = null,
tint = Color(0xFFF59E0B),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Authorization Required",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = Color(0xFFF59E0B)
)
}
// Host Origin Badge
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFF38BDF8).copy(alpha = 0.2f))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Icon(
Icons.Default.Dns,
contentDescription = null,
tint = Color(0xFF38BDF8),
modifier = Modifier.size(12.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = hostDisplayName,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF38BDF8)
)
}
}
if (!approval.description.isNullOrBlank()) {

View file

@ -26,15 +26,19 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Lightbulb
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -59,25 +63,29 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.hermes.mobile.core.model.HermesMessage
import app.hermes.mobile.core.model.HermesHost
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.HostStatus
import app.hermes.mobile.core.model.MessageRole
import app.hermes.mobile.core.model.ToolActivity
import app.hermes.mobile.core.model.UnifiedMessage
import app.hermes.mobile.core.model.UnifiedMessageSource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
viewModel: ChatViewModel,
durableSessionId: String,
onNavigateBack: () -> Unit
) {
val messages by viewModel.messages.collectAsState()
val approvals by viewModel.activeApprovals.collectAsState()
val activeClarify by viewModel.activeClarify.collectAsState()
val sessionInfo by viewModel.sessionInfo.collectAsState()
val isExecuting by viewModel.isExecuting.collectAsState()
val uiState by viewModel.uiState.collectAsState()
val activeConn by viewModel.activeConnection.collectAsState()
val hosts by viewModel.hosts.collectAsState()
val currentSession by viewModel.currentSession.collectAsState()
val activeHost = hosts.find { it.id == currentSession?.activeHostId }
val listState = rememberLazyListState()
LaunchedEffect(messages.size, messages.lastOrNull()?.content?.length, approvals.size) {
@ -93,17 +101,72 @@ fun ChatScreen(
title = {
Column {
Text(
text = sessionInfo?.model ?: activeConn?.name ?: "Hermes Chat",
text = currentSession?.title?.ifEmpty { "Unified Chat" } ?: "Unified Chat",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Session: ${durableSessionId.take(8)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Active host selector chip
Box {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { viewModel.setHostDropdownExpanded(true) }
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
val isOnline = activeHost?.lastKnownStatus == HostStatus.ONLINE
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(if (isOnline) Color(0xFF10B981) else Color(0xFF94A3B8))
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = activeHost?.displayName ?: "Select Host",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.width(2.dp))
Icon(
Icons.Default.ExpandMore,
contentDescription = "Switch Host",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
DropdownMenu(
expanded = uiState.activeHostDropdownExpanded,
onDismissRequest = { viewModel.setHostDropdownExpanded(false) }
) {
hosts.forEach { host ->
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
val online = host.lastKnownStatus == HostStatus.ONLINE
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (online) Color(0xFF10B981) else Color(0xFF94A3B8))
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = host.displayName,
fontWeight = if (host.id == currentSession?.activeHostId) FontWeight.Bold else FontWeight.Normal
)
}
},
onClick = { viewModel.switchActiveHost(host.id) }
)
}
}
}
}
},
navigationIcon = {
@ -148,14 +211,19 @@ fun ChatScreen(
contentPadding = PaddingValues(vertical = 12.dp)
) {
items(messages, key = { it.id }) { message ->
MessageItem(message = message)
if (message.source == UnifiedMessageSource.TRANSFER) {
TransferSeparator(message = message)
} else {
val host = hosts.find { it.id == message.hostId }
MessageItem(message = message, hostDisplayName = host?.displayName)
}
}
items(approvals, key = { it.requestId }) { approval ->
items(approvals, key = { it.hostId.value + it.approval.requestId }) { approval ->
ApprovalCard(
approval = approval,
attributedApproval = approval,
onRespond = { choice, all ->
viewModel.respondApproval(approval.requestId, choice, all)
viewModel.respondApproval(approval.hostId, approval.approval.requestId, choice, all)
}
)
}
@ -163,13 +231,16 @@ fun ChatScreen(
if (isExecuting && messages.lastOrNull()?.isStreaming != true && approvals.isEmpty()) {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
val hostLabel = activeHost?.displayName ?: "Hermes"
Text(
"Hermes is thinking…",
"$hostLabel is thinking…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -180,8 +251,8 @@ fun ChatScreen(
if (activeClarify != null) {
ClarifyDialog(
request = activeClarify!!,
onDismiss = { viewModel.dismissClarify() },
attributedClarify = activeClarify!!,
onDismiss = { /* dismiss */ },
onSubmit = { value ->
viewModel.respondClarify(activeClarify!!, value)
}
@ -191,13 +262,69 @@ fun ChatScreen(
}
@Composable
fun MessageItem(message: HermesMessage) {
fun TransferSeparator(message: UnifiedMessage) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF38BDF8).copy(alpha = 0.12f))
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Sync,
contentDescription = null,
tint = Color(0xFF0284C7),
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = message.content,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF0284C7)
)
}
}
}
}
@Composable
fun MessageItem(message: UnifiedMessage, hostDisplayName: String?) {
val isUser = message.role == MessageRole.USER
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start
) {
// Host attribution badge for assistant responses
if (!isUser && hostDisplayName != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp, start = 4.dp)
) {
Icon(
Icons.Default.Dns,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = hostDisplayName,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
// Thinking Collapsible
if (!message.thinking.isNullOrBlank()) {
ThinkingSection(thinking = message.thinking)

View file

@ -2,14 +2,9 @@ package app.hermes.mobile.feature.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.hermes.mobile.core.model.ClarifyType
import app.hermes.mobile.core.model.DurableSessionId
import app.hermes.mobile.core.model.HermesApproval
import app.hermes.mobile.core.model.HermesClarifyRequest
import app.hermes.mobile.core.model.HermesMessage
import app.hermes.mobile.core.model.SessionInfo
import app.hermes.mobile.core.network.ConnectionState
import app.hermes.mobile.core.repository.HermesGatewayRepository
import app.hermes.mobile.core.model.*
import app.hermes.mobile.core.repository.UnifiedSessionRepository
import app.hermes.mobile.core.runtime.HermesConnectionManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -17,29 +12,59 @@ import kotlinx.coroutines.launch
data class ChatUiState(
val error: String? = null,
val inputText: String = ""
val inputText: String = "",
val activeHostDropdownExpanded: Boolean = false
)
class ChatViewModel(
private val gatewayRepo: HermesGatewayRepository
val sessionRepo: UnifiedSessionRepository,
val connectionManager: HermesConnectionManager,
private val sessionId: UnifiedSessionId
) : ViewModel() {
val messages: StateFlow<List<HermesMessage>> = gatewayRepo.messages
val activeApprovals: StateFlow<List<HermesApproval>> = gatewayRepo.activeApprovals
val activeClarify: StateFlow<HermesClarifyRequest?> = gatewayRepo.activeClarify
val sessionInfo: StateFlow<SessionInfo?> = gatewayRepo.sessionInfo
val isExecuting: StateFlow<Boolean> = gatewayRepo.isExecuting
val connectionState: StateFlow<ConnectionState> = gatewayRepo.connectionState
val activeConnection = gatewayRepo.activeConnection
val activeDurableId: StateFlow<DurableSessionId?> = gatewayRepo.activeDurableId
val hosts: StateFlow<List<HermesHost>> = connectionManager.hosts
val messages: StateFlow<List<UnifiedMessage>> = sessionRepo.getSessionMessages(sessionId)
val isExecuting: StateFlow<Boolean> = sessionRepo.getSessionExecuting(sessionId)
val activeApprovals: StateFlow<List<HostAttributedApproval>> = sessionRepo.activeApprovals
val activeClarify: StateFlow<HostAttributedClarify?> = sessionRepo.activeClarify
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private val _currentSession = MutableStateFlow<UnifiedSession?>(null)
val currentSession: StateFlow<UnifiedSession?> = _currentSession.asStateFlow()
init {
loadSession()
}
fun loadSession() {
viewModelScope.launch {
_currentSession.value = sessionRepo.getUnifiedSession(sessionId)
}
}
fun updateInputText(text: String) {
_uiState.value = _uiState.value.copy(inputText = text)
}
fun setHostDropdownExpanded(expanded: Boolean) {
_uiState.value = _uiState.value.copy(activeHostDropdownExpanded = expanded)
}
fun switchActiveHost(targetHostId: HermesHostId) {
viewModelScope.launch {
try {
sessionRepo.switchSessionActiveHost(sessionId, targetHostId)
connectionManager.switchActiveHost(targetHostId)
loadSession()
_uiState.value = _uiState.value.copy(activeHostDropdownExpanded = false)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.localizedMessage ?: "Failed to switch host")
}
}
}
fun submitPrompt() {
val text = _uiState.value.inputText.trim()
if (text.isEmpty()) return
@ -47,7 +72,8 @@ class ChatViewModel(
_uiState.value = _uiState.value.copy(inputText = "", error = null)
viewModelScope.launch {
try {
gatewayRepo.sendUserPrompt(text)
sessionRepo.sendPrompt(sessionId, text)
loadSession()
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = e.localizedMessage ?: "Failed to submit prompt"
@ -58,14 +84,14 @@ class ChatViewModel(
fun interruptSession() {
viewModelScope.launch {
gatewayRepo.interruptSession()
sessionRepo.interruptSession(sessionId)
}
}
fun respondApproval(requestId: String, choice: String, all: Boolean = false) {
fun respondApproval(hostId: HermesHostId, requestId: String, choice: String, all: Boolean = false) {
viewModelScope.launch {
try {
gatewayRepo.respondApproval(requestId, choice, all)
sessionRepo.respondApproval(hostId, requestId, choice, all)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = e.localizedMessage ?: "Failed to respond to approval"
@ -74,13 +100,15 @@ class ChatViewModel(
}
}
fun respondClarify(request: HermesClarifyRequest, answer: String) {
fun respondClarify(attributed: HostAttributedClarify, answer: String) {
viewModelScope.launch {
try {
when (request.promptType) {
ClarifyType.CLARIFY -> gatewayRepo.respondClarify(request.requestId, answer, request.questionId)
ClarifyType.SUDO -> gatewayRepo.respondSudo(request.requestId, answer)
ClarifyType.SECRET -> gatewayRepo.respondSecret(request.requestId, answer)
val hostId = attributed.hostId
val req = attributed.request
when (req.promptType) {
ClarifyType.CLARIFY -> sessionRepo.respondClarify(hostId, req.requestId, answer, req.questionId)
ClarifyType.SUDO -> sessionRepo.respondSudo(hostId, req.requestId, answer)
ClarifyType.SECRET -> sessionRepo.respondSecret(hostId, req.requestId, answer)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
@ -89,8 +117,4 @@ class ChatViewModel(
}
}
}
fun dismissClarify() {
// Can be cancelled or handled
}
}

View file

@ -1,11 +1,20 @@
package app.hermes.mobile.feature.chat
import androidx.compose.foundation.background
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
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.AlertDialog
@ -20,21 +29,27 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.hermes.mobile.core.model.ClarifyType
import app.hermes.mobile.core.model.HermesClarifyRequest
import app.hermes.mobile.core.model.HostAttributedClarify
@Composable
fun ClarifyDialog(
request: HermesClarifyRequest,
attributedClarify: HostAttributedClarify,
onDismiss: () -> Unit,
onSubmit: (value: String) -> Unit
) {
var input by remember { mutableStateOf("") }
val request = attributedClarify.request
val hostDisplayName = attributedClarify.hostDisplayName
val isMasked = request.promptType == ClarifyType.SUDO || request.promptType == ClarifyType.SECRET
val title = when (request.promptType) {
@ -52,7 +67,33 @@ fun ClarifyDialog(
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
title = { Text(title, fontWeight = FontWeight.Bold) },
title = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Icon(
Icons.Default.Dns,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(12.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = hostDisplayName,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(

View file

@ -0,0 +1,456 @@
package app.hermes.mobile.feature.hosts
import androidx.compose.foundation.background
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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
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.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.hermes.mobile.core.model.HermesHost
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.HostStatus
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HostsScreen(
viewModel: HostsViewModel,
onNavigateBack: () -> Unit,
onNavigateToNativeSessions: (HermesHostId) -> Unit
) {
val context = LocalContext.current
val hosts by viewModel.hosts.collectAsState()
val uiState by viewModel.uiState.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Hermes Hosts", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { showAddDialog = true },
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(Icons.Default.Add, contentDescription = "Add Host")
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
if (uiState.authError != null) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = uiState.authError ?: "",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
if (hosts.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Dns,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"No Hermes hosts registered",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Tap '+' to add a workstation, server, or cloud host.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize()
) {
items(hosts, key = { it.id.value }) { host ->
val isAuth = viewModel.isHostAuthenticated(host.id)
HostCard(
host = host,
isAuthenticated = isAuth,
isAuthenticating = uiState.isAuthenticating,
onConnect = { viewModel.connectHost(host.id) },
onDisconnect = { viewModel.disconnectHost(host.id) },
onSignIn = {
viewModel.startSignIn(context, host) {
viewModel.connectHost(host.id)
}
},
onOpenNativeSessions = { onNavigateToNativeSessions(host.id) },
onDelete = { viewModel.removeHost(host.id) }
)
}
}
}
}
if (showAddDialog) {
AddHostDialog(
uiState = uiState,
onDismiss = { showAddDialog = false },
onTest = { url, cleartext -> viewModel.testHostConnection(url, cleartext) },
onSave = { name, url, cleartext ->
viewModel.saveHost(name, url, cleartext)
showAddDialog = false
}
)
}
}
}
@Composable
fun HostCard(
host: HermesHost,
isAuthenticated: Boolean,
isAuthenticating: Boolean,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onSignIn: () -> Unit,
onOpenNativeSessions: () -> Unit,
onDelete: () -> Unit
) {
val isOnline = host.lastKnownStatus == HostStatus.ONLINE
val isConnecting = host.lastKnownStatus == HostStatus.CONNECTING
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = if (isOnline) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f)
else MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = host.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = host.baseUrl,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (host.allowCleartext) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFFF59E0B).copy(alpha = 0.2f))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text("LAN / HTTP", fontSize = 10.sp, color = Color(0xFFD97706), fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.width(6.dp))
}
IconButton(onClick = onOpenNativeSessions) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = "Native Sessions")
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val badgeColor = when (host.lastKnownStatus) {
HostStatus.ONLINE -> Color(0xFF10B981)
HostStatus.CONNECTING -> Color(0xFF38BDF8)
HostStatus.AUTH_REQUIRED, HostStatus.AUTH_EXPIRED -> Color(0xFFF59E0B)
HostStatus.ERROR -> Color(0xFFEF4444)
HostStatus.OFFLINE -> Color(0xFF94A3B8)
}
val badgeText = when (host.lastKnownStatus) {
HostStatus.ONLINE -> "Online"
HostStatus.CONNECTING -> "Connecting…"
HostStatus.AUTH_REQUIRED -> "Auth Required"
HostStatus.AUTH_EXPIRED -> "Auth Expired"
HostStatus.ERROR -> "Error"
HostStatus.OFFLINE -> if (isAuthenticated) "Ready (Auth Saved)" else "Offline"
}
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(badgeColor)
)
Spacer(modifier = Modifier.width(6.dp))
Text(badgeText, style = MaterialTheme.typography.bodySmall, color = badgeColor, fontWeight = FontWeight.Medium)
}
Row {
if (host.lastKnownStatus == HostStatus.AUTH_REQUIRED || host.lastKnownStatus == HostStatus.AUTH_EXPIRED || !isAuthenticated) {
OutlinedButton(
onClick = onSignIn,
enabled = !isAuthenticating,
shape = RoundedCornerShape(8.dp)
) {
if (isAuthenticating) {
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(4.dp))
} else {
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(4.dp))
}
Text("Sign In", fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(8.dp))
}
if (isOnline) {
OutlinedButton(
onClick = onDisconnect,
shape = RoundedCornerShape(8.dp)
) {
Text("Disconnect", fontSize = 12.sp)
}
} else {
Button(
onClick = onConnect,
enabled = !isConnecting,
shape = RoundedCornerShape(8.dp)
) {
if (isConnecting) {
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp, color = Color.White)
Spacer(modifier = Modifier.width(4.dp))
}
Text("Connect", fontSize = 12.sp)
}
}
}
}
}
}
}
@Composable
fun AddHostDialog(
uiState: HostsUiState,
onDismiss: () -> Unit,
onTest: (String, Boolean) -> Unit,
onSave: (String, String, Boolean) -> Unit
) {
var name by remember { mutableStateOf("") }
var baseUrl by remember { mutableStateOf("http://10.0.2.2:9119") }
var allowCleartext by remember { mutableStateOf(true) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add Hermes Host", fontWeight = FontWeight.Bold) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Host Name (e.g. Linux Server, Office PC)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it },
label = { Text("Base URL (http://... or https://...)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text("Allow Cleartext HTTP (LAN)", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
Text(
"Permits unencrypted local network traffic.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = allowCleartext,
onCheckedChange = { allowCleartext = it }
)
}
if (allowCleartext) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"⚠️ Security Notice: Plain HTTP is unencrypted. Use only on trusted private LANs.",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFD97706)
)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = { onTest(baseUrl, allowCleartext) },
enabled = !uiState.isTesting && baseUrl.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
if (uiState.isTesting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
Text("Testing…")
} else {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Test Connection (/api/status)")
}
}
if (uiState.testStatus != null) {
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Color(0xFF10B981), modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(
"Status OK (v${uiState.testStatus.version ?: "1.0"}, Auth: ${if (uiState.testStatus.authRequired) "Required" else "None"})",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF10B981)
)
}
}
if (uiState.testError != null) {
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(
uiState.testError ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
},
confirmButton = {
Button(
onClick = {
if (baseUrl.isNotBlank()) {
onSave(name, baseUrl, allowCleartext)
}
},
enabled = baseUrl.isNotBlank()
) {
Text("Save Host")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View file

@ -0,0 +1,118 @@
package app.hermes.mobile.feature.hosts
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.hermes.mobile.core.auth.PkceLoopbackAuthManager
import app.hermes.mobile.core.model.HermesHost
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.HermesServerStatus
import app.hermes.mobile.core.model.HostStatus
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.runtime.HermesConnectionManager
import app.hermes.mobile.core.security.TokenVault
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID
data class HostsUiState(
val isTesting: Boolean = false,
val testStatus: HermesServerStatus? = null,
val testError: String? = null,
val isAuthenticating: Boolean = false,
val authError: String? = null
)
class HostsViewModel(
val connectionManager: HermesConnectionManager,
val tokenVault: TokenVault,
val restClient: HermesRestClient = HermesRestClient(),
val pkceAuthManager: PkceLoopbackAuthManager = PkceLoopbackAuthManager(restClient, tokenVault)
) : ViewModel() {
val hosts: StateFlow<List<HermesHost>> = connectionManager.hosts
val activeHostId: StateFlow<HermesHostId?> = connectionManager.activeHostId
private val _uiState = MutableStateFlow(HostsUiState())
val uiState: StateFlow<HostsUiState> = _uiState.asStateFlow()
fun testHostConnection(baseUrl: String, allowCleartext: Boolean) {
_uiState.value = _uiState.value.copy(isTesting = true, testStatus = null, testError = null)
viewModelScope.launch {
val result = restClient.getStatus(baseUrl, allowCleartext)
if (result.isSuccess) {
_uiState.value = _uiState.value.copy(isTesting = false, testStatus = result.getOrNull())
} else {
_uiState.value = _uiState.value.copy(
isTesting = false,
testError = result.exceptionOrNull()?.message ?: "Failed to reach host"
)
}
}
}
fun saveHost(name: String, baseUrl: String, allowCleartext: Boolean) {
val host = HermesHost(
id = HermesHostId(UUID.randomUUID().toString()),
displayName = name.ifBlank { "Hermes Host" },
baseUrl = baseUrl,
allowCleartext = allowCleartext,
enabled = true,
lastSeenAt = System.currentTimeMillis(),
lastKnownStatus = HostStatus.OFFLINE
)
viewModelScope.launch {
connectionManager.addHost(host)
}
}
fun removeHost(hostId: HermesHostId) {
viewModelScope.launch {
connectionManager.removeHost(hostId)
}
}
fun connectHost(hostId: HermesHostId, onConnected: (() -> Unit)? = null) {
viewModelScope.launch {
val res = connectionManager.connectHost(hostId)
if (res.isSuccess) {
onConnected?.invoke()
} else {
_uiState.value = _uiState.value.copy(
authError = res.exceptionOrNull()?.message
)
}
}
}
fun disconnectHost(hostId: HermesHostId) {
connectionManager.disconnectHost(hostId)
}
fun isHostAuthenticated(hostId: HermesHostId): Boolean {
return tokenVault.getTokens(hostId.value) != null
}
fun startSignIn(context: Context, host: HermesHost, onCompleted: (() -> Unit)? = null) {
_uiState.value = _uiState.value.copy(isAuthenticating = true, authError = null)
viewModelScope.launch {
val result = pkceAuthManager.startAuthFlow(
context = context,
connectionId = host.id.value,
baseUrl = host.baseUrl,
allowCleartext = host.allowCleartext
)
if (result.isSuccess) {
_uiState.value = _uiState.value.copy(isAuthenticating = false)
onCompleted?.invoke()
} else {
_uiState.value = _uiState.value.copy(
isAuthenticating = false,
authError = result.exceptionOrNull()?.message ?: "Authentication failed"
)
}
}
}
}

View file

@ -0,0 +1,213 @@
package app.hermes.mobile.feature.native_sessions
import androidx.compose.foundation.background
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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.hermes.mobile.core.model.SessionSummary
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NativeSessionsScreen(
viewModel: NativeSessionsViewModel,
onNavigateBack: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val host = viewModel.host
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
text = host?.displayName ?: "Native Host Sessions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = host?.baseUrl ?: "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.loadSessions() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
if (uiState.isLoading && uiState.sessions.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (uiState.sessions.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Forum,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"No native sessions found",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize()
) {
items(uiState.sessions, key = { it.id.value }) { session ->
NativeSessionCard(session = session)
}
}
}
}
}
}
@Composable
fun NativeSessionCard(session: SessionSummary) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.AutoMirrored.Filled.Chat,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = session.title.ifEmpty { "Session ${session.id.value.take(8)}" },
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(
text = session.source,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
if (session.preview.isNotBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = session.preview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "${session.messageCount} msgs • ${session.id.value.take(8)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
Text(
text = formatTimestamp(session.startedAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}
}
private fun formatTimestamp(timestamp: Long): String {
if (timestamp <= 0) return ""
val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}

View file

@ -0,0 +1,53 @@
package app.hermes.mobile.feature.native_sessions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.hermes.mobile.core.model.HermesHost
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.SessionSummary
import app.hermes.mobile.core.runtime.HermesConnectionManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class NativeSessionsUiState(
val isLoading: Boolean = false,
val sessions: List<SessionSummary> = emptyList(),
val error: String? = null
)
class NativeSessionsViewModel(
val connectionManager: HermesConnectionManager,
val hostId: HermesHostId
) : ViewModel() {
val host: HermesHost? get() = connectionManager.hosts.value.find { it.id == hostId }
private val _uiState = MutableStateFlow(NativeSessionsUiState())
val uiState: StateFlow<NativeSessionsUiState> = _uiState.asStateFlow()
init {
loadSessions()
}
fun loadSessions() {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
viewModelScope.launch {
try {
val runtime = connectionManager.getRuntime(hostId)
if (runtime == null) {
_uiState.value = _uiState.value.copy(isLoading = false, error = "Host not found")
return@launch
}
val list = runtime.gatewayClient.listSessions()
_uiState.value = _uiState.value.copy(isLoading = false, sessions = list)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.localizedMessage ?: "Failed to load native sessions"
)
}
}
}
}

View file

@ -0,0 +1,392 @@
package app.hermes.mobile.feature.unified_sessions
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.hermes.mobile.core.model.HermesHost
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.HostStatus
import app.hermes.mobile.core.model.UnifiedSession
import app.hermes.mobile.core.model.UnifiedSessionId
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UnifiedSessionsScreen(
viewModel: UnifiedSessionsViewModel,
onNavigateToChat: (UnifiedSessionId) -> Unit,
onNavigateToHosts: () -> Unit
) {
val sessions by viewModel.sessions.collectAsState()
val hosts by viewModel.hosts.collectAsState()
val activeHostId by viewModel.activeHostId.collectAsState()
var showCreateDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("Unified Sessions", fontWeight = FontWeight.Bold)
val hostsCount = hosts.size
val onlineCount = hosts.count { it.lastKnownStatus == HostStatus.ONLINE }
Text(
text = "$onlineCount/$hostsCount hosts online",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
actions = {
IconButton(onClick = onNavigateToHosts) {
Icon(Icons.Default.Dns, contentDescription = "Manage Hosts")
}
}
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { showCreateDialog = true },
containerColor = MaterialTheme.colorScheme.primary
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("New Session", fontWeight = FontWeight.Bold)
}
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
if (sessions.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Forum,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"No unified sessions yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Start a session that can roam seamlessly across all your Hermes hosts.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize()
) {
items(sessions, key = { it.id.value }) { session ->
val activeHost = hosts.find { it.id == session.activeHostId }
UnifiedSessionCard(
session = session,
activeHost = activeHost,
onClick = { onNavigateToChat(session.id) },
onDelete = { viewModel.deleteSession(session.id) }
)
}
}
}
}
if (showCreateDialog) {
CreateSessionDialog(
hosts = hosts,
defaultHostId = activeHostId,
onDismiss = { showCreateDialog = false },
onConfirm = { title, hostId ->
viewModel.createNewSession(title, hostId) { newId ->
showCreateDialog = false
onNavigateToChat(newId)
}
}
)
}
}
}
@Composable
fun UnifiedSessionCard(
session: UnifiedSession,
activeHost: HermesHost?,
onClick: () -> Unit,
onDelete: () -> Unit
) {
Card(
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.AutoMirrored.Filled.Chat,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = session.title.ifEmpty { "Unified Session" },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
IconButton(
onClick = onDelete,
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.size(18.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Active Host & Attached Hosts row
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
// Active host badge
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
val isOnline = activeHost?.lastKnownStatus == HostStatus.ONLINE
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(if (isOnline) Color(0xFF10B981) else Color(0xFF94A3B8))
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = activeHost?.displayName ?: session.activeHostId.value.take(8),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
// Attached hosts counter
val attachedCount = session.bindings.size.coerceAtLeast(1)
Text(
text = "$attachedCount attached • ${session.timeline.size} msgs",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "ID: ${session.id.value.take(8)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Text(
text = formatTimestamp(session.updatedAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateSessionDialog(
hosts: List<HermesHost>,
defaultHostId: HermesHostId?,
onDismiss: () -> Unit,
onConfirm: (title: String, initialHostId: HermesHostId?) -> Unit
) {
var title by remember { mutableStateOf("") }
var selectedHostId by remember { mutableStateOf(defaultHostId ?: hosts.firstOrNull()?.id) }
var expandedDropdown by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Start Unified Session", fontWeight = FontWeight.Bold) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Session Title") },
placeholder = { Text("e.g. Android Development, Data Analysis") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(14.dp))
if (hosts.isNotEmpty()) {
Text("Initial Active Host:", style = MaterialTheme.typography.labelMedium)
Spacer(modifier = Modifier.height(6.dp))
ExposedDropdownMenuBox(
expanded = expandedDropdown,
onExpandedChange = { expandedDropdown = !expandedDropdown }
) {
val currentHostName = hosts.find { it.id == selectedHostId }?.displayName ?: "Select host"
OutlinedTextField(
value = currentHostName,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedDropdown) },
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expandedDropdown,
onDismissRequest = { expandedDropdown = false }
) {
hosts.forEach { host ->
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
val isOnline = host.lastKnownStatus == HostStatus.ONLINE
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (isOnline) Color(0xFF10B981) else Color(0xFF94A3B8))
)
Spacer(modifier = Modifier.width(8.dp))
Text(host.displayName)
}
},
onClick = {
selectedHostId = host.id
expandedDropdown = false
}
)
}
}
}
}
}
},
confirmButton = {
Button(
onClick = {
onConfirm(title.ifBlank { "Session" }, selectedHostId)
}
) {
Text("Create")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
private fun formatTimestamp(timestamp: Long): String {
if (timestamp <= 0) return ""
val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}

View file

@ -0,0 +1,57 @@
package app.hermes.mobile.feature.unified_sessions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.hermes.mobile.core.model.HermesHost
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.UnifiedSession
import app.hermes.mobile.core.model.UnifiedSessionId
import app.hermes.mobile.core.repository.UnifiedSessionRepository
import app.hermes.mobile.core.runtime.HermesConnectionManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class UnifiedSessionsUiState(
val isLoading: Boolean = false,
val error: String? = null
)
class UnifiedSessionsViewModel(
val sessionRepo: UnifiedSessionRepository,
val connectionManager: HermesConnectionManager
) : ViewModel() {
val sessions: StateFlow<List<UnifiedSession>> = sessionRepo.sessions
val hosts: StateFlow<List<HermesHost>> = connectionManager.hosts
val activeHostId: StateFlow<HermesHostId?> = connectionManager.activeHostId
private val _uiState = MutableStateFlow(UnifiedSessionsUiState())
val uiState: StateFlow<UnifiedSessionsUiState> = _uiState.asStateFlow()
fun createNewSession(
title: String = "New Session",
initialHostId: HermesHostId? = null,
onCreated: (UnifiedSessionId) -> Unit
) {
viewModelScope.launch {
try {
val session = sessionRepo.createUnifiedSession(title, initialHostId)
onCreated(session.id)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.localizedMessage ?: "Failed to create session")
}
}
}
fun deleteSession(sessionId: UnifiedSessionId) {
viewModelScope.launch {
try {
sessionRepo.deleteUnifiedSession(sessionId)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.localizedMessage ?: "Failed to delete session")
}
}
}
}

View file

@ -0,0 +1,125 @@
package app.hermes.mobile.core.repository
import app.hermes.mobile.core.model.*
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.network.JsonRpcGatewayClient
import app.hermes.mobile.core.runtime.HermesConnectionManager
import app.hermes.mobile.core.runtime.HermesHostRuntime
import app.hermes.mobile.core.security.InMemoryTokenVault
import app.hermes.mobile.core.storage.FakeHostDao
import app.hermes.mobile.core.storage.FakeUnifiedSessionDao
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ApprovalRoutingTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var hostDao: FakeHostDao
private lateinit var sessionDao: FakeUnifiedSessionDao
private lateinit var tokenVault: InMemoryTokenVault
private lateinit var connectionManager: HermesConnectionManager
private lateinit var sessionRepo: UnifiedSessionRepository
private val host1Id = HermesHostId("server-prod")
private val host2Id = HermesHostId("server-dev")
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
hostDao = FakeHostDao()
sessionDao = FakeUnifiedSessionDao()
tokenVault = InMemoryTokenVault()
connectionManager = HermesConnectionManager(
hostDao = hostDao,
tokenVault = tokenVault,
scope = CoroutineScope(testDispatcher)
)
sessionRepo = UnifiedSessionRepository(
connectionManager = connectionManager,
sessionDao = sessionDao,
scope = CoroutineScope(testDispatcher)
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testApprovalAttributionAndRemoval() = runTest(testDispatcher) {
val host1 = HermesHost(id = host1Id, displayName = "Prod Server", baseUrl = "http://prod:9119")
val host2 = HermesHost(id = host2Id, displayName = "Dev Server", baseUrl = "http://dev:9119")
connectionManager.addHost(host1)
connectionManager.addHost(host2)
testScheduler.advanceUntilIdle()
val runtime1 = connectionManager.getRuntime(host1Id)
val runtime2 = connectionManager.getRuntime(host2Id)
assertNotNull(runtime1)
assertNotNull(runtime2)
// Simulate approval request from Prod Server
val prodEventJson = buildJsonObject {
put("method", "event")
put("params", buildJsonObject {
put("event", "approval.request")
put("request_id", "req_prod_1")
put("command", "systemctl restart nginx")
put("description", "Restart web server")
})
}
runtime1?.gatewayClient?.handleIncomingMessage(prodEventJson.toString())
// Simulate approval request from Dev Server
val devEventJson = buildJsonObject {
put("method", "event")
put("params", buildJsonObject {
put("event", "approval.request")
put("request_id", "req_dev_1")
put("command", "docker compose down")
put("description", "Stop containers")
})
}
runtime2?.gatewayClient?.handleIncomingMessage(devEventJson.toString())
testScheduler.advanceUntilIdle()
val approvals = sessionRepo.activeApprovals.value
assertEquals(2, approvals.size)
val prodApproval = approvals.find { it.hostId == host1Id }
val devApproval = approvals.find { it.hostId == host2Id }
assertNotNull(prodApproval)
assertNotNull(devApproval)
assertEquals("Prod Server", prodApproval?.hostDisplayName)
assertEquals("Dev Server", devApproval?.hostDisplayName)
assertEquals("systemctl restart nginx", prodApproval?.approval?.command)
// Responding to prod approval removes it while keeping dev approval
sessionRepo.respondApproval(host1Id, "req_prod_1", "once", false)
testScheduler.advanceUntilIdle()
val remainingApprovals = sessionRepo.activeApprovals.value
assertEquals(1, remainingApprovals.size)
assertEquals(host2Id, remainingApprovals.first().hostId)
}
}

View file

@ -0,0 +1,203 @@
package app.hermes.mobile.core.repository
import app.hermes.mobile.core.model.*
import app.hermes.mobile.core.network.ConnectionState
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.network.JsonRpcGatewayClient
import app.hermes.mobile.core.runtime.HermesConnectionManager
import app.hermes.mobile.core.security.InMemoryTokenVault
import app.hermes.mobile.core.storage.FakeHostDao
import app.hermes.mobile.core.storage.FakeUnifiedSessionDao
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class UnifiedSessionRepositoryTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var hostDao: FakeHostDao
private lateinit var sessionDao: FakeUnifiedSessionDao
private lateinit var tokenVault: InMemoryTokenVault
private lateinit var connectionManager: HermesConnectionManager
private lateinit var repository: UnifiedSessionRepository
private val host1Id = HermesHostId("host-a")
private val host2Id = HermesHostId("host-b")
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
hostDao = FakeHostDao()
sessionDao = FakeUnifiedSessionDao()
tokenVault = InMemoryTokenVault()
connectionManager = HermesConnectionManager(
hostDao = hostDao,
tokenVault = tokenVault,
scope = CoroutineScope(testDispatcher)
)
repository = UnifiedSessionRepository(
connectionManager = connectionManager,
sessionDao = sessionDao,
scope = CoroutineScope(testDispatcher)
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testCreateAndSwitchUnifiedSession() = runTest(testDispatcher) {
val hostA = HermesHost(id = host1Id, displayName = "Host A", baseUrl = "http://host-a:9119")
val hostB = HermesHost(id = host2Id, displayName = "Host B", baseUrl = "http://host-b:9119")
connectionManager.addHost(hostA)
connectionManager.addHost(hostB)
testScheduler.advanceUntilIdle()
val session = repository.createUnifiedSession(title = "Multi-Host Project", initialHostId = host1Id)
testScheduler.advanceUntilIdle()
assertEquals(host1Id, session.activeHostId)
assertEquals("Multi-Host Project", session.title)
// Switch active host to Host B
repository.switchSessionActiveHost(session.id, host2Id)
testScheduler.advanceUntilIdle()
val updated = repository.getUnifiedSession(session.id)
assertEquals(host2Id, updated?.activeHostId)
}
@Test
fun testStreamingEventAttribution() = runTest(testDispatcher) {
val hostA = HermesHost(id = host1Id, displayName = "Host A", baseUrl = "http://host-a:9119")
connectionManager.addHost(hostA)
testScheduler.advanceUntilIdle()
val session = repository.createUnifiedSession(title = "Streaming Test", initialHostId = host1Id)
testScheduler.advanceUntilIdle()
val runtimeA = connectionManager.getRuntime(host1Id)
assertNotNull(runtimeA)
// Stream start event
val msgStart = buildJsonObject {
put("method", "event")
put("params", buildJsonObject {
put("event", "message.start")
put("message_id", "msg_stream_1")
put("role", "assistant")
})
}
runtimeA?.gatewayClient?.handleIncomingMessage(msgStart.toString())
// Stream delta 1
val msgDelta1 = buildJsonObject {
put("method", "event")
put("params", buildJsonObject {
put("event", "message.delta")
put("message_id", "msg_stream_1")
put("delta", "Hello ")
})
}
runtimeA?.gatewayClient?.handleIncomingMessage(msgDelta1.toString())
// Stream delta 2
val msgDelta2 = buildJsonObject {
put("method", "event")
put("params", buildJsonObject {
put("event", "message.delta")
put("message_id", "msg_stream_1")
put("delta", "from Multi-Hermes!")
})
}
runtimeA?.gatewayClient?.handleIncomingMessage(msgDelta2.toString())
// Stream complete
val msgComplete = buildJsonObject {
put("method", "event")
put("params", buildJsonObject {
put("event", "message.complete")
put("message_id", "msg_stream_1")
put("content", "Hello from Multi-Hermes!")
})
}
runtimeA?.gatewayClient?.handleIncomingMessage(msgComplete.toString())
testScheduler.advanceUntilIdle()
val messages = repository.getSessionMessages(session.id).value
assertTrue(messages.any { it.id == "msg_stream_1" })
val streamMsg = messages.find { it.id == "msg_stream_1" }
assertEquals("Hello from Multi-Hermes!", streamMsg?.content)
assertEquals(host1Id, streamMsg?.hostId)
assertFalse(streamMsg?.isStreaming ?: true)
}
@Test
fun testBackgroundHostExecutionEventHandling() = runTest(testDispatcher) {
val hostA = HermesHost(id = host1Id, displayName = "Host A", baseUrl = "http://host-a:9119")
val hostB = HermesHost(id = host2Id, displayName = "Host B", baseUrl = "http://host-b:9119")
connectionManager.addHost(hostA)
connectionManager.addHost(hostB)
testScheduler.advanceUntilIdle()
val session = repository.createUnifiedSession(title = "Background Session", initialHostId = host1Id)
testScheduler.advanceUntilIdle()
val runtimeA = connectionManager.getRuntime(host1Id)
val runtimeB = connectionManager.getRuntime(host2Id)
// Host A starts long tool operation
val toolStart = buildJsonObject {
put("method", "event")
put("params", buildJsonObject {
put("event", "tool.start")
put("tool_id", "tool_bg_1")
put("name", "heavy_build_task")
})
}
runtimeA?.gatewayClient?.handleIncomingMessage(toolStart.toString())
// User switches session active host to Host B
repository.switchSessionActiveHost(session.id, host2Id)
testScheduler.advanceUntilIdle()
// Host A finishes tool in background
val toolComplete = buildJsonObject {
put("method", "event")
put("params", buildJsonObject {
put("event", "tool.complete")
put("tool_id", "tool_bg_1")
put("result", "Build successful in 42s")
put("is_error", false)
})
}
runtimeA?.gatewayClient?.handleIncomingMessage(toolComplete.toString())
testScheduler.advanceUntilIdle()
val messages = repository.getSessionMessages(session.id).value
val msgWithTool = messages.find { it.tools.any { t -> t.id == "tool_bg_1" } }
assertNotNull(msgWithTool)
assertEquals("completed", msgWithTool?.tools?.first()?.status)
assertEquals("Build successful in 42s", msgWithTool?.tools?.first()?.result)
assertEquals(host1Id, msgWithTool?.hostId)
}
}

View file

@ -0,0 +1,99 @@
package app.hermes.mobile.core.runtime
import app.hermes.mobile.core.model.HermesHost
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.HostStatus
import app.hermes.mobile.core.network.ConnectionState
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.network.JsonRpcGatewayClient
import app.hermes.mobile.core.security.InMemoryTokenVault
import app.hermes.mobile.core.storage.FakeHostDao
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MultiHostConcurrencyTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var hostDao: FakeHostDao
private lateinit var tokenVault: InMemoryTokenVault
private lateinit var connectionManager: HermesConnectionManager
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
hostDao = FakeHostDao()
tokenVault = InMemoryTokenVault()
connectionManager = HermesConnectionManager(
hostDao = hostDao,
tokenVault = tokenVault,
scope = CoroutineScope(testDispatcher)
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testMultiHostIsolationAndDisconnect() = runTest(testDispatcher) {
val hostA = HermesHost(
id = HermesHostId("host-a"),
displayName = "Host Alpha",
baseUrl = "http://10.0.0.1:9119",
lastKnownStatus = HostStatus.ONLINE
)
val hostB = HermesHost(
id = HermesHostId("host-b"),
displayName = "Host Beta",
baseUrl = "http://10.0.0.2:9119",
lastKnownStatus = HostStatus.ONLINE
)
connectionManager.addHost(hostA)
connectionManager.addHost(hostB)
testScheduler.advanceUntilIdle()
val runtimeA = connectionManager.getRuntime(hostA.id)
val runtimeB = connectionManager.getRuntime(hostB.id)
assertNotNull(runtimeA)
assertNotNull(runtimeB)
// Disconnecting Host A must not affect Host B
runtimeA?.disconnect()
testScheduler.advanceUntilIdle()
assertEquals(HostStatus.OFFLINE, runtimeA?.status?.value)
assertEquals(ConnectionState.Disconnected, runtimeA?.connectionState?.value)
// Runtime B remains unchanged
assertNotNull(connectionManager.getRuntime(hostB.id))
}
@Test
fun testActiveHostSwitching() = runTest(testDispatcher) {
val host1 = HermesHost(id = HermesHostId("h1"), displayName = "H1", baseUrl = "http://1.1.1.1")
val host2 = HermesHost(id = HermesHostId("h2"), displayName = "H2", baseUrl = "http://2.2.2.2")
connectionManager.addHost(host1)
connectionManager.addHost(host2)
testScheduler.advanceUntilIdle()
assertEquals(HermesHostId("h1"), connectionManager.activeHostId.value)
connectionManager.switchActiveHost(HermesHostId("h2"))
assertEquals(HermesHostId("h2"), connectionManager.activeHostId.value)
}
}

View file

@ -0,0 +1,58 @@
package app.hermes.mobile.core.security
import app.hermes.mobile.core.model.NativeAuthTokens
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class HostScopedTokenManagementTest {
private lateinit var tokenVault: InMemoryTokenVault
@Before
fun setUp() {
tokenVault = InMemoryTokenVault()
}
@Test
fun testHostIsolation() {
val host1Id = "host-cloud-1"
val host2Id = "host-local-lan"
val tokens1 = NativeAuthTokens(
accessToken = "access_token_cloud_123",
refreshToken = "refresh_token_cloud_456",
provider = "github",
expiresAt = 1800000000L
)
val tokens2 = NativeAuthTokens(
accessToken = "access_token_lan_789",
refreshToken = "refresh_token_lan_012",
provider = "local",
expiresAt = 1900000000L
)
tokenVault.saveTokens(host1Id, tokens1)
tokenVault.saveTokens(host2Id, tokens2)
val retrieved1 = tokenVault.getTokens(host1Id)
val retrieved2 = tokenVault.getTokens(host2Id)
assertNotNull(retrieved1)
assertNotNull(retrieved2)
assertEquals("access_token_cloud_123", retrieved1?.accessToken)
assertEquals("access_token_lan_789", retrieved2?.accessToken)
// Clear host 1
tokenVault.clearTokens(host1Id)
assertNull(tokenVault.getTokens(host1Id))
assertNotNull(tokenVault.getTokens(host2Id))
assertEquals(setOf(host2Id), tokenVault.getAllHostIds())
}
}

View file

@ -0,0 +1,186 @@
package app.hermes.mobile.core.storage
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
class FakeHostDao : HostDao {
private val storage = mutableMapOf<String, HostEntity>()
private val flow = MutableStateFlow<List<HostEntity>>(emptyList())
private fun updateFlow() {
flow.value = storage.values.sortedBy { it.displayName }
}
override fun getHostsFlow(): Flow<List<HostEntity>> = flow
override suspend fun getHosts(): List<HostEntity> = storage.values.sortedBy { it.displayName }
override suspend fun getHost(hostId: String): HostEntity? = storage[hostId]
override suspend fun insertOrUpdateHost(host: HostEntity) {
storage[host.id] = host
updateFlow()
}
override suspend fun insertHosts(hosts: List<HostEntity>) {
for (h in hosts) storage[h.id] = h
updateFlow()
}
override suspend fun deleteHost(hostId: String) {
storage.remove(hostId)
updateFlow()
}
override suspend fun updateHostStatus(hostId: String, status: String, lastSeenAt: Long) {
val existing = storage[hostId]
if (existing != null) {
storage[hostId] = existing.copy(lastKnownStatus = status, lastSeenAt = lastSeenAt)
updateFlow()
}
}
}
class FakeUnifiedSessionDao : UnifiedSessionDao {
private val sessions = mutableMapOf<String, UnifiedSessionEntity>()
private val bindings = mutableMapOf<String, MutableList<HostBindingEntity>>()
private val messages = mutableMapOf<String, MutableList<UnifiedMessageEntity>>()
private val sessionsFlow = MutableStateFlow<List<UnifiedSessionEntity>>(emptyList())
private fun updateFlow() {
sessionsFlow.value = sessions.values.sortedByDescending { it.updatedAt }
}
override fun getSessionsFlow(): Flow<List<UnifiedSessionEntity>> = sessionsFlow
override suspend fun getSessions(): List<UnifiedSessionEntity> = sessions.values.sortedByDescending { it.updatedAt }
override fun getSessionWithDetailsFlow(sessionId: String): Flow<UnifiedSessionWithDetails?> {
return sessionsFlow.map { getSessionWithDetails(sessionId) }
}
override suspend fun getSessionWithDetails(sessionId: String): UnifiedSessionWithDetails? {
val s = sessions[sessionId] ?: return null
val b = bindings[sessionId] ?: emptyList()
val m = messages[sessionId] ?: emptyList()
return UnifiedSessionWithDetails(session = s, bindings = b, messages = m)
}
override suspend fun getMessagesForSession(sessionId: String): List<UnifiedMessageEntity> {
return messages[sessionId]?.sortedBy { it.createdAt } ?: emptyList()
}
override suspend fun getBindingsForSession(sessionId: String): List<HostBindingEntity> {
return bindings[sessionId] ?: emptyList()
}
override suspend fun insertSession(session: UnifiedSessionEntity) {
sessions[session.id] = session
updateFlow()
}
override suspend fun updateSession(session: UnifiedSessionEntity) {
sessions[session.id] = session
updateFlow()
}
override suspend fun deleteSession(sessionId: String) {
sessions.remove(sessionId)
bindings.remove(sessionId)
messages.remove(sessionId)
updateFlow()
}
override suspend fun insertOrUpdateBinding(binding: HostBindingEntity) {
val list = bindings.computeIfAbsent(binding.sessionId) { mutableListOf() }
list.removeAll { it.hostId == binding.hostId }
list.add(binding)
}
override suspend fun insertOrUpdateBindings(bindingList: List<HostBindingEntity>) {
for (b in bindingList) insertOrUpdateBinding(b)
}
override suspend fun deleteBinding(sessionId: String, hostId: String) {
bindings[sessionId]?.removeAll { it.hostId == hostId }
}
override suspend fun deleteBindingsForSession(sessionId: String) {
bindings.remove(sessionId)
}
override suspend fun insertOrUpdateMessage(message: UnifiedMessageEntity) {
val list = messages.computeIfAbsent(message.sessionId) { mutableListOf() }
val idx = list.indexOfFirst { it.id == message.id }
if (idx >= 0) {
list[idx] = message
} else {
list.add(message)
}
}
override suspend fun insertMessages(msgList: List<UnifiedMessageEntity>) {
for (m in msgList) insertOrUpdateMessage(m)
}
override suspend fun deleteMessagesForSession(sessionId: String) {
messages.remove(sessionId)
}
override suspend fun updateMessageContent(
messageId: String,
content: String,
isStreaming: Boolean,
thinking: String?,
toolsJson: String?
) {
for ((_, list) in messages) {
val idx = list.indexOfFirst { it.id == messageId }
if (idx >= 0) {
val cur = list[idx]
list[idx] = cur.copy(
content = content,
isStreaming = isStreaming,
thinking = thinking,
toolsJson = toolsJson
)
break
}
}
}
override suspend fun updateActiveHost(sessionId: String, hostId: String, updatedAt: Long) {
val cur = sessions[sessionId]
if (cur != null) {
sessions[sessionId] = cur.copy(activeHostId = hostId, updatedAt = updatedAt)
updateFlow()
}
}
override suspend fun updateBindingSync(
sessionId: String,
hostId: String,
syncedThroughMessageId: String?,
syncedAt: Long,
state: String
) {
val list = bindings[sessionId] ?: return
val idx = list.indexOfFirst { it.hostId == hostId }
if (idx >= 0) {
list[idx] = list[idx].copy(
syncedThroughMessageId = syncedThroughMessageId,
syncedAt = syncedAt,
state = state
)
}
}
override suspend fun updateBindingState(sessionId: String, hostId: String, state: String) {
val list = bindings[sessionId] ?: return
val idx = list.indexOfFirst { it.hostId == hostId }
if (idx >= 0) {
list[idx] = list[idx].copy(state = state)
}
}
}

View file

@ -0,0 +1,123 @@
package app.hermes.mobile.core.sync
import app.hermes.mobile.core.model.HermesHost
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.HostStatus
import app.hermes.mobile.core.model.MessageRole
import app.hermes.mobile.core.model.ToolActivity
import app.hermes.mobile.core.model.UnifiedMessage
import app.hermes.mobile.core.model.UnifiedMessageSource
import app.hermes.mobile.core.model.UnifiedSession
import app.hermes.mobile.core.model.UnifiedSessionId
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class UnifiedContextBuilderTest {
@Test
fun testSecretRedaction() {
val input = "Here is my token Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.doNotLeak and sk-1234567890abcdef1234567890 and password=mySuperSecret123!"
val sanitized = UnifiedContextBuilder.sanitizeContent(input)
assertFalse(sanitized.contains("eyJhbGciOiJIUzI1NiJ9"))
assertFalse(sanitized.contains("sk-1234567890abcdef1234567890"))
assertFalse(sanitized.contains("mySuperSecret123!"))
assertTrue(sanitized.contains("[REDACTED_"))
}
@Test
fun testEmptySessionSync() {
val targetHost = HermesHost(
id = HermesHostId("host-2"),
displayName = "Linux Server",
baseUrl = "http://192.168.1.100:9119"
)
val session = UnifiedSession(
id = UnifiedSessionId("session-1"),
title = "Test Session",
activeHostId = HermesHostId("host-1"),
timeline = emptyList()
)
val result = UnifiedContextBuilder.buildContextSyncPayload(session, targetHost)
assertFalse(result.hasNewContext)
assertEquals("", result.contextPrompt)
assertEquals(null, result.latestSyncedMessageId)
}
@Test
fun testDeltaGeneration() {
val host1Id = HermesHostId("host-1")
val host2Id = HermesHostId("host-2")
val host1 = HermesHost(id = host1Id, displayName = "Office PC", baseUrl = "http://192.168.1.50:9119")
val host2 = HermesHost(id = host2Id, displayName = "Linux Server", baseUrl = "http://192.168.1.100:9119")
val hostsMap = mapOf(host1Id to host1, host2Id to host2)
val msg1 = UnifiedMessage(
id = "msg-1",
role = MessageRole.USER,
content = "Write a python script to parse CSV files.",
source = UnifiedMessageSource.USER
)
val msg2 = UnifiedMessage(
id = "msg-2",
role = MessageRole.ASSISTANT,
content = "Sure! Here is the python script using pandas...",
hostId = host1Id,
tools = listOf(ToolActivity("t1", "fs_read", "completed")),
source = UnifiedMessageSource.HERMES
)
val msg3 = UnifiedMessage(
id = "msg-3",
role = MessageRole.USER,
content = "Now run it on the linux server dataset.",
source = UnifiedMessageSource.USER
)
val session = UnifiedSession(
id = UnifiedSessionId("session-1"),
title = "Python Data Analysis",
activeHostId = host2Id,
timeline = listOf(msg1, msg2, msg3)
)
// Case 1: Brand new host binding (syncedThroughMessageId is null)
val syncAll = UnifiedContextBuilder.buildContextSyncPayload(session, host2, hostsMap, null)
assertTrue(syncAll.hasNewContext)
assertEquals("msg-3", syncAll.latestSyncedMessageId)
assertTrue(syncAll.contextPrompt.contains("[Unified Hermes Session Context Transfer]"))
assertTrue(syncAll.contextPrompt.contains("Office PC"))
assertTrue(syncAll.contextPrompt.contains("Write a python script"))
assertTrue(syncAll.contextPrompt.contains("Linux Server"))
// Case 2: Stale host binding (synced up to msg-1, needs delta msg-2 and msg-3)
val syncDelta = UnifiedContextBuilder.buildContextSyncPayload(session, host2, hostsMap, "msg-1")
assertTrue(syncDelta.hasNewContext)
assertEquals("msg-3", syncDelta.latestSyncedMessageId)
assertFalse(syncDelta.contextPrompt.contains("Write a python script to parse CSV files."))
assertTrue(syncDelta.contextPrompt.contains("Sure! Here is the python script"))
assertTrue(syncDelta.contextPrompt.contains("Now run it on the linux server dataset."))
// Case 3: Fully synced host binding (synced up to msg-3)
val syncUpToDate = UnifiedContextBuilder.buildContextSyncPayload(session, host2, hostsMap, "msg-3")
assertFalse(syncUpToDate.hasNewContext)
assertEquals("", syncUpToDate.contextPrompt)
assertEquals("msg-3", syncUpToDate.latestSyncedMessageId)
}
@Test
fun testMergeContextWithPrompt() {
val context = "[Context] Prior conversation summary"
val prompt = "List the active containers."
val merged = UnifiedContextBuilder.mergeContextWithPrompt(context, prompt)
assertTrue(merged.startsWith("[Context]"))
assertTrue(merged.contains("User request: List the active containers."))
val emptyMerged = UnifiedContextBuilder.mergeContextWithPrompt("", prompt)
assertEquals("List the active containers.", emptyMerged)
}
}

View file

@ -1,14 +1,17 @@
package app.hermes.mobile.feature.chat
import app.hermes.mobile.core.model.DurableSessionId
import app.hermes.mobile.core.model.HermesApproval
import app.hermes.mobile.core.model.HermesMessage
import app.hermes.mobile.core.model.MessageRole
import app.hermes.mobile.core.model.RuntimeSessionId
import app.hermes.mobile.core.model.HermesHost
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.HostAttributedApproval
import app.hermes.mobile.core.model.HostAttributedClarify
import app.hermes.mobile.core.model.UnifiedSessionId
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.network.JsonRpcGatewayClient
import app.hermes.mobile.core.repository.HermesGatewayRepository
import app.hermes.mobile.core.repository.UnifiedSessionRepository
import app.hermes.mobile.core.runtime.HermesConnectionManager
import app.hermes.mobile.core.security.InMemoryTokenVault
import app.hermes.mobile.core.storage.FakeHostDao
import app.hermes.mobile.core.storage.FakeUnifiedSessionDao
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@ -25,20 +28,33 @@ import org.junit.Test
class ChatViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var restClient: HermesRestClient
private lateinit var gatewayClient: JsonRpcGatewayClient
private lateinit var hostDao: FakeHostDao
private lateinit var sessionDao: FakeUnifiedSessionDao
private lateinit var tokenVault: InMemoryTokenVault
private lateinit var repository: HermesGatewayRepository
private lateinit var connectionManager: HermesConnectionManager
private lateinit var repository: UnifiedSessionRepository
private lateinit var viewModel: ChatViewModel
private val sessionId = UnifiedSessionId("test-session-123")
private val hostId = HermesHostId("host-main")
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
restClient = HermesRestClient()
gatewayClient = JsonRpcGatewayClient()
hostDao = FakeHostDao()
sessionDao = FakeUnifiedSessionDao()
tokenVault = InMemoryTokenVault()
repository = HermesGatewayRepository(restClient, gatewayClient, tokenVault)
viewModel = ChatViewModel(repository)
connectionManager = HermesConnectionManager(hostDao, tokenVault, scope = CoroutineScope(testDispatcher))
repository = UnifiedSessionRepository(connectionManager, sessionDao, scope = CoroutineScope(testDispatcher))
val host = HermesHost(id = hostId, displayName = "Main Host", baseUrl = "http://localhost:9119")
runTest(testDispatcher) {
connectionManager.addHost(host)
repository.createUnifiedSession("Test Chat", hostId)
testScheduler.advanceUntilIdle()
}
viewModel = ChatViewModel(repository, connectionManager, sessionId)
}
@After
@ -61,32 +77,28 @@ class ChatViewModelTest {
}
@Test
fun testMessageHandlingStateFlow() {
val initialMessages = viewModel.messages.value
assertEquals(0, initialMessages.size)
fun testHostDropdownToggle() {
assertEquals(false, viewModel.uiState.value.activeHostDropdownExpanded)
viewModel.setHostDropdownExpanded(true)
assertEquals(true, viewModel.uiState.value.activeHostDropdownExpanded)
}
@Test
fun testClarifyRequestHandling() = runTest(testDispatcher) {
val clarifyReq = app.hermes.mobile.core.model.HermesClarifyRequest(
requestId = "req_101",
questionId = "q_param",
question = "Which database?",
promptType = app.hermes.mobile.core.model.ClarifyType.CLARIFY
)
viewModel.respondClarify(clarifyReq, "PostgreSQL")
// No crash, handled gracefully when disconnected
}
fun testSwitchActiveHost() = runTest(testDispatcher) {
val host2Id = HermesHostId("host-secondary")
val host2 = HermesHost(id = host2Id, displayName = "Secondary Host", baseUrl = "http://second:9119")
connectionManager.addHost(host2)
testScheduler.advanceUntilIdle()
@Test
fun testApprovalHandling() = runTest(testDispatcher) {
viewModel.respondApproval("req_app_1", "once", false)
// Handled gracefully
viewModel.switchActiveHost(host2Id)
testScheduler.advanceUntilIdle()
assertEquals(false, viewModel.uiState.value.activeHostDropdownExpanded)
}
@Test
fun testInterruptHandling() = runTest(testDispatcher) {
viewModel.interruptSession()
// Handled gracefully
// No crash, handled gracefully
}
}

View file

@ -3,4 +3,5 @@ plugins {
id("org.jetbrains.kotlin.android") version "2.1.10" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10" apply false
id("com.google.devtools.ksp") version "2.1.10-1.0.29" apply false
}