Implement Multi-Hermes Connection Manager and Unified Sessions with Context Sync and Room DB
This commit is contained in:
parent
4fbb95c6c4
commit
879b834e51
32 changed files with 4270 additions and 242 deletions
142
README.md
142
README.md
|
|
@ -1,57 +1,81 @@
|
||||||
# Hermes Android Native Remote Client
|
# 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
|
## 🌟 Architecture Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
│ Hermes Android Client │
|
│ Hermes Android Client │
|
||||||
│ ┌───────────────────────────────────────────────────┐ │
|
│ ┌───────────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ Jetpack Compose UI (M3) │ │
|
│ │ Jetpack Compose UI (M3) │ │
|
||||||
│ │ • Connections • Sessions • Chat & Approvals │ │
|
│ │ • Multi-Host Switcher • Unified Sessions • Attributed Chat │ │
|
||||||
│ └─────────────────────────┬─────────────────────────┘ │
|
│ └─────────────────────────────────┬─────────────────────────────────┘ │
|
||||||
│ │ StateFlow / Actions │
|
│ │ StateFlow / Actions │
|
||||||
│ ┌─────────────────────────▼─────────────────────────┐ │
|
│ ┌─────────────────────────────────▼─────────────────────────────────┐ │
|
||||||
│ │ Hermes Gateway Layer │ │
|
│ │ Unified Session Repository │ │
|
||||||
│ │ • Reconnection Loop with Exponential Backoff │ │
|
│ │ • Logical Unified Sessions • Context Synchronization Delta │ │
|
||||||
│ │ • Session State Reconciliation │ │
|
│ │ • Host-Tagged Event Routing • Local Persistence (Room DB) │ │
|
||||||
│ │ • Event Stream Dispatcher │ │
|
│ └──────────────────┬──────────────────────────────┬─────────────────┘ │
|
||||||
│ └──────────┬──────────────────────────┬─────────────┘ │
|
│ │ │ │
|
||||||
│ │ JSON-RPC / Ticket │ PKCE Auth │
|
│ ┌──────────────────▼──────────────────┐ ┌────────▼─────────────────┐ │
|
||||||
│ ┌──────────▼──────────┐ ┌──────────▼──────────────┐ │
|
│ │ Hermes Connection Manager │ │ Encrypted Token Vault │ │
|
||||||
│ │ OkHttp WebSocket │ │ Loopback Auth Server │ │
|
│ │ • Map<HostId, HostRuntime> │ │ (Host-Scoped Keystore) │ │
|
||||||
│ │ (Single-Use Auth) │ │ (127.0.0.1:<port>) │ │
|
│ └───────┬─────────────────────────┬───┘ └──────────────────────────┘ │
|
||||||
│ └──────────┬──────────┘ └──────────┬──────────────┘ │
|
│ │ │ │
|
||||||
└─────────────┼──────────────────────────┼─────────────────┘
|
│ ┌───────▼─────────────┐ ┌───────▼─────────────┐ │
|
||||||
│ │
|
│ │ Host #1 Runtime │ │ Host #2 Runtime │ │
|
||||||
▼ ▼
|
│ │ (OkHttp WS + REST) │ │ (OkHttp WS + REST) │ │
|
||||||
┌─────────────────────────────────────────────────────────┐
|
│ └───────┬─────────────┘ └───────┬─────────────┘ │
|
||||||
│ Hermes Host │
|
└──────────┼─────────────────────────┼────────────────────────────────────┘
|
||||||
│ (`hermes serve`) │
|
│ │
|
||||||
│ │
|
▼ ▼
|
||||||
│ • `GET /api/status` • `GET /auth/native/...`│
|
┌──────────────────────┐ ┌──────────────────────┐
|
||||||
│ • `POST /api/auth/ws-ticket` • `WS /ws?ticket=...` │
|
│ 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
|
```powershell
|
||||||
hermes serve --host 0.0.0.0 --port 9119
|
hermes serve --host 0.0.0.0 --port 9119
|
||||||
```
|
```
|
||||||
|
|
||||||
To configure GitHub authentication:
|
With OAuth / GitHub Auth:
|
||||||
```powershell
|
```powershell
|
||||||
$env:HERMES_AUTH_REQUIRED="true"
|
$env:HERMES_AUTH_REQUIRED="true"
|
||||||
$env:HERMES_AUTH_PROVIDERS="github"
|
$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
|
hermes serve --host 0.0.0.0 --port 9119
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Linux Host Setup
|
### Linux Host
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export HERMES_AUTH_REQUIRED="true"
|
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
|
## 🧪 Testing & Verification
|
||||||
|
|
||||||
Run all unit tests via Gradle:
|
Run the full automated test suite:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\gradlew testDebugUnitTest
|
.\gradlew.bat testDebugUnitTest
|
||||||
```
|
```
|
||||||
|
|
||||||
Build the release or debug APK:
|
Run Android Lint:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\gradlew assembleDebug
|
.\gradlew.bat lintDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Assemble Debug APK:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\gradlew.bat assembleDebug
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ plugins {
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
id("org.jetbrains.kotlin.plugin.serialization")
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
id("com.google.devtools.ksp")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -86,6 +87,12 @@ dependencies {
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.2")
|
implementation("androidx.datastore:datastore-preferences:1.1.2")
|
||||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
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
|
// Coroutines
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
|
||||||
|
|
|
||||||
|
|
@ -10,25 +10,32 @@ import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import app.hermes.mobile.core.auth.PkceLoopbackAuthManager
|
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.HermesRestClient
|
||||||
import app.hermes.mobile.core.network.JsonRpcGatewayClient
|
import app.hermes.mobile.core.repository.UnifiedSessionRepository
|
||||||
import app.hermes.mobile.core.repository.ConnectionRepository
|
import app.hermes.mobile.core.runtime.HermesConnectionManager
|
||||||
import app.hermes.mobile.core.repository.HermesGatewayRepository
|
|
||||||
import app.hermes.mobile.core.security.EncryptedTokenVault
|
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.ChatScreen
|
||||||
import app.hermes.mobile.feature.chat.ChatViewModel
|
import app.hermes.mobile.feature.chat.ChatViewModel
|
||||||
import app.hermes.mobile.feature.connections.ConnectionsScreen
|
import app.hermes.mobile.feature.hosts.HostsScreen
|
||||||
import app.hermes.mobile.feature.connections.ConnectionsViewModel
|
import app.hermes.mobile.feature.hosts.HostsViewModel
|
||||||
import app.hermes.mobile.feature.sessions.SessionsScreen
|
import app.hermes.mobile.feature.native_sessions.NativeSessionsScreen
|
||||||
import app.hermes.mobile.feature.sessions.SessionsViewModel
|
import app.hermes.mobile.feature.native_sessions.NativeSessionsViewModel
|
||||||
import app.hermes.mobile.feature.settings.SettingsScreen
|
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 app.hermes.mobile.ui.theme.HermesAndroidTheme
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
|
@ -36,12 +43,28 @@ class MainActivity : ComponentActivity() {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
val db = HermesDatabase.getInstance(applicationContext)
|
||||||
|
val hostDao = db.hostDao()
|
||||||
|
val sessionDao = db.unifiedSessionDao()
|
||||||
val tokenVault = EncryptedTokenVault(applicationContext)
|
val tokenVault = EncryptedTokenVault(applicationContext)
|
||||||
val restClient = HermesRestClient()
|
val restClient = HermesRestClient()
|
||||||
val gatewayClient = JsonRpcGatewayClient()
|
|
||||||
val pkceAuthManager = PkceLoopbackAuthManager(restClient, tokenVault)
|
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 {
|
setContent {
|
||||||
HermesAndroidTheme {
|
HermesAndroidTheme {
|
||||||
|
|
@ -49,9 +72,9 @@ class MainActivity : ComponentActivity() {
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
HermesAppNavigation(
|
HermesUnifiedAppNavigation(
|
||||||
connectionRepo = connectionRepo,
|
connectionManager = connectionManager,
|
||||||
gatewayRepo = gatewayRepo,
|
sessionRepo = unifiedSessionRepo,
|
||||||
tokenVault = tokenVault,
|
tokenVault = tokenVault,
|
||||||
pkceAuthManager = pkceAuthManager
|
pkceAuthManager = pkceAuthManager
|
||||||
)
|
)
|
||||||
|
|
@ -62,66 +85,77 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HermesAppNavigation(
|
fun HermesUnifiedAppNavigation(
|
||||||
connectionRepo: ConnectionRepository,
|
connectionManager: HermesConnectionManager,
|
||||||
gatewayRepo: HermesGatewayRepository,
|
sessionRepo: UnifiedSessionRepository,
|
||||||
tokenVault: EncryptedTokenVault,
|
tokenVault: EncryptedTokenVault,
|
||||||
pkceAuthManager: PkceLoopbackAuthManager
|
pkceAuthManager: PkceLoopbackAuthManager
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
val connectionsViewModel = remember {
|
val unifiedSessionsViewModel = remember {
|
||||||
ConnectionsViewModel(connectionRepo, gatewayRepo, tokenVault, pkceAuthManager)
|
UnifiedSessionsViewModel(sessionRepo, connectionManager)
|
||||||
}
|
}
|
||||||
val sessionsViewModel = remember {
|
val hostsViewModel = remember {
|
||||||
SessionsViewModel(gatewayRepo)
|
HostsViewModel(connectionManager, tokenVault, pkceAuthManager = pkceAuthManager)
|
||||||
}
|
|
||||||
val chatViewModel = remember {
|
|
||||||
ChatViewModel(gatewayRepo)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = "connections"
|
startDestination = "unified_sessions"
|
||||||
) {
|
) {
|
||||||
composable("connections") {
|
composable("unified_sessions") {
|
||||||
ConnectionsScreen(
|
UnifiedSessionsScreen(
|
||||||
viewModel = connectionsViewModel,
|
viewModel = unifiedSessionsViewModel,
|
||||||
onNavigateToSessions = { connId ->
|
onNavigateToChat = { sessionId ->
|
||||||
sessionsViewModel.loadSessions()
|
navController.navigate("chat/${sessionId.value}")
|
||||||
navController.navigate("sessions/$connId")
|
},
|
||||||
|
onNavigateToHosts = {
|
||||||
|
navController.navigate("hosts")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = "sessions/{connectionId}",
|
route = "chat/{unifiedSessionId}",
|
||||||
arguments = listOf(navArgument("connectionId") { type = NavType.StringType })
|
arguments = listOf(navArgument("unifiedSessionId") { type = NavType.StringType })
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val connId = backStackEntry.arguments?.getString("connectionId") ?: ""
|
val sessionIdStr = backStackEntry.arguments?.getString("unifiedSessionId") ?: ""
|
||||||
SessionsScreen(
|
val sessionId = UnifiedSessionId(sessionIdStr)
|
||||||
viewModel = sessionsViewModel,
|
val chatViewModel = remember(sessionIdStr) {
|
||||||
connectionId = connId,
|
ChatViewModel(sessionRepo, connectionManager, sessionId)
|
||||||
|
}
|
||||||
|
ChatScreen(
|
||||||
|
viewModel = chatViewModel,
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable("hosts") {
|
||||||
|
HostsScreen(
|
||||||
|
viewModel = hostsViewModel,
|
||||||
onNavigateBack = {
|
onNavigateBack = {
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
},
|
},
|
||||||
onNavigateToChat = { durableSessionId ->
|
onNavigateToNativeSessions = { hostId ->
|
||||||
navController.navigate("chat/$connId/$durableSessionId")
|
navController.navigate("native_sessions/${hostId.value}")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = "chat/{connectionId}/{durableSessionId}",
|
route = "native_sessions/{hostId}",
|
||||||
arguments = listOf(
|
arguments = listOf(navArgument("hostId") { type = NavType.StringType })
|
||||||
navArgument("connectionId") { type = NavType.StringType },
|
|
||||||
navArgument("durableSessionId") { type = NavType.StringType }
|
|
||||||
)
|
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val durableSessionId = backStackEntry.arguments?.getString("durableSessionId") ?: ""
|
val hostIdStr = backStackEntry.arguments?.getString("hostId") ?: ""
|
||||||
ChatScreen(
|
val hostId = HermesHostId(hostIdStr)
|
||||||
viewModel = chatViewModel,
|
val nativeSessionsViewModel = remember(hostIdStr) {
|
||||||
durableSessionId = durableSessionId,
|
NativeSessionsViewModel(connectionManager, hostId)
|
||||||
|
}
|
||||||
|
NativeSessionsScreen(
|
||||||
|
viewModel = nativeSessionsViewModel,
|
||||||
onNavigateBack = {
|
onNavigateBack = {
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -58,7 +58,7 @@ class JsonRpcGatewayClient(
|
||||||
.readTimeout(0, TimeUnit.MILLISECONDS) // infinite for websockets
|
.readTimeout(0, TimeUnit.MILLISECONDS) // infinite for websockets
|
||||||
.pingInterval(30, TimeUnit.SECONDS)
|
.pingInterval(30, TimeUnit.SECONDS)
|
||||||
.build(),
|
.build(),
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
) {
|
) {
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
|
|
@ -208,8 +208,10 @@ class JsonRpcGatewayClient(
|
||||||
gatewayReadyDeferred.complete(Unit)
|
gatewayReadyDeferred.complete(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
if (!_events.tryEmit(event)) {
|
||||||
_events.emit(event)
|
scope.launch {
|
||||||
|
_events.emit(event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Ignore corrupted frames gracefully or log if debug
|
// Ignore corrupted frames gracefully or log if debug
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,10 +10,13 @@ import kotlinx.serialization.json.Json
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
interface TokenVault {
|
interface TokenVault {
|
||||||
fun saveTokens(connectionId: String, tokens: NativeAuthTokens)
|
fun saveTokens(hostId: String, tokens: NativeAuthTokens)
|
||||||
fun getTokens(connectionId: String): NativeAuthTokens?
|
fun getTokens(hostId: String): NativeAuthTokens?
|
||||||
fun clearTokens(connectionId: String)
|
fun clearTokens(hostId: String)
|
||||||
fun getAllConnectionIds(): Set<String>
|
fun getAllHostIds(): Set<String>
|
||||||
|
|
||||||
|
// Backwards-compatible aliases
|
||||||
|
fun getAllConnectionIds(): Set<String> = getAllHostIds()
|
||||||
}
|
}
|
||||||
|
|
||||||
class EncryptedTokenVault(context: Context) : TokenVault {
|
class EncryptedTokenVault(context: Context) : TokenVault {
|
||||||
|
|
@ -34,13 +37,13 @@ class EncryptedTokenVault(context: Context) : TokenVault {
|
||||||
throw SecurityException("Keystore encryption required for token storage", e)
|
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)
|
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? {
|
override fun getTokens(hostId: String): NativeAuthTokens? {
|
||||||
val raw = prefs.getString("conn_$connectionId", null) ?: return null
|
val raw = prefs.getString("conn_$hostId", null) ?: return null
|
||||||
return try {
|
return try {
|
||||||
json.decodeFromString<NativeAuthTokens>(raw)
|
json.decodeFromString<NativeAuthTokens>(raw)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -48,11 +51,11 @@ class EncryptedTokenVault(context: Context) : TokenVault {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearTokens(connectionId: String) {
|
override fun clearTokens(hostId: String) {
|
||||||
prefs.edit().remove("conn_$connectionId").apply()
|
prefs.edit().remove("conn_$hostId").apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAllConnectionIds(): Set<String> {
|
override fun getAllHostIds(): Set<String> {
|
||||||
return prefs.all.keys
|
return prefs.all.keys
|
||||||
.filter { it.startsWith("conn_") }
|
.filter { it.startsWith("conn_") }
|
||||||
.map { it.removePrefix("conn_") }
|
.map { it.removePrefix("conn_") }
|
||||||
|
|
@ -63,19 +66,19 @@ class EncryptedTokenVault(context: Context) : TokenVault {
|
||||||
class InMemoryTokenVault : TokenVault {
|
class InMemoryTokenVault : TokenVault {
|
||||||
private val storage = ConcurrentHashMap<String, NativeAuthTokens>()
|
private val storage = ConcurrentHashMap<String, NativeAuthTokens>()
|
||||||
|
|
||||||
override fun saveTokens(connectionId: String, tokens: NativeAuthTokens) {
|
override fun saveTokens(hostId: String, tokens: NativeAuthTokens) {
|
||||||
storage[connectionId] = tokens
|
storage[hostId] = tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getTokens(connectionId: String): NativeAuthTokens? {
|
override fun getTokens(hostId: String): NativeAuthTokens? {
|
||||||
return storage[connectionId]
|
return storage[hostId]
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearTokens(connectionId: String) {
|
override fun clearTokens(hostId: String) {
|
||||||
storage.remove(connectionId)
|
storage.remove(hostId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAllConnectionIds(): Set<String> {
|
override fun getAllHostIds(): Set<String> {
|
||||||
return storage.keys.toSet()
|
return storage.keys.toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
93
app/src/main/java/app/hermes/mobile/core/storage/Daos.kt
Normal file
93
app/src/main/java/app/hermes/mobile/core/storage/Daos.kt
Normal 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)
|
||||||
|
}
|
||||||
92
app/src/main/java/app/hermes/mobile/core/storage/Entities.kt
Normal file
92
app/src/main/java/app/hermes/mobile/core/storage/Entities.kt
Normal 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>
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.Shield
|
||||||
import androidx.compose.material.icons.filled.Terminal
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
import androidx.compose.material3.Button
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import app.hermes.mobile.core.model.HermesApproval
|
import app.hermes.mobile.core.model.HostAttributedApproval
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ApprovalCard(
|
fun ApprovalCard(
|
||||||
approval: HermesApproval,
|
attributedApproval: HostAttributedApproval,
|
||||||
onRespond: (choice: String, all: Boolean) -> Unit
|
onRespond: (choice: String, all: Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
|
val approval = attributedApproval.approval
|
||||||
|
val hostDisplayName = attributedApproval.hostDisplayName
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
|
|
@ -50,20 +54,49 @@ fun ApprovalCard(
|
||||||
.border(1.5.dp, Color(0xFFF59E0B), RoundedCornerShape(16.dp))
|
.border(1.5.dp, Color(0xFFF59E0B), RoundedCornerShape(16.dp))
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
Icon(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Icons.Default.Shield,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
contentDescription = null,
|
modifier = Modifier.fillMaxWidth()
|
||||||
tint = Color(0xFFF59E0B),
|
) {
|
||||||
modifier = Modifier.size(20.dp)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
)
|
Icon(
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Icons.Default.Shield,
|
||||||
Text(
|
contentDescription = null,
|
||||||
text = "Action Authorization Required",
|
tint = Color(0xFFF59E0B),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
modifier = Modifier.size(20.dp)
|
||||||
fontWeight = FontWeight.Bold,
|
)
|
||||||
color = Color(0xFFF59E0B)
|
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()) {
|
if (!approval.description.isNullOrBlank()) {
|
||||||
|
|
|
||||||
|
|
@ -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.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.Build
|
import androidx.compose.material.icons.filled.Build
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
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.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Lightbulb
|
import androidx.compose.material.icons.filled.Lightbulb
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material.icons.filled.Sync
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.MessageRole
|
||||||
import app.hermes.mobile.core.model.ToolActivity
|
import app.hermes.mobile.core.model.ToolActivity
|
||||||
|
import app.hermes.mobile.core.model.UnifiedMessage
|
||||||
|
import app.hermes.mobile.core.model.UnifiedMessageSource
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
durableSessionId: String,
|
|
||||||
onNavigateBack: () -> Unit
|
onNavigateBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
val messages by viewModel.messages.collectAsState()
|
val messages by viewModel.messages.collectAsState()
|
||||||
val approvals by viewModel.activeApprovals.collectAsState()
|
val approvals by viewModel.activeApprovals.collectAsState()
|
||||||
val activeClarify by viewModel.activeClarify.collectAsState()
|
val activeClarify by viewModel.activeClarify.collectAsState()
|
||||||
val sessionInfo by viewModel.sessionInfo.collectAsState()
|
|
||||||
val isExecuting by viewModel.isExecuting.collectAsState()
|
val isExecuting by viewModel.isExecuting.collectAsState()
|
||||||
val uiState by viewModel.uiState.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()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
LaunchedEffect(messages.size, messages.lastOrNull()?.content?.length, approvals.size) {
|
LaunchedEffect(messages.size, messages.lastOrNull()?.content?.length, approvals.size) {
|
||||||
|
|
@ -93,17 +101,72 @@ fun ChatScreen(
|
||||||
title = {
|
title = {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = sessionInfo?.model ?: activeConn?.name ?: "Hermes Chat",
|
text = currentSession?.title?.ifEmpty { "Unified Chat" } ?: "Unified Chat",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
// Active host selector chip
|
||||||
text = "Session: ${durableSessionId.take(8)}",
|
Box {
|
||||||
style = MaterialTheme.typography.labelSmall,
|
Row(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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 = {
|
navigationIcon = {
|
||||||
|
|
@ -148,14 +211,19 @@ fun ChatScreen(
|
||||||
contentPadding = PaddingValues(vertical = 12.dp)
|
contentPadding = PaddingValues(vertical = 12.dp)
|
||||||
) {
|
) {
|
||||||
items(messages, key = { it.id }) { message ->
|
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(
|
ApprovalCard(
|
||||||
approval = approval,
|
attributedApproval = approval,
|
||||||
onRespond = { choice, all ->
|
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()) {
|
if (isExecuting && messages.lastOrNull()?.isStreaming != true && approvals.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
val hostLabel = activeHost?.displayName ?: "Hermes"
|
||||||
Text(
|
Text(
|
||||||
"Hermes is thinking…",
|
"$hostLabel is thinking…",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
@ -180,8 +251,8 @@ fun ChatScreen(
|
||||||
|
|
||||||
if (activeClarify != null) {
|
if (activeClarify != null) {
|
||||||
ClarifyDialog(
|
ClarifyDialog(
|
||||||
request = activeClarify!!,
|
attributedClarify = activeClarify!!,
|
||||||
onDismiss = { viewModel.dismissClarify() },
|
onDismiss = { /* dismiss */ },
|
||||||
onSubmit = { value ->
|
onSubmit = { value ->
|
||||||
viewModel.respondClarify(activeClarify!!, value)
|
viewModel.respondClarify(activeClarify!!, value)
|
||||||
}
|
}
|
||||||
|
|
@ -191,13 +262,69 @@ fun ChatScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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
|
val isUser = message.role == MessageRole.USER
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start
|
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
|
// Thinking Collapsible
|
||||||
if (!message.thinking.isNullOrBlank()) {
|
if (!message.thinking.isNullOrBlank()) {
|
||||||
ThinkingSection(thinking = message.thinking)
|
ThinkingSection(thinking = message.thinking)
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,9 @@ package app.hermes.mobile.feature.chat
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.hermes.mobile.core.model.ClarifyType
|
import app.hermes.mobile.core.model.*
|
||||||
import app.hermes.mobile.core.model.DurableSessionId
|
import app.hermes.mobile.core.repository.UnifiedSessionRepository
|
||||||
import app.hermes.mobile.core.model.HermesApproval
|
import app.hermes.mobile.core.runtime.HermesConnectionManager
|
||||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -17,29 +12,59 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class ChatUiState(
|
data class ChatUiState(
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val inputText: String = ""
|
val inputText: String = "",
|
||||||
|
val activeHostDropdownExpanded: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
class ChatViewModel(
|
class ChatViewModel(
|
||||||
private val gatewayRepo: HermesGatewayRepository
|
val sessionRepo: UnifiedSessionRepository,
|
||||||
|
val connectionManager: HermesConnectionManager,
|
||||||
|
private val sessionId: UnifiedSessionId
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val messages: StateFlow<List<HermesMessage>> = gatewayRepo.messages
|
val hosts: StateFlow<List<HermesHost>> = connectionManager.hosts
|
||||||
val activeApprovals: StateFlow<List<HermesApproval>> = gatewayRepo.activeApprovals
|
val messages: StateFlow<List<UnifiedMessage>> = sessionRepo.getSessionMessages(sessionId)
|
||||||
val activeClarify: StateFlow<HermesClarifyRequest?> = gatewayRepo.activeClarify
|
val isExecuting: StateFlow<Boolean> = sessionRepo.getSessionExecuting(sessionId)
|
||||||
val sessionInfo: StateFlow<SessionInfo?> = gatewayRepo.sessionInfo
|
val activeApprovals: StateFlow<List<HostAttributedApproval>> = sessionRepo.activeApprovals
|
||||||
val isExecuting: StateFlow<Boolean> = gatewayRepo.isExecuting
|
val activeClarify: StateFlow<HostAttributedClarify?> = sessionRepo.activeClarify
|
||||||
val connectionState: StateFlow<ConnectionState> = gatewayRepo.connectionState
|
|
||||||
val activeConnection = gatewayRepo.activeConnection
|
|
||||||
val activeDurableId: StateFlow<DurableSessionId?> = gatewayRepo.activeDurableId
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ChatUiState())
|
private val _uiState = MutableStateFlow(ChatUiState())
|
||||||
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
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) {
|
fun updateInputText(text: String) {
|
||||||
_uiState.value = _uiState.value.copy(inputText = text)
|
_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() {
|
fun submitPrompt() {
|
||||||
val text = _uiState.value.inputText.trim()
|
val text = _uiState.value.inputText.trim()
|
||||||
if (text.isEmpty()) return
|
if (text.isEmpty()) return
|
||||||
|
|
@ -47,7 +72,8 @@ class ChatViewModel(
|
||||||
_uiState.value = _uiState.value.copy(inputText = "", error = null)
|
_uiState.value = _uiState.value.copy(inputText = "", error = null)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
gatewayRepo.sendUserPrompt(text)
|
sessionRepo.sendPrompt(sessionId, text)
|
||||||
|
loadSession()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
error = e.localizedMessage ?: "Failed to submit prompt"
|
error = e.localizedMessage ?: "Failed to submit prompt"
|
||||||
|
|
@ -58,14 +84,14 @@ class ChatViewModel(
|
||||||
|
|
||||||
fun interruptSession() {
|
fun interruptSession() {
|
||||||
viewModelScope.launch {
|
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 {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
gatewayRepo.respondApproval(requestId, choice, all)
|
sessionRepo.respondApproval(hostId, requestId, choice, all)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
error = e.localizedMessage ?: "Failed to respond to approval"
|
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 {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
when (request.promptType) {
|
val hostId = attributed.hostId
|
||||||
ClarifyType.CLARIFY -> gatewayRepo.respondClarify(request.requestId, answer, request.questionId)
|
val req = attributed.request
|
||||||
ClarifyType.SUDO -> gatewayRepo.respondSudo(request.requestId, answer)
|
when (req.promptType) {
|
||||||
ClarifyType.SECRET -> gatewayRepo.respondSecret(request.requestId, answer)
|
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) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
|
|
@ -89,8 +117,4 @@ class ChatViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissClarify() {
|
|
||||||
// Can be cancelled or handled
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
package app.hermes.mobile.feature.chat
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
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.Key
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
|
@ -20,21 +29,27 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
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.ClarifyType
|
||||||
import app.hermes.mobile.core.model.HermesClarifyRequest
|
import app.hermes.mobile.core.model.HostAttributedClarify
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ClarifyDialog(
|
fun ClarifyDialog(
|
||||||
request: HermesClarifyRequest,
|
attributedClarify: HostAttributedClarify,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSubmit: (value: String) -> Unit
|
onSubmit: (value: String) -> Unit
|
||||||
) {
|
) {
|
||||||
var input by remember { mutableStateOf("") }
|
var input by remember { mutableStateOf("") }
|
||||||
|
val request = attributedClarify.request
|
||||||
|
val hostDisplayName = attributedClarify.hostDisplayName
|
||||||
|
|
||||||
val isMasked = request.promptType == ClarifyType.SUDO || request.promptType == ClarifyType.SECRET
|
val isMasked = request.promptType == ClarifyType.SUDO || request.promptType == ClarifyType.SECRET
|
||||||
val title = when (request.promptType) {
|
val title = when (request.promptType) {
|
||||||
|
|
@ -52,7 +67,33 @@ fun ClarifyDialog(
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
icon = { Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
|
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 = {
|
text = {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
456
app/src/main/java/app/hermes/mobile/feature/hosts/HostsScreen.kt
Normal file
456
app/src/main/java/app/hermes/mobile/feature/hosts/HostsScreen.kt
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
186
app/src/test/java/app/hermes/mobile/core/storage/FakeDaos.kt
Normal file
186
app/src/test/java/app/hermes/mobile/core/storage/FakeDaos.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
package app.hermes.mobile.feature.chat
|
package app.hermes.mobile.feature.chat
|
||||||
|
|
||||||
import app.hermes.mobile.core.model.DurableSessionId
|
import app.hermes.mobile.core.model.HermesHost
|
||||||
import app.hermes.mobile.core.model.HermesApproval
|
import app.hermes.mobile.core.model.HermesHostId
|
||||||
import app.hermes.mobile.core.model.HermesMessage
|
import app.hermes.mobile.core.model.HostAttributedApproval
|
||||||
import app.hermes.mobile.core.model.MessageRole
|
import app.hermes.mobile.core.model.HostAttributedClarify
|
||||||
import app.hermes.mobile.core.model.RuntimeSessionId
|
import app.hermes.mobile.core.model.UnifiedSessionId
|
||||||
import app.hermes.mobile.core.network.HermesRestClient
|
import app.hermes.mobile.core.network.HermesRestClient
|
||||||
import app.hermes.mobile.core.network.JsonRpcGatewayClient
|
import app.hermes.mobile.core.repository.UnifiedSessionRepository
|
||||||
import app.hermes.mobile.core.repository.HermesGatewayRepository
|
import app.hermes.mobile.core.runtime.HermesConnectionManager
|
||||||
import app.hermes.mobile.core.security.InMemoryTokenVault
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
|
@ -25,20 +28,33 @@ import org.junit.Test
|
||||||
class ChatViewModelTest {
|
class ChatViewModelTest {
|
||||||
|
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private lateinit var restClient: HermesRestClient
|
private lateinit var hostDao: FakeHostDao
|
||||||
private lateinit var gatewayClient: JsonRpcGatewayClient
|
private lateinit var sessionDao: FakeUnifiedSessionDao
|
||||||
private lateinit var tokenVault: InMemoryTokenVault
|
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 lateinit var viewModel: ChatViewModel
|
||||||
|
|
||||||
|
private val sessionId = UnifiedSessionId("test-session-123")
|
||||||
|
private val hostId = HermesHostId("host-main")
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
restClient = HermesRestClient()
|
hostDao = FakeHostDao()
|
||||||
gatewayClient = JsonRpcGatewayClient()
|
sessionDao = FakeUnifiedSessionDao()
|
||||||
tokenVault = InMemoryTokenVault()
|
tokenVault = InMemoryTokenVault()
|
||||||
repository = HermesGatewayRepository(restClient, gatewayClient, tokenVault)
|
connectionManager = HermesConnectionManager(hostDao, tokenVault, scope = CoroutineScope(testDispatcher))
|
||||||
viewModel = ChatViewModel(repository)
|
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
|
@After
|
||||||
|
|
@ -61,32 +77,28 @@ class ChatViewModelTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testMessageHandlingStateFlow() {
|
fun testHostDropdownToggle() {
|
||||||
val initialMessages = viewModel.messages.value
|
assertEquals(false, viewModel.uiState.value.activeHostDropdownExpanded)
|
||||||
assertEquals(0, initialMessages.size)
|
viewModel.setHostDropdownExpanded(true)
|
||||||
|
assertEquals(true, viewModel.uiState.value.activeHostDropdownExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testClarifyRequestHandling() = runTest(testDispatcher) {
|
fun testSwitchActiveHost() = runTest(testDispatcher) {
|
||||||
val clarifyReq = app.hermes.mobile.core.model.HermesClarifyRequest(
|
val host2Id = HermesHostId("host-secondary")
|
||||||
requestId = "req_101",
|
val host2 = HermesHost(id = host2Id, displayName = "Secondary Host", baseUrl = "http://second:9119")
|
||||||
questionId = "q_param",
|
connectionManager.addHost(host2)
|
||||||
question = "Which database?",
|
testScheduler.advanceUntilIdle()
|
||||||
promptType = app.hermes.mobile.core.model.ClarifyType.CLARIFY
|
|
||||||
)
|
|
||||||
viewModel.respondClarify(clarifyReq, "PostgreSQL")
|
|
||||||
// No crash, handled gracefully when disconnected
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
viewModel.switchActiveHost(host2Id)
|
||||||
fun testApprovalHandling() = runTest(testDispatcher) {
|
testScheduler.advanceUntilIdle()
|
||||||
viewModel.respondApproval("req_app_1", "once", false)
|
|
||||||
// Handled gracefully
|
assertEquals(false, viewModel.uiState.value.activeHostDropdownExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testInterruptHandling() = runTest(testDispatcher) {
|
fun testInterruptHandling() = runTest(testDispatcher) {
|
||||||
viewModel.interruptSession()
|
viewModel.interruptSession()
|
||||||
// Handled gracefully
|
// No crash, handled gracefully
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,5 @@ plugins {
|
||||||
id("org.jetbrains.kotlin.android") version "2.1.10" apply false
|
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.compose") version "2.1.10" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue