Initial commit of HermesControl Android app

This commit is contained in:
Hy4ri committed 2026-06-16 00:00:36 +03:00
commit d6ff3de594
92 files changed
+10004

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
root = true
[*.{kt,kts}]
indent_size = 4
insert_final_newline = true
max_line_length = 120
ktlint_standard_no-wildcard-imports = disabled
ktlint_function_naming_ignore_when_annotated_with = Composable
+1
View File
@@ -0,0 +1 @@
use flake
+15
View File
@@ -0,0 +1,15 @@
*.iml
.gradle
.gradle-home
$PWD
.agents
*.log
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
*.apk
+594
View File
@@ -0,0 +1,594 @@
# Android App for Hermes — Chat & Control
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Build an Android app that lets m57 chat with his Hermes agent instance and send remote control commands from anywhere, directly from his phone.
**Architecture:** The app talks directly to Hermes' built-in **Dashboard REST API** (port 9119) for management and **Dashboard WebSocket** (`/api/ws`) for real-time chat via JSON-RPC. **No custom bridge needed.**
**Tech Stack:** Android (Kotlin/Jetpack Compose), OkHttp (WebSocket + Retrofit), JSON-RPC protocol, Hermes Dashboard API.
---
## Current State (What Already Works)
| Component | Status | Endpoint |
|-----------|--------|----------|
| **Dashboard REST API** | ✅ Running on `127.0.0.1:9119` | `GET /api/status`, sessions, config, etc. |
| **TUI Gateway WebSocket** | ✅ Running at `/api/ws?token=<TOKEN>` | JSON-RPC over WebSocket |
| **Auth (loopback mode)** | ✅ `Authorization: Bearer <TOKEN>` or `X-Hermes-Session-Token: <TOKEN>` | For REST API |
| **WebSocket Auth** | ✅ `?token=<TOKEN>` query param | For WS upgrade |
| **Dashboard Token** | ✅ Stored in `~/.hermes/.env` | `HERMES_DASHBOARD_SESSION_TOKEN=y9oAS3...iTZ4` |
**Available JSON-RPC Methods (for Chat):**
- `session.list` — List all sessions
- `session.active_list` — List active sessions
- `session.status` — Get session status
- `session.history` — Get session message history
- `session.resume` — Resume an existing session
- `session.create` — Create a new session
- `prompt.submit` — **Send a message to Hermes!** (key endpoint)
- `session.interrupt` — Interrupt a response
- `session.steer` — Steer the agent
- `clarify.respond` — Respond to clarifications
- `approval.respond` — Approve/reject commands
- `session.delete` — Delete sessions
- `session.title` — Set session title
- `config.get` / `config.set` — Manage config
- `image.attach`, `file.attach` — Attach files
- And many more...
**Available REST Endpoints (for Management):**
- `GET /api/status` — Hermes version, gateway status, active sessions
- `GET /api/sessions` — List sessions with metadata
- `GET /api/sessions/{id}/messages` — Get messages for a session
- `GET /api/system/stats` — System stats
- `GET /api/config` — Full config
- `GET /api/logs` — Logs
- `GET /api/cron/jobs` — Cron jobs
- And many more (check /api/docs)
---
## Architecture
```
┌──────────────────────────────────────────────────────┐
│ Android Device │
│ ┌─────────────────┐ ┌────────────────────────┐ │
│ │ Android App │ │ Hermes (Termux/Ubuntu)│ │
│ │ (Kotlin/Compose)│────▶│ Dashboard (127.0.0.1) │ │
│ │ │ │ │ │
│ │ • REST client │────▶│ REST API :9119 │ │
│ │ • WS client │────▶│ WS /api/ws?token=... │ │
│ │ • Local cache │ │ TUI Gateway (JSON-RPC)│ │
│ └─────────────────┘ └────────────────────────┘ │
└──────────────────────────────────────────────────────┘
```
Since Hermes runs on the same Android device in Termux, the app connects to **localhost** — no network issues, no CORS, no latency.
---
## Phase 1: Android Project Setup
### Task 1.1: Scaffold Android Project
**Objective:** Create a new Android project with Jetpack Compose and the right SDK.
**Actions:**
1. Open Android Studio → New Project → Empty Compose Activity
2. Name: `HermesControl`, Package: `com.m57.hermescontrol`
3. Language: **Kotlin**, Minimum SDK: **26**
4. Build system: Gradle with Kotlin DSL
**Files:**
- `app/build.gradle.kts` — Dependencies
- `AndroidManifest.xml` — Internet permission
**Verify:** App builds and runs on device/emulator.
---
### Task 1.2: Add Dependencies
**Objective:** Add OkHttp (WebSocket + HTTP), Gson for JSON-RPC.
**File:** `app/build.gradle.kts`
```kotlin
dependencies {
// Core
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
// Compose BOM
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
// Networking
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// JSON
implementation("com.google.code.gson:gson:2.10.1")
// Local storage
implementation("androidx.security:security-crypto:1.1.0-alpha06")
}
```
**Verify:** Gradle sync succeeds.
---
## Phase 2: REST API Client (Management)
### Task 2.1: Data Models
**Objective:** Create Kotlin data classes for REST API responses.
**Files:**
- `data/model/StatusResponse.kt`
- `data/model/SessionListResponse.kt`
- `data/model/SessionMessagesResponse.kt`
```kotlin
// data/model/StatusResponse.kt
data class StatusResponse(
val version: String,
val gateway_running: Boolean,
val active_sessions: Int,
val gateway_platforms: Map<String, PlatformStatus>
)
data class PlatformStatus(
val state: String,
val error_code: String? = null
)
```
**Verify:** Compiles cleanly.
---
### Task 2.2: REST API Interface
**Objective:** Create Retrofit service for Hermes dashboard REST API.
**Files:**
- `data/remote/HermesApiService.kt`
- `data/remote/ApiClient.kt`
```kotlin
// data/remote/HermesApiService.kt
interface HermesApiService {
@GET("api/status")
suspend fun getStatus(): Response<StatusResponse>
@GET("api/sessions")
suspend fun getSessions(): Response<SessionListResponse>
@GET("api/sessions/{id}/messages")
suspend fun getSessionMessages(@Path("id") sessionId: String): Response<SessionMessagesResponse>
@GET("api/system/stats")
suspend fun getSystemStats(): Response<SystemStatsResponse>
}
// data/remote/ApiClient.kt
object ApiClient {
private val client = OkHttpClient.Builder()
.addInterceptor { chain ->
val original = chain.request()
val request = original.newBuilder()
.header("Authorization", "Bearer ${AuthManager.getToken()}")
.build()
chain.proceed(request)
}
.build()
val retrofit = Retrofit.Builder()
.baseUrl("http://127.0.0.1:9119/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
val hermesApi: HermesApiService = retrofit.create(HermesApiService::class.java)
}
```
**Verify:** API calls return valid data.
---
## Phase 3: WebSocket Client (Chat)
### Task 3.1: JSON-RPC Models
**Objective:** Create models for JSON-RPC over WebSocket.
**Files:**
- `data/ws/JsonRpcRequest.kt`
- `data/ws/JsonRpcResponse.kt`
- `data/ws/WsMethods.kt`
```kotlin
// data/ws/JsonRpcRequest.kt
data class JsonRpcRequest(
val jsonrpc: String = "2.0",
val id: String,
val method: String,
val params: Map<String, Any> = emptyMap()
)
// data/ws/WsMethods.kt
object WsMethods {
const val SESSION_LIST = "session.list"
const val SESSION_ACTIVE_LIST = "session.active_list"
const val SESSION_STATUS = "session.status"
const val SESSION_HISTORY = "session.history"
const val SESSION_RESUME = "session.resume"
const val SESSION_CREATE = "session.create"
const val PROMPT_SUBMIT = "prompt.submit"
const val SESSION_INTERRUPT = "session.interrupt"
}
```
**Verify:** Data classes serialize/deserialize correctly with Gson.
---
### Task 3.2: WebSocket Client
**Objective:** Implement WebSocket connection to `/api/ws?token=<TOKEN>` with reconnection support.
**Files:**
- `data/ws/HermesWsClient.kt`
```kotlin
// data/ws/HermesWsClient.kt
class HermesWsClient(
private val token: String,
private val onMessage: (JsonRpcResponse) -> Unit,
private val onConnected: () -> Unit,
private val onDisconnected: (String) -> Unit
) {
private val client = OkHttpClient()
private var ws: WebSocket? = null
private val requestId = AtomicInteger(0)
fun connect() {
val url = "ws://127.0.0.1:9119/api/ws?token=$token"
val request = Request.Builder().url(url).build()
ws = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(ws: WebSocket, response: Response) {
onConnected()
}
override fun onMessage(ws: WebSocket, text: String) {
val response = Gson().fromJson(text, JsonRpcResponse::class.java)
onMessage(response)
}
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
onDisconnected(t.message ?: "Unknown error")
}
override fun onClosed(ws: WebSocket, code: Int, reason: String) {
onDisconnected(reason)
}
})
}
fun sendMessage(text: String) {
val request = JsonRpcRequest(
id = requestId.incrementAndGet().toString(),
method = WsMethods.PROMPT_SUBMIT,
params = mapOf("text" to text)
)
ws?.send(Gson().toJson(request))
}
fun resumeSession(sessionId: String) {
val request = JsonRpcRequest(
id = requestId.incrementAndGet().toString(),
method = WsMethods.SESSION_RESUME,
params = mapOf("id" to sessionId)
)
ws?.send(Gson().toJson(request))
}
fun disconnect() {
ws?.close(1000, "App closing")
}
}
```
**Verify:** App connects to WebSocket, receives JSON-RPC responses.
---
### Task 3.3: WebSocket Event Parser
**Objective:** Handle the event-based responses from Hermes (responses come as events, not just RPC replies).
**Files:**
- `data/ws/EventParser.kt`
The TUI Gateway sends:
- **RPC responses** (to our requests) with `id` matching
- **Events** (tool calls, streamed tokens, clarify requests) with `event` field
```kotlin
data class JsonRpcResponse(
val jsonrpc: String?,
val id: String?,
val result: Any?,
val error: JsonRpcError?,
val event: String?, // Event type (for push events)
val data: Map<String, Any>? // Event payload
)
data class JsonRpcError(
val code: Int,
val message: String
)
```
**Event types to handle:**
- `message.complete` — Agent finished responding
- `message.token` — Streaming token
- `clarify.request` — Agent needs clarification
- `tool.start` / `tool.complete` — Tool calls
- `session.updated` — Session state changed
**Verify:** Parse events correctly from Gson.
---
## Phase 4: UI Screens
### Task 4.1: Connection Screen
**Objective:** Enter/display connection info (token auto-loaded or configurable).
**Files:**
- `ui/connect/ConnectScreen.kt`
- `ui/connect/ConnectViewModel.kt`
**Features:**
- Text field for token (or load from SharedPreferences)
- "Connect" button
- Connection status indicator
---
### Task 4.2: Chat Screen
**Objective:** Full chat UI with real-time messaging.
**Files:**
- `ui/chat/ChatScreen.kt`
- `ui/chat/ChatViewModel.kt`
- `ui/chat/ChatBubble.kt`
**Features:**
- `LazyColumn` for message list (user + agent bubbles)
- Bottom input bar with send button
- Streaming text (update bubble as tokens arrive)
- Image/file attachment support
- Loading / typing indicator
- Session selector (pick which session to resume)
**Message types:**
- User messages (right-aligned, primary color)
- Agent messages (left-aligned, surface color)
- Tool calls (collapsible, monospace)
- System notifications (centered, muted)
---
### Task 4.3: Command Panel
**Objective:** Quick-action buttons for common Hermes commands.
**Files:**
- `ui/commands/CommandPanel.kt`
- `ui/commands/CommandViewModel.kt`
**Commands (can be customized):**
- `/status` — Check system status
- `/skills` — List skills
- `/memory` — Show recent memories
- `/sessions` — List active sessions
- `🔄 Restart Gateway` — Trigger gateway restart
- `📊 System Stats` — Show CPU/memory
- `⚡ Quick Prompt` — Pre-set prompts
---
### Task 4.4: Settings Screen
**Objective:** Configure connection parameters.
**Files:**
- `ui/settings/SettingsScreen.kt`
- `ui/settings/SettingsViewModel.kt`
**Settings:**
- Host/IP (default: `127.0.0.1`)
- Port (default: `9119`)
- Auth token (loaded from encrypted storage)
- Theme (Light/Dark/System)
- Auto-reconnect toggle
---
## Phase 5: Polish & Security
### Task 5.1: Encrypted Token Storage
**Objective:** Store the dashboard token securely.
**Files:**
- `data/local/AuthManager.kt`
Use Android `EncryptedSharedPreferences` to store the `HERMES_DASHBOARD_SESSION_TOKEN`.
**Verify:** Token persists across app restarts; is encrypted at rest.
---
### Task 5.2: Auto-Reconnect
**Objective:** Reconnect WebSocket if connection drops.
**Files:**
- Update `HermesWsClient.kt` — Exponential backoff reconnection.
**Verify:** Kill dashboard process → app shows "disconnected" → restart dashboard → app reconnects.
---
### Task 5.3: Error Handling & Offline Mode
**Objective:** Graceful handling of network errors.
**Files:**
- Update `ChatViewModel.kt` — Show offline banner, cache last N messages locally.
**Verify:** Turn off network → app shows offline state, cached messages visible.
---
## Task-by-Task Summary
| # | Task | Phase | Est. Time | Status |
|---|------|-------|-----------|--------|
| 1.1 | Scaffold Android Project | 1 | 10 min | ⬜ |
| 1.2 | Add Dependencies | 1 | 10 min | ⬜ |
| 2.1 | REST Data Models | 2 | 15 min | ⬜ |
| 2.2 | REST API Client | 2 | 20 min | ⬜ |
| 3.1 | JSON-RPC Models | 3 | 10 min | ⬜ |
| 3.2 | WebSocket Client | 3 | 30 min | ⬜ |
| 3.3 | Event Parser | 3 | 20 min | ⬜ |
| 4.1 | Connection Screen | 4 | 20 min | ⬜ |
| 4.2 | Chat Screen | 4 | 45 min | ⬜ |
| 4.3 | Command Panel | 4 | 25 min | ⬜ |
| 4.4 | Settings Screen | 4 | 20 min | ⬜ |
| 5.1 | Encrypted Token Storage | 5 | 15 min | ⬜ |
| 5.2 | Auto-Reconnect | 5 | 20 min | ⬜ |
| 5.3 | Error Handling & Offline | 5 | 15 min | ⬜ |
---
## What You DON'T Need Anymore (vs Original Plan)
| Originally Planned | Now? | Reason |
|--------------------|------|--------|
| Custom Python Flask bridge | ❌ Removed | Dashboard IS the bridge |
| Custom SQLite reads | ❌ Removed | Dashboard API handles sessions |
| Hermes DB schema analysis | ❌ Removed | REST API abstracts the DB |
| Queue-based message passing | ❌ Removed | WebSocket JSON-RPC handles it |
---
## App Connection Details
| Setting | Value |
|---------|-------|
| **REST API Base** | `http://127.0.0.1:9119/` |
| **WS Endpoint** | `ws://127.0.0.1:9119/api/ws?token=<TOKEN>` |
| **REST Auth** | `Authorization: Bearer *** or `X-Hermes-Session-Token: <TOKEN>` |
| **WS Auth** | `?token=<TOKEN>` query parameter (NOT header-based for WS) |
| **Current Token** | `y9oAS3frCosCSaynhmAofMkeCsuiNuF2ad2XB35iTZ4` (in `~/.hermes/.env`) |
| **Dashboard Binding** | `0.0.0.0:9119` (LAN accessible via `--insecure`) |
---
## 🧪 Verified Protocol Details (E2E Tested)
### REST API
| Endpoint | Auth | Returns |
|----------|------|---------|
| `GET /api/status` | ❌ Public | `version`, `gateway_running`, `active_sessions`, `auth_required` |
| `GET /api/sessions` | ✅ Token | `{"sessions": [...]}` — full session list with message counts |
| `GET /api/config` | ✅ Token | Full config as flat JSON dict |
| `GET /api/system/stats` | ✅ Token | System statistics |
**Auth methods tested:**
- `Authorization: Bearer <token>` ✅ works
- `X-Hermes-Session-Token: <token>` ✅ works
- No header → `401` ✅
- Bad token → `401` ✅
### WebSocket JSON-RPC Protocol
**Connection:** `ws://HOST:9119/api/ws?token=<TOKEN>`
**First message (always):** `gateway.ready` event — server pushes this immediately on connect.
**RPC methods tested:**
| Method | Params | Response | Status |
|--------|--------|----------|--------|
| `session.list` | `{}` | `{"sessions": [...]}` — all TUI sessions | ✅ |
| `session.active_list` | `{}` | `{"sessions": [...]}` — active TUI sessions | ✅ |
| `session.status` | `{}` | `{"sessions": [...]}` — session statuses | ✅ |
| `session.create` | `{}` | `{"session_id": "xxx", "stored_session_id": "xxx", ...}` | ✅ |
| `session.resume` | `{"id": "session_id"}` | Session data | ✅ (when valid session) |
| `prompt.submit` | `{"session_id": "xxx", "text": "message"}` | `{"status": "streaming"}` then events | ✅ |
| `nonexistent.method` | `{}` | `{"error": {"code": -32601, "message": "..."}}` | ✅ |
### Chat Flow (Event Sequence)
After `prompt.submit`, events arrive on the same WebSocket in this order:
1. `session.info` — Session metadata, model info, tool list
2. `message.start` — Processing began
3. `thinking.delta` (0+) — Reasoning tokens (model-dependent)
4. `message.token` (0+) — Streaming text tokens (model-dependent)
5. `status.update` — Status changes
6. `tool.start` / `tool.complete` — Tool execution (when applicable)
7. `clarify.request` (optional) — Agent needs input
8. **`message.complete`** — **Final response!** `payload.text` contains the full response text.
9. `message.done` — Processing fully finished
**Key finding:** The actual response text is in `message.complete.payload.text`, NOT nested under `payload.message.content`. Use:
```kotlin
val responseText = event.payload["text"] as? String
```
### Important Implementation Notes
1. **Agent build time:** After `session.create`, the AI agent needs ~15-20s to initialize (tool discovery, model loading, skill loading). The Android app should:
- Show a loading/connecting state during this time
- Listen for events but ignore them until `message.complete` or set a timer
- OR create a session on app startup and keep it alive for instant responses
2. **Session reuse:** Multiple `prompt.submit` calls work on the same session_id. Create one session at startup and reuse it.
3. **No heartbeat needed:** The WS connection stays alive without pings.
4. **Error codes:**
- `4001` — session not found (invalid/missing session_id)
- `4009` — session busy (agent still processing previous prompt)
- `-32601` — unknown method
- `-32600` — invalid JSON-RPC format
---
## Open Questions
1. **Chat flow**: Do you want the app to resume the current active session, or start fresh each time?
2. **Deep linking**: Want to add a way to share messages/commands from other apps?
3. **Notifications**: Want push notifications when Hermes completes a task? (Would need a server component for push)
---
*Plan updated: 2026-06-15. Simplified architecture using Hermes Dashboard + TUI Gateway. Ready to build!* 💋
+57
View File
@@ -0,0 +1,57 @@
# Progress Status: HermesControl Android App
This document tracks the completed features and the pending backlog for the HermesControl Jetpack Compose Android application.
## 👥 Done (Implemented & Verified)
### 1. Base Infrastructure & Environment
* **Nix Flake Dev Environment (`flake.nix`)**: Fully configured with JDK 17, Android SDK (platforms 34-36), `ktlint`, and Gradle.
* **Scaffolding & Build Pipeline**: Android Jetpack Compose project compiles cleanly using `./gradlew assembleDebug`.
* **Code Formatting**: Code formatting checks integrated using `ktlint` (`./gradlew ktlintCheck`).
* **Unit Testing Suite**: Verified with `./gradlew test` (AuthManager, EventParser, ConnectViewModel, ChatViewModel).
* **Network Capabilities**: Cleartext traffic enabled in `AndroidManifest.xml` to allow connection to local networks (e.g., `192.168.1.18` to `192.168.1.48`).
### 2. Implemented Features (~45 of 213 endpoints, ~21%)
* **Connection Screen (`ConnectScreen`)**: Saves Host, Port, and Auth Token to `AuthManager` (EncryptedSharedPreferences), with live verification via `GET /api/status`.
* **Chat Screen (`ChatScreen`)**: Live chat over WebSocket (JSON-RPC) supporting:
* Creating sessions, resuming sessions, listing sessions, and submitting prompts.
* Message token streaming, thinking delta indicator, and interrupting sessions.
* Basic Markdown-ish rendering (bold, code blocks) and expandable tool call indicators.
* **Skills Screen (`SkillsScreen`)**: Lists skills (`GET /api/skills`) and allows toggling them (`PUT /api/skills/toggle`) with auto-refresh and swipe-to-refresh. Toggles have optimistic state reversion on failure.
* **Cron Jobs Screen (`CronJobsScreen`)**: Lists cron jobs (`GET /api/cron/jobs`) with actions for Trigger, Pause, Resume, and Delete. Auto-refresh and swipe-to-refresh.
* **Gateway Control Screen (`GatewayScreen`)**: Integrates Start (`POST /api/gateway/start`), Stop (`POST /api/gateway/stop`), and Restart (`POST /api/gateway/restart`) with platform state reporting.
* **Profiles Management Screen (`ProfilesScreen`)**: Switch active profile, update soul personality description (`PUT /api/profiles/{name}/soul`), and switch AI model providers.
* **Toolsets Screen (`ToolsetsScreen`)**: List and toggle toolsets (`GET/PUT /api/tools/toolsets`) to enable/disable specific tool groups (e.g., Web Search, Python local run).
* **Achievements Screen (`AchievementsScreen`)**: Displays a gamified list of discovered/locked/unlocked achievements with progress bars (`GET /api/plugins/hermes-achievements/achievements`).
* **Settings Screen (`SettingsScreen`)**: Allows changing settings (Host, Port, Token, Theme, Auto-reconnect) and tests connection.
* **Navigation Drawer**: Integrated Material3 navigation drawer using Navigation3 for switching screens.
---
## ⏳ Left (To Be Implemented)
All remaining **168 endpoints** across the Hermes Dashboard API need to be implemented. The categories and their priority order are:
### Priority 1: Model & Provider Management (3 endpoints)
* List model providers (`GET /api/model/providers`)
* Get and set recommended default model (`GET/POST /api/model/recommended-default`)
* **UI**: Select model directly from a dropdown in Settings or Profiles.
### Priority 2: Skills Hub & Editing (8 endpoints)
* Browse hub, preview, install, uninstall, update skills.
* Create/edit custom skills by editing their raw YAML/markdown files (`GET/PUT /api/skills/content`).
### Priority 3: Advanced Sessions & Search (8 endpoints)
* Search through session transcripts.
* Delete sessions in bulk, prune empty sessions, export session transcripts.
* Get session statistics.
### Priority 4: Cron Creation & Blueprint Instantiation (3 endpoints)
* Form/UI to create new Cron Jobs (`POST /api/cron/jobs`).
* Form/UI to update Cron Jobs (`PUT /api/cron/jobs/{id}`).
### Priority 5: Kanban Plugin (46 endpoints)
* Full Kanban task boards, lists, cards, comments, attachments.
### Priority 6: Other Administrative Tools
* Env vars management, webhooks, system backup/restore, credential storage.
+66
View File
@@ -0,0 +1,66 @@
# Project: HermesControl Jetpack Compose Android App
## Architecture
The HermesControl Jetpack Compose Android application communicates with the local Hermes agent instance via two communication channels:
1. **REST API (Port 9119)**: For status queries, listing sessions, and system configuration.
2. **WebSocket Gateway (`/api/ws?token=<TOKEN>`)**: For real-time JSON-RPC messaging (chat, prompt submission, stream events, tool execution updates, etc.).
### Package Boundaries
- `com.m57.hermescontrol.data.model` — REST API data classes.
- `com.m57.hermescontrol.data.remote` — Retrofit ApiClient and HermesApiService.
- `com.m57.hermescontrol.data.ws` — JSON-RPC models, HermesWsClient, and EventParser.
- `com.m57.hermescontrol.data.local` — AuthManager (EncryptedSharedPreferences).
- `com.m57.hermescontrol.ui.connect` — ConnectScreen and ConnectViewModel.
- `com.m57.hermescontrol.ui.chat` — ChatScreen, ChatViewModel, and ChatBubble.
- `com.m57.hermescontrol.ui.settings` — SettingsScreen and SettingsViewModel.
- `com.m57.hermescontrol.theme` — Color, Theme, and Type.
---
## Milestones
| # | Name | Scope | Dependencies | Status |
|---|---|---|---|---|
| M1 | Exploration & Setup | Explore codebase, identify dependencies and structure | None | DONE |
| M2 | Networking & Data Layer | Implement/Fix AuthManager, api clients, WS clients, EventParser | M1 | DONE |
| M3 | UI Screen Implementation | Implement ConnectScreen, ChatScreen, SettingsScreen, and Nav integration | M2 | DONE |
| M4 | Unit Testing | Implement unit tests for AuthManager, EventParser, Connect/Chat ViewModels | M2, M3 | DONE |
| M5 | Styling & Audit Validation | Perform ktlint formatting fixes, compiler error fixes, and run Forensic Audit | M4 | DONE |
| M6 | E2E Testing Track | Design and build comprehensive opaque-box integration tests (Tiers 1-4) | M5 | PLANNED |
| M7 | API Integration & Models | Add Retrofit endpoints and JSON serialization models for Skills & Cron Jobs | M5 | PLANNED |
| M8 | Skills Management UI | Implement Viewmodel and UI Screen for skills management | M7 | PLANNED |
| M9 | Cron Jobs Management UI | Implement Viewmodel and UI Screen for cron jobs control | M7 | PLANNED |
| M10 | Navigation Drawer | Integrate Material3 navigation drawer using Navigation3 keys | M8, M9 | PLANNED |
| M11 | E2E & Hardening Phase | Verify all E2E tests, perform adversarial coverage hardening (Tier 5) | M6, M10 | PLANNED |
---
## Interface Contracts
### REST API Service
- `getStatus()`: Returns `StatusResponse` (version, gateway status, active sessions)
- `getSessions()`: Returns `SessionListResponse` (all sessions and metadata)
- `getSessionMessages(sessionId: String)`: Returns `SessionMessagesResponse` (message history)
- `getSystemStats()`: Returns `SystemStatsResponse` (CPU, memory, etc.)
- `getSkills()`: Returns list of skills (`GET api/skills`)
- `toggleSkill(payload)`: Toggles a skill's state (`PUT api/skills/toggle`)
- `getCronJobs()`: Returns list of cron jobs (`GET api/cron/jobs`)
- `pauseCronJob(id)`: Pauses a job (`POST api/cron/jobs/{id}/pause`)
- `resumeCronJob(id)`: Resumes a job (`POST api/cron/jobs/{id}/resume`)
- `triggerCronJob(id)`: Triggers a job immediately (`POST api/cron/jobs/{id}/trigger`)
- `deleteCronJob(id)`: Deletes a job (`DELETE api/cron/jobs/{id}`)
### WebSocket Client (`HermesWsClient`)
- `connect()`: Establish connection to `/api/ws?token=<TOKEN>`
- `disconnect()`: Terminate connection
- `sendMessage(text: String)`: Submit prompt (`prompt.submit` JSON-RPC method)
- `resumeSession(sessionId: String)`: Resume a session (`session.resume` JSON-RPC method)
- Callbacks: `onConnected()`, `onDisconnected(reason: String)`, `onMessage(JsonRpcResponse)`
### Event Parser (`EventParser`)
- Parses incoming JSON messages from WebSocket and extracts event fields (`event`, `data`, `id`, `result`, `error`).
- Categorizes events: `message.complete`, `message.token`, `tool.start`, `tool.complete`, `clarify.request`, etc.
## Code Layout
- `app/src/main/java/com/m57/hermescontrol/` (Main Source)
- `app/src/test/java/com/m57/hermescontrol/` (Unit Tests)
+57
View File
@@ -0,0 +1,57 @@
# Test Infrastructure - hermes-mobile
This document describes the test infrastructure, philosophy, architecture, and feature coverage for the **hermes-mobile** Jetpack Compose Android application.
## Test Philosophy
The test suite utilizes **opaque-box JVM-based integration and E2E tests** that drive the ViewModels directly, bypassing the UI rendering layer, with mocked Retrofit responses using `Mockk` for service stubs. This allows testing the application logic, optimistic UI state changes, API integration pathways, and navigation flows synchronously and reliably without requiring an Android emulator or device.
## Feature Inventory
### Feature 1: Skills Management Screen
- **Listing**: Fetches and lists available agent skills.
- **Category/Description Display**: Shows skill details (category, optional description, name).
- **Toggle Success**: Modifies a skill's state optimistically, sends API request, and confirms success state.
- **Toggle Failure Revert**: Handles API failure, displays toast, and reverts the state optimistically.
- **Refresh**: Supports reloading/refreshing the list of skills from the API.
### Feature 2: Cron Jobs Screen
- **Listing**: Fetches and lists all configured cron jobs.
- **State/Last Run/Next Run Display**: Displays status fields (state, last_run_status, next_run).
- **Pause Action**: Optimistically transitions a job's state to "paused", sends API request.
- **Resume Action**: Optimistically transitions a job's state to "active", sends API request.
- **Trigger Action**: Triggers a job immediately and displays a success toast.
- **Delete Action**: Optimistically removes a job from the list and sends a DELETE API request.
- **Refresh**: Supports reloading/refreshing the list of cron jobs.
### Feature 3: Navigation Drawer
- **Transitions**: Simulates moving between top-level destinations (Chat, Skills, Cron Jobs) via the Material3 Navigation Drawer using the Custom `NavigationController` and its `backStack`.
---
## Test Architecture
- **Test Runner**:
- Main Test suite command: `nix develop --command ./gradlew test`
- Style checking command: `nix develop --command ./gradlew ktlintCheck`
- **Test Case Format**:
- JUnit 4 / JUnit Platform JVM tests implemented in Kotlin.
- Mocking library: `Mockk` for mock objects, static mocks, and API endpoints.
- **Directory Layout**:
- `app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt` contains the complete end-to-end integration test suite.
---
## Tier 4 Scenario: Complete User Journey
The Tier 4 scenario is implemented in `testFullUserSessionFlow()` which simulates a complete user session:
1. **Connect**: User enters coordinates and token, successfully authenticating/connecting.
2. **Open Drawer & Navigate**: Transitions from Chat to Skills.
3. **Toggle a Skill (Optimistic Update -> API Failure -> Toast/Revert)**:
- User toggles a skill.
- UI updates optimistically to the new state.
- Retrofit API returns a simulated HTTP 500 error.
- The state is reverted back to original, and an error Toast message is shown.
4. **Navigate to Cron Jobs**: User navigates from Skills to Cron Jobs.
5. **Trigger Cron Job**:
- User triggers a cron job.
- Retrofit API returns a success code.
- Success Toast message is displayed and verified.
+35
View File
@@ -0,0 +1,35 @@
# Test Readiness - hermes-mobile
This document provides a summary of test readiness, including test suite execution details, expected outcomes, coverage summary, and a feature readiness checklist.
## Execution and Expected Outcome
- **Test Runner Command**: `nix develop --command ./gradlew test`
- **Expected Outcome**: All tests pass successfully.
- **Total Test Count**: 28 tests
---
## Coverage Summary
| Test Tier | Description | Test Count | Features Covered |
|-----------|-------------|------------|------------------|
| **Tier 1** | Feature Coverage | 13 | Skills (5), Cron Jobs (7), Drawer (1) |
| **Tier 2** | Boundary & Corner Cases | 12 | HTTP errors (400, 404, 500), timeouts, empty lists, action failures |
| **Tier 3** | Cross-Feature Combinations | 2 | Drawer transition during active toggle, Auth token revocation |
| **Tier 4** | Real-World Application Scenarios | 1 | Full end-to-end user session journey flow |
| **Total** | | **28** | |
---
## Feature Checklist
| Feature | Tier 1 (Coverage) | Tier 2 (Boundaries) | Tier 3 (Combinations) | Tier 4 (E2E Scenario) |
|---------|:-----------------:|:-------------------:|:---------------------:|:---------------------:|
| **Skills Management** | ✅ | ✅ | ✅ | ✅ |
| **Cron Jobs** | ✅ | ✅ | N/A | ✅ |
| **Navigation Drawer** | ✅ | N/A | ✅ | ✅ |
| **Authentication** | N/A | N/A | ✅ | ✅ |
### Legend:
- ✅: Covered by tests in `E2eIntegrationTest.kt`
- N/A: Not applicable or handled by combinations/other screens.
+1
View File
@@ -0,0 +1 @@
/build
+120
View File
@@ -0,0 +1,120 @@
import org.gradle.api.tasks.testing.Test
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.m57.hermescontrol"
compileSdk = 36
defaultConfig {
applicationId = "com.m57.hermescontrol"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
aidl = false
buildConfig = false
shaders = false
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
kotlin {
jvmToolchain(17)
}
dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
// Core Android dependencies
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
// Arch Components
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Compose
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
// Tooling
debugImplementation(libs.androidx.compose.ui.tooling)
// Instrumented tests
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
// Networking
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
implementation(libs.gson)
// Encrypted storage
implementation(libs.androidx.security.crypto)
// Local tests: jUnit, coroutines, Android runner
testImplementation(libs.junit)
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.vintage.engine)
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
// Instrumented tests: jUnit rules and runners
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.espresso.core)
// Navigation
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
}
tasks.register<Exec>("ktlintCheck") {
group = "verification"
description = "Runs ktlint check."
workingDir = rootProject.projectDir
commandLine("sh", "-c", "ktlint \"app/src/**/*.kt\"")
}
tasks.register<Exec>("ktlintFormat") {
group = "formatting"
description = "Runs ktlint format."
workingDir = rootProject.projectDir
commandLine("sh", "-c", "ktlint -F \"app/src/**/*.kt\"")
}
tasks.withType<Test> {
useJUnitPlatform()
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.HermesControl">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,32 @@
package com.m57.hermescontrol
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.theme.HermesControlTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AuthManager.init(this)
enableEdgeToEdge()
setContent {
HermesControlTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
MainNavigation()
}
}
}
}
}
@@ -0,0 +1,200 @@
package com.m57.hermescontrol
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.launch
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
import com.m57.hermescontrol.ui.connect.ConnectScreen as ConnectScreenContent
import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent
import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent
import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent
import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent
import com.m57.hermescontrol.ui.skills.SkillsScreen as SkillsScreenContent
import com.m57.hermescontrol.ui.toolsets.ToolsetsScreen as ToolsetsScreenContent
@Composable
fun MainNavigation() {
val hasToken = !AuthManager.getToken().isNullOrBlank()
val startScreen: NavKey = if (hasToken) ChatScreen else ConnectScreen
val backStack = rememberNavBackStack(startScreen)
NavigationController.backStack = backStack
val currentScreen = backStack.lastOrNull() ?: startScreen
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val gesturesEnabled =
currentScreen == ChatScreen || currentScreen == SkillsScreen || currentScreen == CronJobsScreen ||
currentScreen == GatewayScreen || currentScreen == ProfilesScreen ||
currentScreen == ToolsetsScreen || currentScreen == AchievementsScreen
val openDrawer = { scope.launch { drawerState.open() } }
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = gesturesEnabled,
drawerContent = {
if (gesturesEnabled) {
ModalDrawerSheet {
Text(
text = "Hermes Control",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleLarge,
)
HorizontalDivider()
NavigationDrawerItem(
label = { Text("Chat") },
selected = currentScreen == ChatScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(ChatScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
label = { Text("Skills") },
selected = currentScreen == SkillsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(SkillsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
label = { Text("Cron Jobs") },
selected = currentScreen == CronJobsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(CronJobsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
label = { Text("Profiles") },
selected = currentScreen == ProfilesScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(ProfilesScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
label = { Text("Toolsets") },
selected = currentScreen == ToolsetsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(ToolsetsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
label = { Text("Achievements") },
selected = currentScreen == AchievementsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(AchievementsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
label = { Text("Gateway") },
selected = currentScreen == GatewayScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(GatewayScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
}
},
) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider =
entryProvider {
entry<ConnectScreen> {
ConnectScreenContent(
onConnected = {
backStack.clear()
backStack.add(ChatScreen)
},
modifier = Modifier.safeDrawingPadding(),
)
}
entry<ChatScreen> {
ChatScreenContent(
onNavigateToSettings = {
backStack.add(SettingsScreen)
},
onOpenDrawer = { openDrawer() },
)
}
entry<SettingsScreen> {
SettingsScreenContent(
onBack = {
backStack.removeLastOrNull()
},
)
}
entry<SkillsScreen> {
SkillsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<CronJobsScreen> {
CronJobsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ProfilesScreen> {
ProfilesScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ToolsetsScreen> {
ToolsetsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<AchievementsScreen> {
AchievementsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<GatewayScreen> {
GatewayScreenContent(
onOpenDrawer = { openDrawer() },
)
}
},
)
}
}
@@ -0,0 +1,26 @@
package com.m57.hermescontrol
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
object NavigationController {
var backStack: NavBackStack<NavKey>? = null
fun navigateTo(key: NavKey) {
val stack = backStack ?: return
if (stack.lastOrNull() == key) return
if (key == ChatScreen || key == SkillsScreen || key == CronJobsScreen) {
stack.clear()
}
stack.add(key)
}
fun add(key: NavKey) {
backStack?.add(key)
}
fun goBack() {
backStack?.removeLastOrNull()
}
}
@@ -0,0 +1,22 @@
package com.m57.hermescontrol
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable data object ConnectScreen : NavKey
@Serializable data object ChatScreen : NavKey
@Serializable data object SettingsScreen : NavKey
@Serializable data object SkillsScreen : NavKey
@Serializable data object CronJobsScreen : NavKey
@Serializable data object GatewayScreen : NavKey
@Serializable data object ProfilesScreen : NavKey
@Serializable data object ToolsetsScreen : NavKey
@Serializable data object AchievementsScreen : NavKey
@@ -0,0 +1,96 @@
package com.m57.hermescontrol.data.local
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* Singleton that manages encrypted storage of the Hermes dashboard token
* and connection settings.
*
* Must call [init] with a Context before any other method.
*/
object AuthManager {
private const val PREFS_FILE = "hermes_secure_prefs"
private const val KEY_TOKEN = "auth_token"
private const val KEY_HOST = "host"
private const val KEY_PORT = "port"
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
private const val DEFAULT_HOST = "127.0.0.1"
private const val DEFAULT_PORT = 9119
private const val DEFAULT_AUTO_RECONNECT = true
@Volatile
private var prefs: SharedPreferences? = null
/**
* Initialise the encrypted preferences.
* Call this once from Application.onCreate() or MainActivity.onCreate().
*/
fun init(context: Context) {
if (prefs != null) return
synchronized(this) {
if (prefs != null) return
val masterKey =
MasterKey
.Builder(context.applicationContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
prefs =
EncryptedSharedPreferences.create(
context.applicationContext,
PREFS_FILE,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
}
private fun requirePrefs(): SharedPreferences =
prefs ?: throw IllegalStateException(
"AuthManager not initialised – call AuthManager.init(context) first",
)
// ── Token ────────────────────────────────────────────────────────────
fun getToken(): String? = requirePrefs().getString(KEY_TOKEN, null)
fun setToken(token: String?) {
requirePrefs().edit().putString(KEY_TOKEN, token).apply()
}
// ── Host ─────────────────────────────────────────────────────────────
fun getHost(): String = requirePrefs().getString(KEY_HOST, DEFAULT_HOST) ?: DEFAULT_HOST
fun setHost(host: String) {
requirePrefs().edit().putString(KEY_HOST, host).apply()
}
// ── Port ─────────────────────────────────────────────────────────────
fun getPort(): Int = requirePrefs().getInt(KEY_PORT, DEFAULT_PORT)
fun setPort(port: Int) {
requirePrefs().edit().putInt(KEY_PORT, port).apply()
}
// ── Auto-reconnect ───────────────────────────────────────────────────
fun isAutoReconnect(): Boolean = requirePrefs().getBoolean(KEY_AUTO_RECONNECT, DEFAULT_AUTO_RECONNECT)
fun setAutoReconnect(enabled: Boolean) {
requirePrefs().edit().putBoolean(KEY_AUTO_RECONNECT, enabled).apply()
}
/** Convenience: build the base URL from current host + port. */
fun baseUrl(): String = "http://${getHost()}:${getPort()}/"
/** Convenience: build the WebSocket URL with token query param. */
fun wsUrl(): String = "ws://${getHost()}:${getPort()}/api/ws?token=${getToken().orEmpty()}"
}
@@ -0,0 +1,22 @@
package com.m57.hermescontrol.data.model
data class AchievementsResponse(
val achievements: List<Achievement>,
)
data class Achievement(
val id: String,
val name: String,
val description: String?,
val category: String?,
val kind: String?,
val icon: String?,
val unlocked: Boolean,
val discovered: Boolean,
val state: String?,
val tier: String?,
val progress: Double?,
val next_tier: String?,
val next_threshold: Double?,
val progress_pct: Double?,
)
@@ -0,0 +1,10 @@
package com.m57.hermescontrol.data.model
data class CronJob(
val id: String,
val name: String,
val schedule: String?,
val state: String?,
val last_run_status: String?,
val next_run: String?,
)
@@ -0,0 +1,43 @@
package com.m57.hermescontrol.data.model
data class ProfilesResponse(
val profiles: List<ProfileInfo>,
)
data class ProfileInfo(
val name: String,
val path: String?,
val is_default: Boolean?,
val model: String?,
val provider: String?,
val has_env: Boolean?,
val skill_count: Int?,
val gateway_running: Boolean?,
val description: String?,
)
data class ActiveProfileResponse(
val active: String,
val current: String?,
)
data class SetActiveProfileRequest(
val name: String,
)
data class ProfileSoulResponse(
val content: String,
)
data class UpdateProfileSoulRequest(
val content: String,
)
data class UpdateProfileModelRequest(
val provider: String,
val model: String,
)
data class UpdateProfileDescriptionRequest(
val description: String,
)
@@ -0,0 +1,13 @@
package com.m57.hermescontrol.data.model
data class SessionListResponse(
val sessions: List<SessionInfo>,
)
data class SessionInfo(
val id: String,
val title: String?,
val created_at: String?,
val message_count: Int?,
val status: String?,
)
@@ -0,0 +1,12 @@
package com.m57.hermescontrol.data.model
data class SessionMessagesResponse(
val messages: List<SessionMessage>,
)
data class SessionMessage(
val role: String?,
val content: String?,
val timestamp: String?,
val type: String?,
)
@@ -0,0 +1,8 @@
package com.m57.hermescontrol.data.model
data class Skill(
val name: String,
val description: String?,
val category: String?,
val enabled: Boolean,
)
@@ -0,0 +1,14 @@
package com.m57.hermescontrol.data.model
data class StatusResponse(
val version: String?,
val gateway_running: Boolean?,
val active_sessions: Int?,
val auth_required: Boolean?,
val gateway_platforms: Map<String, PlatformStatus>?,
)
data class PlatformStatus(
val state: String?,
val error_code: String? = null,
)
@@ -0,0 +1,24 @@
package com.m57.hermescontrol.data.model
/**
* Wrapper around the /api/system/stats response.
*
* The payload shape can change across Hermes versions, so we keep it
* flexible with a Map and provide convenience accessors for the
* most commonly used fields.
*/
data class SystemStatsResponse(
val cpu: Map<String, Any?>? = null,
val memory: Map<String, Any?>? = null,
val disk: Map<String, Any?>? = null,
val uptime: Any? = null,
val extra: Map<String, Any?>? = null,
) {
/** CPU usage percentage, if available. */
val cpuPercent: Double?
get() = (cpu?.get("percent") as? Number)?.toDouble()
/** Memory usage percentage, if available. */
val memoryPercent: Double?
get() = (memory?.get("percent") as? Number)?.toDouble()
}
@@ -0,0 +1,6 @@
package com.m57.hermescontrol.data.model
data class ToggleSkillRequest(
val name: String,
val enabled: Boolean,
)
@@ -0,0 +1,16 @@
package com.m57.hermescontrol.data.model
data class Toolset(
val name: String,
val label: String?,
val description: String?,
val enabled: Boolean,
val available: Boolean?,
val configured: Boolean?,
val tools: List<String>?,
)
data class ToolsetToggleRequest(
val enabled: Boolean,
val profile: String? = null,
)
@@ -0,0 +1,86 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Provides a Retrofit-backed [HermesApiService].
*
* The client is lazily built using the current [AuthManager] settings and
* can be rebuilt at any time via [rebuild] (e.g. after the user changes
* host / port / token).
*/
object ApiClient {
@Volatile
private var retrofit: Retrofit? = null
@Volatile
private var service: HermesApiService? = null
/** The current [HermesApiService] instance. Lazily created on first access. */
val hermesApi: HermesApiService
get() {
return service ?: synchronized(this) {
service ?: buildService().also { service = it }
}
}
/** Force-rebuild the Retrofit client (e.g. after settings change). */
fun rebuild() {
synchronized(this) {
retrofit = null
service = null
}
}
// ── Internal ─────────────────────────────────────────────────────────
private fun buildService(): HermesApiService {
val logging =
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val authInterceptor =
Interceptor { chain ->
val token = AuthManager.getToken()
val request =
if (!token.isNullOrBlank()) {
chain
.request()
.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
chain.proceed(request)
}
val okHttp =
OkHttpClient
.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(logging)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
val rf =
Retrofit
.Builder()
.baseUrl(AuthManager.baseUrl())
.client(okHttp)
.addConverterFactory(GsonConverterFactory.create())
.build()
.also { retrofit = it }
return rf.create(HermesApiService::class.java)
}
}
@@ -0,0 +1,131 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.AchievementsResponse
import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.ProfileSoulResponse
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
interface HermesApiService {
@GET("api/status")
suspend fun getStatus(): Response<StatusResponse>
@GET("api/sessions")
suspend fun getSessions(): Response<SessionListResponse>
@GET("api/sessions/{id}/messages")
suspend fun getSessionMessages(
@Path("id") sessionId: String,
): Response<SessionMessagesResponse>
@GET("api/system/stats")
suspend fun getSystemStats(): Response<SystemStatsResponse>
@GET("api/config")
suspend fun getConfig(): Response<Map<String, Any?>>
@GET("api/skills")
suspend fun getSkills(): Response<List<Skill>>
@PUT("api/skills/toggle")
suspend fun toggleSkill(
@Body body: ToggleSkillRequest,
): Response<Unit>
@GET("api/cron/jobs")
suspend fun getCronJobs(): Response<List<CronJob>>
@POST("api/cron/jobs/{id}/pause")
suspend fun pauseCronJob(
@Path("id") id: String,
): Response<Unit>
@POST("api/cron/jobs/{id}/resume")
suspend fun resumeCronJob(
@Path("id") id: String,
): Response<Unit>
@POST("api/cron/jobs/{id}/trigger")
suspend fun triggerCronJob(
@Path("id") id: String,
): Response<Unit>
@DELETE("api/cron/jobs/{id}")
suspend fun deleteCronJob(
@Path("id") id: String,
): Response<Unit>
@POST("api/gateway/start")
suspend fun startGateway(): Response<Unit>
@POST("api/gateway/stop")
suspend fun stopGateway(): Response<Unit>
@POST("api/gateway/restart")
suspend fun restartGateway(): Response<Unit>
@GET("api/profiles")
suspend fun getProfiles(): Response<ProfilesResponse>
@GET("api/profiles/active")
suspend fun getActiveProfile(): Response<ActiveProfileResponse>
@POST("api/profiles/active")
suspend fun setActiveProfile(
@Body body: SetActiveProfileRequest,
): Response<Unit>
@GET("api/profiles/{name}/soul")
suspend fun getProfileSoul(
@Path("name") name: String,
): Response<ProfileSoulResponse>
@PUT("api/profiles/{name}/soul")
suspend fun updateProfileSoul(
@Path("name") name: String,
@Body body: UpdateProfileSoulRequest,
): Response<Unit>
@PUT("api/profiles/{name}/model")
suspend fun updateProfileModel(
@Path("name") name: String,
@Body body: UpdateProfileModelRequest,
): Response<Unit>
@PUT("api/profiles/{name}/description")
suspend fun updateProfileDescription(
@Path("name") name: String,
@Body body: UpdateProfileDescriptionRequest,
): Response<Unit>
@GET("api/tools/toolsets")
suspend fun getToolsets(): Response<List<Toolset>>
@PUT("api/tools/toolsets/{name}")
suspend fun toggleToolset(
@Path("name") name: String,
@Body body: ToolsetToggleRequest,
): Response<Unit>
@GET("api/plugins/hermes-achievements/achievements")
suspend fun getAchievements(): Response<AchievementsResponse>
}
@@ -0,0 +1,106 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
/**
* Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances.
*
* The Hermes TUI gateway sends events as JSON-RPC **notifications** (no `id`
* field). The `method` is always `"event"` and the event type lives in
* `params.type`. The event payload is in `params.payload`.
*
* Regular RPC responses have an `id` and either a `result` or `error`.
*/
object EventParser {
private const val TAG = "EventParser"
fun parse(
response: JsonRpcResponse,
rawJson: String = "",
): WsEvent {
// ── RPC response (has id) ────────────────────────────────────────
val id = response.id
if (id != null) {
return if (response.error != null) {
WsEvent.RpcError(id, response.error)
} else {
WsEvent.RpcResult(id, response.result)
}
}
// ── Notification / event (no id, has method) ─────────────────────
val params = response.params ?: return WsEvent.Unknown(rawJson)
val eventType = params["type"] as? String ?: return WsEvent.Unknown(rawJson)
@Suppress("UNCHECKED_CAST")
val payload = params["payload"] as? Map<String, Any?>
val sessionId = payload?.get("session_id") as? String
return when (eventType) {
"gateway.ready" -> {
WsEvent.GatewayReady(payload)
}
"session.info" -> {
WsEvent.SessionInfo(payload)
}
"message.start" -> {
WsEvent.MessageStart(sessionId)
}
"message.token" -> {
val token = payload?.get("text") as? String ?: ""
WsEvent.MessageToken(token, sessionId)
}
"thinking.delta" -> {
val token = payload?.get("text") as? String ?: ""
WsEvent.ThinkingDelta(token, sessionId)
}
"message.complete" -> {
val text = payload?.get("text") as? String ?: ""
WsEvent.MessageComplete(text, sessionId)
}
"message.done" -> {
WsEvent.MessageDone(sessionId)
}
"tool.start" -> {
val name = payload?.get("name") as? String
WsEvent.ToolStart(name, payload)
}
"tool.complete" -> {
val name = payload?.get("name") as? String
WsEvent.ToolComplete(name, payload)
}
"clarify.request" -> {
val text = payload?.get("text") as? String
val rawOptions = payload?.get("options")
@Suppress("UNCHECKED_CAST")
val options = (rawOptions as? List<*>)?.filterIsInstance<String>()
WsEvent.ClarifyRequest(text, options)
}
"status.update" -> {
val status = payload?.get("status") as? String
WsEvent.StatusUpdate(status, payload)
}
"session.updated" -> {
WsEvent.SessionUpdated(payload)
}
else -> {
Log.w(TAG, "Unknown event type: $eventType")
WsEvent.Unknown(rawJson)
}
}
}
}
@@ -0,0 +1,224 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.google.gson.Gson
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
* WebSocket client for the Hermes Dashboard JSON-RPC 2.0 interface.
*
* Connects to `ws://HOST:PORT/api/ws?token=TOKEN`, auto-reconnects with
* exponential backoff, and emits parsed [WsEvent]s via [events] SharedFlow
* as well as direct callbacks.
*/
object HermesWsClient {
private const val TAG = "HermesWsClient"
// ── Backoff settings ─────────────────────────────────────────────────
private const val INITIAL_BACKOFF_MS = 1_000L
private const val MAX_BACKOFF_MS = 30_000L
private const val BACKOFF_MULTIPLIER = 2.0
// ── Internal state (all access through synchronized / atomic) ────────
private val gson = Gson()
private val requestId = AtomicInteger(0)
private val connected = AtomicBoolean(false)
private val intentionalClose = AtomicBoolean(false)
@Volatile
private var webSocket: WebSocket? = null
@Volatile
private var currentBackoff = INITIAL_BACKOFF_MS
private val okHttpClient =
OkHttpClient
.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) // keep-alive forever
.pingInterval(30, TimeUnit.SECONDS)
.build()
// ── Public observable stream ─────────────────────────────────────────
private val _events = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
/** Collect this from ViewModels to receive all parsed [WsEvent]s. */
val events: SharedFlow<WsEvent> = _events.asSharedFlow()
// ── Callbacks (legacy / convenience) ─────────────────────────────────
var onMessage: ((WsEvent) -> Unit)? = null
var onConnected: (() -> Unit)? = null
var onDisconnected: ((reason: String?) -> Unit)? = null
var onError: ((Throwable) -> Unit)? = null
// ── Connection helpers ────────────────────────────────────────────────
val isConnected: Boolean get() = connected.get()
/** Open a WebSocket connection using settings from [AuthManager]. */
fun connect() {
if (connected.get()) {
Log.d(TAG, "Already connected — skipping")
return
}
intentionalClose.set(false)
currentBackoff = INITIAL_BACKOFF_MS
openSocket()
}
/** Cleanly close the WebSocket and stop auto-reconnect. */
fun disconnect() {
intentionalClose.set(true)
webSocket?.close(1000, "Client closed")
webSocket = null
connected.set(false)
}
// ── Send helpers ─────────────────────────────────────────────────────
/**
* Send a JSON-RPC request with the given [method] and optional [params].
* @return the request id used (can be matched against [WsEvent.RpcResult]).
*/
fun send(
method: String,
params: Map<String, Any> = emptyMap(),
): String {
val id = requestId.incrementAndGet().toString()
val request = JsonRpcRequest(id = id, method = method, params = params)
val json = gson.toJson(request)
Log.d(TAG, "→ $json")
webSocket?.send(json) ?: Log.w(TAG, "send() called while disconnected")
return id
}
/** Convenience: submit a user prompt to an existing session. */
fun sendMessage(
sessionId: String,
text: String,
): String =
send(
method = WsMethods.PROMPT_SUBMIT,
params = mapOf("session_id" to sessionId, "text" to text),
)
// ── Internal ─────────────────────────────────────────────────────────
private fun openSocket() {
val url = AuthManager.wsUrl()
Log.d(TAG, "Connecting to $url")
val request = Request.Builder().url(url).build()
webSocket = okHttpClient.newWebSocket(request, WsListenerImpl())
}
private fun scheduleReconnect() {
if (intentionalClose.get()) return
if (!AuthManager.isAutoReconnect()) {
Log.d(TAG, "Auto-reconnect disabled")
return
}
val delay = currentBackoff
currentBackoff =
(currentBackoff * BACKOFF_MULTIPLIER)
.toLong()
.coerceAtMost(MAX_BACKOFF_MS)
Log.d(TAG, "Reconnecting in ${delay}ms …")
// Simple thread-based scheduling; could be replaced with coroutine delay.
Thread {
try {
Thread.sleep(delay)
} catch (_: InterruptedException) {
return@Thread
}
if (!intentionalClose.get() && !connected.get()) {
openSocket()
}
}.apply {
isDaemon = true
name = "hermes-ws-reconnect"
start()
}
}
private fun emit(event: WsEvent) {
_events.tryEmit(event)
onMessage?.invoke(event)
}
// ── Listener ─────────────────────────────────────────────────────────
private class WsListenerImpl : WebSocketListener() {
override fun onOpen(
ws: WebSocket,
response: Response,
) {
Log.i(TAG, "WebSocket opened")
connected.set(true)
currentBackoff = INITIAL_BACKOFF_MS
onConnected?.invoke()
}
override fun onMessage(
ws: WebSocket,
text: String,
) {
Log.d(TAG, "← $text")
try {
val rpc = gson.fromJson(text, JsonRpcResponse::class.java)
val event = EventParser.parse(rpc, text)
emit(event)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
emit(WsEvent.Unknown(text))
}
}
override fun onClosing(
ws: WebSocket,
code: Int,
reason: String,
) {
Log.d(TAG, "WebSocket closing: $code $reason")
ws.close(1000, null)
}
override fun onClosed(
ws: WebSocket,
code: Int,
reason: String,
) {
Log.i(TAG, "WebSocket closed: $code $reason")
connected.set(false)
onDisconnected?.invoke(reason)
scheduleReconnect()
}
override fun onFailure(
ws: WebSocket,
t: Throwable,
response: Response?,
) {
Log.e(TAG, "WebSocket failure: ${t.message}", t)
connected.set(false)
onError?.invoke(t)
onDisconnected?.invoke(t.message)
scheduleReconnect()
}
}
}
@@ -0,0 +1,32 @@
package com.m57.hermescontrol.data.ws
/**
* Outgoing JSON-RPC 2.0 request sent over the WebSocket.
*/
data class JsonRpcRequest(
val jsonrpc: String = "2.0",
val id: String,
val method: String,
val params: Map<String, Any> = emptyMap(),
)
/**
* Incoming JSON-RPC 2.0 response **or** server-pushed notification.
*
* - RPC result/error → [id] is non-null, [result] or [error] is populated.
* - Event notification → [id] is null, [method] is `"event"`, event data in [params].
*/
data class JsonRpcResponse(
val jsonrpc: String?,
val id: String?,
val result: Any?,
val error: JsonRpcError?,
val method: String?,
val params: Map<String, Any?>?,
)
data class JsonRpcError(
val code: Int,
val message: String,
val data: Any? = null,
)
@@ -0,0 +1,89 @@
package com.m57.hermescontrol.data.ws
/** Parsed WebSocket events emitted by [HermesWsClient]. */
sealed class WsEvent {
// ── Gateway lifecycle ────────────────────────────────────────────────
data class GatewayReady(
val data: Map<String, Any?>?,
) : WsEvent()
// ── Session information ──────────────────────────────────────────────
data class SessionInfo(
val data: Map<String, Any?>?,
) : WsEvent()
// ── Message streaming ────────────────────────────────────────────────
data class MessageStart(
val sessionId: String?,
) : WsEvent()
data class MessageToken(
val token: String,
val sessionId: String?,
) : WsEvent()
data class ThinkingDelta(
val token: String,
val sessionId: String?,
) : WsEvent()
data class MessageComplete(
val text: String,
val sessionId: String?,
) : WsEvent()
data class MessageDone(
val sessionId: String?,
) : WsEvent()
// ── Tool execution ───────────────────────────────────────────────────
data class ToolStart(
val name: String?,
val data: Map<String, Any?>?,
) : WsEvent()
data class ToolComplete(
val name: String?,
val data: Map<String, Any?>?,
) : WsEvent()
// ── Interactive ──────────────────────────────────────────────────────
data class ClarifyRequest(
val text: String?,
val options: List<String>?,
) : WsEvent()
// ── Status ───────────────────────────────────────────────────────────
data class StatusUpdate(
val status: String?,
val data: Map<String, Any?>?,
) : WsEvent()
data class SessionUpdated(
val data: Map<String, Any?>?,
) : WsEvent()
// ── RPC responses ────────────────────────────────────────────────────
data class RpcResult(
val id: String,
val result: Any?,
) : WsEvent()
data class RpcError(
val id: String,
val error: JsonRpcError,
) : WsEvent()
// ── Fallback ─────────────────────────────────────────────────────────
data class Unknown(
val raw: String,
) : WsEvent()
}
@@ -0,0 +1,17 @@
package com.m57.hermescontrol.data.ws
/** JSON-RPC method name constants used with [HermesWsClient.send]. */
object WsMethods {
const val SESSION_LIST = "session.list"
const val SESSION_ACTIVE_LIST = "session.active_list"
const val SESSION_STATUS = "session.status"
const val SESSION_HISTORY = "session.history"
const val SESSION_RESUME = "session.resume"
const val SESSION_CREATE = "session.create"
const val PROMPT_SUBMIT = "prompt.submit"
const val SESSION_INTERRUPT = "session.interrupt"
const val SESSION_DELETE = "session.delete"
const val SESSION_TITLE = "session.title"
const val CLARIFY_RESPOND = "clarify.respond"
const val APPROVAL_RESPOND = "approval.respond"
}
@@ -0,0 +1,43 @@
package com.m57.hermescontrol.theme
import androidx.compose.ui.graphics.Color
// Primary - Deep Purple
val HermesPurple = Color(0xFF6B4EFF)
val HermesPurpleLight = Color(0xFF9D8AFF)
val HermesPurpleDark = Color(0xFF4A35B3)
val HermesPurple80 = Color(0xFFCFC3FF)
// Secondary - Amber / Gold
val HermesAmber = Color(0xFFFFB300)
val HermesAmberLight = Color(0xFFFFE54C)
val HermesAmberDark = Color(0xFFC68400)
val HermesAmber80 = Color(0xFFFFE082)
// Dark theme surfaces
val DarkBackground = Color(0xFF121212)
val DarkSurface = Color(0xFF1E1E2E)
val DarkSurfaceVariant = Color(0xFF2A2A3C)
val DarkOnSurface = Color(0xFFE6E1E5)
val DarkOnSurfaceVariant = Color(0xFFCAC4D0)
// Light theme surfaces
val LightBackground = Color(0xFFFFFBFE)
val LightSurface = Color(0xFFFFFBFE)
val LightSurfaceVariant = Color(0xFFE7E0EC)
val LightOnSurface = Color(0xFF1C1B1F)
val LightOnSurfaceVariant = Color(0xFF49454F)
// Status colors
val StatusGreen = Color(0xFF4CAF50)
val StatusRed = Color(0xFFEF5350)
val StatusYellow = Color(0xFFFFC107)
// Chat bubble colors
val UserBubble = Color(0xFF6B4EFF)
val UserBubbleDark = Color(0xFF5A3FE0)
val AssistantBubble = Color(0xFF2A2A3C)
val AssistantBubbleLight = Color(0xFFE7E0EC)
val SystemMessageColor = Color(0xFF9E9E9E)
val ToolChipColor = Color(0xFF37474F)
val ToolChipColorLight = Color(0xFFECEFF1)
@@ -0,0 +1,78 @@
package com.m57.hermescontrol.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
enum class ThemePreference { SYSTEM, LIGHT, DARK }
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
private val HermesDarkColorScheme =
darkColorScheme(
primary = HermesPurple,
onPrimary = Color.White,
primaryContainer = HermesPurpleDark,
onPrimaryContainer = HermesPurple80,
secondary = HermesAmber,
onSecondary = Color.Black,
secondaryContainer = HermesAmberDark,
onSecondaryContainer = HermesAmber80,
tertiary = HermesAmberLight,
background = DarkBackground,
onBackground = DarkOnSurface,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
error = Color(0xFFCF6679),
onError = Color.Black,
outline = Color(0xFF938F99),
)
private val HermesLightColorScheme =
lightColorScheme(
primary = HermesPurple,
onPrimary = Color.White,
primaryContainer = HermesPurple80,
onPrimaryContainer = HermesPurpleDark,
secondary = HermesAmber,
onSecondary = Color.Black,
secondaryContainer = HermesAmber80,
onSecondaryContainer = HermesAmberDark,
tertiary = HermesAmberDark,
background = LightBackground,
onBackground = LightOnSurface,
surface = LightSurface,
onSurface = LightOnSurface,
surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightOnSurfaceVariant,
error = Color(0xFFB00020),
onError = Color.White,
outline = Color(0xFF79747E),
)
@Composable
fun HermesControlTheme(
themePreference: ThemePreference = LocalThemePreference.current,
content: @Composable () -> Unit,
) {
val darkTheme =
when (themePreference) {
ThemePreference.SYSTEM -> isSystemInDarkTheme()
ThemePreference.LIGHT -> false
ThemePreference.DARK -> true
}
val colorScheme = if (darkTheme) HermesDarkColorScheme else HermesLightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -0,0 +1,36 @@
package com.m57.hermescontrol.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography =
Typography(
bodyLarge =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,203 @@
package com.m57.hermescontrol.ui.achievements
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
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.LinearProgressIndicator
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AchievementsScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: AchievementsViewModel = viewModel { AchievementsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.loadAchievements()
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Agent Achievements") },
actions = {
IconButton(onClick = { viewModel.loadAchievements() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.achievements.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.achievements.isEmpty()) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
IconButton(onClick = { viewModel.loadAchievements() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.achievements) { achievement ->
val pct = achievement.progress_pct?.toFloat()?.div(100f) ?: 0f
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (achievement.unlocked) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = achievement.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
achievement.category?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Display Icon/Emoji/Tier
val badgeText =
when {
achievement.unlocked -> achievement.tier ?: "🏆"
achievement.discovered -> "🔒 Discovered"
else -> "❓ Secret"
}
Text(
text = badgeText,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color =
if (achievement.unlocked) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
achievement.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
)
}
if (achievement.discovered && !achievement.unlocked) {
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text =
"Progress: ${achievement.progress?.toInt() ?: 0} / " +
"${achievement.next_threshold?.toInt() ?: 0}",
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "${achievement.progress_pct?.toInt() ?: 0}%",
style = MaterialTheme.typography.bodySmall,
)
}
LinearProgressIndicator(
progress = { pct.coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,58 @@
package com.m57.hermescontrol.ui.achievements
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.Achievement
import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class AchievementsUiState(
val isLoading: Boolean = false,
val achievements: List<Achievement> = emptyList(),
val errorMessage: String? = null,
)
class AchievementsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(AchievementsUiState())
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
fun loadAchievements() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getAchievements()
}
if (response.isSuccessful) {
_uiState.update {
it.copy(
isLoading = false,
achievements = response.body()?.achievements.orEmpty(),
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load achievements: HTTP ${response.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load achievements: ${e.message}",
)
}
}
}
}
}
@@ -0,0 +1,385 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.LocalConfiguration
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.theme.AssistantBubble
import com.m57.hermescontrol.theme.AssistantBubbleLight
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.theme.SystemMessageColor
import com.m57.hermescontrol.theme.ToolChipColor
import com.m57.hermescontrol.theme.ToolChipColorLight
import com.m57.hermescontrol.theme.UserBubble
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ChatBubble(
message: ChatMessage,
isDarkTheme: Boolean,
modifier: Modifier = Modifier,
) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val maxBubbleWidth = screenWidth * 0.80f
AnimatedVisibility(
visible = true,
enter =
fadeIn() +
expandVertically(
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
),
) {
when (message.role) {
MessageRole.USER -> UserBubble(message, maxBubbleWidth, modifier)
MessageRole.ASSISTANT -> AssistantBubble(message, maxBubbleWidth, isDarkTheme, modifier)
MessageRole.SYSTEM -> SystemBubble(message, modifier)
MessageRole.TOOL -> ToolBubble(message, isDarkTheme, modifier)
}
}
}
@Composable
private fun UserBubble(
message: ChatMessage,
maxWidth: androidx.compose.ui.unit.Dp,
modifier: Modifier = Modifier,
) {
Box(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp),
contentAlignment = Alignment.CenterEnd,
) {
Surface(
modifier =
Modifier
.widthIn(max = maxWidth)
.clip(
RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = 16.dp,
bottomEnd = 4.dp,
),
),
color = UserBubble,
tonalElevation = 0.dp,
) {
Column(modifier = Modifier.padding(12.dp)) {
SelectionContainer {
Text(
text = message.content,
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
text = formatTimestamp(message.timestamp),
color = Color.White.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelSmall,
modifier =
Modifier
.align(Alignment.End)
.padding(top = 4.dp),
)
}
}
}
}
@Composable
private fun AssistantBubble(
message: ChatMessage,
maxWidth: androidx.compose.ui.unit.Dp,
isDarkTheme: Boolean,
modifier: Modifier = Modifier,
) {
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
val textColor = MaterialTheme.colorScheme.onSurface
Box(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp),
contentAlignment = Alignment.CenterStart,
) {
Surface(
modifier =
Modifier
.widthIn(max = maxWidth)
.animateContentSize()
.clip(
RoundedCornerShape(
topStart = 4.dp,
topEnd = 16.dp,
bottomStart = 16.dp,
bottomEnd = 16.dp,
),
),
color = bubbleColor,
tonalElevation = 1.dp,
) {
Column(modifier = Modifier.padding(12.dp)) {
SelectionContainer {
RichText(
text = message.content,
textColor = textColor,
)
}
if (!message.isStreaming) {
Text(
text = formatTimestamp(message.timestamp),
color = textColor.copy(alpha = 0.5f),
style = MaterialTheme.typography.labelSmall,
modifier =
Modifier
.align(Alignment.End)
.padding(top = 4.dp),
)
}
}
}
}
}
@Composable
private fun SystemBubble(
message: ChatMessage,
modifier: Modifier = Modifier,
) {
Box(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = message.content,
style =
MaterialTheme.typography.bodySmall.copy(
fontStyle = FontStyle.Italic,
color = SystemMessageColor,
),
)
}
}
@Composable
private fun ToolBubble(
message: ChatMessage,
isDarkTheme: Boolean,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val chipColor = if (isDarkTheme) ToolChipColor else ToolChipColorLight
val contentColor = if (isDarkTheme) Color.White else Color.Black
Box(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
contentAlignment = Alignment.CenterStart,
) {
Card(
onClick = { expanded = !expanded },
colors = CardDefaults.cardColors(containerColor = chipColor),
shape = RoundedCornerShape(8.dp),
) {
Column(
modifier =
Modifier
.animateContentSize()
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val (icon, tint) =
when (message.toolStatus) {
ToolStatus.RUNNING -> Icons.Filled.Build to MaterialTheme.colorScheme.secondary
ToolStatus.COMPLETED -> Icons.Filled.CheckCircle to StatusGreen
ToolStatus.FAILED -> Icons.Filled.Error to StatusRed
null -> Icons.Filled.Build to contentColor.copy(alpha = 0.6f)
}
if (message.toolStatus == ToolStatus.RUNNING) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.secondary,
)
} else {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = tint,
)
}
Text(
text = message.toolName ?: "Tool",
style =
MaterialTheme.typography.labelMedium.copy(
color = contentColor,
fontFamily = FontFamily.Monospace,
),
)
}
if (expanded && message.content.isNotBlank()) {
Text(
text = message.content,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
}
/**
* Simple rich text renderer supporting **bold**, `inline code`, and ```code blocks```.
*/
@Composable
private fun RichText(
text: String,
textColor: Color,
) {
val annotated =
remember(text) {
buildAnnotatedString {
var i = 0
val src = text
while (i < src.length) {
when {
// Code block: ```...```
src.startsWith("```", i) -> {
val end = src.indexOf("```", i + 3)
if (end != -1) {
val code = src.substring(i + 3, end).trimStart('\n').trimEnd('\n')
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
background = textColor.copy(alpha = 0.08f),
),
) {
append(code)
}
i = end + 3
} else {
append(src[i])
i++
}
}
// Inline code: `...`
src[i] == '`' -> {
val end = src.indexOf('`', i + 1)
if (end != -1) {
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
background = textColor.copy(alpha = 0.08f),
),
) {
append(src.substring(i + 1, end))
}
i = end + 1
} else {
append(src[i])
i++
}
}
// Bold: **...**
src.startsWith("**", i) -> {
val end = src.indexOf("**", i + 2)
if (end != -1) {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(src.substring(i + 2, end))
}
i = end + 2
} else {
append(src[i])
i++
}
}
else -> {
append(src[i])
i++
}
}
}
}
}
Text(
text = annotated,
style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
)
}
private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}
@@ -0,0 +1,26 @@
package com.m57.hermescontrol.ui.chat
import java.util.UUID
data class ChatMessage(
val id: String = UUID.randomUUID().toString(),
val role: MessageRole,
val content: String,
val timestamp: Long = System.currentTimeMillis(),
val isStreaming: Boolean = false,
val toolName: String? = null,
val toolStatus: ToolStatus? = null,
)
enum class MessageRole {
USER,
ASSISTANT,
SYSTEM,
TOOL,
}
enum class ToolStatus {
RUNNING,
COMPLETED,
FAILED,
}
@@ -0,0 +1,562 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
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.lazy.rememberLazyListState
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.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog
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.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusRed
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
onNavigateToSettings: () -> Unit,
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: ChatViewModel = viewModel { ChatViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
var inputText by rememberSaveable { mutableStateOf("") }
val snackbarHostState = remember { SnackbarHostState() }
val isDark = isSystemInDarkTheme()
// Auto-scroll to bottom on new messages
LaunchedEffect(state.messages.size, state.currentStreamingText) {
if (state.messages.isNotEmpty()) {
listState.animateScrollToItem(state.messages.lastIndex)
}
}
// Show error as snackbar
LaunchedEffect(state.errorMessage) {
state.errorMessage?.let { error ->
snackbarHostState.showSnackbar(error)
viewModel.clearError()
}
}
// Clarify dialog
state.clarifyRequest?.let { clarify ->
ClarifyDialog(
clarify = clarify,
onOptionSelected = viewModel::respondToClarify,
onDismiss = viewModel::dismissClarify,
)
}
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Hermes",
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
),
)
Spacer(modifier = Modifier.width(8.dp))
// Connection status dot
Box(
modifier =
Modifier
.size(10.dp)
.clip(CircleShape)
.background(if (state.isConnected) StatusGreen else StatusRed),
)
}
},
actions = {
// Session picker
Box {
IconButton(onClick = {
viewModel.loadSessions()
viewModel.toggleSessionPicker()
}) {
Icon(
imageVector = Icons.Filled.History,
contentDescription = "Sessions",
)
}
DropdownMenu(
expanded = state.showSessionPicker,
onDismissRequest = { viewModel.toggleSessionPicker() },
) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text("New Session")
}
},
onClick = {
viewModel.createNewSession()
viewModel.toggleSessionPicker()
},
)
if (state.sessions.isNotEmpty()) {
HorizontalDivider()
}
state.sessions.forEach { session ->
DropdownMenuItem(
text = {
Column {
Text(
text = session.title,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
)
Text(
text = "${session.messageCount} messages",
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
},
onClick = { viewModel.switchSession(session.id) },
trailingIcon = {
if (session.id == state.currentSessionId) {
Icon(
Icons.Filled.ArrowDropDown,
contentDescription = "Current",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
},
)
}
}
}
// Settings
IconButton(onClick = onNavigateToSettings) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = "Settings",
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
snackbarHost = {
SnackbarHost(snackbarHostState) { data ->
Snackbar(
snackbarData = data,
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
)
}
},
bottomBar = {
ChatInputBar(
inputText = inputText,
onInputChange = { inputText = it },
onSend = {
viewModel.sendMessage(inputText)
inputText = ""
},
onInterrupt = viewModel::interruptSession,
isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected,
)
},
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.messages.isEmpty() && !state.isLoading) {
// Empty state
EmptyState()
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
items(
items = state.messages,
key = { it.id },
) { message ->
ChatBubble(
message = message,
isDarkTheme = isDark,
)
}
// Thinking indicator
if (state.isThinking) {
item(key = "thinking") {
ThinkingIndicator(state.thinkingText)
}
}
}
// Loading overlay
AnimatedVisibility(
visible = state.isLoading,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
contentAlignment = Alignment.Center,
) {
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(4.dp),
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Connecting to Hermes…",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
}
@Composable
private fun EmptyState() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "⚡",
fontSize = 48.sp,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Ready to chat",
style =
MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Text(
text = "Send a message to start a conversation",
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
),
)
}
}
}
@Composable
private fun ThinkingIndicator(thinkingText: String) {
val infiniteTransition = rememberInfiniteTransition(label = "thinking")
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation = tween(600, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "thinking_alpha",
)
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "🤔",
fontSize = 14.sp,
modifier = Modifier.graphicsLayer { this.alpha = alpha },
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
if (thinkingText.isNotBlank()) {
val display = thinkingText.takeLast(100)
"Thinking: $display…"
} else {
"Thinking…"
},
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
maxLines = 2,
modifier = Modifier.animateContentSize(),
)
}
}
}
}
@Composable
private fun ChatInputBar(
inputText: String,
onInputChange: (String) -> Unit,
onSend: () -> Unit,
onInterrupt: () -> Unit,
isAgentTyping: Boolean,
isConnected: Boolean,
) {
val canSend = inputText.isNotBlank() && !isAgentTyping && isConnected
AnimatedVisibility(
visible = true,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
) {
Card(
modifier =
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePadding(),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(8.dp),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom,
) {
OutlinedTextField(
value = inputText,
onValueChange = onInputChange,
modifier =
Modifier
.weight(1f)
.heightIn(max = 140.dp),
placeholder = {
Text(
if (!isConnected) {
"Not connected…"
} else if (isAgentTyping) {
"Waiting for response…"
} else {
"Type a message…"
},
)
},
enabled = isConnected,
maxLines = 5,
shape = RoundedCornerShape(20.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
cursorColor = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.width(8.dp))
if (isAgentTyping) {
// Interrupt button
FilledTonalButton(
onClick = onInterrupt,
modifier = Modifier.size(48.dp),
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = "Interrupt",
tint = MaterialTheme.colorScheme.error,
)
}
} else {
// Send button
FilledTonalButton(
onClick = onSend,
enabled = canSend,
modifier = Modifier.size(48.dp),
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
)
}
}
}
}
}
}
@Composable
private fun ClarifyDialog(
clarify: ClarifyUi,
onOptionSelected: (String) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Clarification Needed") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = clarify.text)
Spacer(modifier = Modifier.height(8.dp))
clarify.options.forEach { option ->
FilledTonalButton(
onClick = { onOptionSelected(option) },
modifier = Modifier.fillMaxWidth(),
) {
Text(option)
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Dismiss")
}
},
)
}
@@ -0,0 +1,422 @@
package com.m57.hermescontrol.ui.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class ChatUiState(
val messages: List<ChatMessage> = emptyList(),
val currentSessionId: String? = null,
val sessions: List<SessionUi> = emptyList(),
val isConnected: Boolean = false,
val isAgentTyping: Boolean = false,
val isThinking: Boolean = false,
val thinkingText: String = "",
val isLoading: Boolean = false,
val currentStreamingText: String = "",
val errorMessage: String? = null,
val clarifyRequest: ClarifyUi? = null,
val showSessionPicker: Boolean = false,
)
data class SessionUi(
val id: String,
val title: String,
val messageCount: Int = 0,
)
data class ClarifyUi(
val text: String,
val options: List<String>,
)
class ChatViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private val wsClient = HermesWsClient
private val pendingRequests = mutableMapOf<String, String>() // requestId -> method
init {
connectWebSocket()
}
private fun connectWebSocket() {
val token = AuthManager.getToken() ?: return
_uiState.update { it.copy(isLoading = true) }
viewModelScope.launch(Dispatchers.IO) {
wsClient.connect()
}
viewModelScope.launch {
wsClient.events.collect { event ->
handleWsEvent(event)
}
}
}
private fun handleWsEvent(event: WsEvent) {
when (event) {
is WsEvent.GatewayReady -> {
_uiState.update { it.copy(isConnected = true, isLoading = false) }
addSystemMessage("Connected to Hermes")
loadSessions()
// Create a new session if none exists
if (_uiState.value.currentSessionId == null) {
createNewSession()
}
}
is WsEvent.SessionInfo -> {
// We may receive session info after creation
}
is WsEvent.MessageStart -> {
_uiState.update {
it.copy(
isAgentTyping = true,
currentStreamingText = "",
isThinking = false,
thinkingText = "",
)
}
}
is WsEvent.MessageToken -> {
_uiState.update { state ->
val newText = state.currentStreamingText + event.token
// Update or create the streaming message
val messages = state.messages.toMutableList()
val streamingIdx =
messages.indexOfLast {
it.role == MessageRole.ASSISTANT && it.isStreaming
}
if (streamingIdx >= 0) {
messages[streamingIdx] = messages[streamingIdx].copy(content = newText)
} else {
messages.add(
ChatMessage(
role = MessageRole.ASSISTANT,
content = newText,
isStreaming = true,
),
)
}
state.copy(
messages = messages,
currentStreamingText = newText,
isThinking = false,
)
}
}
is WsEvent.ThinkingDelta -> {
_uiState.update { state ->
state.copy(
isThinking = true,
thinkingText = state.thinkingText + event.token,
)
}
}
is WsEvent.MessageComplete -> {
_uiState.update { state ->
val messages = state.messages.toMutableList()
val streamingIdx =
messages.indexOfLast {
it.role == MessageRole.ASSISTANT && it.isStreaming
}
if (streamingIdx >= 0) {
messages[streamingIdx] =
messages[streamingIdx].copy(
content = event.text,
isStreaming = false,
)
} else {
messages.add(
ChatMessage(
role = MessageRole.ASSISTANT,
content = event.text,
),
)
}
state.copy(
messages = messages,
isAgentTyping = false,
currentStreamingText = "",
isThinking = false,
thinkingText = "",
)
}
}
is WsEvent.MessageDone -> {
_uiState.update {
it.copy(isAgentTyping = false, isThinking = false, thinkingText = "")
}
}
is WsEvent.ToolStart -> {
val toolMessage =
ChatMessage(
role = MessageRole.TOOL,
content = event.data.toString(),
toolName = event.name,
toolStatus = ToolStatus.RUNNING,
)
_uiState.update { state ->
state.copy(messages = state.messages + toolMessage)
}
}
is WsEvent.ToolComplete -> {
_uiState.update { state ->
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx >= 0) {
messages[toolIdx] =
messages[toolIdx].copy(
toolStatus = ToolStatus.COMPLETED,
content = event.data.toString(),
)
}
state.copy(messages = messages)
}
}
is WsEvent.ClarifyRequest -> {
_uiState.update {
it.copy(
clarifyRequest =
ClarifyUi(
text = event.text.orEmpty(),
options = event.options.orEmpty(),
),
)
}
}
is WsEvent.StatusUpdate -> {
// Can be used for general status
}
is WsEvent.SessionUpdated -> {
// Refresh sessions list
loadSessions()
}
is WsEvent.RpcResult -> {
handleRpcResult(event.id, event.result)
}
is WsEvent.RpcError -> {
handleRpcError(event.id, event.error)
}
is WsEvent.Unknown -> {
// Handle or ignore unknown/unhandled events
}
}
}
@Suppress("UNCHECKED_CAST")
private fun handleRpcResult(
id: String,
result: Any?,
) {
val method = pendingRequests.remove(id) ?: return
when (method) {
WsMethods.SESSION_CREATE -> {
val resultMap = result as? Map<String, Any?> ?: return
val sessionId = resultMap["session_id"] as? String ?: return
_uiState.update {
it.copy(
currentSessionId = sessionId,
isLoading = false,
messages = it.messages,
)
}
addSystemMessage("Session created")
loadSessions()
}
WsMethods.SESSION_LIST -> {
val resultMap = result as? Map<String, Any?> ?: return
val sessionsList = resultMap["sessions"] as? List<Map<String, Any?>> ?: return
val sessions =
sessionsList.map { s ->
SessionUi(
id = s["id"] as? String ?: "",
title = s["title"] as? String ?: "Untitled",
messageCount = (s["message_count"] as? Double)?.toInt() ?: 0,
)
}
_uiState.update { it.copy(sessions = sessions) }
}
WsMethods.SESSION_RESUME -> {
_uiState.update { it.copy(isLoading = false) }
addSystemMessage("Session resumed")
}
WsMethods.SESSION_INTERRUPT -> {
_uiState.update {
it.copy(isAgentTyping = false, isThinking = false)
}
addSystemMessage("Session interrupted")
}
}
}
private fun handleRpcError(
id: String,
error: Any?,
) {
val method = pendingRequests.remove(id)
val errorMsg =
when (error) {
is Map<*, *> -> error["message"] as? String ?: error.toString()
else -> error.toString()
}
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Error${if (method != null) " ($method)" else ""}: $errorMsg",
)
}
}
fun sendMessage(text: String) {
if (text.isBlank()) return
val sessionId = _uiState.value.currentSessionId ?: return
// Add user message to UI
val userMessage =
ChatMessage(
role = MessageRole.USER,
content = text,
)
_uiState.update { state ->
state.copy(
messages = state.messages + userMessage,
isAgentTyping = true,
)
}
viewModelScope.launch(Dispatchers.IO) {
val requestId = wsClient.sendMessage(sessionId, text)
pendingRequests[requestId] = WsMethods.PROMPT_SUBMIT
}
}
fun interruptSession() {
val sessionId = _uiState.value.currentSessionId ?: return
viewModelScope.launch(Dispatchers.IO) {
val requestId =
wsClient.send(
WsMethods.SESSION_INTERRUPT,
mapOf("session_id" to sessionId),
)
pendingRequests[requestId] = WsMethods.SESSION_INTERRUPT
}
}
fun createNewSession() {
_uiState.update { it.copy(isLoading = true, messages = emptyList()) }
viewModelScope.launch(Dispatchers.IO) {
val requestId = wsClient.send(WsMethods.SESSION_CREATE)
pendingRequests[requestId] = WsMethods.SESSION_CREATE
}
}
fun loadSessions() {
viewModelScope.launch(Dispatchers.IO) {
val requestId = wsClient.send(WsMethods.SESSION_LIST)
pendingRequests[requestId] = WsMethods.SESSION_LIST
}
}
fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return
_uiState.update {
it.copy(
isLoading = true,
messages = emptyList(),
currentSessionId = sessionId,
showSessionPicker = false,
)
}
viewModelScope.launch(Dispatchers.IO) {
val requestId =
wsClient.send(
WsMethods.SESSION_RESUME,
mapOf("session_id" to sessionId),
)
pendingRequests[requestId] = WsMethods.SESSION_RESUME
}
}
fun toggleSessionPicker() {
_uiState.update { it.copy(showSessionPicker = !it.showSessionPicker) }
}
fun dismissClarify() {
_uiState.update { it.copy(clarifyRequest = null) }
}
fun respondToClarify(option: String) {
_uiState.update { it.copy(clarifyRequest = null) }
sendMessage(option)
}
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
fun reconnect() {
_uiState.update {
it.copy(
isConnected = false,
isLoading = true,
errorMessage = null,
)
}
viewModelScope.launch(Dispatchers.IO) {
wsClient.disconnect()
}
viewModelScope.launch {
delay(500)
connectWebSocket()
}
}
private fun addSystemMessage(text: String) {
val msg = ChatMessage(role = MessageRole.SYSTEM, content = text)
_uiState.update { it.copy(messages = it.messages + msg) }
}
override fun onCleared() {
super.onCleared()
viewModelScope.launch(Dispatchers.IO) {
wsClient.disconnect()
}
}
}
@@ -0,0 +1,95 @@
package com.m57.hermescontrol.ui.commands
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Analytics
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
data class QuickCommand(
val label: String,
val icon: ImageVector,
val action: () -> Unit,
)
@Composable
fun CommandPanel(
onStatusClick: () -> Unit,
onSessionsClick: () -> Unit,
onSystemStatsClick: () -> Unit,
onNewSessionClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val commands =
listOf(
QuickCommand("Status", Icons.Filled.Info, onStatusClick),
QuickCommand("Sessions", Icons.Filled.History, onSessionsClick),
QuickCommand("System Stats", Icons.Filled.Analytics, onSystemStatsClick),
QuickCommand("New Session", Icons.Filled.Add, onNewSessionClick),
)
Card(
modifier = modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
elevation = CardDefaults.cardElevation(0.dp),
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
text = "Quick Commands",
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
modifier = Modifier.padding(bottom = 6.dp),
)
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
commands.forEach { cmd ->
AssistChip(
onClick = cmd.action,
label = { Text(cmd.label, style = MaterialTheme.typography.labelMedium) },
leadingIcon = {
Icon(
imageVector = cmd.icon,
contentDescription = cmd.label,
modifier = Modifier.padding(0.dp),
)
},
colors =
AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
}
}
}
}
}
@@ -0,0 +1,256 @@
package com.m57.hermescontrol.ui.connect
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun ConnectScreen(
onConnected: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ConnectViewModel = viewModel { ConnectViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.connectionSuccess) {
if (state.connectionSuccess) {
onConnected()
}
}
var passwordVisible by remember { mutableStateOf(false) }
Box(
modifier =
modifier
.fillMaxSize()
.imePadding(),
contentAlignment = Alignment.Center,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Branding
Text(
text = "⚡",
fontSize = 64.sp,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Hermes",
style =
MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
),
)
Text(
text = "AI Agent Control",
style =
MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Spacer(modifier = Modifier.height(32.dp))
// Connection Card
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Connect to Hermes",
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
),
)
// Token field
OutlinedTextField(
value = state.token,
onValueChange = viewModel::onTokenChange,
label = { Text("Auth Token") },
placeholder = { Text("Enter your API token") },
singleLine = true,
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector =
if (passwordVisible) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription =
if (passwordVisible) {
"Hide token"
} else {
"Show token"
},
)
}
},
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
// Host field
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text("Host") },
placeholder = { Text("127.0.0.1") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
// Port field
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text("Port") },
placeholder = { Text("9119") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
// Error message
AnimatedVisibility(
visible = state.errorMessage != null,
enter = fadeIn(),
exit = fadeOut(),
) {
state.errorMessage?.let { error ->
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
)
}
}
}
// Connect button
Button(
onClick = viewModel::connect,
enabled = !state.isConnecting,
modifier =
Modifier
.fillMaxWidth()
.height(52.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
if (state.isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp,
)
} else {
Text(
text = "Connect",
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
}
}
}
@@ -0,0 +1,119 @@
package com.m57.hermescontrol.ui.connect
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ConnectUiState(
val token: String = "",
val host: String = "127.0.0.1",
val port: String = "9119",
val isConnecting: Boolean = false,
val connectionSuccess: Boolean = false,
val errorMessage: String? = null,
)
class ConnectViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ConnectUiState())
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow()
init {
// Load saved values
val savedToken = AuthManager.getToken() ?: ""
val savedHost = AuthManager.getHost()
val savedPort = AuthManager.getPort()
_uiState.update {
it.copy(
token = savedToken,
host = savedHost,
port = savedPort.toString(),
)
}
}
fun onTokenChange(value: String) {
_uiState.update { it.copy(token = value.trim(), errorMessage = null) }
}
fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), errorMessage = null) }
}
fun onPortChange(value: String) {
_uiState.update { it.copy(port = value.filter { c -> c.isDigit() }, errorMessage = null) }
}
fun connect() {
val state = _uiState.value
if (state.token.isBlank()) {
_uiState.update { it.copy(errorMessage = "Token is required") }
return
}
if (state.host.isBlank()) {
_uiState.update { it.copy(errorMessage = "Host is required") }
return
}
val port = state.port.toIntOrNull()
if (port == null || port !in 1..65535) {
_uiState.update { it.copy(errorMessage = "Port must be between 1 and 65535") }
return
}
_uiState.update { it.copy(isConnecting = true, errorMessage = null) }
// Save settings before testing so ApiClient uses the right base URL
AuthManager.setToken(state.token)
AuthManager.setHost(state.host)
AuthManager.setPort(port)
ApiClient.rebuild()
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getStatus()
}
if (response.isSuccessful) {
_uiState.update {
it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null)
}
} else {
val code = response.code()
val msg =
when (code) {
401 -> {
AuthManager.setToken(null)
"Invalid token (401 Unauthorized)"
}
403 -> {
"Access denied (403 Forbidden)"
}
else -> {
"Server returned HTTP $code"
}
}
_uiState.update { it.copy(isConnecting = false, errorMessage = msg) }
}
} catch (e: Exception) {
val msg =
when {
e.message?.contains("timeout", true) == true -> "Connection timed out"
e.message?.contains("refused", true) == true -> "Connection refused – is Hermes running?"
e.message?.contains("resolve", true) == true -> "Could not resolve host"
else -> "Connection failed: ${e.message}"
}
_uiState.update { it.copy(isConnecting = false, errorMessage = msg) }
}
}
}
}
@@ -0,0 +1,156 @@
package com.m57.hermescontrol.ui.cron
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CronJobsScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: CronJobsViewModel = viewModel { CronJobsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.loadCronJobs()
}
LaunchedEffect(state.toastMessage) {
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Cron Jobs") },
actions = {
IconButton(onClick = { viewModel.loadCronJobs() }) {
Text("Refresh")
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.jobs) { job ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = job.name, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Text(text = "Schedule: ${job.schedule}", style = MaterialTheme.typography.bodyMedium)
Text(text = "Status: ${job.state}", style = MaterialTheme.typography.bodyMedium)
Text(
text = "Last Run: ${job.last_run_status ?: "Never"}",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Next Run: ${job.next_run ?: "N/A"}",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (job.state == "active") {
Button(onClick = { viewModel.pauseCronJob(job.id) }) {
Text("Pause")
}
} else {
Button(onClick = { viewModel.resumeCronJob(job.id) }) {
Text("Resume")
}
}
Button(onClick = { viewModel.triggerCronJob(job.id) }) {
Text("Trigger")
}
Button(
onClick = { viewModel.deleteCronJob(job.id) },
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text("Delete")
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,151 @@
package com.m57.hermescontrol.ui.cron
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class CronJobsUiState(
val isLoading: Boolean = false,
val jobs: List<CronJob> = emptyList(),
val errorMessage: String? = null,
val toastMessage: String? = null,
)
class CronJobsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(CronJobsUiState())
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
fun loadCronJobs() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getCronJobs()
}
if (response.isSuccessful) {
_uiState.update { it.copy(isLoading = false, jobs = response.body().orEmpty()) }
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load cron jobs: HTTP ${response.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load cron jobs: ${e.message}") }
}
}
}
fun pauseCronJob(id: String) {
val originalJobs = _uiState.value.jobs
// Optimistic update
_uiState.update { state ->
state.copy(
jobs =
state.jobs.map {
if (it.id == id) it.copy(state = "paused") else it
},
)
}
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.pauseCronJob(id)
}
if (!response.isSuccessful) {
revertJobs(originalJobs, "Failed to pause cron job: HTTP ${response.code()}")
}
} catch (e: Exception) {
revertJobs(originalJobs, "Failed to pause cron job: ${e.message}")
}
}
}
fun resumeCronJob(id: String) {
val originalJobs = _uiState.value.jobs
// Optimistic update
_uiState.update { state ->
state.copy(
jobs =
state.jobs.map {
if (it.id == id) it.copy(state = "active") else it
},
)
}
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.resumeCronJob(id)
}
if (!response.isSuccessful) {
revertJobs(originalJobs, "Failed to resume cron job: HTTP ${response.code()}")
}
} catch (e: Exception) {
revertJobs(originalJobs, "Failed to resume cron job: ${e.message}")
}
}
}
fun triggerCronJob(id: String) {
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.triggerCronJob(id)
}
if (response.isSuccessful) {
_uiState.update { it.copy(toastMessage = "Job triggered successfully") }
} else {
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: HTTP ${response.code()}") }
}
} catch (e: Exception) {
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${e.message}") }
}
}
}
fun deleteCronJob(id: String) {
val originalJobs = _uiState.value.jobs
// Optimistic update
_uiState.update { state ->
state.copy(jobs = state.jobs.filter { it.id != id })
}
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.deleteCronJob(id)
}
if (!response.isSuccessful) {
revertJobs(originalJobs, "Failed to delete cron job: HTTP ${response.code()}")
}
} catch (e: Exception) {
revertJobs(originalJobs, "Failed to delete cron job: ${e.message}")
}
}
}
private fun revertJobs(
originalJobs: List<CronJob>,
errorMsg: String,
) {
_uiState.update { it.copy(jobs = originalJobs, toastMessage = errorMsg) }
}
fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}
@@ -0,0 +1,306 @@
package com.m57.hermescontrol.ui.gateway
import android.widget.Toast
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
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.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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GatewayScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: GatewayViewModel = viewModel { GatewayViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.loadStatus()
}
LaunchedEffect(state.toastMessage) {
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Gateway Control") },
actions = {
IconButton(
onClick = { viewModel.loadStatus() },
enabled = !state.isLoading,
) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.status == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.status == null) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadStatus() }) {
Text("Retry")
}
}
} else {
val status = state.status
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val isRunning = status?.gateway_running == true
// Status Overview Card
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (isRunning) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = if (isRunning) "GATEWAY RUNNING" else "GATEWAY STOPPED",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color =
if (isRunning) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
},
)
status?.version?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Version: $it",
style = MaterialTheme.typography.bodySmall,
color =
if (isRunning) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
},
)
}
}
}
// Action Controls Card
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Controls",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { viewModel.startGateway() },
modifier = Modifier.weight(1f),
enabled = !isRunning && !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = Color(0xFF4CAF50), // Green for start
contentColor = Color.White,
),
) {
Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text("Start")
}
Button(
onClick = { viewModel.stopGateway() },
modifier = Modifier.weight(1f),
enabled = isRunning && !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = Color(0xFFF44336), // Red for stop
contentColor = Color.White,
),
) {
Text("Stop")
}
}
Button(
onClick = { viewModel.restartGateway() },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary,
),
) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text("Restart")
}
if (state.isActionRunning) {
Spacer(modifier = Modifier.height(4.dp))
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
}
// Platforms Card
status?.gateway_platforms?.let { platforms ->
if (platforms.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Active Platforms",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
platforms.forEach { (platform, pStatus) ->
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = platform.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
val stateText = pStatus.state?.uppercase() ?: "UNKNOWN"
val badgeColor =
when (stateText) {
"RUNNING" -> Color(0xFF4CAF50)
"STOPPED" -> Color(0xFFF44336)
"ERROR" -> Color(0xFFFF9800)
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
text = stateText,
style = MaterialTheme.typography.bodyMedium,
color = badgeColor,
fontWeight = FontWeight.Bold,
)
}
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,106 @@
package com.m57.hermescontrol.ui.gateway
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class GatewayUiState(
val isLoading: Boolean = false,
val isActionRunning: Boolean = false,
val status: StatusResponse? = null,
val errorMessage: String? = null,
val toastMessage: String? = null,
)
class GatewayViewModel : ViewModel() {
private val _uiState = MutableStateFlow(GatewayUiState())
val uiState: StateFlow<GatewayUiState> = _uiState.asStateFlow()
fun loadStatus() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getStatus()
}
if (response.isSuccessful) {
_uiState.update { it.copy(isLoading = false, status = response.body()) }
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load status: HTTP ${response.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load status: ${e.message}",
)
}
}
}
}
fun startGateway() {
runGatewayAction("start") { ApiClient.hermesApi.startGateway() }
}
fun stopGateway() {
runGatewayAction("stop") { ApiClient.hermesApi.stopGateway() }
}
fun restartGateway() {
runGatewayAction("restart") { ApiClient.hermesApi.restartGateway() }
}
private fun runGatewayAction(
actionName: String,
apiCall: suspend () -> retrofit2.Response<Unit>,
) {
_uiState.update { it.copy(isActionRunning = true) }
viewModelScope.launch {
try {
val response = withContext(Dispatchers.IO) { apiCall() }
if (response.isSuccessful) {
_uiState.update {
it.copy(
isActionRunning = false,
toastMessage = "Gateway ${actionName}ed successfully",
)
}
loadStatus()
} else {
_uiState.update {
it.copy(
isActionRunning = false,
toastMessage = "Failed to $actionName gateway: HTTP ${response.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isActionRunning = false,
toastMessage = "Failed to $actionName gateway: ${e.message}",
)
}
}
}
}
fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}
@@ -0,0 +1,341 @@
package com.m57.hermescontrol.ui.profiles
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
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.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.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfilesScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: ProfilesViewModel = viewModel { ProfilesViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var soulEditProfileName by remember { mutableStateOf<String?>(null) }
var modelEditProfileName by remember { mutableStateOf<String?>(null) }
var tempModelProvider by remember { mutableStateOf("") }
var tempModelName by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.loadProfiles()
}
LaunchedEffect(state.toastMessage) {
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Agent Profiles") },
actions = {
IconButton(onClick = { viewModel.loadProfiles() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.profiles.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.profiles.isEmpty()) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadProfiles() }) {
Text("Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.profiles) { profile ->
val isActive = profile.name == state.activeProfileName
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (isActive) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
onClick = {
if (!isActive) {
viewModel.selectActiveProfile(profile.name)
}
},
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = profile.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
profile.description?.let {
if (it.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Active Profile",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp),
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = "Model: ${profile.model ?: "None"}",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Provider: ${profile.provider ?: "None"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(
onClick = {
soulEditProfileName = profile.name
viewModel.loadSoul(profile.name)
},
modifier = Modifier.padding(end = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = null,
modifier = Modifier.width(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Edit Soul")
}
Button(
onClick = {
modelEditProfileName = profile.name
tempModelProvider = profile.provider ?: ""
tempModelName = profile.model ?: ""
},
) {
Text("Set Model")
}
}
}
}
}
}
}
}
// Soul Edit Dialog
soulEditProfileName?.let { profileName ->
var soulText by remember(state.selectedSoulContent) {
mutableStateOf(state.selectedSoulContent ?: "")
}
AlertDialog(
onDismissRequest = {
soulEditProfileName = null
viewModel.closeSoulDialog()
},
title = { Text("Edit Soul ($profileName)") },
text = {
Box(modifier = Modifier.fillMaxWidth()) {
if (state.isLoadingSoul) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
OutlinedTextField(
value = soulText,
onValueChange = { soulText = it },
modifier =
Modifier
.fillMaxWidth()
.height(200.dp),
label = { Text("Soul/Persona Prompts") },
maxLines = 10,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.saveSoul(profileName, soulText)
soulEditProfileName = null
},
enabled = !state.isLoadingSoul,
) {
Text("Save")
}
},
dismissButton = {
TextButton(
onClick = {
soulEditProfileName = null
viewModel.closeSoulDialog()
},
) {
Text("Cancel")
}
},
)
}
// Model Edit Dialog
modelEditProfileName?.let { profileName ->
AlertDialog(
onDismissRequest = { modelEditProfileName = null },
title = { Text("Switch Model ($profileName)") },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = tempModelProvider,
onValueChange = { tempModelProvider = it },
label = { Text("Provider (e.g. nvidia, openai, local)") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = tempModelName,
onValueChange = { tempModelName = it },
label = { Text("Model Name") },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.updateModel(profileName, tempModelProvider, tempModelName)
modelEditProfileName = null
},
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { modelEditProfileName = null }) {
Text("Cancel")
}
},
)
}
}
}
@@ -0,0 +1,201 @@
package com.m57.hermescontrol.ui.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ProfilesUiState(
val isLoading: Boolean = false,
val profiles: List<ProfileInfo> = emptyList(),
val activeProfileName: String? = null,
val selectedSoulContent: String? = null,
val isLoadingSoul: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
)
class ProfilesViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ProfilesUiState())
val uiState: StateFlow<ProfilesUiState> = _uiState.asStateFlow()
fun loadProfiles() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val profilesRes = withContext(Dispatchers.IO) { ApiClient.hermesApi.getProfiles() }
val activeRes = withContext(Dispatchers.IO) { ApiClient.hermesApi.getActiveProfile() }
if (profilesRes.isSuccessful && activeRes.isSuccessful) {
_uiState.update {
it.copy(
isLoading = false,
profiles = profilesRes.body()?.profiles.orEmpty(),
activeProfileName = activeRes.body()?.active,
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage =
"Failed to load profiles/active: HTTP " +
"${profilesRes.code()} / ${activeRes.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Error loading profiles: ${e.message}",
)
}
}
}
}
fun selectActiveProfile(name: String) {
val originalActive = _uiState.value.activeProfileName
// Optimistically update active profile name
_uiState.update { it.copy(activeProfileName = name) }
viewModelScope.launch {
try {
val res =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.setActiveProfile(SetActiveProfileRequest(name))
}
if (res.isSuccessful) {
_uiState.update { it.copy(toastMessage = "Switched to profile $name") }
loadProfiles()
} else {
_uiState.update {
it.copy(
activeProfileName = originalActive,
toastMessage = "Failed to switch profile: HTTP ${res.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
activeProfileName = originalActive,
toastMessage = "Failed to switch profile: ${e.message}",
)
}
}
}
}
fun loadSoul(profileName: String) {
_uiState.update { it.copy(isLoadingSoul = true, selectedSoulContent = null) }
viewModelScope.launch {
try {
val res =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getProfileSoul(profileName)
}
if (res.isSuccessful) {
_uiState.update {
it.copy(
isLoadingSoul = false,
selectedSoulContent = res.body()?.content ?: "",
)
}
} else {
_uiState.update {
it.copy(
isLoadingSoul = false,
toastMessage = "Failed to load soul: HTTP ${res.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoadingSoul = false,
toastMessage = "Failed to load soul: ${e.message}",
)
}
}
}
}
fun saveSoul(
profileName: String,
content: String,
) {
viewModelScope.launch {
try {
val res =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.updateProfileSoul(profileName, UpdateProfileSoulRequest(content))
}
if (res.isSuccessful) {
_uiState.update {
it.copy(
selectedSoulContent = null,
toastMessage = "Soul updated successfully",
)
}
loadProfiles()
} else {
_uiState.update {
it.copy(
toastMessage = "Failed to save soul: HTTP ${res.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
toastMessage = "Failed to save soul: ${e.message}",
)
}
}
}
}
fun updateModel(
profileName: String,
provider: String,
model: String,
) {
viewModelScope.launch {
try {
val res =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.updateProfileModel(profileName, UpdateProfileModelRequest(provider, model))
}
if (res.isSuccessful) {
_uiState.update { it.copy(toastMessage = "Model settings updated") }
loadProfiles()
} else {
_uiState.update { it.copy(toastMessage = "Failed to update model settings: HTTP ${res.code()}") }
}
} catch (e: Exception) {
_uiState.update { it.copy(toastMessage = "Failed to update model settings: ${e.message}") }
}
}
}
fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
fun closeSoulDialog() {
_uiState.update { it.copy(selectedSoulContent = null) }
}
}
@@ -0,0 +1,343 @@
package com.m57.hermescontrol.ui.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
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.ExperimentalMaterial3Api
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.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
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.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.theme.ThemePreference
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = viewModel { SettingsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var passwordVisible by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = {
Text(
"Settings",
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
),
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { paddingValues ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Connection section
SectionCard(title = "Connection") {
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text("Port") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.token,
onValueChange = viewModel::onTokenChange,
label = { Text("Auth Token") },
singleLine = true,
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector =
if (passwordVisible) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription =
if (passwordVisible) {
"Hide token"
} else {
"Show token"
},
)
}
},
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
}
// Behavior section
SectionCard(title = "Behavior") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = "Auto-Reconnect",
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = "Automatically reconnect on disconnect",
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
Switch(
checked = state.autoReconnect,
onCheckedChange = viewModel::onAutoReconnectChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
)
}
}
// Appearance section
SectionCard(title = "Appearance") {
Text(
text = "Theme",
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ThemePreference.entries.forEachIndexed { index, pref ->
SegmentedButton(
selected = state.themePreference == pref,
onClick = { viewModel.onThemeChange(pref) },
shape =
SegmentedButtonDefaults.itemShape(
index = index,
count = ThemePreference.entries.size,
),
) {
Text(
pref.name
.lowercase()
.replaceFirstChar { it.uppercase() },
)
}
}
}
}
// Test result
AnimatedVisibility(
visible = state.testResult != null,
enter = fadeIn(),
exit = fadeOut(),
) {
state.testResult?.let { result ->
Card(
colors =
CardDefaults.cardColors(
containerColor =
if (result.startsWith("✅")) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = result,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
// Save indicator
AnimatedVisibility(
visible = state.isSaved,
enter = fadeIn(),
exit = fadeOut(),
) {
Text(
text = "✅ Settings saved",
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.primary,
),
)
}
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = viewModel::testConnection,
enabled = !state.isTesting,
modifier = Modifier.weight(1f),
) {
if (state.isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Text("Test Connection")
}
}
Button(
onClick = viewModel::save,
modifier = Modifier.weight(1f),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text("Save")
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@Composable
private fun SectionCard(
title: String,
content: @Composable () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style =
MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.padding(bottom = 12.dp),
)
content()
}
}
}
@@ -0,0 +1,119 @@
package com.m57.hermescontrol.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.theme.ThemePreference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class SettingsUiState(
val host: String = "127.0.0.1",
val port: String = "9119",
val token: String = "",
val autoReconnect: Boolean = true,
val themePreference: ThemePreference = ThemePreference.SYSTEM,
val isTesting: Boolean = false,
val testResult: String? = null,
val isSaved: Boolean = false,
)
class SettingsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
init {
loadSettings()
}
private fun loadSettings() {
_uiState.update {
it.copy(
host = AuthManager.getHost(),
port = AuthManager.getPort().toString(),
token = AuthManager.getToken() ?: "",
autoReconnect = AuthManager.isAutoReconnect(),
)
}
}
fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), isSaved = false) }
}
fun onPortChange(value: String) {
_uiState.update { it.copy(port = value.filter { c -> c.isDigit() }, isSaved = false) }
}
fun onTokenChange(value: String) {
_uiState.update { it.copy(token = value.trim(), isSaved = false) }
}
fun onAutoReconnectChange(enabled: Boolean) {
_uiState.update { it.copy(autoReconnect = enabled, isSaved = false) }
}
fun onThemeChange(theme: ThemePreference) {
_uiState.update { it.copy(themePreference = theme, isSaved = false) }
}
fun save() {
val state = _uiState.value
val port = state.port.toIntOrNull() ?: 9119
AuthManager.setHost(state.host)
AuthManager.setPort(port)
AuthManager.setToken(state.token)
AuthManager.setAutoReconnect(state.autoReconnect)
ApiClient.rebuild()
_uiState.update { it.copy(isSaved = true, testResult = null) }
}
fun testConnection() {
val state = _uiState.value
if (state.token.isBlank()) {
_uiState.update { it.copy(testResult = "❌ Token is required") }
return
}
// Save first so ApiClient uses updated settings
save()
_uiState.update { it.copy(isTesting = true, testResult = null) }
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getStatus()
}
if (response.isSuccessful) {
_uiState.update {
it.copy(isTesting = false, testResult = "✅ Connected successfully")
}
} else {
_uiState.update {
it.copy(
isTesting = false,
testResult = "❌ HTTP ${response.code()}: ${response.message()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isTesting = false,
testResult = "❌ ${e.message ?: "Connection failed"}",
)
}
}
}
}
}
@@ -0,0 +1,139 @@
package com.m57.hermescontrol.ui.skills
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Card
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.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SkillsScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: SkillsViewModel = viewModel { SkillsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.loadSkills()
}
LaunchedEffect(state.toastMessage) {
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Skills Management") },
actions = {
IconButton(onClick = { viewModel.loadSkills() }) {
Text("Refresh")
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.skills) { skill ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = skill.name, style = MaterialTheme.typography.titleMedium)
Text(
text = skill.category ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = skill.description ?: "",
style = MaterialTheme.typography.bodyMedium,
)
}
Switch(
checked = skill.enabled,
onCheckedChange = { viewModel.toggleSkill(skill) },
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,99 @@
package com.m57.hermescontrol.ui.skills
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class SkillsUiState(
val isLoading: Boolean = false,
val skills: List<Skill> = emptyList(),
val errorMessage: String? = null,
val toastMessage: String? = null,
)
class SkillsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SkillsUiState())
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
fun loadSkills() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getSkills()
}
if (response.isSuccessful) {
_uiState.update { it.copy(isLoading = false, skills = response.body().orEmpty()) }
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load skills: HTTP ${response.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load skills: ${e.message}") }
}
}
}
fun toggleSkill(skill: Skill) {
val originalEnabled = skill.enabled
val targetEnabled = !originalEnabled
// Optimistically update
_uiState.update { state ->
state.copy(
skills =
state.skills.map {
if (it.name == skill.name) it.copy(enabled = targetEnabled) else it
},
)
}
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled))
}
if (!response.isSuccessful) {
revertSkillToggle(skill.name, originalEnabled, "Failed to toggle skill: HTTP ${response.code()}")
}
} catch (e: Exception) {
revertSkillToggle(skill.name, originalEnabled, "Failed to toggle skill: ${e.message}")
}
}
}
private fun revertSkillToggle(
name: String,
originalEnabled: Boolean,
errorMsg: String,
) {
_uiState.update { state ->
state.copy(
skills =
state.skills.map {
if (it.name == name) it.copy(enabled = originalEnabled) else it
},
toastMessage = errorMsg,
)
}
}
fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}
@@ -0,0 +1,186 @@
package com.m57.hermescontrol.ui.toolsets
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
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.SuggestionChip
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ToolsetsScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: ToolsetsViewModel = viewModel { ToolsetsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.loadToolsets()
}
LaunchedEffect(state.toastMessage) {
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Toolsets Manager") },
actions = {
IconButton(onClick = { viewModel.loadToolsets() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.toolsets.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.toolsets.isEmpty()) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
IconButton(onClick = { viewModel.loadToolsets() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.toolsets) { toolset ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (toolset.enabled) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = toolset.label ?: toolset.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
toolset.description?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
toolset.tools?.let { tools ->
if (tools.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
tools.forEach { tool ->
SuggestionChip(
onClick = {},
label = {
Text(
text = tool,
style = MaterialTheme.typography.labelSmall,
)
},
)
}
}
}
}
}
Switch(
checked = toolset.enabled,
onCheckedChange = { viewModel.toggleToolset(toolset) },
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,104 @@
package com.m57.hermescontrol.ui.toolsets
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
import com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ToolsetsUiState(
val isLoading: Boolean = false,
val toolsets: List<Toolset> = emptyList(),
val errorMessage: String? = null,
val toastMessage: String? = null,
)
class ToolsetsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ToolsetsUiState())
val uiState: StateFlow<ToolsetsUiState> = _uiState.asStateFlow()
fun loadToolsets() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getToolsets()
}
if (response.isSuccessful) {
_uiState.update { it.copy(isLoading = false, toolsets = response.body().orEmpty()) }
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load toolsets: HTTP ${response.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load toolsets: ${e.message}",
)
}
}
}
}
fun toggleToolset(toolset: Toolset) {
val originalEnabled = toolset.enabled
val targetEnabled = !originalEnabled
// Optimistic UI update
_uiState.update { state ->
state.copy(
toolsets =
state.toolsets.map {
if (it.name == toolset.name) it.copy(enabled = targetEnabled) else it
},
)
}
viewModelScope.launch {
try {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.toggleToolset(toolset.name, ToolsetToggleRequest(targetEnabled))
}
if (!response.isSuccessful) {
revertToggle(toolset.name, originalEnabled, "Failed to toggle toolset: HTTP ${response.code()}")
}
} catch (e: Exception) {
revertToggle(toolset.name, originalEnabled, "Failed to toggle toolset: ${e.message}")
}
}
}
private fun revertToggle(
name: String,
originalEnabled: Boolean,
errorMsg: String,
) {
_uiState.update { state ->
state.copy(
toolsets =
state.toolsets.map {
if (it.name == name) it.copy(enabled = originalEnabled) else it
},
toastMessage = errorMsg,
)
}
}
fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">HermesControl</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HermesControl" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,757 @@
package com.m57.hermescontrol
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.ui.connect.ConnectViewModel
import com.m57.hermescontrol.ui.cron.CronJobsViewModel
import com.m57.hermescontrol.ui.skills.SkillsViewModel
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import retrofit2.Response
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class E2eIntegrationTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApiService: HermesApiService
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
val testMainDispatcher = Dispatchers.Main
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns testDispatcher
every { Dispatchers.Main } returns testMainDispatcher
mockkObject(AuthManager)
mockkObject(ApiClient)
mockApiService = mockk()
every { ApiClient.hermesApi } returns mockApiService
every { ApiClient.rebuild() } returns Unit
// Default AuthManager stubs
every { AuthManager.getToken() } returns "mock-token"
every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119
every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(any()) } returns Unit
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
NavigationController.backStack = null
}
private fun <T> createErrorResponse(code: Int): Response<T> {
val errorBody = "".toResponseBody(null)
return Response.error(code, errorBody)
}
// ── Tier 1: Feature Coverage (>=5 per feature) ───────────────────────
// Skills Management Screen:
@Test
fun testSkillsListing_success() =
runTest {
val skill1 = Skill("Skill 1", "Description 1", "Category 1", true)
val skill2 = Skill("Skill 2", "Description 2", "Category 2", false)
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill1, skill2))
val viewModel = SkillsViewModel()
viewModel.loadSkills()
assertTrue(viewModel.uiState.value.isLoading)
assertNull(viewModel.uiState.value.errorMessage)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertNull(viewModel.uiState.value.errorMessage)
assertEquals(2, viewModel.uiState.value.skills.size)
assertEquals(
"Skill 1",
viewModel.uiState.value.skills[0]
.name,
)
assertEquals(
"Description 1",
viewModel.uiState.value.skills[0]
.description,
)
assertEquals(
"Category 1",
viewModel.uiState.value.skills[0]
.category,
)
assertTrue(
viewModel.uiState.value.skills[0]
.enabled,
)
assertEquals(
"Skill 2",
viewModel.uiState.value.skills[1]
.name,
)
assertFalse(
viewModel.uiState.value.skills[1]
.enabled,
)
}
@Test
fun testSkillsToggle_success() =
runTest {
val skill = Skill("Skill 1", "Description 1", "Category 1", false)
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns Response.success(Unit)
val viewModel = SkillsViewModel()
viewModel.loadSkills()
advanceUntilIdle()
assertFalse(
viewModel.uiState.value.skills[0]
.enabled,
)
viewModel.toggleSkill(viewModel.uiState.value.skills[0])
// Verify optimistic update
assertTrue(
viewModel.uiState.value.skills[0]
.enabled,
)
advanceUntilIdle()
// Verify it remains enabled
assertTrue(
viewModel.uiState.value.skills[0]
.enabled,
)
assertNull(viewModel.uiState.value.toastMessage)
}
@Test
fun testSkillsToggle_failure() =
runTest {
val skill = Skill("Skill 1", "Description 1", "Category 1", false)
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns createErrorResponse(500)
val viewModel = SkillsViewModel()
viewModel.loadSkills()
advanceUntilIdle()
assertFalse(
viewModel.uiState.value.skills[0]
.enabled,
)
viewModel.toggleSkill(viewModel.uiState.value.skills[0])
// Verify optimistic update
assertTrue(
viewModel.uiState.value.skills[0]
.enabled,
)
advanceUntilIdle()
// Verify reverted state and toast error message
assertFalse(
viewModel.uiState.value.skills[0]
.enabled,
)
assertNotNull(viewModel.uiState.value.toastMessage)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testSkillsLoad_failure() =
runTest {
coEvery { mockApiService.getSkills() } returns createErrorResponse(500)
val viewModel = SkillsViewModel()
viewModel.loadSkills()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertNotNull(viewModel.uiState.value.errorMessage)
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testSkillsRefresh() =
runTest {
val skillA = Skill("Skill A", null, null, false)
val skillB = Skill("Skill B", null, null, true)
coEvery { mockApiService.getSkills() } returnsMany
listOf(
Response.success(listOf(skillA)),
Response.success(listOf(skillB)),
)
val viewModel = SkillsViewModel()
viewModel.loadSkills()
advanceUntilIdle()
assertEquals(
"Skill A",
viewModel.uiState.value.skills[0]
.name,
)
viewModel.loadSkills()
advanceUntilIdle()
assertEquals(
"Skill B",
viewModel.uiState.value.skills[0]
.name,
)
}
// Cron Jobs Screen:
@Test
fun testCronJobsListing_success() =
runTest {
val job = CronJob("id1", "Job 1", "*/5 * * * *", "active", "success", "2026-06-15T15:10:00Z")
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
assertTrue(viewModel.uiState.value.isLoading)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertEquals(1, viewModel.uiState.value.jobs.size)
assertEquals(
"id1",
viewModel.uiState.value.jobs[0]
.id,
)
assertEquals(
"Job 1",
viewModel.uiState.value.jobs[0]
.name,
)
assertEquals(
"active",
viewModel.uiState.value.jobs[0]
.state,
)
assertEquals(
"success",
viewModel.uiState.value.jobs[0]
.last_run_status,
)
assertEquals(
"2026-06-15T15:10:00Z",
viewModel.uiState.value.jobs[0]
.next_run,
)
}
@Test
fun testCronJobPause_success() =
runTest {
val job = CronJob("id1", "Job 1", "*/5 * * * *", "active", null, null)
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
coEvery { mockApiService.pauseCronJob("id1") } returns Response.success(Unit)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
viewModel.pauseCronJob("id1")
// Verify optimistic update
assertEquals(
"paused",
viewModel.uiState.value.jobs[0]
.state,
)
advanceUntilIdle()
assertEquals(
"paused",
viewModel.uiState.value.jobs[0]
.state,
)
}
@Test
fun testCronJobResume_success() =
runTest {
val job = CronJob("id1", "Job 1", "*/5 * * * *", "paused", null, null)
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
coEvery { mockApiService.resumeCronJob("id1") } returns Response.success(Unit)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
viewModel.resumeCronJob("id1")
// Verify optimistic update
assertEquals(
"active",
viewModel.uiState.value.jobs[0]
.state,
)
advanceUntilIdle()
assertEquals(
"active",
viewModel.uiState.value.jobs[0]
.state,
)
}
@Test
fun testCronJobTrigger_success() =
runTest {
coEvery { mockApiService.triggerCronJob("id1") } returns Response.success(Unit)
val viewModel = CronJobsViewModel()
viewModel.triggerCronJob("id1")
advanceUntilIdle()
assertEquals("Job triggered successfully", viewModel.uiState.value.toastMessage)
}
@Test
fun testCronJobDelete_success() =
runTest {
val job = CronJob("id1", "Job 1", null, null, null, null)
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
coEvery { mockApiService.deleteCronJob("id1") } returns Response.success(Unit)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.jobs.size)
viewModel.deleteCronJob("id1")
// Verify optimistic update (immediate removal)
assertTrue(
viewModel.uiState.value.jobs
.isEmpty(),
)
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.jobs
.isEmpty(),
)
}
@Test
fun testCronJobsLoad_failure() =
runTest {
coEvery { mockApiService.getCronJobs() } returns createErrorResponse(500)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertNotNull(viewModel.uiState.value.errorMessage)
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testCronJobsRefresh() =
runTest {
val jobA = CronJob("idA", "Job A", null, null, null, null)
val jobB = CronJob("idB", "Job B", null, null, null, null)
coEvery { mockApiService.getCronJobs() } returnsMany
listOf(
Response.success(listOf(jobA)),
Response.success(listOf(jobB)),
)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
assertEquals(
"Job A",
viewModel.uiState.value.jobs[0]
.name,
)
viewModel.loadCronJobs()
advanceUntilIdle()
assertEquals(
"Job B",
viewModel.uiState.value.jobs[0]
.name,
)
}
// Navigation Drawer:
@Test
fun testNavigationDrawerTransitions() {
val backStack = NavBackStack<NavKey>(ChatScreen)
NavigationController.backStack = backStack
assertEquals(ChatScreen, backStack.lastOrNull())
NavigationController.navigateTo(SkillsScreen)
assertEquals(SkillsScreen, backStack.lastOrNull())
assertEquals(1, backStack.size)
NavigationController.navigateTo(CronJobsScreen)
assertEquals(CronJobsScreen, backStack.lastOrNull())
assertEquals(1, backStack.size)
// Non-clearing key settings adds to stack
NavigationController.navigateTo(SettingsScreen)
assertEquals(SettingsScreen, backStack.lastOrNull())
assertEquals(2, backStack.size)
assertEquals(CronJobsScreen, backStack[0])
}
// ── Tier 2: Boundary & Corner Cases (>=5 per feature) ────────────────
// Skills Screen boundary cases:
@Test
fun testSkillsLoad_httpError_400() =
runTest {
coEvery { mockApiService.getSkills() } returns createErrorResponse(400)
val viewModel = SkillsViewModel()
viewModel.loadSkills()
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("HTTP 400"),
)
}
@Test
fun testSkillsLoad_httpError_404() =
runTest {
coEvery { mockApiService.getSkills() } returns createErrorResponse(404)
val viewModel = SkillsViewModel()
viewModel.loadSkills()
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("HTTP 404"),
)
}
@Test
fun testSkillsLoad_httpError_500() =
runTest {
coEvery { mockApiService.getSkills() } returns createErrorResponse(500)
val viewModel = SkillsViewModel()
viewModel.loadSkills()
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testSkillsLoad_networkTimeout() =
runTest {
coEvery { mockApiService.getSkills() } throws IOException("Timeout")
val viewModel = SkillsViewModel()
viewModel.loadSkills()
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("Timeout"),
)
}
@Test
fun testSkillsLoad_emptyResponse() =
runTest {
coEvery { mockApiService.getSkills() } returns Response.success(emptyList())
val viewModel = SkillsViewModel()
viewModel.loadSkills()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertTrue(
viewModel.uiState.value.skills
.isEmpty(),
)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun testSkillsToggle_httpError_500() =
runTest {
val skill = Skill("Skill 1", null, null, false)
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500)
val viewModel = SkillsViewModel()
viewModel.loadSkills()
advanceUntilIdle()
viewModel.toggleSkill(viewModel.uiState.value.skills[0])
advanceUntilIdle()
assertFalse(
viewModel.uiState.value.skills[0]
.enabled,
)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
// Cron Jobs Screen boundary cases:
@Test
fun testCronJobsLoad_httpError_500() =
runTest {
coEvery { mockApiService.getCronJobs() } returns createErrorResponse(500)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testCronJobPause_httpError_500() =
runTest {
val job = CronJob("id1", "Job 1", null, "active", null, null)
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
coEvery { mockApiService.pauseCronJob("id1") } returns createErrorResponse(500)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
viewModel.pauseCronJob("id1")
advanceUntilIdle()
assertEquals(
"active",
viewModel.uiState.value.jobs[0]
.state,
)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testCronJobTrigger_httpError_500() =
runTest {
coEvery { mockApiService.triggerCronJob("id1") } returns createErrorResponse(500)
val viewModel = CronJobsViewModel()
viewModel.triggerCronJob("id1")
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testCronJobDelete_httpError_500() =
runTest {
val job = CronJob("id1", "Job 1", null, null, null, null)
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
coEvery { mockApiService.deleteCronJob("id1") } returns createErrorResponse(500)
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
viewModel.deleteCronJob("id1")
advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.jobs.size)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
@Test
fun testCronJobsLoad_networkTimeout() =
runTest {
coEvery { mockApiService.getCronJobs() } throws IOException("Timeout")
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.errorMessage!!
.contains("Timeout"),
)
}
@Test
fun testCronJobsLoad_emptyResponse() =
runTest {
coEvery { mockApiService.getCronJobs() } returns Response.success(emptyList())
val viewModel = CronJobsViewModel()
viewModel.loadCronJobs()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertTrue(
viewModel.uiState.value.jobs
.isEmpty(),
)
assertNull(viewModel.uiState.value.errorMessage)
}
// ── Tier 3: Cross-Feature Combinations ────────────────────────────────
@Test
fun testDrawerTransitionDuringAction() =
runTest {
val skill = Skill("Skill 1", null, null, false)
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(any()) } returns Response.success(Unit)
val viewModel = SkillsViewModel()
viewModel.loadSkills()
advanceUntilIdle()
// Set up drawer/backstack
val backStack = NavBackStack<NavKey>(SkillsScreen)
NavigationController.backStack = backStack
viewModel.toggleSkill(viewModel.uiState.value.skills[0])
// Transition during action
NavigationController.navigateTo(CronJobsScreen)
assertEquals(CronJobsScreen, NavigationController.backStack?.lastOrNull())
// Complete the action
advanceUntilIdle()
// Verify the state is correct and didn't crash
assertTrue(
viewModel.uiState.value.skills[0]
.enabled,
)
}
@Test
fun testAuthTokenRevocation() =
runTest {
val mockResponse = createErrorResponse<StatusResponse>(401)
coEvery { mockApiService.getStatus() } returns mockResponse
val viewModel = ConnectViewModel()
viewModel.onTokenChange("expired-token")
viewModel.connect()
advanceUntilIdle()
// Verify state is updated with error message
assertEquals("Invalid token (401 Unauthorized)", viewModel.uiState.value.errorMessage)
assertFalse(viewModel.uiState.value.connectionSuccess)
// Verify AuthManager.setToken(null) was called
verify { AuthManager.setToken(null) }
}
// ── Tier 4: Real-World Scenarios ─────────────────────────────────────
@Test
fun testFullUserSessionFlow() =
runTest {
// Step 1: User connects with valid token
val statusResponse = mockk<StatusResponse>()
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
val connectViewModel = ConnectViewModel()
connectViewModel.onTokenChange("valid-token")
connectViewModel.onHostChange("127.0.0.1")
connectViewModel.onPortChange("9119")
connectViewModel.connect()
advanceUntilIdle()
assertTrue(connectViewModel.uiState.value.connectionSuccess)
verify { AuthManager.setToken("valid-token") }
// Step 2: User navigates to SkillsScreen
val backStack = NavBackStack<NavKey>(ChatScreen)
NavigationController.backStack = backStack
NavigationController.navigateTo(SkillsScreen)
assertEquals(SkillsScreen, NavigationController.backStack?.lastOrNull())
// Step 3: User toggles a skill, but API fails
val skill = Skill("Skill X", "Desc X", "Cat X", false)
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500)
val skillsViewModel = SkillsViewModel()
skillsViewModel.loadSkills()
advanceUntilIdle()
assertEquals(1, skillsViewModel.uiState.value.skills.size)
assertFalse(
skillsViewModel.uiState.value.skills[0]
.enabled,
)
skillsViewModel.toggleSkill(skill)
// Verify optimistic update
assertTrue(
skillsViewModel.uiState.value.skills[0]
.enabled,
)
advanceUntilIdle()
// Verify state reverts and toast is shown
assertFalse(
skillsViewModel.uiState.value.skills[0]
.enabled,
)
assertTrue(
skillsViewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
// Step 4: User navigates to CronJobsScreen
NavigationController.navigateTo(CronJobsScreen)
assertEquals(CronJobsScreen, NavigationController.backStack?.lastOrNull())
// Step 5: User triggers a cron job
coEvery { mockApiService.triggerCronJob("job-1") } returns Response.success(Unit)
val cronViewModel = CronJobsViewModel()
cronViewModel.triggerCronJob("job-1")
advanceUntilIdle()
assertEquals("Job triggered successfully", cronViewModel.uiState.value.toastMessage)
}
}
@@ -0,0 +1,134 @@
package com.m57.hermescontrol.data.local
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import io.mockk.*
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class AuthManagerTest {
private lateinit var mockPrefs: SharedPreferences
private lateinit var mockEditor: SharedPreferences.Editor
private lateinit var mockContext: Context
@Before
fun setUp() {
mockPrefs = mockk(relaxed = true)
mockEditor = mockk(relaxed = true)
mockContext = mockk(relaxed = true)
every { mockPrefs.edit() } returns mockEditor
every { mockEditor.putString(any(), any()) } returns mockEditor
every { mockEditor.putInt(any(), any()) } returns mockEditor
every { mockEditor.putBoolean(any(), any()) } returns mockEditor
// Mock EncryptedSharedPreferences static methods
mockkStatic(EncryptedSharedPreferences::class)
every {
EncryptedSharedPreferences.create(
any<Context>(),
any<String>(),
any<MasterKey>(),
any<EncryptedSharedPreferences.PrefKeyEncryptionScheme>(),
any<EncryptedSharedPreferences.PrefValueEncryptionScheme>(),
)
} returns mockPrefs
// Mock MasterKey.Builder constructor and its build method
mockkConstructor(MasterKey.Builder::class)
val mockMasterKey = mockk<MasterKey>(relaxed = true)
every { anyConstructed<MasterKey.Builder>().build() } returns mockMasterKey
// Reset prefs singleton instance using reflection
val field = AuthManager::class.java.getDeclaredField("prefs")
field.isAccessible = true
field.set(AuthManager, null)
// Initialise AuthManager
AuthManager.init(mockContext)
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun testGetAndSetToken() {
every { mockPrefs.getString("auth_token", null) } returns "my_secret_token"
assertEquals("my_secret_token", AuthManager.getToken())
AuthManager.setToken("new_token")
verify { mockEditor.putString("auth_token", "new_token") }
verify { mockEditor.apply() }
}
@Test
fun testGetAndSetToken_null() {
every { mockPrefs.getString("auth_token", null) } returns null
assertNull(AuthManager.getToken())
AuthManager.setToken(null)
verify { mockEditor.putString("auth_token", null) }
verify { mockEditor.apply() }
}
@Test
fun testGetAndSetHost() {
every { mockPrefs.getString("host", "127.0.0.1") } returns "192.168.1.1"
assertEquals("192.168.1.1", AuthManager.getHost())
AuthManager.setHost("10.0.0.1")
verify { mockEditor.putString("host", "10.0.0.1") }
verify { mockEditor.apply() }
}
@Test
fun testGetAndSetPort() {
every { mockPrefs.getInt("port", 9119) } returns 8080
assertEquals(8080, AuthManager.getPort())
AuthManager.setPort(9090)
verify { mockEditor.putInt("port", 9090) }
verify { mockEditor.apply() }
}
@Test
fun testGetAndSetAutoReconnect() {
every { mockPrefs.getBoolean("auto_reconnect", true) } returns false
assertEquals(false, AuthManager.isAutoReconnect())
AuthManager.setAutoReconnect(true)
verify { mockEditor.putBoolean("auto_reconnect", true) }
verify { mockEditor.apply() }
}
@Test
fun testBaseUrl() {
every { mockPrefs.getString("host", "127.0.0.1") } returns "hermes.local"
every { mockPrefs.getInt("port", 9119) } returns 1234
assertEquals("http://hermes.local:1234/", AuthManager.baseUrl())
}
@Test
fun testWsUrl() {
every { mockPrefs.getString("host", "127.0.0.1") } returns "hermes.local"
every { mockPrefs.getInt("port", 9119) } returns 1234
every { mockPrefs.getString("auth_token", null) } returns "token123"
assertEquals("ws://hermes.local:1234/api/ws?token=token123", AuthManager.wsUrl())
}
@Test
fun testWsUrl_nullToken() {
every { mockPrefs.getString("host", "127.0.0.1") } returns "hermes.local"
every { mockPrefs.getInt("port", 9119) } returns 1234
every { mockPrefs.getString("auth_token", null) } returns null
assertEquals("ws://hermes.local:1234/api/ws?token=", AuthManager.wsUrl())
}
}
@@ -0,0 +1,428 @@
package com.m57.hermescontrol.data.model
import com.google.gson.Gson
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class ModelSerializationTest {
private val gson = Gson()
@Test
fun testSkillDeserialization_allFields() {
val json =
"""
{
"name": "weather",
"description": "Get current weather info",
"category": "utility",
"enabled": true
}
""".trimIndent()
val skill = gson.fromJson(json, Skill::class.java)
assertEquals("weather", skill.name)
assertEquals("Get current weather info", skill.description)
assertEquals("utility", skill.category)
assertEquals(true, skill.enabled)
}
@Test
fun testSkillDeserialization_optionalFieldsNull() {
val json =
"""
{
"name": "light_control",
"description": null,
"category": null,
"enabled": false
}
""".trimIndent()
val skill = gson.fromJson(json, Skill::class.java)
assertEquals("light_control", skill.name)
assertNull(skill.description)
assertNull(skill.category)
assertEquals(false, skill.enabled)
}
@Test
fun testCronJobDeserialization_allFields() {
val json =
"""
{
"id": "job-123",
"name": "Daily Backup",
"schedule": "0 0 * * *",
"state": "active",
"last_run_status": "success",
"next_run": "2026-06-16T00:00:00Z"
}
""".trimIndent()
val job = gson.fromJson(json, CronJob::class.java)
assertEquals("job-123", job.id)
assertEquals("Daily Backup", job.name)
assertEquals("0 0 * * *", job.schedule)
assertEquals("active", job.state)
assertEquals("success", job.last_run_status)
assertEquals("2026-06-16T00:00:00Z", job.next_run)
}
@Test
fun testCronJobDeserialization_optionalFieldsNull() {
val json =
"""
{
"id": "job-456",
"name": "One-off Task",
"schedule": null,
"state": null,
"last_run_status": null,
"next_run": null
}
""".trimIndent()
val job = gson.fromJson(json, CronJob::class.java)
assertEquals("job-456", job.id)
assertEquals("One-off Task", job.name)
assertNull(job.schedule)
assertNull(job.state)
assertNull(job.last_run_status)
assertNull(job.next_run)
}
@Test
fun testToggleSkillRequestSerialization() {
val request = ToggleSkillRequest(name = "weather", enabled = false)
val json = gson.toJson(request)
// Ensure serialization produces correct JSON structure
val parsed = gson.fromJson(json, Map::class.java)
assertEquals("weather", parsed["name"])
assertEquals(false, parsed["enabled"])
}
@Test
fun testSkillDeserialization_missingRequiredFields() {
val json = "{}"
val skill = gson.fromJson(json, Skill::class.java)
// Verify null-safety issue: Gson allows non-nullable 'name' to be null at runtime
assertNull(skill.name)
// Primitive boolean defaults to false in Gson/JVM when missing
assertEquals(false, skill.enabled)
}
@Test
fun testSkillDeserialization_explicitNullForNonNullable() {
val json =
"""
{
"name": null,
"enabled": null
}
""".trimIndent()
val skill = gson.fromJson(json, Skill::class.java)
assertNull(skill.name)
assertEquals(false, skill.enabled)
}
@Test
fun testCronJobDeserialization_missingRequiredFields() {
val json = "{}"
val job = gson.fromJson(json, CronJob::class.java)
assertNull(job.id)
assertNull(job.name)
}
@Test
fun testSessionListResponseDeserialization_missingRequiredFields() {
val json = "{}"
val response = gson.fromJson(json, SessionListResponse::class.java)
assertNull(response.sessions)
}
@Test
fun testSessionInfoDeserialization_missingRequiredFields() {
val json = "{}"
val info = gson.fromJson(json, SessionInfo::class.java)
assertNull(info.id)
assertNull(info.title)
}
@Test
fun testSessionMessagesResponseDeserialization_missingRequiredFields() {
val json = "{}"
val response = gson.fromJson(json, SessionMessagesResponse::class.java)
assertNull(response.messages)
}
@Test
fun testSkillDeserialization_malformedJson() {
val json = "{\"name\": \"weather\","
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) {
gson.fromJson(json, Skill::class.java)
}
}
@Test
fun testSkillDeserialization_typeMismatchObject() {
val json =
"""
{
"name": "weather",
"enabled": {}
}
""".trimIndent()
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) {
gson.fromJson(json, Skill::class.java)
}
}
@Test
fun testSkillDeserialization_typeMismatchArray() {
val json =
"""
{
"name": "weather",
"enabled": []
}
""".trimIndent()
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) {
gson.fromJson(json, Skill::class.java)
}
}
@Test
fun testSkillDeserialization_missingName_causesNullOnNonNullableField() {
val json =
"""
{
"description": "Get current weather info",
"category": "utility",
"enabled": true
}
""".trimIndent()
val skill = gson.fromJson(json, Skill::class.java)
// Gson bypasses Kotlin null-safety constraints, resulting in null on non-nullable field
val nameNullable: String? = skill.name
assertNull(nameNullable)
}
@Test
fun testSkillDeserialization_nullName_causesNullOnNonNullableField() {
val json =
"""
{
"name": null,
"description": "Get current weather info",
"category": "utility",
"enabled": true
}
""".trimIndent()
val skill = gson.fromJson(json, Skill::class.java)
val nameNullable: String? = skill.name
assertNull(nameNullable)
}
@Test
fun testSkillDeserialization_missingEnabled_usesJvmDefaultValue() {
val json =
"""
{
"name": "weather",
"description": "Get current weather info",
"category": "utility"
}
""".trimIndent()
val skill = gson.fromJson(json, Skill::class.java)
// Boolean primitive defaults to false
assertEquals(false, skill.enabled)
}
@Test
fun testCronJobDeserialization_missingId_causesNullOnNonNullableField() {
val json =
"""
{
"name": "Daily Backup",
"schedule": "0 0 * * *",
"state": "active"
}
""".trimIndent()
val job = gson.fromJson(json, CronJob::class.java)
val idNullable: String? = job.id
assertNull(idNullable)
}
@Test
fun testSessionListResponseDeserialization_missingSessions_causesNullOnNonNullableField() {
val json = "{}"
val response = gson.fromJson(json, SessionListResponse::class.java)
val sessionsNullable: List<SessionInfo>? = response.sessions
assertNull(sessionsNullable)
}
@Test
fun testSessionInfoDeserialization_missingId_causesNullOnNonNullableField() {
val json =
"""
{
"title": "My Session",
"created_at": "2026-06-15T12:00:00Z"
}
""".trimIndent()
val info = gson.fromJson(json, SessionInfo::class.java)
val idNullable: String? = info.id
assertNull(idNullable)
}
@Test
fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() {
val json = "{}"
val response = gson.fromJson(json, SessionMessagesResponse::class.java)
val messagesNullable: List<SessionMessage>? = response.messages
assertNull(messagesNullable)
}
@Test
fun testDeserialization_malformedJson_throwsJsonSyntaxException() {
val json = """{"name": "weather", "enabled": """
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) {
gson.fromJson(json, Skill::class.java)
}
}
@Test
fun testStatusResponseDeserialization_allFields() {
val json =
"""
{
"version": "1.0.0",
"gateway_running": true,
"active_sessions": 5,
"auth_required": false,
"gateway_platforms": {
"platform1": {
"state": "connected",
"error_code": null
},
"platform2": {
"state": "error",
"error_code": "ERR_CONN"
}
}
}
""".trimIndent()
val response = gson.fromJson(json, StatusResponse::class.java)
assertEquals("1.0.0", response.version)
assertEquals(true, response.gateway_running)
assertEquals(5, response.active_sessions)
assertEquals(false, response.auth_required)
assertEquals(2, response.gateway_platforms?.size)
assertEquals("connected", response.gateway_platforms?.get("platform1")?.state)
assertNull(response.gateway_platforms?.get("platform1")?.error_code)
assertEquals("error", response.gateway_platforms?.get("platform2")?.state)
assertEquals("ERR_CONN", response.gateway_platforms?.get("platform2")?.error_code)
}
@Test
fun testStatusResponseDeserialization_missingFields() {
val json = "{}"
val response = gson.fromJson(json, StatusResponse::class.java)
assertNull(response.version)
assertNull(response.gateway_running)
assertNull(response.active_sessions)
assertNull(response.auth_required)
assertNull(response.gateway_platforms)
}
@Test
fun testStatusResponseDeserialization_gatewayPlatformsNullValue() {
val json =
"""
{
"gateway_platforms": {
"android": null,
"ios": {
"state": null
}
}
}
""".trimIndent()
val response = gson.fromJson(json, StatusResponse::class.java)
val platforms = response.gateway_platforms
assertNotNull(platforms)
assertNull(platforms!!["android"])
val iosPlatform = platforms["ios"]
assertNotNull(iosPlatform)
assertNull(iosPlatform!!.state)
assertNull(iosPlatform.error_code)
}
@Test
fun testStatusResponseDeserialization_typeMismatchInGatewayPlatforms() {
val json =
"""
{
"gateway_platforms": "not_a_map"
}
""".trimIndent()
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) {
gson.fromJson(json, StatusResponse::class.java)
}
}
@Test
fun testSystemStatsResponse_cpuPercentTypeSafety() {
val jsonNumber = """{"cpu":{"percent":45.2}}"""
val responseNumber = gson.fromJson(jsonNumber, SystemStatsResponse::class.java)
assertEquals(45.2, responseNumber.cpuPercent)
val jsonStringNum = """{"cpu":{"percent":"45.2"}}"""
val responseStringNum = gson.fromJson(jsonStringNum, SystemStatsResponse::class.java)
assertNull(responseStringNum.cpuPercent)
val jsonNullPercent = """{"cpu":{"percent":null}}"""
val responseNullPercent = gson.fromJson(jsonNullPercent, SystemStatsResponse::class.java)
assertNull(responseNullPercent.cpuPercent)
val jsonMissingPercent = """{"cpu":{}}"""
val responseMissingPercent = gson.fromJson(jsonMissingPercent, SystemStatsResponse::class.java)
assertNull(responseMissingPercent.cpuPercent)
val jsonNullCpu = """{"cpu":null}"""
val responseNullCpu = gson.fromJson(jsonNullCpu, SystemStatsResponse::class.java)
assertNull(responseNullCpu.cpuPercent)
val jsonMissingCpu = "{}"
val responseMissingCpu = gson.fromJson(jsonMissingCpu, SystemStatsResponse::class.java)
assertNull(responseMissingCpu.cpuPercent)
}
@Test
fun testToggleSkillRequestDeserialization_missingRequiredFields() {
val json = """{"enabled":true}"""
val request = gson.fromJson(json, ToggleSkillRequest::class.java)
val nameNullable: String? = request.name
assertNull(nameNullable)
assertEquals(true, request.enabled)
}
@Test
fun testToggleSkillRequestDeserialization_explicitNull() {
val json = """{"name":null,"enabled":null}"""
val request = gson.fromJson(json, ToggleSkillRequest::class.java)
val nameNullable: String? = request.name
assertNull(nameNullable)
assertEquals(false, request.enabled)
}
@Test
fun testToggleSkillRequestSerialization_withNullName() {
val jsonInput = """{"name":null,"enabled":true}"""
val request = gson.fromJson(jsonInput, ToggleSkillRequest::class.java)
val jsonOutput = gson.toJson(request)
val parsed = gson.fromJson(jsonOutput, Map::class.java)
assertFalse(parsed.containsKey("name"))
assertEquals(true, parsed["enabled"])
}
}
@@ -0,0 +1,355 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
class HermesApiServiceTest {
private lateinit var mockWebServer: MockWebServer
private lateinit var apiService: HermesApiService
@BeforeEach
fun setUp() {
mockWebServer = MockWebServer()
mockWebServer.start()
val retrofit =
Retrofit
.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(GsonConverterFactory.create())
.build()
apiService = retrofit.create(HermesApiService::class.java)
}
@AfterEach
fun tearDown() {
mockWebServer.shutdown()
}
@Test
fun testGetSkills_requestsCorrectUrlAndMethod() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""[{"name":"weather","description":"info","category":"utils","enabled":true}]"""),
)
val response = apiService.getSkills()
assertTrue(response.isSuccessful)
assertEquals(1, response.body()?.size)
assertEquals("weather", response.body()?.get(0)?.name)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/skills", recordedRequest.path)
assertEquals("GET", recordedRequest.method)
}
@Test
fun testToggleSkill_postsCorrectBody() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(""),
)
val response = apiService.toggleSkill(ToggleSkillRequest(name = "weather", enabled = true))
assertTrue(response.isSuccessful)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/skills/toggle", recordedRequest.path)
assertEquals("PUT", recordedRequest.method)
assertEquals("""{"name":"weather","enabled":true}""", recordedRequest.body.readUtf8())
}
@Test
fun testGetCronJobs_requestsCorrectUrlAndMethod() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""[{"id":"job_1","name":"backup","schedule":"* * * * *","state":"active","last_run_status":null,"next_run":null}]""",
),
)
val response = apiService.getCronJobs()
assertTrue(response.isSuccessful)
assertEquals(1, response.body()?.size)
assertEquals("job_1", response.body()?.get(0)?.id)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/cron/jobs", recordedRequest.path)
assertEquals("GET", recordedRequest.method)
}
@Test
fun testPauseCronJob_sendsPostRequest() =
runTest {
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(""))
val response = apiService.pauseCronJob("job_1")
assertTrue(response.isSuccessful)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/cron/jobs/job_1/pause", recordedRequest.path)
assertEquals("POST", recordedRequest.method)
}
@Test
fun testResumeCronJob_sendsPostRequest() =
runTest {
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(""))
val response = apiService.resumeCronJob("job_1")
assertTrue(response.isSuccessful)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/cron/jobs/job_1/resume", recordedRequest.path)
assertEquals("POST", recordedRequest.method)
}
@Test
fun testTriggerCronJob_sendsPostRequest() =
runTest {
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(""))
val response = apiService.triggerCronJob("job_1")
assertTrue(response.isSuccessful)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/cron/jobs/job_1/trigger", recordedRequest.path)
assertEquals("POST", recordedRequest.method)
}
@Test
fun testDeleteCronJob_sendsDeleteRequest() =
runTest {
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(""))
val response = apiService.deleteCronJob("job_1")
assertTrue(response.isSuccessful)
val recordedRequest = mockWebServer.takeRequest()
assertEquals("/api/cron/jobs/job_1", recordedRequest.path)
assertEquals("DELETE", recordedRequest.method)
}
@Test
fun testGetSkills_missingFieldsInResponse() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""[{"description":"info"}]"""),
)
val response = apiService.getSkills()
assertTrue(response.isSuccessful)
val skills = response.body()
assertEquals(1, skills?.size)
val skill = skills?.get(0)
org.junit.jupiter.api.Assertions
.assertNull(skill?.name)
assertEquals(false, skill?.enabled)
}
@Test
fun testGetCronJobs_missingFieldsInResponse() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""[{"schedule":"* * * * *"}]"""),
)
val response = apiService.getCronJobs()
assertTrue(response.isSuccessful)
val jobs = response.body()
assertEquals(1, jobs?.size)
val job = jobs?.get(0)
org.junit.jupiter.api.Assertions
.assertNull(job?.id)
org.junit.jupiter.api.Assertions
.assertNull(job?.name)
}
@Test
fun testGetSessions_missingSessionsField() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{}"""),
)
val response = apiService.getSessions()
assertTrue(response.isSuccessful)
org.junit.jupiter.api.Assertions
.assertNull(response.body()?.sessions)
}
@Test
fun testGetSessionMessages_apiReturnsMalformedJson_throwsException() {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{"messages": ["""),
)
assertThrows(java.io.EOFException::class.java) {
runTest {
apiService.getSessionMessages("session_1")
}
}
}
@Test
fun testGetSessionMessages_missingMessagesField() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{}"""),
)
val response = apiService.getSessionMessages("session_1")
assertTrue(response.isSuccessful)
org.junit.jupiter.api.Assertions
.assertNull(response.body()?.messages)
}
@Test
fun testGetSkills_malformedJsonResponse_throwsException() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""[{"name":"weather""""),
)
org.junit.jupiter.api.Assertions.assertThrows(java.io.EOFException::class.java) {
kotlinx.coroutines.runBlocking {
apiService.getSkills()
}
}
}
@Test
fun testGetSkills_typeMismatchInResponse_throwsException() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""[{"name":"weather","enabled":{}}]"""),
)
org.junit.jupiter.api.Assertions.assertThrows(com.google.gson.JsonSyntaxException::class.java) {
kotlinx.coroutines.runBlocking {
apiService.getSkills()
}
}
}
@Test
fun testGetStatus_missingFieldsInResponse() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("{}"),
)
val response = apiService.getStatus()
assertTrue(response.isSuccessful)
val status = response.body()
assertNotNull(status)
assertNull(status?.version)
assertNull(status?.gateway_running)
assertNull(status?.active_sessions)
assertNull(status?.auth_required)
assertNull(status?.gateway_platforms)
}
@Test
fun testGetStatus_gatewayPlatformsNullValue() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{"gateway_platforms": {"android": null}}"""),
)
val response = apiService.getStatus()
assertTrue(response.isSuccessful)
val status = response.body()
assertNotNull(status)
val platforms = status?.gateway_platforms
assertNotNull(platforms)
assertNull(platforms!!["android"])
}
@Test
fun testGetSystemStats_missingFields() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("{}"),
)
val response = apiService.getSystemStats()
assertTrue(response.isSuccessful)
val stats = response.body()
assertNotNull(stats)
assertNull(stats?.cpuPercent)
assertNull(stats?.memoryPercent)
}
@Test
fun testGetSystemStats_nonNumericPercent_returnsNull() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{"cpu":{"percent":"not_a_number"}}"""),
)
val response = apiService.getSystemStats()
assertTrue(response.isSuccessful)
val stats = response.body()
assertNotNull(stats)
assertNull(stats?.cpuPercent)
}
@Test
fun testGetSystemStats_typeMismatchArray_behavior() =
runTest {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{"cpu": []}"""),
)
val response = apiService.getSystemStats()
assertTrue(response.isSuccessful)
val stats = response.body()
assertNotNull(stats)
// Gson deserializes an empty array [] into an empty map {} for Map fields
assertTrue(stats?.cpu?.isEmpty() == true)
}
}
@@ -0,0 +1,136 @@
package com.m57.hermescontrol.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class EventParserTest {
@Test
fun testParseRpcResult_returnsRpcResultEvent() {
val response =
JsonRpcResponse(
jsonrpc = "2.0",
id = "123",
result = mapOf("status" to "success"),
error = null,
method = null,
params = null,
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.RpcResult)
val rpcResult = event as WsEvent.RpcResult
assertEquals("123", rpcResult.id)
assertEquals(mapOf("status" to "success"), rpcResult.result)
}
@Test
fun testParseRpcError_returnsRpcErrorEvent() {
val error = JsonRpcError(code = -32600, message = "Invalid Request")
val response =
JsonRpcResponse(
jsonrpc = "2.0",
id = "456",
result = null,
error = error,
method = null,
params = null,
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.RpcError)
val rpcError = event as WsEvent.RpcError
assertEquals("456", rpcError.id)
assertEquals(-32600, rpcError.error.code)
assertEquals("Invalid Request", rpcError.error.message)
}
@Test
fun testParseGatewayReady_returnsGatewayReadyEvent() {
val response =
JsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "gateway.ready",
"payload" to mapOf("session_id" to "session-1"),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.GatewayReady)
val gatewayReady = event as WsEvent.GatewayReady
assertEquals(mapOf("session_id" to "session-1"), gatewayReady.data)
}
@Test
fun testParseMessageToken_returnsMessageTokenEvent() {
val response =
JsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "message.token",
"payload" to mapOf("text" to "hello", "session_id" to "session-1"),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.MessageToken)
val tokenEvent = event as WsEvent.MessageToken
assertEquals("hello", tokenEvent.token)
assertEquals("session-1", tokenEvent.sessionId)
}
@Test
fun testParseThinkingDelta_returnsThinkingDeltaEvent() {
val response =
JsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "thinking.delta",
"payload" to mapOf("text" to "thinking token", "session_id" to "session-2"),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ThinkingDelta)
val deltaEvent = event as WsEvent.ThinkingDelta
assertEquals("thinking token", deltaEvent.token)
assertEquals("session-2", deltaEvent.sessionId)
}
@Test
fun testParseClarifyRequest_returnsClarifyRequestEvent() {
val response =
JsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "clarify.request",
"payload" to
mapOf(
"text" to "Select option?",
"options" to listOf("Yes", "No"),
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ClarifyRequest)
val clarifyEvent = event as WsEvent.ClarifyRequest
assertEquals("Select option?", clarifyEvent.text)
assertEquals(listOf("Yes", "No"), clarifyEvent.options)
}
}
@@ -0,0 +1,354 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
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.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockEventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
val testMainDispatcher = Dispatchers.Main
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns testDispatcher
every { Dispatchers.Main } returns testMainDispatcher
mockkObject(AuthManager)
mockkObject(HermesWsClient)
every { AuthManager.getToken() } returns "test-token"
every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connect() } returns Unit
every { HermesWsClient.disconnect() } returns Unit
// Default mock stubs for requests returning unique IDs
var reqCount = 0
every { HermesWsClient.send(any(), any()) } answers {
reqCount++
"req-id-$reqCount"
}
every { HermesWsClient.sendMessage(any(), any()) } answers {
reqCount++
"req-msg-$reqCount"
}
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun testInitialStateAndConnection() =
runTest {
val viewModel = ChatViewModel()
advanceUntilIdle()
verify { HermesWsClient.connect() }
assertTrue(viewModel.uiState.value.isLoading)
assertFalse(viewModel.uiState.value.isConnected)
}
@Test
fun testGatewayReady_createsSessionIfNoneExists() =
runTest {
val viewModel = ChatViewModel()
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
val state = viewModel.uiState.value
assertTrue(state.isConnected)
// Note: GatewayReady immediately calls createNewSession(), which sets isLoading = true and clears messages
assertTrue(state.isLoading)
assertEquals(0, state.messages.size)
// Verify that list sessions and create session requests are triggered
verify { HermesWsClient.send(WsMethods.SESSION_LIST) }
verify { HermesWsClient.send(WsMethods.SESSION_CREATE) }
}
@Test
fun testSessionCreateRpcResult() =
runTest {
var createReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers {
createReqId = "custom-create-id"
createReqId
}
val viewModel = ChatViewModel()
advanceUntilIdle()
// Trigger GatewayReady -> triggers createNewSession
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
// Feed SESSION_CREATE result
mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123")))
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals("session-123", state.currentSessionId)
assertFalse(state.isLoading)
// Connected to Hermes was cleared when createNewSession() was called, so only "Session created" exists
assertEquals(1, state.messages.size)
assertEquals("Session created", state.messages[0].content)
}
@Test
fun testSessionListRpcResult() =
runTest {
var listReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_LIST, any()) } answers {
listReqId = "custom-list-id"
listReqId
}
val viewModel = ChatViewModel()
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
// Feed SESSION_LIST result
mockEventsFlow.emit(
WsEvent.RpcResult(
listReqId,
mapOf(
"sessions" to
listOf(
mapOf(
"id" to "session-123",
"title" to "My Session Title",
"message_count" to 12.0,
),
),
),
),
)
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(1, state.sessions.size)
assertEquals("session-123", state.sessions[0].id)
assertEquals("My Session Title", state.sessions[0].title)
assertEquals(12, state.sessions[0].messageCount)
}
@Test
fun testMessageStreamingFlow() =
runTest {
val viewModel = ChatViewModel()
advanceUntilIdle()
// Set active session ID first
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
// Stream Start
mockEventsFlow.emit(WsEvent.MessageStart("session-123"))
advanceUntilIdle()
var state = viewModel.uiState.value
assertTrue(state.isAgentTyping)
assertEquals("", state.currentStreamingText)
assertFalse(state.isThinking)
assertEquals("", state.thinkingText)
// Thinking Delta 1
mockEventsFlow.emit(WsEvent.ThinkingDelta("Thinking...", "session-123"))
advanceUntilIdle()
state = viewModel.uiState.value
assertTrue(state.isThinking)
assertEquals("Thinking...", state.thinkingText)
// Thinking Delta 2
mockEventsFlow.emit(WsEvent.ThinkingDelta(" deeper", "session-123"))
advanceUntilIdle()
state = viewModel.uiState.value
assertTrue(state.isThinking)
assertEquals("Thinking... deeper", state.thinkingText)
// Token 1
mockEventsFlow.emit(WsEvent.MessageToken("Hello", "session-123"))
advanceUntilIdle()
state = viewModel.uiState.value
assertFalse(state.isThinking)
assertEquals("Hello", state.currentStreamingText)
// System message was cleared when createNewSession() was called, so only Assistant streaming message is present
assertEquals(1, state.messages.size)
assertEquals("Hello", state.messages[0].content)
assertTrue(state.messages[0].isStreaming)
// Token 2
mockEventsFlow.emit(WsEvent.MessageToken(" world", "session-123"))
advanceUntilIdle()
state = viewModel.uiState.value
assertEquals("Hello world", state.currentStreamingText)
assertEquals(1, state.messages.size)
assertEquals("Hello world", state.messages[0].content)
assertTrue(state.messages[0].isStreaming)
// Complete
mockEventsFlow.emit(WsEvent.MessageComplete("Hello world!", "session-123"))
advanceUntilIdle()
state = viewModel.uiState.value
assertFalse(state.isAgentTyping)
assertEquals("", state.currentStreamingText)
assertFalse(state.isThinking)
assertEquals("", state.thinkingText)
assertEquals(1, state.messages.size)
assertEquals("Hello world!", state.messages[0].content)
assertFalse(state.messages[0].isStreaming)
}
@Test
fun testClarifyRequestAndRespond() =
runTest {
var createReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers {
createReqId = "create-req-clarify"
createReqId
}
val viewModel = ChatViewModel()
advanceUntilIdle()
// Set session ID
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123")))
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.ClarifyRequest("Please choose:", listOf("Yes", "No")))
advanceUntilIdle()
var state = viewModel.uiState.value
assertEquals("Please choose:", state.clarifyRequest?.text)
assertEquals(listOf("Yes", "No"), state.clarifyRequest?.options)
// Respond to clarify
viewModel.respondToClarify("Yes")
advanceUntilIdle()
// verify clarify request is dismissed, and user message is sent
state = viewModel.uiState.value
assertNull(state.clarifyRequest)
// Session created (System) + user message = 2 messages
assertEquals(2, state.messages.size)
assertEquals("Session created", state.messages[0].content)
assertEquals("Yes", state.messages[1].content)
assertEquals(MessageRole.USER, state.messages[1].role)
}
@Test
fun testSendMessage() =
runTest {
val viewModel = ChatViewModel()
advanceUntilIdle()
// Trigger GatewayReady -> triggers createSession -> feed result to set active session
var createReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers {
createReqId = "create-req"
createReqId
}
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123")))
advanceUntilIdle()
// Now send message
viewModel.sendMessage("Hello Hermes")
advanceUntilIdle()
val state = viewModel.uiState.value
// Connected to Hermes was cleared, so only Session created (System) + Hello Hermes (User) = 2 messages
assertEquals(2, state.messages.size)
assertEquals("Session created", state.messages[0].content)
assertEquals("Hello Hermes", state.messages[1].content)
assertEquals(MessageRole.USER, state.messages[1].role)
assertTrue(state.isAgentTyping)
verify { HermesWsClient.sendMessage("session-123", "Hello Hermes") }
}
@Test
fun testSwitchSession() =
runTest {
val viewModel = ChatViewModel()
advanceUntilIdle()
viewModel.switchSession("session-456")
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals("session-456", state.currentSessionId)
assertTrue(state.isLoading)
assertTrue(state.messages.isEmpty())
verify { HermesWsClient.send(WsMethods.SESSION_RESUME, mapOf("session_id" to "session-456")) }
}
@Test
fun testRpcErrorHandling() =
runTest {
var createReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers {
createReqId = "create-req-err"
createReqId
}
val viewModel = ChatViewModel()
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
// Emit RpcError
mockEventsFlow.emit(
WsEvent.RpcError(
createReqId,
JsonRpcError(code = -32603, message = "Internal error during creation"),
),
)
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isLoading)
assertTrue(state.errorMessage!!.contains("Internal error during creation"))
assertTrue(state.errorMessage.contains(WsMethods.SESSION_CREATE))
}
}
@@ -0,0 +1,287 @@
package com.m57.hermescontrol.ui.connect
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
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.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class ConnectViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApiService: HermesApiService
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
val testMainDispatcher = Dispatchers.Main
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns testDispatcher
every { Dispatchers.Main } returns testMainDispatcher
mockkObject(AuthManager)
mockkObject(ApiClient)
mockApiService = mockk()
every { ApiClient.hermesApi } returns mockApiService
every { ApiClient.rebuild() } returns Unit
// Default AuthManager stubs
every { AuthManager.getToken() } returns ""
every { AuthManager.getHost() } returns "127.0.0.1"
every { AuthManager.getPort() } returns 9119
every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(any()) } returns Unit
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun testInitialLoadingFromAuthManager() {
every { AuthManager.getToken() } returns "saved-token"
every { AuthManager.getHost() } returns "hermes.local"
every { AuthManager.getPort() } returns 8888
val viewModel = ConnectViewModel()
val state = viewModel.uiState.value
assertEquals("saved-token", state.token)
assertEquals("hermes.local", state.host)
assertEquals("8888", state.port)
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertNull(state.errorMessage)
}
@Test
fun testOnTokenChange() {
val viewModel = ConnectViewModel()
viewModel.onTokenChange(" new-token ")
assertEquals("new-token", viewModel.uiState.value.token)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun testOnHostChange() {
val viewModel = ConnectViewModel()
viewModel.onHostChange(" 192.168.1.50 ")
assertEquals("192.168.1.50", viewModel.uiState.value.host)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun testOnPortChange() {
val viewModel = ConnectViewModel()
viewModel.onPortChange("90abc90")
assertEquals("9090", viewModel.uiState.value.port)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun testConnect_blankToken_showsError() {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("")
viewModel.connect()
assertEquals("Token is required", viewModel.uiState.value.errorMessage)
}
@Test
fun testConnect_blankHost_showsError() {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("")
viewModel.connect()
assertEquals("Host is required", viewModel.uiState.value.errorMessage)
}
@Test
fun testConnect_invalidPortBounds_showsError() {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("0")
viewModel.connect()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage)
viewModel.onPortChange("65536")
viewModel.connect()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage)
viewModel.onPortChange("")
viewModel.connect()
assertEquals("Port must be between 1 and 65535", viewModel.uiState.value.errorMessage)
}
@Test
fun testConnect_success() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("valid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns true
coEvery { mockApiService.getStatus() } returns mockResponse
viewModel.connect()
assertTrue(viewModel.uiState.value.isConnecting)
advanceUntilIdle()
verify { AuthManager.setToken("valid-token") }
verify { AuthManager.setHost("127.0.0.1") }
verify { AuthManager.setPort(9119) }
verify { ApiClient.rebuild() }
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertTrue(state.connectionSuccess)
assertNull(state.errorMessage)
}
@Test
fun testConnect_failure_unauthorized() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
every { mockResponse.code() } returns 401
coEvery { mockApiService.getStatus() } returns mockResponse
viewModel.connect()
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertEquals("Invalid token (401 Unauthorized)", state.errorMessage)
}
@Test
fun testConnect_failure_forbidden() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("invalid-token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
every { mockResponse.code() } returns 403
coEvery { mockApiService.getStatus() } returns mockResponse
viewModel.connect()
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertEquals("Access denied (403 Forbidden)", state.errorMessage)
}
@Test
fun testConnect_failure_otherHttpCode() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns false
every { mockResponse.code() } returns 500
coEvery { mockApiService.getStatus() } returns mockResponse
viewModel.connect()
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertEquals("Server returned HTTP 500", state.errorMessage)
}
@Test
fun testConnect_exception_connectionRefused() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
coEvery { mockApiService.getStatus() } throws Exception("Connection refused")
viewModel.connect()
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertEquals("Connection refused – is Hermes running?", state.errorMessage)
}
@Test
fun testConnect_exception_timeout() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
coEvery { mockApiService.getStatus() } throws Exception("timeout exception")
viewModel.connect()
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertEquals("Connection timed out", state.errorMessage)
}
@Test
fun testConnect_exception_unknown() =
runTest {
val viewModel = ConnectViewModel()
viewModel.onTokenChange("token")
viewModel.onHostChange("127.0.0.1")
viewModel.onPortChange("9119")
coEvery { mockApiService.getStatus() } throws Exception("Something went wrong")
viewModel.connect()
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isConnecting)
assertFalse(state.connectionSuccess)
assertEquals("Connection failed: Something went wrong", state.errorMessage)
}
}
+6
View File
@@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1781074563,
"narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+98
View File
@@ -0,0 +1,98 @@
{
description = "HermesControl - Android app for Hermes agent";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
config = {
android_sdk.accept_license = true;
allowUnfree = true;
};
};
buildToolsVersion = "35.0.0";
androidComposition = pkgs.androidenv.composeAndroidPackages {
# Command-line & platform tools
cmdLineToolsVersion = "11.0";
platformToolsVersion = "35.0.2";
# Build tools
buildToolsVersions = [ buildToolsVersion "34.0.0" ];
# Target platforms (36 required by AGP 9.0.1 / compileSdk 36)
platformVersions = [ "36" "35" "34" ];
# For the emulator (optional, remove if not needed)
includeEmulator = false;
includeSystemImages = false;
# NDK (not needed for this project, but handy)
includeNDK = false;
# Extra packages
extraLicenses = [
"android-googletv-license"
"android-sdk-arm-dbt-license"
"android-sdk-license"
"android-sdk-preview-license"
"google-gdk-license"
"intel-android-extra-license"
"intel-android-sysimage-license"
"mips-android-sysimage-license"
];
};
androidSdk = androidComposition.androidsdk;
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Java 17 (required for AGP 8.x / Gradle 8.x)
jdk17
# Android SDK (platforms, build-tools, platform-tools)
androidSdk
# Kotlin compiler
kotlin
# Gradle
gradle
# Useful utilities
ktlint # Kotlin linter
];
# Point everything at the Nix-managed SDK
ANDROID_HOME = "${androidSdk}/libexec/android-sdk";
ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk";
JAVA_HOME = "${pkgs.jdk17}";
# Gradle needs a writable home
GRADLE_USER_HOME = "$PWD/.gradle-home";
shellHook = ''
echo "🤖 HermesControl Android dev shell"
echo " Java: $(java -version 2>&1 | head -1)"
echo " Kotlin: $(kotlin -version 2>&1)"
echo " Gradle: $(gradle --version 2>&1 | grep '^Gradle' || echo 'available')"
echo " ANDROID_HOME: $ANDROID_HOME"
echo ""
# Make sure the android CLI from ~/.local/bin is on PATH
export PATH="$HOME/.local/bin:$PATH"
# Writable gradle home
mkdir -p "$GRADLE_USER_HOME"
'';
};
}
);
}
+29
View File
@@ -0,0 +1,29 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# Enables Gradle Build Cache.
# See https://docs.gradle.org/current/userguide/build_cache.html
org.gradle.caching=true
# Enables Gradle Configuration Cache, the preferred Gradle execution mode.
# See https://docs.gradle.org/current/userguide/configuration_cache.html
org.gradle.configuration-cache=true
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
+67
View File
@@ -0,0 +1,67 @@
[versions]
androidGradlePlugin = "9.0.1"
androidxCore = "1.18.0"
androidxLifecycle = "2.10.0"
androidxActivity = "1.13.0"
androidxComposeBom = "2026.03.01"
androidxTest = "1.7.0"
androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0"
androidxTestEspresso = "3.7.0"
coroutines = "1.10.2"
junit = "4.13.2"
junit5 = "5.11.4"
junitPlatform = "1.11.4"
kotlin = "2.3.20"
nav3Core = "1.0.1"
lifecycleViewmodelNav3 = "2.10.0"
okhttp = "4.12.0"
retrofit = "2.11.0"
gson = "2.11.0"
securityCrypto = "1.1.0-alpha06"
mockk = "1.13.12"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3"}
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui"}
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview"}
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4"}
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling"}
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest"}
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidxLifecycle" }
androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTest" }
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExt" }
androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" }
junit = { module = "junit:junit", version.ref = "junit" }
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit5" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" }
junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
# Networking
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
# Security
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"
Vendored
+84
View File
@@ -0,0 +1,84 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+33
View File
@@ -0,0 +1,33 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("androidx.*")
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google {
content {
includeGroupByRegex("androidx.*")
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
}
}
mavenCentral()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "HermesControl"
include(":app")