diff --git a/README.md b/README.md index dd05ca5..fe4a8f8 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,81 @@ # Hermes Android Native Remote Client -A production-grade, native Android client application for **Hermes**, implementing Protocol & Architecture Contract v1. +A production-grade, native Android client application for **Hermes**, implementing Protocol & Architecture Contract v1 with **Multi-Hermes Connection Manager** and **Unified Sessions**. -Built with **Kotlin**, **Jetpack Compose (Material 3)**, **Coroutines**, **OkHttp**, and **Android Keystore (EncryptedSharedPreferences)**. +Built with **Kotlin**, **Jetpack Compose (Material 3)**, **Coroutines**, **Room Database**, **OkHttp**, and **Android Keystore (EncryptedSharedPreferences)**. --- ## ๐ŸŒŸ Architecture Overview ``` - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Hermes Android Client โ”‚ - โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ - โ”‚ โ”‚ Jetpack Compose UI (M3) โ”‚ โ”‚ - โ”‚ โ”‚ โ€ข Connections โ€ข Sessions โ€ข Chat & Approvals โ”‚ โ”‚ - โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ - โ”‚ โ”‚ StateFlow / Actions โ”‚ - โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ - โ”‚ โ”‚ Hermes Gateway Layer โ”‚ โ”‚ - โ”‚ โ”‚ โ€ข Reconnection Loop with Exponential Backoff โ”‚ โ”‚ - โ”‚ โ”‚ โ€ข Session State Reconciliation โ”‚ โ”‚ - โ”‚ โ”‚ โ€ข Event Stream Dispatcher โ”‚ โ”‚ - โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ - โ”‚ โ”‚ JSON-RPC / Ticket โ”‚ PKCE Auth โ”‚ - โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ - โ”‚ โ”‚ OkHttp WebSocket โ”‚ โ”‚ Loopback Auth Server โ”‚ โ”‚ - โ”‚ โ”‚ (Single-Use Auth) โ”‚ โ”‚ (127.0.0.1:) โ”‚ โ”‚ - โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ โ”‚ - โ–ผ โ–ผ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Hermes Host โ”‚ - โ”‚ (`hermes serve`) โ”‚ - โ”‚ โ”‚ - โ”‚ โ€ข `GET /api/status` โ€ข `GET /auth/native/...`โ”‚ - โ”‚ โ€ข `POST /api/auth/ws-ticket` โ€ข `WS /ws?ticket=...` โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Hermes Android Client โ”‚ + โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ + โ”‚ โ”‚ Jetpack Compose UI (M3) โ”‚ โ”‚ + โ”‚ โ”‚ โ€ข Multi-Host Switcher โ€ข Unified Sessions โ€ข Attributed Chat โ”‚ โ”‚ + โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ”‚ โ”‚ StateFlow / Actions โ”‚ + โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ + โ”‚ โ”‚ Unified Session Repository โ”‚ โ”‚ + โ”‚ โ”‚ โ€ข Logical Unified Sessions โ€ข Context Synchronization Delta โ”‚ โ”‚ + โ”‚ โ”‚ โ€ข Host-Tagged Event Routing โ€ข Local Persistence (Room DB) โ”‚ โ”‚ + โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ + โ”‚ โ”‚ Hermes Connection Manager โ”‚ โ”‚ Encrypted Token Vault โ”‚ โ”‚ + โ”‚ โ”‚ โ€ข Map โ”‚ โ”‚ (Host-Scoped Keystore) โ”‚ โ”‚ + โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ + โ”‚ โ”‚ Host #1 Runtime โ”‚ โ”‚ Host #2 Runtime โ”‚ โ”‚ + โ”‚ โ”‚ (OkHttp WS + REST) โ”‚ โ”‚ (OkHttp WS + REST) โ”‚ โ”‚ + โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ + โ–ผ โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Hermes Host #1 โ”‚ โ”‚ Hermes Host #2 โ”‚ + โ”‚ (Windows Office) โ”‚ โ”‚ (Linux Server) โ”‚ + โ”‚ `hermes serve` โ”‚ โ”‚ `hermes serve` โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` --- -## ๐Ÿš€ Getting Started & Host Setup +## ๐Ÿš€ Key Multi-Host Features -### 1. Windows Host Setup +1. **Multi-Hermes Connection Manager**: + - Save and manage multiple independent Hermes installations (e.g. Workstation, Linux Server, Cloud VM). + - Independent WebSocket connections, concurrent state management, and isolated reconnect loops. + - Individual host health badges: `Online`, `Connecting`, `Offline`, `Auth Expired`. -Run the Hermes server binding to all interfaces (or your LAN / Tailscale IP): +2. **Unified Sessions & Context Synchronization**: + - Create one logical conversation (`UnifiedSession`) that spans multiple physical Hermes hosts. + - Seamlessly switch active execution hosts mid-conversation via the top-bar dropdown. + - **Delta Context Sync**: Injects conversation history and task context to newly attached hosts automatically without full-history re-transmission or secret leakage. + - **Host Attribution**: Every response bubble, tool card, and thinking trace displays its originating host badge (e.g. `[Office PC]`, `[Linux Server]`). + - **Non-Blocking Host Switching**: If Host #1 is executing a long tool or computation and you switch to Host #2, Host #1 completes its work in the background and commits results into the shared timeline. + +3. **Isolated Host Security & Approvals**: + - Host-scoped credentials stored securely in Android Keystore (`hostId -> tokens`). + - **Host-Targeted Approvals & Clarifications**: Dangerous command approvals (`approval.request`) and sudo prompts route back strictly to the exact host runtime and native session that emitted them. + +4. **Local Persistence (Room DB)**: + - Full offline caching for `UnifiedSession`, `HostSessionBinding`, and `UnifiedMessage`. + - Raw native session browser for inspecting individual host histories. + +--- + +## ๐Ÿ–ฅ๏ธ Hermes Host Setup + +### Windows Host ```powershell hermes serve --host 0.0.0.0 --port 9119 ``` -To configure GitHub authentication: +With OAuth / GitHub Auth: ```powershell $env:HERMES_AUTH_REQUIRED="true" $env:HERMES_AUTH_PROVIDERS="github" @@ -60,7 +84,7 @@ $env:HERMES_AUTH_GITHUB_CLIENT_SECRET="" hermes serve --host 0.0.0.0 --port 9119 ``` -### 2. Linux Host Setup +### Linux Host ```bash export HERMES_AUTH_REQUIRED="true" @@ -73,50 +97,22 @@ hermes serve --host 0.0.0.0 --port 9119 --- -## ๐Ÿ“ฑ Android Client Features - -1. **Host Connection Manager**: - - Save multiple Hermes host endpoints. - - Live endpoint verification (`GET /api/status`). - - Cleartext HTTP toggling with explicit security warning badges for local development. - -2. **Native PKCE Authentication**: - - RFC 7636 & RFC 8252 compliant PKCE loopback authentication on `127.0.0.1:`. - - Single-use WebSocket tickets with 30s TTL. - - Credentials securely stored via Android Keystore & `EncryptedSharedPreferences`. Zero token logging. - -3. **Session Management**: - - Resume durable sessions (`DurableSessionId`) or create new sessions. - - Dynamic reconciliation across network disconnects. - -4. **Real-time Chat Experience**: - - Streaming token deltas (`message.delta`). - - Collapsible reasoning & chain-of-thought section (`thinking.delta`). - - Real-time tool execution tracking cards (`tool.start`, `tool.progress`, `tool.complete`). - - **Interactive Approvals**: Immediate in-stream approval card for dangerous commands (`Allow Once`, `Allow Always`, `Deny`). - - **Clarifications & Sudo**: Masked dialogs for `sudo.request`, `secret.request`, and `clarify.request`. - - Interrupt / Stop execution control. - ---- - -## ๐Ÿ”’ Security Best Practices for Remote Access - -- **Do NOT expose cleartext HTTP directly to the public internet.** -- **Recommended**: Connect via **Tailscale**, **WireGuard**, or a TLS Reverse Proxy (Caddy / Nginx) with HTTPS & WSS. -- The Android client strictly enforces `usesCleartextTraffic="false"` at the manifest level by default. - ---- - ## ๐Ÿงช Testing & Verification -Run all unit tests via Gradle: +Run the full automated test suite: ```powershell -.\gradlew testDebugUnitTest +.\gradlew.bat testDebugUnitTest ``` -Build the release or debug APK: +Run Android Lint: ```powershell -.\gradlew assembleDebug +.\gradlew.bat lintDebug +``` + +Assemble Debug APK: + +```powershell +.\gradlew.bat assembleDebug ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 92d4f45..f5adcc9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.serialization") + id("com.google.devtools.ksp") } android { @@ -86,6 +87,12 @@ dependencies { implementation("androidx.datastore:datastore-preferences:1.1.2") implementation("androidx.security:security-crypto:1.1.0-alpha06") + // Room Database + val roomVersion = "2.6.1" + implementation("androidx.room:room-runtime:$roomVersion") + implementation("androidx.room:room-ktx:$roomVersion") + ksp("androidx.room:room-compiler:$roomVersion") + // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1") diff --git a/app/src/main/java/app/hermes/mobile/MainActivity.kt b/app/src/main/java/app/hermes/mobile/MainActivity.kt index e12c4c8..6d0ae82 100644 --- a/app/src/main/java/app/hermes/mobile/MainActivity.kt +++ b/app/src/main/java/app/hermes/mobile/MainActivity.kt @@ -10,25 +10,32 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import app.hermes.mobile.core.auth.PkceLoopbackAuthManager +import app.hermes.mobile.core.model.HermesHostId +import app.hermes.mobile.core.model.UnifiedSessionId import app.hermes.mobile.core.network.HermesRestClient -import app.hermes.mobile.core.network.JsonRpcGatewayClient -import app.hermes.mobile.core.repository.ConnectionRepository -import app.hermes.mobile.core.repository.HermesGatewayRepository +import app.hermes.mobile.core.repository.UnifiedSessionRepository +import app.hermes.mobile.core.runtime.HermesConnectionManager import app.hermes.mobile.core.security.EncryptedTokenVault +import app.hermes.mobile.core.storage.HermesDatabase +import app.hermes.mobile.core.storage.MigrationHelper import app.hermes.mobile.feature.chat.ChatScreen import app.hermes.mobile.feature.chat.ChatViewModel -import app.hermes.mobile.feature.connections.ConnectionsScreen -import app.hermes.mobile.feature.connections.ConnectionsViewModel -import app.hermes.mobile.feature.sessions.SessionsScreen -import app.hermes.mobile.feature.sessions.SessionsViewModel +import app.hermes.mobile.feature.hosts.HostsScreen +import app.hermes.mobile.feature.hosts.HostsViewModel +import app.hermes.mobile.feature.native_sessions.NativeSessionsScreen +import app.hermes.mobile.feature.native_sessions.NativeSessionsViewModel import app.hermes.mobile.feature.settings.SettingsScreen +import app.hermes.mobile.feature.unified_sessions.UnifiedSessionsScreen +import app.hermes.mobile.feature.unified_sessions.UnifiedSessionsViewModel import app.hermes.mobile.ui.theme.HermesAndroidTheme +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { @@ -36,12 +43,28 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() + val db = HermesDatabase.getInstance(applicationContext) + val hostDao = db.hostDao() + val sessionDao = db.unifiedSessionDao() val tokenVault = EncryptedTokenVault(applicationContext) val restClient = HermesRestClient() - val gatewayClient = JsonRpcGatewayClient() val pkceAuthManager = PkceLoopbackAuthManager(restClient, tokenVault) - val connectionRepo = ConnectionRepository(applicationContext) - val gatewayRepo = HermesGatewayRepository(restClient, gatewayClient, tokenVault) + + // Migrate legacy connections from DataStore if present + lifecycleScope.launch { + MigrationHelper.migrateLegacyConnections(applicationContext, hostDao) + } + + val connectionManager = HermesConnectionManager( + hostDao = hostDao, + tokenVault = tokenVault, + restClient = restClient + ) + + val unifiedSessionRepo = UnifiedSessionRepository( + connectionManager = connectionManager, + sessionDao = sessionDao + ) setContent { HermesAndroidTheme { @@ -49,9 +72,9 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - HermesAppNavigation( - connectionRepo = connectionRepo, - gatewayRepo = gatewayRepo, + HermesUnifiedAppNavigation( + connectionManager = connectionManager, + sessionRepo = unifiedSessionRepo, tokenVault = tokenVault, pkceAuthManager = pkceAuthManager ) @@ -62,66 +85,77 @@ class MainActivity : ComponentActivity() { } @Composable -fun HermesAppNavigation( - connectionRepo: ConnectionRepository, - gatewayRepo: HermesGatewayRepository, +fun HermesUnifiedAppNavigation( + connectionManager: HermesConnectionManager, + sessionRepo: UnifiedSessionRepository, tokenVault: EncryptedTokenVault, pkceAuthManager: PkceLoopbackAuthManager ) { val navController = rememberNavController() - val connectionsViewModel = remember { - ConnectionsViewModel(connectionRepo, gatewayRepo, tokenVault, pkceAuthManager) + val unifiedSessionsViewModel = remember { + UnifiedSessionsViewModel(sessionRepo, connectionManager) } - val sessionsViewModel = remember { - SessionsViewModel(gatewayRepo) - } - val chatViewModel = remember { - ChatViewModel(gatewayRepo) + val hostsViewModel = remember { + HostsViewModel(connectionManager, tokenVault, pkceAuthManager = pkceAuthManager) } NavHost( navController = navController, - startDestination = "connections" + startDestination = "unified_sessions" ) { - composable("connections") { - ConnectionsScreen( - viewModel = connectionsViewModel, - onNavigateToSessions = { connId -> - sessionsViewModel.loadSessions() - navController.navigate("sessions/$connId") + composable("unified_sessions") { + UnifiedSessionsScreen( + viewModel = unifiedSessionsViewModel, + onNavigateToChat = { sessionId -> + navController.navigate("chat/${sessionId.value}") + }, + onNavigateToHosts = { + navController.navigate("hosts") } ) } composable( - route = "sessions/{connectionId}", - arguments = listOf(navArgument("connectionId") { type = NavType.StringType }) + route = "chat/{unifiedSessionId}", + arguments = listOf(navArgument("unifiedSessionId") { type = NavType.StringType }) ) { backStackEntry -> - val connId = backStackEntry.arguments?.getString("connectionId") ?: "" - SessionsScreen( - viewModel = sessionsViewModel, - connectionId = connId, + val sessionIdStr = backStackEntry.arguments?.getString("unifiedSessionId") ?: "" + val sessionId = UnifiedSessionId(sessionIdStr) + val chatViewModel = remember(sessionIdStr) { + ChatViewModel(sessionRepo, connectionManager, sessionId) + } + ChatScreen( + viewModel = chatViewModel, + onNavigateBack = { + navController.popBackStack() + } + ) + } + + composable("hosts") { + HostsScreen( + viewModel = hostsViewModel, onNavigateBack = { navController.popBackStack() }, - onNavigateToChat = { durableSessionId -> - navController.navigate("chat/$connId/$durableSessionId") + onNavigateToNativeSessions = { hostId -> + navController.navigate("native_sessions/${hostId.value}") } ) } composable( - route = "chat/{connectionId}/{durableSessionId}", - arguments = listOf( - navArgument("connectionId") { type = NavType.StringType }, - navArgument("durableSessionId") { type = NavType.StringType } - ) + route = "native_sessions/{hostId}", + arguments = listOf(navArgument("hostId") { type = NavType.StringType }) ) { backStackEntry -> - val durableSessionId = backStackEntry.arguments?.getString("durableSessionId") ?: "" - ChatScreen( - viewModel = chatViewModel, - durableSessionId = durableSessionId, + val hostIdStr = backStackEntry.arguments?.getString("hostId") ?: "" + val hostId = HermesHostId(hostIdStr) + val nativeSessionsViewModel = remember(hostIdStr) { + NativeSessionsViewModel(connectionManager, hostId) + } + NativeSessionsScreen( + viewModel = nativeSessionsViewModel, onNavigateBack = { navController.popBackStack() } diff --git a/app/src/main/java/app/hermes/mobile/core/model/MultiHostModels.kt b/app/src/main/java/app/hermes/mobile/core/model/MultiHostModels.kt new file mode 100644 index 0000000..5400357 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/model/MultiHostModels.kt @@ -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 = 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 = emptyMap(), + val timeline: List = 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 +) diff --git a/app/src/main/java/app/hermes/mobile/core/network/JsonRpcGatewayClient.kt b/app/src/main/java/app/hermes/mobile/core/network/JsonRpcGatewayClient.kt index 3c26ae0..01adc00 100644 --- a/app/src/main/java/app/hermes/mobile/core/network/JsonRpcGatewayClient.kt +++ b/app/src/main/java/app/hermes/mobile/core/network/JsonRpcGatewayClient.kt @@ -58,7 +58,7 @@ class JsonRpcGatewayClient( .readTimeout(0, TimeUnit.MILLISECONDS) // infinite for websockets .pingInterval(30, TimeUnit.SECONDS) .build(), - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) ) { private val json = Json { ignoreUnknownKeys = true @@ -208,8 +208,10 @@ class JsonRpcGatewayClient( gatewayReadyDeferred.complete(Unit) } } - scope.launch { - _events.emit(event) + if (!_events.tryEmit(event)) { + scope.launch { + _events.emit(event) + } } } catch (e: Exception) { // Ignore corrupted frames gracefully or log if debug diff --git a/app/src/main/java/app/hermes/mobile/core/repository/UnifiedSessionRepository.kt b/app/src/main/java/app/hermes/mobile/core/repository/UnifiedSessionRepository.kt new file mode 100644 index 0000000..7f252f8 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/repository/UnifiedSessionRepository.kt @@ -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> = 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>(emptyList()) + val activeApprovals: StateFlow> = _activeApprovals.asStateFlow() + + private val _activeClarify = MutableStateFlow(null) + val activeClarify: StateFlow = _activeClarify.asStateFlow() + + // Mapping from runtimeSessionId to (sessionId, hostId) + private val runtimeToSessionMap = ConcurrentHashMap>() + + // In-memory active session messages cache for reactive streaming updates + private val sessionMessagesState = ConcurrentHashMap>>() + private val sessionExecutingState = ConcurrentHashMap>() + + init { + scope.launch { + connectionManager.allEvents.collect { hostEvent -> + handleHostGatewayEvent(hostEvent) + } + } + } + + fun getSessionMessages(sessionId: UnifiedSessionId): StateFlow> { + return sessionMessagesState.computeIfAbsent(sessionId) { + val flow = MutableStateFlow>(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 { + 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>(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 + ) + } +} diff --git a/app/src/main/java/app/hermes/mobile/core/runtime/HermesConnectionManager.kt b/app/src/main/java/app/hermes/mobile/core/runtime/HermesConnectionManager.kt new file mode 100644 index 0000000..88eb514 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/runtime/HermesConnectionManager.kt @@ -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() + + private val _hosts = MutableStateFlow>(emptyList()) + val hosts: StateFlow> = _hosts.asStateFlow() + + private val _activeHostId = MutableStateFlow(null) + val activeHostId: StateFlow = _activeHostId.asStateFlow() + + private val _allEvents = MutableSharedFlow(extraBufferCapacity = 128) + val allEvents: SharedFlow = _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 { + 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 + ) + } +} diff --git a/app/src/main/java/app/hermes/mobile/core/runtime/HermesHostRuntime.kt b/app/src/main/java/app/hermes/mobile/core/runtime/HermesHostRuntime.kt new file mode 100644 index 0000000..95fbc4a --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/runtime/HermesHostRuntime.kt @@ -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 = _host.asStateFlow() + + val hostId: HermesHostId get() = _host.value.id + + private val _status = MutableStateFlow(initialHost.lastKnownStatus) + val status: StateFlow = _status.asStateFlow() + + private val _serverStatus = MutableStateFlow(null) + val serverStatus: StateFlow = _serverStatus.asStateFlow() + + val connectionState: StateFlow = gatewayClient.connectionState + + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _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 { + 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 { + autoReconnectEnabled = true + return connectInternal() + } + + private suspend fun connectInternal(): Result { + 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" + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/core/security/TokenVault.kt b/app/src/main/java/app/hermes/mobile/core/security/TokenVault.kt index 0ce2304..01c4f71 100644 --- a/app/src/main/java/app/hermes/mobile/core/security/TokenVault.kt +++ b/app/src/main/java/app/hermes/mobile/core/security/TokenVault.kt @@ -10,10 +10,13 @@ import kotlinx.serialization.json.Json import java.util.concurrent.ConcurrentHashMap interface TokenVault { - fun saveTokens(connectionId: String, tokens: NativeAuthTokens) - fun getTokens(connectionId: String): NativeAuthTokens? - fun clearTokens(connectionId: String) - fun getAllConnectionIds(): Set + fun saveTokens(hostId: String, tokens: NativeAuthTokens) + fun getTokens(hostId: String): NativeAuthTokens? + fun clearTokens(hostId: String) + fun getAllHostIds(): Set + + // Backwards-compatible aliases + fun getAllConnectionIds(): Set = getAllHostIds() } class EncryptedTokenVault(context: Context) : TokenVault { @@ -34,13 +37,13 @@ class EncryptedTokenVault(context: Context) : TokenVault { throw SecurityException("Keystore encryption required for token storage", e) } - override fun saveTokens(connectionId: String, tokens: NativeAuthTokens) { + override fun saveTokens(hostId: String, tokens: NativeAuthTokens) { val serialized = json.encodeToString(tokens) - prefs.edit().putString("conn_$connectionId", serialized).apply() + prefs.edit().putString("conn_$hostId", serialized).apply() } - override fun getTokens(connectionId: String): NativeAuthTokens? { - val raw = prefs.getString("conn_$connectionId", null) ?: return null + override fun getTokens(hostId: String): NativeAuthTokens? { + val raw = prefs.getString("conn_$hostId", null) ?: return null return try { json.decodeFromString(raw) } catch (e: Exception) { @@ -48,11 +51,11 @@ class EncryptedTokenVault(context: Context) : TokenVault { } } - override fun clearTokens(connectionId: String) { - prefs.edit().remove("conn_$connectionId").apply() + override fun clearTokens(hostId: String) { + prefs.edit().remove("conn_$hostId").apply() } - override fun getAllConnectionIds(): Set { + override fun getAllHostIds(): Set { return prefs.all.keys .filter { it.startsWith("conn_") } .map { it.removePrefix("conn_") } @@ -63,19 +66,19 @@ class EncryptedTokenVault(context: Context) : TokenVault { class InMemoryTokenVault : TokenVault { private val storage = ConcurrentHashMap() - override fun saveTokens(connectionId: String, tokens: NativeAuthTokens) { - storage[connectionId] = tokens + override fun saveTokens(hostId: String, tokens: NativeAuthTokens) { + storage[hostId] = tokens } - override fun getTokens(connectionId: String): NativeAuthTokens? { - return storage[connectionId] + override fun getTokens(hostId: String): NativeAuthTokens? { + return storage[hostId] } - override fun clearTokens(connectionId: String) { - storage.remove(connectionId) + override fun clearTokens(hostId: String) { + storage.remove(hostId) } - override fun getAllConnectionIds(): Set { + override fun getAllHostIds(): Set { return storage.keys.toSet() } } diff --git a/app/src/main/java/app/hermes/mobile/core/storage/Daos.kt b/app/src/main/java/app/hermes/mobile/core/storage/Daos.kt new file mode 100644 index 0000000..0ba81cf --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/storage/Daos.kt @@ -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> + + @Query("SELECT * FROM hosts ORDER BY displayName ASC") + suspend fun getHosts(): List + + @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) + + @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> + + @Query("SELECT * FROM unified_sessions ORDER BY updatedAt DESC") + suspend fun getSessions(): List + + @Transaction + @Query("SELECT * FROM unified_sessions WHERE id = :sessionId LIMIT 1") + fun getSessionWithDetailsFlow(sessionId: String): Flow + + @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 + + @Query("SELECT * FROM host_bindings WHERE sessionId = :sessionId") + suspend fun getBindingsForSession(sessionId: String): List + + @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) + + @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) + + @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) +} diff --git a/app/src/main/java/app/hermes/mobile/core/storage/Entities.kt b/app/src/main/java/app/hermes/mobile/core/storage/Entities.kt new file mode 100644 index 0000000..8766e1b --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/storage/Entities.kt @@ -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, + @Relation( + parentColumn = "id", + entityColumn = "sessionId" + ) + val messages: List +) diff --git a/app/src/main/java/app/hermes/mobile/core/storage/HermesDatabase.kt b/app/src/main/java/app/hermes/mobile/core/storage/HermesDatabase.kt new file mode 100644 index 0000000..c1c8e78 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/storage/HermesDatabase.kt @@ -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() + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/core/storage/MigrationHelper.kt b/app/src/main/java/app/hermes/mobile/core/storage/MigrationHelper.kt new file mode 100644 index 0000000..3f2287a --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/storage/MigrationHelper.kt @@ -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>(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 + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/core/sync/UnifiedContextBuilder.kt b/app/src/main/java/app/hermes/mobile/core/sync/UnifiedContextBuilder.kt new file mode 100644 index 0000000..565f1b2 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/sync/UnifiedContextBuilder.kt @@ -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 = 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" + } +} diff --git a/app/src/main/java/app/hermes/mobile/feature/chat/ApprovalCard.kt b/app/src/main/java/app/hermes/mobile/feature/chat/ApprovalCard.kt index 14f751a..6f6902e 100644 --- a/app/src/main/java/app/hermes/mobile/feature/chat/ApprovalCard.kt +++ b/app/src/main/java/app/hermes/mobile/feature/chat/ApprovalCard.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Terminal import androidx.compose.material3.Button @@ -33,13 +34,16 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import app.hermes.mobile.core.model.HermesApproval +import app.hermes.mobile.core.model.HostAttributedApproval @Composable fun ApprovalCard( - approval: HermesApproval, + attributedApproval: HostAttributedApproval, onRespond: (choice: String, all: Boolean) -> Unit ) { + val approval = attributedApproval.approval + val hostDisplayName = attributedApproval.hostDisplayName + Card( shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( @@ -50,20 +54,49 @@ fun ApprovalCard( .border(1.5.dp, Color(0xFFF59E0B), RoundedCornerShape(16.dp)) ) { Column(modifier = Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.Shield, - contentDescription = null, - tint = Color(0xFFF59E0B), - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Action Authorization Required", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = Color(0xFFF59E0B) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Shield, + contentDescription = null, + tint = Color(0xFFF59E0B), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Authorization Required", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = Color(0xFFF59E0B) + ) + } + + // Host Origin Badge + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFF38BDF8).copy(alpha = 0.2f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Icon( + Icons.Default.Dns, + contentDescription = null, + tint = Color(0xFF38BDF8), + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = hostDisplayName, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF38BDF8) + ) + } } if (!approval.description.isNullOrBlank()) { diff --git a/app/src/main/java/app/hermes/mobile/feature/chat/ChatScreen.kt b/app/src/main/java/app/hermes/mobile/feature/chat/ChatScreen.kt index 1b48c1a..b80f617 100644 --- a/app/src/main/java/app/hermes/mobile/feature/chat/ChatScreen.kt +++ b/app/src/main/java/app/hermes/mobile/feature/chat/ChatScreen.kt @@ -26,15 +26,19 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Lightbulb import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Sync import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -59,25 +63,29 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import app.hermes.mobile.core.model.HermesMessage +import app.hermes.mobile.core.model.HermesHost +import app.hermes.mobile.core.model.HermesHostId +import app.hermes.mobile.core.model.HostStatus import app.hermes.mobile.core.model.MessageRole import app.hermes.mobile.core.model.ToolActivity +import app.hermes.mobile.core.model.UnifiedMessage +import app.hermes.mobile.core.model.UnifiedMessageSource @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( viewModel: ChatViewModel, - durableSessionId: String, onNavigateBack: () -> Unit ) { val messages by viewModel.messages.collectAsState() val approvals by viewModel.activeApprovals.collectAsState() val activeClarify by viewModel.activeClarify.collectAsState() - val sessionInfo by viewModel.sessionInfo.collectAsState() val isExecuting by viewModel.isExecuting.collectAsState() val uiState by viewModel.uiState.collectAsState() - val activeConn by viewModel.activeConnection.collectAsState() + val hosts by viewModel.hosts.collectAsState() + val currentSession by viewModel.currentSession.collectAsState() + val activeHost = hosts.find { it.id == currentSession?.activeHostId } val listState = rememberLazyListState() LaunchedEffect(messages.size, messages.lastOrNull()?.content?.length, approvals.size) { @@ -93,17 +101,72 @@ fun ChatScreen( title = { Column { Text( - text = sessionInfo?.model ?: activeConn?.name ?: "Hermes Chat", + text = currentSession?.title?.ifEmpty { "Unified Chat" } ?: "Unified Chat", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Text( - text = "Session: ${durableSessionId.take(8)}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + // Active host selector chip + Box { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { viewModel.setHostDropdownExpanded(true) } + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + val isOnline = activeHost?.lastKnownStatus == HostStatus.ONLINE + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background(if (isOnline) Color(0xFF10B981) else Color(0xFF94A3B8)) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = activeHost?.displayName ?: "Select Host", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.width(2.dp)) + Icon( + Icons.Default.ExpandMore, + contentDescription = "Switch Host", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + DropdownMenu( + expanded = uiState.activeHostDropdownExpanded, + onDismissRequest = { viewModel.setHostDropdownExpanded(false) } + ) { + hosts.forEach { host -> + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + val online = host.lastKnownStatus == HostStatus.ONLINE + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (online) Color(0xFF10B981) else Color(0xFF94A3B8)) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = host.displayName, + fontWeight = if (host.id == currentSession?.activeHostId) FontWeight.Bold else FontWeight.Normal + ) + } + }, + onClick = { viewModel.switchActiveHost(host.id) } + ) + } + } + } } }, navigationIcon = { @@ -148,14 +211,19 @@ fun ChatScreen( contentPadding = PaddingValues(vertical = 12.dp) ) { items(messages, key = { it.id }) { message -> - MessageItem(message = message) + if (message.source == UnifiedMessageSource.TRANSFER) { + TransferSeparator(message = message) + } else { + val host = hosts.find { it.id == message.hostId } + MessageItem(message = message, hostDisplayName = host?.displayName) + } } - items(approvals, key = { it.requestId }) { approval -> + items(approvals, key = { it.hostId.value + it.approval.requestId }) { approval -> ApprovalCard( - approval = approval, + attributedApproval = approval, onRespond = { choice, all -> - viewModel.respondApproval(approval.requestId, choice, all) + viewModel.respondApproval(approval.hostId, approval.approval.requestId, choice, all) } ) } @@ -163,13 +231,16 @@ fun ChatScreen( if (isExecuting && messages.lastOrNull()?.isStreaming != true && approvals.isEmpty()) { item { Row( - modifier = Modifier.fillMaxWidth().padding(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) Spacer(modifier = Modifier.width(8.dp)) + val hostLabel = activeHost?.displayName ?: "Hermes" Text( - "Hermes is thinkingโ€ฆ", + "$hostLabel is thinkingโ€ฆ", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -180,8 +251,8 @@ fun ChatScreen( if (activeClarify != null) { ClarifyDialog( - request = activeClarify!!, - onDismiss = { viewModel.dismissClarify() }, + attributedClarify = activeClarify!!, + onDismiss = { /* dismiss */ }, onSubmit = { value -> viewModel.respondClarify(activeClarify!!, value) } @@ -191,13 +262,69 @@ fun ChatScreen( } @Composable -fun MessageItem(message: HermesMessage) { +fun TransferSeparator(message: UnifiedMessage) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF38BDF8).copy(alpha = 0.12f)) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Sync, + contentDescription = null, + tint = Color(0xFF0284C7), + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = message.content, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF0284C7) + ) + } + } + } +} + +@Composable +fun MessageItem(message: UnifiedMessage, hostDisplayName: String?) { val isUser = message.role == MessageRole.USER Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = if (isUser) Alignment.End else Alignment.Start ) { + // Host attribution badge for assistant responses + if (!isUser && hostDisplayName != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 4.dp, start = 4.dp) + ) { + Icon( + Icons.Default.Dns, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = hostDisplayName, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + // Thinking Collapsible if (!message.thinking.isNullOrBlank()) { ThinkingSection(thinking = message.thinking) diff --git a/app/src/main/java/app/hermes/mobile/feature/chat/ChatViewModel.kt b/app/src/main/java/app/hermes/mobile/feature/chat/ChatViewModel.kt index 15b5380..50f2134 100644 --- a/app/src/main/java/app/hermes/mobile/feature/chat/ChatViewModel.kt +++ b/app/src/main/java/app/hermes/mobile/feature/chat/ChatViewModel.kt @@ -2,14 +2,9 @@ package app.hermes.mobile.feature.chat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.hermes.mobile.core.model.ClarifyType -import app.hermes.mobile.core.model.DurableSessionId -import app.hermes.mobile.core.model.HermesApproval -import app.hermes.mobile.core.model.HermesClarifyRequest -import app.hermes.mobile.core.model.HermesMessage -import app.hermes.mobile.core.model.SessionInfo -import app.hermes.mobile.core.network.ConnectionState -import app.hermes.mobile.core.repository.HermesGatewayRepository +import app.hermes.mobile.core.model.* +import app.hermes.mobile.core.repository.UnifiedSessionRepository +import app.hermes.mobile.core.runtime.HermesConnectionManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,29 +12,59 @@ import kotlinx.coroutines.launch data class ChatUiState( val error: String? = null, - val inputText: String = "" + val inputText: String = "", + val activeHostDropdownExpanded: Boolean = false ) class ChatViewModel( - private val gatewayRepo: HermesGatewayRepository + val sessionRepo: UnifiedSessionRepository, + val connectionManager: HermesConnectionManager, + private val sessionId: UnifiedSessionId ) : ViewModel() { - val messages: StateFlow> = gatewayRepo.messages - val activeApprovals: StateFlow> = gatewayRepo.activeApprovals - val activeClarify: StateFlow = gatewayRepo.activeClarify - val sessionInfo: StateFlow = gatewayRepo.sessionInfo - val isExecuting: StateFlow = gatewayRepo.isExecuting - val connectionState: StateFlow = gatewayRepo.connectionState - val activeConnection = gatewayRepo.activeConnection - val activeDurableId: StateFlow = gatewayRepo.activeDurableId + val hosts: StateFlow> = connectionManager.hosts + val messages: StateFlow> = sessionRepo.getSessionMessages(sessionId) + val isExecuting: StateFlow = sessionRepo.getSessionExecuting(sessionId) + val activeApprovals: StateFlow> = sessionRepo.activeApprovals + val activeClarify: StateFlow = sessionRepo.activeClarify private val _uiState = MutableStateFlow(ChatUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _currentSession = MutableStateFlow(null) + val currentSession: StateFlow = _currentSession.asStateFlow() + + init { + loadSession() + } + + fun loadSession() { + viewModelScope.launch { + _currentSession.value = sessionRepo.getUnifiedSession(sessionId) + } + } + fun updateInputText(text: String) { _uiState.value = _uiState.value.copy(inputText = text) } + fun setHostDropdownExpanded(expanded: Boolean) { + _uiState.value = _uiState.value.copy(activeHostDropdownExpanded = expanded) + } + + fun switchActiveHost(targetHostId: HermesHostId) { + viewModelScope.launch { + try { + sessionRepo.switchSessionActiveHost(sessionId, targetHostId) + connectionManager.switchActiveHost(targetHostId) + loadSession() + _uiState.value = _uiState.value.copy(activeHostDropdownExpanded = false) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(error = e.localizedMessage ?: "Failed to switch host") + } + } + } + fun submitPrompt() { val text = _uiState.value.inputText.trim() if (text.isEmpty()) return @@ -47,7 +72,8 @@ class ChatViewModel( _uiState.value = _uiState.value.copy(inputText = "", error = null) viewModelScope.launch { try { - gatewayRepo.sendUserPrompt(text) + sessionRepo.sendPrompt(sessionId, text) + loadSession() } catch (e: Exception) { _uiState.value = _uiState.value.copy( error = e.localizedMessage ?: "Failed to submit prompt" @@ -58,14 +84,14 @@ class ChatViewModel( fun interruptSession() { viewModelScope.launch { - gatewayRepo.interruptSession() + sessionRepo.interruptSession(sessionId) } } - fun respondApproval(requestId: String, choice: String, all: Boolean = false) { + fun respondApproval(hostId: HermesHostId, requestId: String, choice: String, all: Boolean = false) { viewModelScope.launch { try { - gatewayRepo.respondApproval(requestId, choice, all) + sessionRepo.respondApproval(hostId, requestId, choice, all) } catch (e: Exception) { _uiState.value = _uiState.value.copy( error = e.localizedMessage ?: "Failed to respond to approval" @@ -74,13 +100,15 @@ class ChatViewModel( } } - fun respondClarify(request: HermesClarifyRequest, answer: String) { + fun respondClarify(attributed: HostAttributedClarify, answer: String) { viewModelScope.launch { try { - when (request.promptType) { - ClarifyType.CLARIFY -> gatewayRepo.respondClarify(request.requestId, answer, request.questionId) - ClarifyType.SUDO -> gatewayRepo.respondSudo(request.requestId, answer) - ClarifyType.SECRET -> gatewayRepo.respondSecret(request.requestId, answer) + val hostId = attributed.hostId + val req = attributed.request + when (req.promptType) { + ClarifyType.CLARIFY -> sessionRepo.respondClarify(hostId, req.requestId, answer, req.questionId) + ClarifyType.SUDO -> sessionRepo.respondSudo(hostId, req.requestId, answer) + ClarifyType.SECRET -> sessionRepo.respondSecret(hostId, req.requestId, answer) } } catch (e: Exception) { _uiState.value = _uiState.value.copy( @@ -89,8 +117,4 @@ class ChatViewModel( } } } - - fun dismissClarify() { - // Can be cancelled or handled - } } diff --git a/app/src/main/java/app/hermes/mobile/feature/chat/ClarifyDialog.kt b/app/src/main/java/app/hermes/mobile/feature/chat/ClarifyDialog.kt index b6a1410..a35a7c7 100644 --- a/app/src/main/java/app/hermes/mobile/feature/chat/ClarifyDialog.kt +++ b/app/src/main/java/app/hermes/mobile/feature/chat/ClarifyDialog.kt @@ -1,11 +1,20 @@ package app.hermes.mobile.feature.chat +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.AlertDialog @@ -20,21 +29,27 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import app.hermes.mobile.core.model.ClarifyType -import app.hermes.mobile.core.model.HermesClarifyRequest +import app.hermes.mobile.core.model.HostAttributedClarify @Composable fun ClarifyDialog( - request: HermesClarifyRequest, + attributedClarify: HostAttributedClarify, onDismiss: () -> Unit, onSubmit: (value: String) -> Unit ) { var input by remember { mutableStateOf("") } + val request = attributedClarify.request + val hostDisplayName = attributedClarify.hostDisplayName val isMasked = request.promptType == ClarifyType.SUDO || request.promptType == ClarifyType.SECRET val title = when (request.promptType) { @@ -52,7 +67,33 @@ fun ClarifyDialog( AlertDialog( onDismissRequest = onDismiss, icon = { Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }, - title = { Text(title, fontWeight = FontWeight.Bold) }, + title = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(title, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Icon( + Icons.Default.Dns, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = hostDisplayName, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + }, text = { Column(modifier = Modifier.fillMaxWidth()) { Text( diff --git a/app/src/main/java/app/hermes/mobile/feature/hosts/HostsScreen.kt b/app/src/main/java/app/hermes/mobile/feature/hosts/HostsScreen.kt new file mode 100644 index 0000000..255f959 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/hosts/HostsScreen.kt @@ -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") + } + } + ) +} diff --git a/app/src/main/java/app/hermes/mobile/feature/hosts/HostsViewModel.kt b/app/src/main/java/app/hermes/mobile/feature/hosts/HostsViewModel.kt new file mode 100644 index 0000000..6e9f0ee --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/hosts/HostsViewModel.kt @@ -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> = connectionManager.hosts + val activeHostId: StateFlow = connectionManager.activeHostId + + private val _uiState = MutableStateFlow(HostsUiState()) + val uiState: StateFlow = _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" + ) + } + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/feature/native_sessions/NativeSessionsScreen.kt b/app/src/main/java/app/hermes/mobile/feature/native_sessions/NativeSessionsScreen.kt new file mode 100644 index 0000000..aabbb85 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/native_sessions/NativeSessionsScreen.kt @@ -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)) +} diff --git a/app/src/main/java/app/hermes/mobile/feature/native_sessions/NativeSessionsViewModel.kt b/app/src/main/java/app/hermes/mobile/feature/native_sessions/NativeSessionsViewModel.kt new file mode 100644 index 0000000..068de1c --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/native_sessions/NativeSessionsViewModel.kt @@ -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 = 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 = _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" + ) + } + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/feature/unified_sessions/UnifiedSessionsScreen.kt b/app/src/main/java/app/hermes/mobile/feature/unified_sessions/UnifiedSessionsScreen.kt new file mode 100644 index 0000000..395a04d --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/unified_sessions/UnifiedSessionsScreen.kt @@ -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, + 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)) +} diff --git a/app/src/main/java/app/hermes/mobile/feature/unified_sessions/UnifiedSessionsViewModel.kt b/app/src/main/java/app/hermes/mobile/feature/unified_sessions/UnifiedSessionsViewModel.kt new file mode 100644 index 0000000..5788c4f --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/unified_sessions/UnifiedSessionsViewModel.kt @@ -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> = sessionRepo.sessions + val hosts: StateFlow> = connectionManager.hosts + val activeHostId: StateFlow = connectionManager.activeHostId + + private val _uiState = MutableStateFlow(UnifiedSessionsUiState()) + val uiState: StateFlow = _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") + } + } + } +} diff --git a/app/src/test/java/app/hermes/mobile/core/repository/ApprovalRoutingTest.kt b/app/src/test/java/app/hermes/mobile/core/repository/ApprovalRoutingTest.kt new file mode 100644 index 0000000..2d4513e --- /dev/null +++ b/app/src/test/java/app/hermes/mobile/core/repository/ApprovalRoutingTest.kt @@ -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) + } +} diff --git a/app/src/test/java/app/hermes/mobile/core/repository/UnifiedSessionRepositoryTest.kt b/app/src/test/java/app/hermes/mobile/core/repository/UnifiedSessionRepositoryTest.kt new file mode 100644 index 0000000..31a7fb5 --- /dev/null +++ b/app/src/test/java/app/hermes/mobile/core/repository/UnifiedSessionRepositoryTest.kt @@ -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) + } +} diff --git a/app/src/test/java/app/hermes/mobile/core/runtime/MultiHostConcurrencyTest.kt b/app/src/test/java/app/hermes/mobile/core/runtime/MultiHostConcurrencyTest.kt new file mode 100644 index 0000000..219af00 --- /dev/null +++ b/app/src/test/java/app/hermes/mobile/core/runtime/MultiHostConcurrencyTest.kt @@ -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) + } +} diff --git a/app/src/test/java/app/hermes/mobile/core/security/HostScopedTokenManagementTest.kt b/app/src/test/java/app/hermes/mobile/core/security/HostScopedTokenManagementTest.kt new file mode 100644 index 0000000..2a135ad --- /dev/null +++ b/app/src/test/java/app/hermes/mobile/core/security/HostScopedTokenManagementTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/app/hermes/mobile/core/storage/FakeDaos.kt b/app/src/test/java/app/hermes/mobile/core/storage/FakeDaos.kt new file mode 100644 index 0000000..d02ba5d --- /dev/null +++ b/app/src/test/java/app/hermes/mobile/core/storage/FakeDaos.kt @@ -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() + private val flow = MutableStateFlow>(emptyList()) + + private fun updateFlow() { + flow.value = storage.values.sortedBy { it.displayName } + } + + override fun getHostsFlow(): Flow> = flow + + override suspend fun getHosts(): List = 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) { + 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() + private val bindings = mutableMapOf>() + private val messages = mutableMapOf>() + private val sessionsFlow = MutableStateFlow>(emptyList()) + + private fun updateFlow() { + sessionsFlow.value = sessions.values.sortedByDescending { it.updatedAt } + } + + override fun getSessionsFlow(): Flow> = sessionsFlow + + override suspend fun getSessions(): List = sessions.values.sortedByDescending { it.updatedAt } + + override fun getSessionWithDetailsFlow(sessionId: String): Flow { + 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 { + return messages[sessionId]?.sortedBy { it.createdAt } ?: emptyList() + } + + override suspend fun getBindingsForSession(sessionId: String): List { + 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) { + 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) { + 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) + } + } +} diff --git a/app/src/test/java/app/hermes/mobile/core/sync/UnifiedContextBuilderTest.kt b/app/src/test/java/app/hermes/mobile/core/sync/UnifiedContextBuilderTest.kt new file mode 100644 index 0000000..c6a3389 --- /dev/null +++ b/app/src/test/java/app/hermes/mobile/core/sync/UnifiedContextBuilderTest.kt @@ -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) + } +} diff --git a/app/src/test/java/app/hermes/mobile/feature/chat/ChatViewModelTest.kt b/app/src/test/java/app/hermes/mobile/feature/chat/ChatViewModelTest.kt index b47e8ca..3b77781 100644 --- a/app/src/test/java/app/hermes/mobile/feature/chat/ChatViewModelTest.kt +++ b/app/src/test/java/app/hermes/mobile/feature/chat/ChatViewModelTest.kt @@ -1,14 +1,17 @@ package app.hermes.mobile.feature.chat -import app.hermes.mobile.core.model.DurableSessionId -import app.hermes.mobile.core.model.HermesApproval -import app.hermes.mobile.core.model.HermesMessage -import app.hermes.mobile.core.model.MessageRole -import app.hermes.mobile.core.model.RuntimeSessionId +import app.hermes.mobile.core.model.HermesHost +import app.hermes.mobile.core.model.HermesHostId +import app.hermes.mobile.core.model.HostAttributedApproval +import app.hermes.mobile.core.model.HostAttributedClarify +import app.hermes.mobile.core.model.UnifiedSessionId import app.hermes.mobile.core.network.HermesRestClient -import app.hermes.mobile.core.network.JsonRpcGatewayClient -import app.hermes.mobile.core.repository.HermesGatewayRepository +import app.hermes.mobile.core.repository.UnifiedSessionRepository +import app.hermes.mobile.core.runtime.HermesConnectionManager import app.hermes.mobile.core.security.InMemoryTokenVault +import app.hermes.mobile.core.storage.FakeHostDao +import app.hermes.mobile.core.storage.FakeUnifiedSessionDao +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -25,20 +28,33 @@ import org.junit.Test class ChatViewModelTest { private val testDispatcher = StandardTestDispatcher() - private lateinit var restClient: HermesRestClient - private lateinit var gatewayClient: JsonRpcGatewayClient + private lateinit var hostDao: FakeHostDao + private lateinit var sessionDao: FakeUnifiedSessionDao private lateinit var tokenVault: InMemoryTokenVault - private lateinit var repository: HermesGatewayRepository + private lateinit var connectionManager: HermesConnectionManager + private lateinit var repository: UnifiedSessionRepository private lateinit var viewModel: ChatViewModel + private val sessionId = UnifiedSessionId("test-session-123") + private val hostId = HermesHostId("host-main") + @Before fun setUp() { Dispatchers.setMain(testDispatcher) - restClient = HermesRestClient() - gatewayClient = JsonRpcGatewayClient() + hostDao = FakeHostDao() + sessionDao = FakeUnifiedSessionDao() tokenVault = InMemoryTokenVault() - repository = HermesGatewayRepository(restClient, gatewayClient, tokenVault) - viewModel = ChatViewModel(repository) + connectionManager = HermesConnectionManager(hostDao, tokenVault, scope = CoroutineScope(testDispatcher)) + repository = UnifiedSessionRepository(connectionManager, sessionDao, scope = CoroutineScope(testDispatcher)) + + val host = HermesHost(id = hostId, displayName = "Main Host", baseUrl = "http://localhost:9119") + runTest(testDispatcher) { + connectionManager.addHost(host) + repository.createUnifiedSession("Test Chat", hostId) + testScheduler.advanceUntilIdle() + } + + viewModel = ChatViewModel(repository, connectionManager, sessionId) } @After @@ -61,32 +77,28 @@ class ChatViewModelTest { } @Test - fun testMessageHandlingStateFlow() { - val initialMessages = viewModel.messages.value - assertEquals(0, initialMessages.size) + fun testHostDropdownToggle() { + assertEquals(false, viewModel.uiState.value.activeHostDropdownExpanded) + viewModel.setHostDropdownExpanded(true) + assertEquals(true, viewModel.uiState.value.activeHostDropdownExpanded) } @Test - fun testClarifyRequestHandling() = runTest(testDispatcher) { - val clarifyReq = app.hermes.mobile.core.model.HermesClarifyRequest( - requestId = "req_101", - questionId = "q_param", - question = "Which database?", - promptType = app.hermes.mobile.core.model.ClarifyType.CLARIFY - ) - viewModel.respondClarify(clarifyReq, "PostgreSQL") - // No crash, handled gracefully when disconnected - } + fun testSwitchActiveHost() = runTest(testDispatcher) { + val host2Id = HermesHostId("host-secondary") + val host2 = HermesHost(id = host2Id, displayName = "Secondary Host", baseUrl = "http://second:9119") + connectionManager.addHost(host2) + testScheduler.advanceUntilIdle() - @Test - fun testApprovalHandling() = runTest(testDispatcher) { - viewModel.respondApproval("req_app_1", "once", false) - // Handled gracefully + viewModel.switchActiveHost(host2Id) + testScheduler.advanceUntilIdle() + + assertEquals(false, viewModel.uiState.value.activeHostDropdownExpanded) } @Test fun testInterruptHandling() = runTest(testDispatcher) { viewModel.interruptSession() - // Handled gracefully + // No crash, handled gracefully } } diff --git a/build.gradle.kts b/build.gradle.kts index 6eeba4e..d7cd1c7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,5 @@ plugins { id("org.jetbrains.kotlin.android") version "2.1.10" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.1.10" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10" apply false + id("com.google.devtools.ksp") version "2.1.10-1.0.29" apply false }