feat: rename repo to hermes-relay, add GitHub Pages docs deployment

Rename from hermes-android to hermes-relay for platform-agnostic branding.
Add VitePress GitHub Pages workflow (builds on user-docs/** changes).
Update all repo URL references across docs, code, Docker, systemd, plugin.
Remove ARC/ClawPort from Related Projects, clean up README.
Fix pre-existing bug in plugin/install.sh (wrong source directory name).

Includes accumulated changes: ASCII morphing sphere, ambient mode,
animation settings, configurable limits, token tracking fixes,
file attachments, feature gating, developer options, MCP tooling,
audit fixes, and VitePress docs site.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bailey Dixon
2026-04-07 22:43:05 -04:00
co-authored by Claude Opus 4.6
parent a3ceaad014
commit b4f79ceb90
143 changed files with 16607 additions and 4489 deletions
+10 -10
View File
@@ -1,16 +1,16 @@
## Summary
<!-- Brief description of the changes -->
<!-- Brief description of what this PR does -->
## Changes
-
## Checklist
- [ ] Build passes (`scripts/dev.bat build` or `./gradlew assembleDebug`)
- [ ] Tests pass (`scripts/dev.bat test` or `./gradlew test`)
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
- [ ] CHANGELOG.md updated (if user-facing change)
- [ ] No credentials, API keys, or secrets in committed files
- [ ] `./gradlew assembleDebug` succeeds
- [ ] `./gradlew test` passes
- [ ] Tested on emulator or device (if UI change)
## Test Plan
<!-- How was this tested? -->
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
- [ ] CHANGELOG.md updated (if user-facing)
- [ ] No credentials or secrets in committed files
+24
View File
@@ -0,0 +1,24 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
jobs:
claude-review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
+139
View File
@@ -0,0 +1,139 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned, labeled]
pull_request_review:
types: [submitted]
jobs:
auth:
runs-on: ubuntu-latest
outputs:
authorized: ${{ steps.check.outputs.authorized }}
steps:
- name: Check collaborator status
id: check
uses: actions/github-script@v8
with:
script: |
if (context.eventName === 'issues' && ['opened', 'labeled'].includes(context.payload.action)) {
core.setOutput('authorized', 'true');
return;
}
const sender = context.payload.sender?.login;
if (!sender) { core.setOutput('authorized', 'false'); return; }
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner, repo: context.repo.repo, username: sender,
});
const allowed = ['admin', 'write', 'maintain'].includes(data.permission);
core.setOutput('authorized', allowed ? 'true' : 'false');
} catch {
core.setOutput('authorized', 'false');
}
triage:
needs: auth
if: |
needs.auth.outputs.authorized == 'true' && (
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'claude') ||
(github.event_name == 'issues' && github.event.action == 'opened')
)
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: write
id-token: write
actions: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Triage this GitHub issue. Analysis-only — do NOT write code or create PRs.
1. **Classify** — bug, feature request, question, or docs issue?
2. **Priority** — critical, high, medium, low based on impact.
3. **Affected area** — which module(s)? Check CLAUDE.md for architecture.
(ui/, network/, viewmodel/, auth/, data/, relay_server/, plugin/)
4. **Reproduction** — for bugs, is there enough info? Ask for device, Android version, steps.
5. **Suggested approach** — brief outline (files, strategy).
6. **Labels** — suggest appropriate labels.
Keep it concise and actionable.
claude_args: "--max-turns 5"
fix:
needs: auth
if: |
needs.auth.outputs.authorized == 'true' &&
github.event_name == 'issues' &&
github.event.action == 'labeled' &&
github.event.label.name == 'claude-fix'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
actions: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Implement a fix for this GitHub issue. Read CLAUDE.md for project conventions.
1. Understand the issue — read relevant source files.
2. Implement the minimal fix.
3. Follow conventions: Kotlin + Jetpack Compose, kotlinx.serialization, Conventional Commits.
4. Run `./gradlew assembleDebug` and fix any errors.
5. Create a PR with Conventional Commits format title.
Do NOT over-engineer. Only change what is needed.
claude_args: "--max-turns 25"
chat:
needs: auth
if: |
needs.auth.outputs.authorized == 'true' && (
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && github.event.action == 'assigned' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
)
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
actions: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Responding to a collaborator comment. Read CLAUDE.md for project context.
Default mode is analysis — investigate, explain, suggest. Do NOT write code
unless explicitly asked ("fix this", "implement", "create a PR").
If asked to fix: follow conventions (Kotlin, Compose, Conventional Commits),
run `./gradlew assembleDebug`, and create a PR.
claude_args: "--max-turns 15"
@@ -0,0 +1,25 @@
name: Dependabot Auto-Merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Fetch Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge (patch + minor only)
if: steps.metadata.outputs.update-type != 'version-update:semver-major'
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+71
View File
@@ -0,0 +1,71 @@
# Hermes Relay — Docs Deployment
#
# Builds VitePress docs and deploys to GitHub Pages.
# Triggers on pushes to main that change user-docs/ content,
# or manually via workflow_dispatch.
name: Deploy Docs
on:
push:
branches: [main]
paths:
- 'user-docs/**'
- '.github/workflows/docs.yml'
workflow_dispatch:
# Allow only one concurrent deployment
concurrency:
group: pages
cancel-in-progress: false
# Sets permissions for GITHUB_TOKEN to enable Pages deployment
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
name: Build Docs
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for lastUpdated timestamps
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: user-docs/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: user-docs
- name: Build VitePress site
run: npm run build
working-directory: user-docs
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: user-docs/.vitepress/dist
deploy:
name: Deploy to GitHub Pages
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+58 -25
View File
@@ -1,8 +1,8 @@
# Hermes Relay — Release Pipeline
#
# Triggered when a version tag (v*) is pushed.
# Validates the tag matches the app version, builds a release APK,
# and creates a GitHub Release with the artifact attached.
# Validates the tag matches the app version in libs.versions.toml,
# runs CI checks, builds a release APK, and creates a GitHub Release.
name: Release
@@ -11,15 +11,44 @@ on:
tags:
- "v*"
permissions:
contents: write
id-token: write
jobs:
release:
name: Build & Publish Release
validate:
name: Validate Release
runs-on: ubuntu-latest
permissions:
contents: write # Required for creating GitHub Releases
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Verify version sync
run: |
TAG_VERSION="${{ steps.version.outputs.version }}"
TOML_VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
echo "Tag version: $TAG_VERSION"
echo "libs.versions.toml version: $TOML_VERSION"
if [ "$TAG_VERSION" != "$TOML_VERSION" ]; then
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
exit 1
fi
echo "Version validated: $TAG_VERSION"
ci:
name: CI Checks
needs: validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
@@ -30,38 +59,42 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
# Extract versionName from build.gradle.kts and compare with the git tag
- name: Validate version matches tag
run: |
# Extract versionName from app/build.gradle.kts
VERSION_NAME=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
TAG_VERSION="${GITHUB_REF_NAME#v}"
- name: Build debug APK
run: ./gradlew assembleDebug
echo "App versionName: $VERSION_NAME"
echo "Tag version: $TAG_VERSION"
- name: Run unit tests
run: ./gradlew test
if [ "$VERSION_NAME" != "$TAG_VERSION" ]; then
echo "::error::Version mismatch! Tag $TAG_VERSION does not match app versionName $VERSION_NAME"
exit 1
fi
release:
name: Build & Publish Release
needs: [validate, ci]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
echo "Version validated: $VERSION_NAME"
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build release APK
run: ./gradlew assembleRelease
# Generate SHA256 checksum for the APK
- name: Generate checksum
run: |
cd app/build/outputs/apk/release
sha256sum *.apk > SHA256SUMS.txt
# Create a GitHub Release using RELEASE_NOTES.md as the body
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: v${{ needs.validate.outputs.version }}
body_path: RELEASE_NOTES.md
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
files: |
app/build/outputs/apk/release/*.apk
app/build/outputs/apk/release/SHA256SUMS.txt
body_path: RELEASE_NOTES.md
prerelease: ${{ contains(github.ref_name, '-') }}
+8
View File
@@ -48,3 +48,11 @@ certs/
user-docs/.vitepress/cache/
user-docs/.vitepress/dist/
node_modules/
package.json
package-lock.json
# Local upstream reference
hermes-agent-upstream/
# Kotlin compiler cache
.kotlin/
+12 -1
View File
@@ -5,10 +5,21 @@ This extension adds Android device control to hermes-agent via the `android` too
It communicates with the Hermes Relay app running on an Android device over WSS.
## Setup
### Quick start (relay + plugin)
```bash
pip install aiohttp pyyaml && python -m relay_server --no-ssl # start relay
cp -r plugin ~/.hermes/plugins/hermes-android # install plugin
```
Then restart hermes-agent. See [docs/relay-server.md](docs/relay-server.md) for Docker, systemd, TLS, and configuration options.
### Full setup
1. Install the Hermes Relay APK on the Android device (build via `scripts/dev.bat build`)
2. Grant the app Accessibility Service permission in Settings > Accessibility
3. Grant SYSTEM_ALERT_WINDOW permission
4. Start the relay server: `python -m relay_server --no-ssl`
4. Start the relay server: `pip install aiohttp pyyaml && python -m relay_server --no-ssl`
5. Install the plugin: `cp -r plugin ~/.hermes/plugins/hermes-android`
6. Restart hermes-agent
+85 -51
View File
@@ -2,69 +2,103 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.2.0] - 2026-04-05
## [0.1.0] - 2026-04-07
### Added
- **Direct API chat** — chat connects directly to Hermes API Server via `/api/sessions` SSE
- **Session management** — create, switch, rename, delete chat sessions
- **Session drawer** — slide-out panel listing all sessions with title, timestamp, message count
- **Message history** — loads when switching sessions from the server
- **Auto-session titles** — first user message auto-titles the session (truncated to 50 chars)
- **Session persistence** — last session ID saved to DataStore, resumes on app restart
- **What's New auto-show** — dialog shown automatically when app version changes
- **HermesApiClient** — full session CRUD + SSE streaming via `/api/sessions/{id}/chat/stream`
- **API key storage** — securely stored in EncryptedSharedPreferences
- **Dual connection model** — API Server for chat, Relay Server for bridge/terminal
- **Test Connection** — verify API server reachability from onboarding and settings
- **Cancel streaming** — stop button to cancel in-flight chat responses
- **Network security config** — cleartext restricted to localhost only
- **Markdown rendering** — assistant messages render code blocks, bold, italic, links, lists
- **Message copy** — long-press any message to copy text to clipboard
- **Reasoning display** — collapsible thinking block above assistant responses (toggle in Settings)
- **Token tracking** — per-message input/output token count and estimated cost
- **Personality picker** — 8 built-in personalities (concise, creative, technical, pirate, etc.)
- **Error retry** — retry button in error banner re-sends last failed message
- **Offline detection** — banner shown when network connectivity is lost
- **Haptic feedback** — on send, stream complete, error, and message copy
- **Input character limit** — 4096 character limit with counter
- **Responsive layout** — bubble widths adapt to phone, tablet, and landscape
- **Enriched tool cards** — tool-type icons, completion duration tracking
### Changed
- Chat no longer routes through relay server — direct to API server
- Onboarding collects API Server URL + API Key (required) + Relay URL (optional)
- Settings split into separate API Server and Relay Server cards
- ChatHandler refactored from envelope-based to typed SSE entry points
- Backup format bumped to v2 with separate apiServerUrl/relayUrl fields
- App version bumped to 0.2.0
- **ASCII morphing sphere** — animated 3D character sphere on empty chat screen (pure Compose Canvas, `. : - = + * # % @` characters, green-purple color pulse, 3D lighting)
- **Ambient mode** — toggle in chat header hides messages and shows sphere fullscreen; tap to return to chat
- **Animation behind messages** — sphere renders at 15% opacity behind chat message list as subtle background (toggleable)
- **Animation settings** — Settings > Appearance section with "ASCII sphere" and "Behind messages" toggles
- **File attachments** — attach files via `+` button; images, documents, PDFs sent as base64 in the Hermes API `attachments` format
- **Attachment preview** — horizontal strip above input shows thumbnails (images) or file badges (other types) with remove button
- **Message queuing** — send messages while the agent is streaming; queued messages auto-send when the current response completes
- **Queue indicator** — animated bar above input shows queued count with clear button
- **Configurable limits** — expandable Limits section in Chat settings for max attachment size (1–50 MB) and message length (1K–16K chars)
- **Stats for Nerds enhancements** — reset button, tokens per message average, peak TTFT, slowest completion, seconds subtext on all ms values
- **Feature gating** — `FeatureFlags` singleton with compile-time defaults (`BuildConfig.DEV_MODE`) and runtime DataStore overrides
- **Developer Options** — hidden settings section, tap version 7 times to unlock (same pattern as Android system Developer Options)
- **Relay feature toggle** — relay server settings and pairing sections gated behind developer options in release builds
- **Dynamic onboarding** — terminal, bridge, and relay pages excluded from onboarding when relay feature disabled
- **Parse tool annotations** — experimental annotation parsing for Sessions mode (marked with badge, disabled for Runs mode)
- **Privacy policy link** — accessible from Settings → About
- **MCP tooling docs** — `docs/mcp-tooling.md` reference for android-tools-mcp + mobile-mcp development setup
- **Dev scripts** — added `release`, `bundle`, `version` commands to `scripts/dev.bat`
- **MIT LICENSE** — added project license file
### Fixed
- SSE callbacks now dispatched to main thread for safe StateFlow updates
- Overlapping streams prevented — previous stream cancelled before new send
- Tool call completion now matches by tool call ID (not first incomplete)
- Onboarding test connection properly cleans up client on failure
- Health check loop only runs when API client is configured
- **Empty bubbles** — messages with blank content and no tool calls are now hidden from chat
- **App icon** — adaptive icon foreground scaled to 75% via `<group>` transform for proper safe zone padding
- **Token tracking** — usage data now extracted before SSE event type resolution, fixing 0 token counts when server sends OpenAI-format events
- **Token field compatibility** — `UsageInfo` accepts both `input_tokens`/`output_tokens` (Hermes) and `prompt_tokens`/`completion_tokens` (OpenAI)
- **Keyboard gap** — removed Scaffold content window insets that stacked with ChatScreen's IME padding
- **Session drawer highlight** — active session now properly highlighted (background color was computed but not applied)
- **Privacy doc** — added CAMERA permission, corrected network security description
- **CHANGELOG URLs** — fixed comparison links to use correct GitHub repository
- **FOREGROUND_SERVICE** — removed unused permission from AndroidManifest
- **Plugin refs** — updated from raulvidis to Codename-11
## [0.1.0] - 2026-04-05
### Changed
- Version bumped from `0.1.0-beta` to `0.1.0` for Google Play release
- Input bar shows both Stop and Send buttons during streaming (previously only Stop)
- Onboarding page flow now uses enum-based dynamic list instead of hardcoded indices
## [0.1.0-beta] - 2026-04-06
MVP release — native Android companion app for Hermes agent with direct API chat, session management, and full Compose UI.
### Added
- **Android app** — Jetpack Compose scaffold with chat, terminal (stub), bridge (stub), settings
#### Core Chat
- **Direct API chat** — connects to Hermes API Server via `/api/sessions/{id}/chat/stream` with SSE streaming
- **HermesApiClient** — full session CRUD + SSE streaming, health checks, cancel support
- **Dual connection model** — API Server (HTTP) for chat, Relay Server (WSS) for bridge/terminal
- **API key auth** — optional Bearer token stored in EncryptedSharedPreferences
- **Cancel streaming** — stop button to cancel in-flight chat responses
- **Error retry** — retry button in error banner re-sends last failed message
#### Session Management
- **Session CRUD** — create, switch, rename, delete chat sessions via Sessions API
- **Session drawer** — slide-out panel listing all sessions with title, timestamp, message count
- **Message history** — loads from server when switching sessions
- **Auto-session titles** — first user message auto-titles the session (truncated to 50 chars)
- **Session persistence** — last session ID saved to DataStore, resumes on app restart
#### Chat UI
- **Markdown rendering** — assistant messages render code blocks, bold, italic, links, lists (mikepenz multiplatform-markdown-renderer)
- **Reasoning display** — collapsible thinking block above assistant responses (toggle in Settings)
- **Token tracking** — per-message input/output token count and estimated cost
- **Personality picker** — dynamic personalities from server config (`config.agent.personalities`), agent name on chat bubbles
- **Message copy** — long-press any message to copy text to clipboard
- **Enriched tool cards** — tool-type icons, completion duration tracking
- **Responsive layout** — bubble widths adapt to phone, tablet, and landscape
- **Input character limit** — 4096 character limit with counter
- **Haptic feedback** — on send, stream complete, error, and message copy
#### App Foundation
- **Jetpack Compose scaffold** — bottom nav with Chat, Terminal (stub), Bridge (stub), Settings
- **WSS connection manager** — OkHttp WebSocket with auto-reconnect and exponential backoff
- **Channel multiplexer** — typed envelope protocol for chat/terminal/bridge/system
- **Auth flow** — 6-character pairing code with session token persistence
- **Chat UI** — message bubbles, streaming text, tool progress cards, profile selector
- **Relay server** — Python aiohttp WSS server proxying to Hermes WebAPI
- **Material 3 + Material You** — dynamic theming with light/dark/auto
- **Onboarding** — 5-page pager with feature overview and connection setup
- **Settings** — connection management, theme, data export/import/reset
- **CI/CD** — GitHub Actions for lint, build, test, and tag-driven releases
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
- **Onboarding** — multi-page pager with feature overview and connection setup
- **Settings** — API Server + Relay Server config, theme, reasoning toggle, data export/import/reset
- **Offline detection** — banner shown when network connectivity is lost
- **What's New dialog** — shown automatically when app version changes
- **Splash screen** — branded splash via core-splashscreen API
- **Network security** — cleartext restricted to localhost only
[Unreleased]: https://github.com/user/hermes-android/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/user/hermes-android/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/user/hermes-android/releases/tag/v0.1.0
#### Infrastructure
- **Relay server** — Python aiohttp WSS server for bridge/terminal channels
- **CI/CD** — GitHub Actions for lint, build, test, and tag-driven releases
- **Claude Code automation** — issue triage, PR fix, chat, and code review workflows
- **Dependabot** — weekly Gradle + GitHub Actions dependency updates with auto-merge
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
- **ProGuard rules** — okhttp-sse, markdown renderer, intellij-markdown parser
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0-beta...v0.1.0
[0.1.0-beta]: https://github.com/Codename-11/hermes-relay/releases/tag/v0.1.0-beta
+112 -16
View File
@@ -6,16 +6,53 @@
A native Android app for Hermes agent. Chat connects directly to the Hermes API Server. Bridge and terminal channels use a relay server over WSS. The app is Kotlin + Jetpack Compose. The server relay is Python + aiohttp.
**Current state:** MVP Phase 0 + Phase 1 complete with direct API chat. The Android app connects to the Hermes API Server (`/api/sessions/{id}/chat/stream`) for chat via HTTP/SSE. The relay server handles bridge (Phase 3) and terminal (Phase 2) via WSS. Auth uses optional Bearer token for API, pairing code for relay.
**Current state:** v0.1.0 (Google Play). Phase 0 + Phase 1 complete with direct API chat, session management, markdown rendering, messaging-style chat header (avatar + agent name + model subtitle), personality picker with agent name on bubbles, searchable command palette (29 gateway commands + dynamic personalities + server skills), QR code pairing, ConnectionStatusBadge (animated pulse ring), in-app analytics (Stats for Nerds with reset, peak times, tokens/msg), animated splash screen, tool display configuration, client-side message queuing (send while streaming), file attachments (images, documents, any file type via base64), configurable limits (attachment size, message length), feature gating with Developer Options, ASCII morphing sphere animation (empty chat state + ambient mode + behind-messages background), and animation settings in Settings. The relay server handles bridge (Phase 3) and terminal (Phase 2) via WSS. Auth uses optional Bearer token for API, pairing code for relay. Relay/pairing settings are hidden in production behind Developer Options (tap version 7x to unlock).
## Architecture
```
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct, OpenAI-compatible]
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
Phone (WSS) → Relay Server (:8767) [bridge, terminal]
```
Chat goes directly to the API server using the Hermes Sessions API (`/api/sessions/{id}/chat/stream`) with SSE streaming. The API key (Bearer token) is optional — most local setups run without one. Terminal will go through tmux via the relay. Bridge wraps existing relay protocol. See docs/decisions.md for why.
Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is optional — most local setups run without one. Terminal will go through tmux via the relay. Bridge wraps existing relay protocol. See docs/decisions.md for why.
### Upstream Hermes API Reference
**IMPORTANT:** Always verify endpoints against the actual hermes-agent source (`gateway/platforms/api_server.py`). The upstream repo is the source of truth — not our docs, not our memory, not assumptions from other frontends.
**Standard endpoints (confirmed in hermes-agent source):**
| Endpoint | Purpose | Tool Call Format |
|----------|---------|-----------------|
| `POST /v1/chat/completions` | OpenAI-compatible chat (stream=true for SSE) | Inline markdown text (`` `💻 terminal` ``) — no separate tool events |
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
| `GET /v1/models` | List available models | — |
| `GET /health` | Health check | — |
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management | — |
**Non-standard endpoints (may be version-specific):**
These endpoints work on our hermes-agent v0.7.0 but are **not in the upstream source**. They may be fork-specific, version-specific, or added by plugins. Always use `detectChatMode()` to probe availability.
| Endpoint | Purpose | Fallback |
|----------|---------|----------|
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Use `/v1/runs` or `/v1/chat/completions` |
| `GET/POST/PATCH/DELETE /api/sessions` | Session CRUD | Use `X-Hermes-Session-Id` header with `/v1/chat/completions` |
| `GET /api/skills` | Skill discovery | Hardcoded command list |
| `GET /api/config` | Server config (personalities, model) | No fallback — personality picker empty |
**Tool call rendering paths:**
1. **Runs API** (`/v1/runs`) — Best for tool display. Emits `tool.started`/`tool.completed` as real SSE events → rendered as ToolProgressCards.
2. **Sessions/Chat Completions** — Tool progress injected as inline markdown (`` `💻 terminal` ``). The `ChatHandler` annotation parser detects these patterns and converts them to ToolCall objects client-side.
3. **Annotation parser** (`ChatHandler.parseAnnotationLine`) — Fallback for any endpoint. Matches backtick-wrapped emoji+tool_name patterns. If your Hermes version uses a different format, check `adb logcat -s HermesApiClient` for raw SSE events and update the regex.
## Key Instructions
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document it as non-standard and implement a fallback.
- When building features that interface with hermes-agent, reference the upstream source — not just our spec docs. Our spec may be aspirational or based on a specific server version.
- If we use a non-standard endpoint, mark it clearly in code comments and ensure `detectChatMode()` handles its absence gracefully.
## Repository Layout
@@ -44,6 +81,8 @@ hermes-android/ ← Android Studio opens this root
│ ├── tools/ # Standalone toolset
│ ├── skills/ # Agent skills
│ └── tests/
├── skills/ ← Installable Hermes skills
│ └── hermes-pairing-qr/ # QR code pairing (hermes-pair script + SKILL.md)
├── docs/ ← spec, decisions, security
└── .github/workflows/ ← CI + release
```
@@ -86,13 +125,38 @@ hermes-android/ ← Android Studio opens this root
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
| `app/src/main/kotlin/.../ui/RelayApp.kt` | Main scaffold — bottom nav, navigation |
| `app/src/main/kotlin/.../network/HermesApiClient.kt` | Direct HTTP/SSE client for Hermes API Server |
| `app/src/main/kotlin/.../network/HermesApiClient.kt` | Direct HTTP/SSE client — `sendChatStream()` for sessions endpoint, `sendRunStream()` for runs endpoint, `detectChatMode()` for capability probing |
| `app/src/main/kotlin/.../network/ConnectionManager.kt` | WSS connection with auto-reconnect (relay) |
| `app/src/main/kotlin/.../network/ChannelMultiplexer.kt` | Envelope routing by channel (relay) |
| `app/src/main/kotlin/.../network/handlers/ChatHandler.kt` | Chat message state + streaming event processing |
| `app/src/main/kotlin/.../ui/screens/ChatScreen.kt` | Chat UI — streaming messages, tool cards |
| `relay_server/relay.py` | Relay server (bridge/terminal only) |
| `app/src/main/kotlin/.../network/ConnectivityObserver.kt` | Reactive network connectivity listener |
| `app/src/main/kotlin/.../network/handlers/ChatHandler.kt` | Chat message state, streaming events, tool annotation parser (inline markdown → ToolCall) |
| `app/src/main/kotlin/.../network/models/SessionModels.kt` | Session, message, SSE event data models |
| `app/src/main/kotlin/.../data/FeatureFlags.kt` | Feature gating — compile-time defaults (DEV_MODE) + runtime DataStore overrides |
| `app/src/main/kotlin/.../data/AppAnalytics.kt` | In-app analytics singleton (TTFT, tokens, health, stream rates) |
| `app/src/main/kotlin/.../ui/screens/ChatScreen.kt` | Chat UI — streaming messages, slash commands, tool cards |
| `app/src/main/kotlin/.../ui/screens/SettingsScreen.kt` | Settings — connection, chat, appearance, analytics, about |
| `app/src/main/kotlin/.../ui/components/StatsForNerds.kt` | Canvas bar charts for analytics display |
| `app/src/main/kotlin/.../ui/components/CompactToolCall.kt` | Inline compact tool call display |
| `app/src/main/kotlin/.../ui/components/PersonalityPicker.kt` | Personality picker dropdown (from config.agent.personalities) |
| `app/src/main/kotlin/.../ui/components/CommandPalette.kt` | Searchable command palette (bottom sheet) + inline autocomplete |
| `app/src/main/kotlin/.../ui/components/ConnectionStatusBadge.kt` | Animated pulse ring status indicator (connected/connecting/disconnected) |
| `app/src/main/kotlin/.../ui/components/MorphingSphere.kt` | ASCII morphing sphere — 3D lit character sphere with color pulse, used in empty chat state, ambient mode, and behind-messages background |
| `app/src/main/kotlin/.../ui/components/MessageBubble.kt` | Message bubbles with markdown, tokens, tool cards |
| `app/src/main/kotlin/.../ui/components/ToolProgressCard.kt` | Expandable tool execution card (auto-expand/collapse) |
| `app/src/main/kotlin/.../viewmodel/ChatViewModel.kt` | Chat orchestration — send, stream, cancel, slash commands |
| `app/src/main/kotlin/.../viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay) |
| `app/src/main/res/drawable/splash_icon.xml` | Splash screen icon (0.9x scale) |
| `app/src/main/res/drawable/splash_icon_animated.xml` | Animated splash (scale + overshoot + fade) |
| `relay_server/relay.py` | Relay server — main WSS server (bridge/terminal only) |
| `relay_server/SKILL.md` | Hermes skill reference for relay self-setup |
| `relay_server/Dockerfile` | Container image for relay server |
| `relay_server/hermes-relay.service` | Systemd unit file for persistent deployment |
| `docs/relay-server.md` | Relay server setup, config, Docker, systemd, TLS reference |
| `app/src/main/kotlin/.../ui/components/QrPairingScanner.kt` | QR code scanner + Hermes pairing payload parser |
| `skills/hermes-pairing-qr/SKILL.md` | QR pairing skill for hermes-agent (install to ~/.hermes/skills/) |
| `skills/hermes-pairing-qr/hermes-pair` | QR code generator script (install to ~/.local/bin/) |
| `AGENTS.md` | Tool usage patterns for the `android_*` toolset |
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp |
## What NOT to Do
@@ -104,12 +168,26 @@ hermes-android/ ← Android Studio opens this root
- **Don't put documentation in root** — long-form docs go in `docs/`
- **Don't forget DEVLOG.md** — update it
## MCP Tooling
Two MCP servers are configured for AI-assisted development. See `docs/mcp-tooling.md` for full reference.
| Server | Layer | Requires |
|--------|-------|----------|
| `android-tools-mcp` | IDE/Build — Compose previews, Gradle, code search, Android docs | Android Studio running with project open |
| `mobile-mcp` | Device/Runtime — tap, swipe, screenshot, app management | ADB + connected device/emulator |
Together they cover the full loop: code → preview → build → deploy → interact → screenshot.
## Dev Workflow
```bash
scripts/dev.bat build # Build debug APK
scripts/dev.bat build # Build debug APK (DEV_MODE=true)
scripts/dev.bat release # Build signed release APK (DEV_MODE=false)
scripts/dev.bat bundle # Build release AAB for Google Play upload
scripts/dev.bat run # Build + install + launch + logcat
scripts/dev.bat test # Run unit tests
scripts/dev.bat version # Show current version from libs.versions.toml
scripts/dev.bat relay # Start relay server (dev mode, no SSL)
```
@@ -117,15 +195,33 @@ Open repo root in Android Studio for Compose previews and device deployment.
## Integration Points
| Surface | Endpoint |
|---------|----------|
| WebAPI chat | `POST localhost:8642/api/sessions/{id}/chat/stream` (SSE) |
| WebAPI sessions | `GET/POST localhost:8642/api/sessions` |
| Agent profiles | Read from `~/.hermes/config.yaml` |
| Plugin tools | `android_*` via `plugin/` |
| Surface | Standard Endpoint | Non-Standard Fallback |
|---------|-------------------|----------------------|
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` (structured tool events) | `POST /api/sessions/{id}/chat/stream` (inline tool text) |
| Chat (OpenAI compat) | `POST /v1/chat/completions` (stream=true) | — |
| Session CRUD | `X-Hermes-Session-Id` header on `/v1/chat/completions` | `GET/POST/PATCH/DELETE /api/sessions` (non-standard) |
| Personalities | Read from `~/.hermes/config.yaml` | `GET /api/config` (non-standard) |
| Server skills | — | `GET /api/skills` (non-standard) |
| Health check | `GET /health` or `GET /v1/health` | — |
| Models | `GET /v1/models` | — |
| Plugin tools | `android_*` via `plugin/` | — |
## Upstream References
When working on features that interface with hermes-agent, consult these source files directly:
| Topic | Upstream File |
|-------|--------------|
| API endpoints | `gateway/platforms/api_server.py` — all registered HTTP routes |
| Platform adapter interface | `gateway/platforms/base.py` — `BasePlatformAdapter` abstract class |
| Adding a platform | `gateway/platforms/ADDING_A_PLATFORM.md` — 16-step checklist |
| Platform registration | `gateway/run.py` → `_create_adapter()`, `gateway/config.py` → `Platform` enum |
| Channel directory | `gateway/channel_directory.py` — how platforms/channels are enumerated |
| Send message routing | `tools/send_message_tool.py` → `platform_map` dict |
| SSE streaming (runs) | `gateway/platforms/api_server.py` → runs endpoint, `_on_tool_progress` |
## Related Projects
- **[hermes-agent](https://github.com/NousResearch/hermes-agent)** — the agent platform (gateway, WebAPI, plugin system)
- **[ARC](https://github.com/Codename-11/ARC)** — CI/CD patterns, project conventions reference
- **[ClawPort](https://github.com/Codename-11/clawport-ui)** — web dashboard (parallel project, uses same WebAPI)
- **[android-tools-mcp](https://github.com/Codename-11/android-tools-mcp)** — our fork of Android Studio MCP bridge (Compose previews, Gradle, docs)
- **[mobile-mcp](https://github.com/mobile-next/mobile-mcp)** — device control MCP server (ADB, tap/swipe, screenshots)
+284
View File
@@ -1,5 +1,289 @@
# Hermes Relay — Dev Log
## 2026-04-07 — ASCII Morphing Sphere, Ambient Mode, Animation Settings, Polish Fixes
**Done:**
- **ASCII morphing sphere** — animated visualization on the empty chat screen, inspired by AMP Code CLI. Pure Compose Canvas rendering (no OpenGL). Characters `. : - = + * # % @` form a sphere shape with 3D lighting. Color pulses green to purple. Contained in square aspect ratio box above "Start a conversation" text.
- **Ambient mode** — toggle button (AutoAwesome icon) in chat header bar hides messages and shows the sphere fullscreen. Tap the ChatBubble icon to return to chat.
- **Animation behind messages** — sphere renders at 15% opacity behind the chat message list as a subtle ambient background. Toggleable in Settings.
- **Animation settings** — new section in Settings under Appearance: "ASCII sphere" toggle (on by default), "Behind messages" toggle (on by default, disabled when animation is off).
- **Parse tool annotations** — marked as "Experimental" badge, disabled/dimmed when streaming endpoint is "Runs" mode (only relevant for Sessions mode).
- **Empty bubble fix** — messages with blank content and no tool calls are now hidden from chat.
- **App icon fix** — adaptive icon foreground scaled to 75% via `<group>` transform for proper safe zone padding.
- **Dev scripts** — added `release`, `bundle`, `version` commands to `scripts/dev.bat`.
- **MCP tooling** — android-tools-mcp v0.1.1 (IDE/build layer) + mobile-mcp (device/runtime layer) configured as companion MCP servers. Full reference in `docs/mcp-tooling.md`.
- **Audit fixes** — MIT LICENSE added, orphaned `companion/` and `companion_relay/` removed, `FOREGROUND_SERVICE` permission removed, CHANGELOG URLs fixed, version refs updated to 0.1.0, .gitignore updated, plugin refs updated from raulvidis to Codename-11.
**New files:**
- `ui/components/MorphingSphere.kt` — ASCII morphing sphere composable (Canvas-based, 3D lighting, color pulse)
**Files changed:**
- `ui/screens/ChatScreen.kt` — Empty state sphere, ambient mode toggle, behind-messages background layer
- `ui/screens/SettingsScreen.kt` — Animation settings section (ASCII sphere toggle, behind messages toggle), parse tool annotations experimental badge
- `viewmodel/ConnectionViewModel.kt` — Animation preference DataStore keys/flows
- `app/build.gradle.kts` — Version and build config updates
- `res/mipmap-anydpi-v26/ic_launcher.xml` — 75% scale group transform on foreground
- `scripts/dev.bat` — Added release, bundle, version commands
---
## 2026-04-07 — Token Tracking Fix, Stats Enhancements, Keyboard Gap, Configurable Limits
**Done:**
- **Token tracking fix** — Root cause: OpenAI-format SSE events (e.g. `chat.completion.chunk`) have no `type`/`event` field, so the `val eventType = type ?: event.resolvedType ?: return` line exited before usage was ever checked. Moved usage extraction **before** the type resolution in both `sendChatStream()` and `sendRunStream()`. Also added `prompt_tokens`/`completion_tokens` (OpenAI naming) support in `UsageInfo` via `resolvedInputTokens`/`resolvedOutputTokens` helper properties.
- **Stats for Nerds enhancements** — Reset button with confirmation dialog, tokens per message average in summary line (`~Xk/msg`), peak TTFT and slowest completion times (tertiary color), `formatMsWithSeconds()` helper shows `1234ms (1.2s)` for all time displays >= 1s.
- **Configurable limits** — Expandable "Limits" section in Chat settings with segmented button rows for max attachment size (1/5/10/25/50 MB, default 10) and max message length (1K/2K/4K/8K/16K chars, default 4K). Persisted to DataStore, read reactively in ChatScreen.
- **Keyboard gap fix** — Set `contentWindowInsets = WindowInsets(0)` on the Scaffold in RelayApp.kt. The Scaffold was adding system bar padding to `innerPadding` that stacked with ChatScreen's `imePadding()`, causing a visible gap between input bar and keyboard.
**Files changed:**
- `network/HermesApiClient.kt` — Usage check moved before eventType resolution in both streaming methods
- `network/models/SessionModels.kt` — `UsageInfo` now accepts `prompt_tokens`/`completion_tokens`, added `resolvedInputTokens`/`resolvedOutputTokens`/`resolvedTotalTokens`
- `viewmodel/ChatViewModel.kt` — Uses `usage.resolvedInputTokens` etc
- `viewmodel/ConnectionViewModel.kt` — `maxAttachmentMb` + `maxMessageLength` DataStore keys/flows/setters
- `ui/components/StatsForNerds.kt` — Reset button+dialog, tokens/msg, peak/slowest times, `formatMsWithSeconds()`
- `ui/screens/SettingsScreen.kt` — Expandable "Limits" section with segmented buttons
- `ui/screens/ChatScreen.kt` — Reads `charLimit`/`maxAttachmentMb` from settings
- `ui/RelayApp.kt` — `contentWindowInsets = WindowInsets(0)` on Scaffold
---
## 2026-04-07 — File Attachments
**Done:**
- **Generic file attachments** — users can attach any file type via `+` button in the input bar. Uses Android `OpenMultipleDocuments` picker (accepts `*/*`). Files base64-encoded and sent in the Hermes API `attachments` array (`{contentType, content}`).
- **Attachment preview strip** — horizontal scrollable row above input bar showing pending attachments. Image attachments show decoded thumbnails, other files show document icon + filename + size. Each attachment has a remove (X) button.
- **Attachment rendering in bubbles** — user messages display attached images inline (decoded from base64), non-image attachments show as file badge with name. Forward-compatible with agent-sent images.
- **10 MB file size limit** — enforced client-side with toast warning.
- **Send with attachments only** — send button enabled when attachments are present even without text. Sends `[attachment]` as placeholder text.
- **API integration** — `attachments` parameter added to both `sendChatStream()` and `sendRunStream()` in HermesApiClient. Serialized as JSON array matching Hermes WebAPI spec.
- **Message history support** — `MessageItem.imageUrls` extracts `image_url` content blocks from OpenAI-format content arrays for future server-side image rendering.
**Files changed:**
- `data/ChatMessage.kt` — Added `Attachment` data class, `attachments` field on `ChatMessage`
- `network/models/SessionModels.kt` — Added `imageUrls` property to `MessageItem`
- `network/HermesApiClient.kt` — `attachments` param on `sendChatStream()` + `sendRunStream()`, JSON array serialization
- `viewmodel/ChatViewModel.kt` — `_pendingAttachments` StateFlow, add/remove/clear, snapshot-and-clear on send, pass through to API
- `ui/screens/ChatScreen.kt` — `+` button, `OpenMultipleDocuments` picker, attachment preview strip, `formatFileSize()` helper
- `ui/components/MessageBubble.kt` — Inline image rendering (base64 decode), file badge for non-images
---
## 2026-04-07 — Client-Side Message Queuing
**Done:**
- **Message queuing** — Users can now send messages while the agent is streaming. Messages are queued locally and auto-sent when the current stream completes. Queue drains one at a time, maintaining proper ordering.
- **Input bar redesign** — During streaming, both Stop and Send buttons are visible side by side. Send button uses `tertiary` color during streaming to indicate "queue" mode. Placeholder changes to "Queue a message..." when streaming.
- **Queue indicator** — Animated bar above the input field shows queued message count ("1 message queued" / "3 messages queued") with a Clear button to discard the queue. Uses `AnimatedVisibility` for smooth entrance/exit.
- **Queue lifecycle** — Queue is cleared on stream cancellation (Stop button) and on stream error, preventing stale messages from auto-sending after failures.
**Design decisions:**
- Client-side queuing (not server-side `/queue` command) because the Hermes HTTP API doesn't support concurrent SSE streams to the same session. The gateway's `/queue` is a CLI-level feature, not an HTTP endpoint.
- Queue drains automatically — no manual "send next" required. Provides a seamless conversation flow.
- No purple glow on Send button during streaming — visual distinction between "send now" and "queue for later".
**Files changed:**
- `viewmodel/ChatViewModel.kt` — `_queuedMessages` StateFlow, `sendMessage()` queues during streaming, `sendMessageInternal()` extracted, `drainQueue()` on complete, `clearQueue()`, queue cleared on error/cancel
- `ui/screens/ChatScreen.kt` — Queue indicator row, input bar with both Stop+Send buttons, tertiary send tint during streaming, "Queue a message..." placeholder
---
## 2026-04-07 — Feature Gating, MCP Tooling, v0.1.0 Release Prep
**Done:**
- **Feature gating system** — `FeatureFlags.kt` singleton with compile-time defaults (`BuildConfig.DEV_MODE`) and runtime DataStore overrides. Debug builds have all features unlocked; release builds gate experimental features behind Developer Options.
- **Developer Options** — Hidden settings section activated by tapping version number 7 times (same UX as Android system Developer Options). Contains relay features toggle and lock button. Uses `tertiary` color scheme for visual distinction.
- **Gated relay/pairing settings** — Relay Server and Pairing sections in Settings hidden by default in release builds. Only visible when relay feature flag is enabled via Developer Options.
- **Gated onboarding pages** — Terminal, Bridge, and Relay pages dynamically excluded from onboarding flow when relay feature is disabled. Page count and indices adjust automatically.
- **Version bump** — `0.1.0-beta` → `0.1.0` for Google Play submission.
- **BuildConfig.DEV_MODE** — `true` for debug, `false` for release. Used by FeatureFlags as compile-time default.
- **android-tools-mcp v0.1.1** — Fixed MCP server path, built plugin from fork, committed wrapper jar fix, repo cleanup (fork attribution, VM option name fix, cross-platform release script), released to GitHub.
- **mobile-mcp added** — Added `mobile-next/mobile-mcp` as companion MCP server for device/runtime testing (tap, swipe, screenshot, app management). Configured with telemetry disabled.
- **MCP tooling docs** — Created `docs/mcp-tooling.md` with full reference for both MCP servers (setup, prerequisites, 40 tools listed, when-to-use guide, overlap analysis).
**New files:**
- `data/FeatureFlags.kt` — Feature flag singleton
- `docs/mcp-tooling.md` — MCP tooling reference
**Files changed:**
- `app/build.gradle.kts` — Added `DEV_MODE` BuildConfig field, `buildConfig = true`
- `gradle/libs.versions.toml` — Version `0.1.0`
- `ui/screens/SettingsScreen.kt` — Feature-gated relay/pairing, added Developer Options section with tap-to-unlock
- `ui/onboarding/OnboardingScreen.kt` — Dynamic page list based on feature flags
- `CLAUDE.md` — Updated current state, added FeatureFlags to key files, MCP tooling section, related projects
**Next:**
- Build release APK and submit to Google Play (closed testing track)
- Test feature gating on release build (relay settings hidden, dev options tap unlock)
- Phase 2: Terminal channel
- Phase 3: Bridge channel
---
## 2026-04-07 — Session Management Audit, Play Store Release Prep
**Done:**
- **Session management audit** — Full review of session CRUD, persistence, capability detection, error handling. Implementation is complete and solid against upstream Hermes API (both `/api/sessions` non-standard and `/v1/runs` standard endpoints).
- **Fixed SessionDrawer highlight bug** — `backgroundColor` variable was computed but never applied to the Row modifier. Active sessions now properly highlighted with `secondaryContainer`.
- **Added Privacy Policy link** — New "Privacy Policy" button in Settings → About, linking to GitHub-hosted `docs/privacy.md`. Required for Google Play Store submission.
- **Fixed privacy.md inaccuracies** — Added CAMERA permission to the permissions table (used for QR scanning, declared `required="false"`). Corrected network security description to accurately reflect cleartext policy.
- **Fixed RELEASE_NOTES.md URL** — Changed generic `user/hermes-android` to actual `Codename-11/hermes-android`.
- **Improved network_security_config.xml docs** — Expanded comment explaining why cleartext is globally permitted (Android doesn't support IP range restrictions, users connect to arbitrary LAN IPs) and how security is enforced at the application layer (insecure mode toggle + warning badge).
**Session management features confirmed working:**
- List/create/switch/rename/delete sessions with optimistic updates + rollback
- Message history loading with tool call reconstruction
- Auto-session creation on first message send with auto-title
- Session ID persistence via DataStore across app restarts
- Capability detection (`detectChatMode()`) with graceful degradation
- Both Sessions and Runs streaming endpoints
**Play Store readiness:**
- Signing config loads from env vars / local.properties ✅
- ProGuard rules comprehensive ✅
- Release workflow with version validation ✅
- Privacy policy link in app ✅
- Network security documented ✅
- No hardcoded debug flags in release ✅
- Version: `0.1.0-beta` (versionCode 1) — ready for open testing track
**Files changed:**
- `ui/components/SessionDrawer.kt` — Added `background` import + applied `backgroundColor` to Row
- `ui/screens/SettingsScreen.kt` — Added Shield icon import + Privacy Policy button in About card
- `docs/privacy.md` — Added CAMERA permission, fixed network security description
- `RELEASE_NOTES.md` — Fixed issues URL
- `res/xml/network_security_config.xml` — Expanded documentation comment
---
## 2026-04-07 — Chat UI Polish, Annotation Stripping, Reasoning Extraction
**Done:**
- **Scroll-to-bottom FAB** — SmallFloatingActionButton appears when scrolled up from bottom. Animated fade + slide. Haptic on click. Positioned bottom-end of message area.
- **Message entrance animations** — `animateItem()` on all LazyColumn items (messages, spacers, streaming dots). Smooth fade + slide when items appear/reorder.
- **Date separators** — "Today", "Yesterday", or "EEE, MMM d" chips between messages from different calendar days. Subtle surfaceVariant pill style.
- **Message grouping** — Consecutive same-sender messages have tighter spacing (2dp base + 1dp vs 6dp padding), suppressed agent name on non-first messages, grouped bubble corner shapes (flat edges where messages meet).
- **Pre-first-token indicator** — Placeholder assistant message with streaming dots appears immediately after send, before any SSE delta. Fills naturally when first delta arrives.
- **Copy feedback toast** — Snackbar "Copied to clipboard" on long-press copy. Previously only haptic with no visual confirmation.
- **Annotation stripping** — When the tool annotation parser matches inline text (`` `💻 terminal` ``), it now strips that text from the message content. Previously the raw annotation text remained visible alongside the ToolCall card.
- **Inline reasoning extraction** — `<think>`/`<thinking>` tags in assistant text are detected and redirected to `thinkingContent` for the ThinkingBlock. Handles tags split across streaming deltas. Resets on stream complete.
**Files changed:**
- `ui/screens/ChatScreen.kt` — FAB, date separators, grouping, snackbar, animation modifiers, Box wrapper for message area
- `ui/components/MessageBubble.kt` — `isFirstInGroup`/`isLastInGroup` params, grouped bubble shapes, conditional agent name
- `network/handlers/ChatHandler.kt` — `addPlaceholderMessage()`, `stripLineFromContent()`, `processInlineReasoning()`, thinking tag parser, `parseAnnotationLine` returns Boolean
- `viewmodel/ChatViewModel.kt` — placeholder message before stream start
**Note:** Code block copy button already existed (`MarkdownContent.kt` → `CodeBlockWithCopyButton`).
---
## 2026-04-07 — Tool Call Rendering Fix, Runs API, SSE Architecture Correction
**Done:**
- **Fixed premature stream completion** — `assistant.completed` was calling `onComplete()`, terminating the stream before tool events arrived in multi-turn agent loops. Now only `run.completed`/`done` end the stream. `assistant.completed` calls new `onTurnComplete()` which marks one message done without stopping the stream.
- **Added `message.started` handling** — Server-assigned message IDs now tracked via `onMessageStarted` callback. Enables proper multi-turn message tracking (each assistant turn gets its own message).
- **Dynamic message ID tracking** — `ChatViewModel.startStream()` uses `currentMessageId` variable that updates when the server sends new message IDs, instead of hardcoding one UUID for the whole stream.
- **Rewrote tool annotation parser** — Regex patterns now match actual Hermes format: `` `💻 terminal` `` (any emoji + tool name in backticks). Uses state tracking: first occurrence = start, second = complete. Also handles explicit completion/failure emojis (✅/❌) and verbose format (`🔧 Running: tool_name`).
- **Fixed message history tool calls** — `loadMessageHistory()` now reconstructs `ToolCall` objects from assistant messages' `tool_calls` field and matches tool results from `role:"tool"` messages. Previously skipped all tool data.
- **Runs API event coverage** — Added `message.delta`, `reasoning.available`, `run.failed` event handling. Updated `HermesSseEvent` model with `event` field (alias for `type`), `tool` field (Runs API format), `duration`, `output`, `text`, `timestamp`. Added `resolvedType` and `resolvedToolName` helpers.
- **SSE debug logging** — All events logged with `HermesApiClient` tag. Filter with `adb logcat -s HermesApiClient` to see what the server actually sends.
- **Updated decisions.md** — Documented the two streaming endpoints (Sessions vs Runs), tool call transparency differences, upstream API notes.
- **Updated settings description** — Streaming endpoint toggle now explains the difference.
**Architecture correction (from upstream research):**
- `/api/sessions` CRUD endpoints are NOT in upstream hermes-agent source. They may be version-specific (v0.7.0). Standard endpoints are `/v1/chat/completions`, `/v1/responses`, `/v1/runs`.
- `/v1/chat/completions` streaming embeds tool calls as **inline markdown text** (`` `💻 terminal` ``), NOT as separate SSE events. The annotation parser is the primary detection path.
- `/v1/runs` + `/v1/runs/{run_id}/events` provides **structured lifecycle events** with real `tool.started`/`tool.completed` — this is the correct endpoint for rich tool display.
- Hermes has no "channels" API (Discord/Telegram-style). The `channel_directory.py` is for cross-platform message routing, not a chat API.
**Files changed:**
- `network/HermesApiClient.kt` — new callbacks, fixed completion flow, debug logging
- `network/handlers/ChatHandler.kt` — `onTurnComplete()`, annotation rewrite, history tool calls
- `network/models/SessionModels.kt` — new fields for Runs API compatibility
- `viewmodel/ChatViewModel.kt` — dynamic message ID tracking, new callback wiring
- `ui/screens/SettingsScreen.kt` — updated endpoint toggle description
- `docs/decisions.md` — corrected API architecture documentation
**Next:**
- Deploy to device and test tool call rendering with `adb logcat -s HermesApiClient`
- Test with both "Sessions" and "Runs" endpoint modes
- Verify annotation parser matches actual Hermes verbose output
- If Runs API works well, consider making it the default endpoint
**Blockers:**
- Need a running hermes-agent server with tools configured to validate tool event flow end-to-end
---
## 2026-04-06 — Personality System, Command Palette, QR Pairing, Chat Header
**Done:**
- **Personality system fix** — `getProfiles()` was reading wrong JSON path and returning empty list. Replaced with `getPersonalities()` reading `config.agent.personalities` + `config.display.personality`. Server default personality shown first in picker. Switching sends personality's system prompt via `system_message` (previous `profile` field was ignored by server).
- **Agent name on chat bubbles** — Added `agentName` field to `ChatMessage`. Active personality name displayed above assistant messages.
- **Chat header redesign** — Messaging-app style: avatar circle with initial letter + `ConnectionStatusBadge` pulse overlay, agent name (`titleMedium`), model name subtitle from `/api/config`.
- **Command palette** — Searchable bottom sheet with category filter chips (2-row limit, expandable), 29 gateway built-in commands + dynamic personality commands + 90+ server skills from `GET /api/skills`. `/` button on input bar opens palette.
- **Inline autocomplete improved** — Extracted to `InlineAutocomplete` component with `LazyColumn`, 2-line descriptions, up to 8 results.
- **QR code pairing** — ML Kit barcode scanner + CameraX. Detects `{"hermes":1,...}` payload, auto-fills server URL + API key, triggers connection test. Available in Settings and Onboarding.
- **`hermes-pair` skill** — Added to `skills/hermes-pairing-qr/` for users to install on their server. Generator script + SKILL.md.
- **ConnectionStatusBadge** — Reusable animated status indicator with pulse ring (green connected, amber connecting, red disconnected). Wired into Settings, Onboarding, and chat header.
- **Relay server docs** — `docs/relay-server.md`, `relay_server/Dockerfile`, `relay_server/hermes-relay.service`, `relay_server/SKILL.md`.
- **Upstream contributions doc** — `docs/upstream-contributions.md` — proposed `GET /api/commands`, `personality` parameter, terminal HTTP API.
**Corrections to previous session:**
- "Server profile picker" was actually fetching from wrong path — now correctly reads `config.agent.personalities`
- "Sends `profile` field" — server ignores this; now sends `system_message` with personality prompt
- "13 personality commands" were hardcoded — now generated dynamically from server config
- ProfilePicker renamed to PersonalityPicker
---
## 2026-04-06 — v0.1.0-beta Polish, Profiles, Analytics, Splash
**Done:**
- **Package rename** — `com.hermesandroid.companion` → `com.hermesandroid.relay`. All files moved, manifest updated, app name changed to "Hermes Relay".
- **Server profile picker** — Replaced hardcoded 8-personality system with dynamic server profiles fetched from `GET /api/config`. ProfilePicker in top bar shows Default + server-configured profiles. Sends `profile` field in chat requests.
- **Personality switching** — 13 built-in Hermes personalities available via `/personality <name>` slash commands (server-side, session-level switching).
- **Slash command autocomplete** — Type `/` in chat input to see built-in commands (`/help`, `/verbose`, `/clear`, `/status`) + 13 personality commands + dynamically fetched server skills via `GET /api/skills`. Filterable dropdown overlay.
- **In-app analytics (Stats for Nerds)** — `AppAnalytics` singleton tracking response times (TTFT, completion), token usage, health check latency, stream success rates. Canvas bar charts in Settings with purple gradient. Accessible via Settings > Chat > Stats for Nerds.
- **Tool call display config** — Off/Compact/Detailed modes in Settings. `CompactToolCall` inline component for compact mode. `ToolProgressCard` auto-expands while tool is running, auto-collapses on complete.
- **App context prompt** — Toggleable system message telling the agent the user is on mobile. Enabled by default in Settings > Chat.
- **Animated splash screen** — `AnimatedVectorDrawable` with scale + overshoot + fade animation. Icon background color matches theme. Hold-while-loading (stays until DataStore ready). Smooth fade-out exit transition. Separate `splash_icon.xml` at 0.9x scale.
- **Chat empty state** — Logo + "Start a conversation" + suggestion chips that populate input.
- **Animated streaming dots** — Replaces static "streaming..." text with pulsing 3-dot animation.
- **Haptic feedback** — On send, copy, stream complete, error.
- **About section redesign** — Logo on dark background, dynamic version from `BuildConfig`, Source + Docs link buttons, credits line.
- **Hermes docs links** — In onboarding welcome page, API key help dialog, and Settings About section.
- **Release signing config** — Environment variables + `local.properties` fallback with graceful debug-signing fallback.
- **Centralized versioning** — `libs.versions.toml` as single source of truth (`appVersionName`, `appVersionCode`).
- **Logo fix** — Removed vertical H bars from ghost layer, now matches actual SVG (V-crossbar + diagonal feathers only).
- **SSE debug logging** — Unhandled event types now logged for diagnostics.
- **Release infrastructure (from ARC patterns)** — 3-job release workflow (validate → CI → release) reading from `libs.versions.toml`. Claude automation workflows (issue triage, fix, code review). Dependabot auto-merge. CHANGELOG.md + RELEASE_NOTES.md for v0.1.0-beta. Updated PR template with Android checklist.
**New files:**
- `data/AppAnalytics.kt` — In-app analytics singleton
- `ui/components/StatsForNerds.kt` — Canvas bar charts for analytics
- `ui/components/CompactToolCall.kt` — Inline compact tool call display
- `network/models/SessionModels.kt` — Session, message, SSE event models
- `res/drawable/splash_icon.xml` — Static splash icon (0.9x scale)
- `res/drawable/splash_icon_animated.xml` — Animated splash vector
- `res/animator/` — Splash animation resources
- `.github/workflows/claude.yml` — Claude automation
- `.github/workflows/claude-code-review.yml` — Claude code review
- `.github/workflows/dependabot-auto-merge.yml` — Dependabot auto-merge
**Next:**
- Build and test against running Hermes API server
- Test on emulator and physical device (S25 Ultra)
- Set up keystore/signing secrets for release CI
- Deploy docs site (GitHub Pages or similar)
- Phase 2: Terminal channel (xterm.js in WebView, tmux integration)
- Phase 3: Bridge channel migration
**Blockers:**
- None — ready for on-device testing
---
## 2026-04-05 — Project Scaffolding
**Done:**
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Axiom Labs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+82 -23
View File
@@ -12,7 +12,7 @@
<p align="center">
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT"></a>
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/Platform-Android-green.svg" alt="Android"></a>
<a href="https://github.com/Codename-11/hermes-android/actions/workflows/ci.yml"><img src="https://github.com/Codename-11/hermes-android/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Min%20SDK-26-brightgreen.svg" alt="Min SDK 26"></a>
</p>
@@ -37,34 +37,46 @@ A native Android app for [Hermes Agent](https://github.com/NousResearch/hermes-a
Chat connects directly to the Hermes API Server (`/api/sessions/{id}/chat/stream`). Terminal and bridge use a WebSocket relay with channel multiplexing.
## Architecture
## Server Components
The app talks to two server-side services. Only the first is required.
| Component | Required? | What |
|-----------|-----------|------|
| **Hermes API Server** (`:8642`) | Yes | Chat, sessions, profiles, skills. Part of `hermes gateway`. |
| **Relay Server** (`:8767`) | Only for terminal/bridge | WSS server for interactive terminal and device bridge. |
```
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
Phone (WSS) → Relay Server (:8767) [terminal]
Phone (WSS) → Bridge Relay (:8766) [bridge]
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
Phone (WSS) --> Relay Server (:8767) [terminal, bridge]
```
Chat bypasses the relay entirely — same direct connection used by Open WebUI, ClawPort, and other Hermes frontends. Auth is via optional Bearer token (`API_SERVER_KEY`). The relay handles channels that need persistent bidirectional communication.
Chat connects directly to the Hermes API Server — same pattern used by Open WebUI, ClawPort, and other Hermes frontends. The relay server is a separate lightweight Python service for features that need persistent bidirectional communication. See [docs/relay-server.md](docs/relay-server.md) for details.
## Features
| Layer | Capabilities |
|-------|-------------|
| **Chat** | Direct API streaming (SSE), session management, auto-titles, personality picker (8 styles) |
| **Rendering** | Full markdown, syntax-highlighted code blocks (Atom theme), reasoning display |
| **Tools** | Rich progress cards with type-specific icons, arguments, duration, error display |
| **Chat** | Direct API streaming (SSE), session management, auto-titles, message queuing, file attachments, personality picker, agent name on bubbles, slash command autocomplete, QR code pairing |
| **Slash Commands** | 29 gateway commands + dynamic personality commands + server skill discovery (`GET /api/skills`). Searchable command palette with category filtering. |
| **Personalities** | Dynamic from `GET /api/config` (`config.agent.personalities`). Picker shows server default + all configured. Agent name displayed on chat bubbles. `/personality <name>` slash commands. |
| **Rendering** | Full markdown, syntax-highlighted code blocks (Atom theme), reasoning display, animated streaming dots |
| **Tools** | Configurable display (Off/Compact/Detailed). Rich progress cards with type-specific icons, auto-expand/collapse, duration |
| **Analytics** | Stats for Nerds — TTFT, completion times, token usage, peak/slowest times, health latency, stream success rates. Canvas bar charts. Reset button. |
| **Tokens** | Per-message input/output counts and estimated cost |
| **Animation** | ASCII morphing sphere on empty chat, ambient fullscreen mode (toggle in header), 15% opacity behind messages (toggleable). Settings: sphere on/off, behind messages on/off. |
| **UX** | Animated splash screen, chat empty state with suggestion chips, haptic feedback, app context prompt, configurable limits (attachment size, message length) |
| **Security** | EncryptedSharedPreferences (AES-256-GCM), HTTPS enforced, cleartext only for localhost |
| **Connectivity** | Network monitoring, auto-reconnect, capability detection (enhanced/portable/disconnected) |
## Repository Structure
```
hermes-android/
hermes-relay/
├── app/ # Android app (Kotlin + Jetpack Compose)
├── relay_server/ # WSS relay server (Python + aiohttp)
├── plugin/ # Hermes agent plugin (14 android_* tools)
├── skills/ # Hermes agent skills (QR pairing)
├── user-docs/ # VitePress documentation site
├── docs/ # Spec, decisions, security
├── scripts/ # Dev helper scripts
@@ -84,21 +96,69 @@ hermes-android/
```bash
scripts/dev.bat build # Build debug APK
scripts/dev.bat release # Build signed release APK
scripts/dev.bat bundle # Build release AAB for Google Play
scripts/dev.bat run # Build + install + launch + logcat
scripts/dev.bat test # Run unit tests
scripts/dev.bat version # Show current version
scripts/dev.bat relay # Start relay server (dev, no TLS)
scripts/dev.bat devices # List connected devices
scripts/dev.bat wireless # Pair for wireless debugging
```
### Start Relay Server
### Android Studio Workflow
| Action | Shortcut | What it does | Wipes data? |
|--------|----------|-------------|-------------|
| **Gradle Sync** | Toolbar elephant icon | Reads `build.gradle.kts` + `libs.versions.toml`, resolves dependencies. No compilation. | No |
| **Assemble** | Build > Make Project | Compile → DEX → package APK. Does not deploy. | No |
| **Run** | Shift+F10 | Assemble + install + launch on device/emulator | No (upgrade install) |
| **Apply Changes** | Ctrl+F10 | Hot-patch changed code, restart Activity. ViewModels survive. | No |
| **Apply Code Changes** | Ctrl+Shift+F10 | Patch method bodies only, no restart | No |
To **wipe app data**: emulator app icon long-press > App Info > Storage > Clear Data, or `adb shell pm clear com.hermesandroid.relay`.
**Versioning** lives in `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`) — the single source of truth read by `build.gradle.kts`.
### Relay Server (optional — for terminal/bridge)
```bash
pip install -r relay_server/requirements.txt
python -m relay_server --no-ssl --log-level DEBUG
pip install aiohttp pyyaml && python -m relay_server --no-ssl
```
### Install as Hermes Plugin
Or with Docker:
```bash
docker build -t hermes-relay relay_server/ && docker run -d --network host --name hermes-relay hermes-relay
```
Or as a systemd service:
```bash
sudo cp relay_server/hermes-relay.service /etc/systemd/system/
sudo systemctl enable --now hermes-relay
```
See [docs/relay-server.md](docs/relay-server.md) for TLS, configuration, and full setup.
### QR Code Pairing (optional)
Generate a QR code on your server that the app can scan to auto-configure:
```bash
# Install the skill + script
cp -r skills/hermes-pairing-qr ~/.hermes/skills/hermes-pairing-qr
cp skills/hermes-pairing-qr/hermes-pair ~/.local/bin/hermes-pair
chmod +x ~/.local/bin/hermes-pair
sudo apt install qrencode
# Generate QR
hermes-pair
```
Scan it in the app (Settings > Scan QR, or during onboarding). See [skills/hermes-pairing-qr/SKILL.md](skills/hermes-pairing-qr/SKILL.md) for details.
### Hermes Plugin (optional — for bridge/device control)
```bash
cp -r plugin ~/.hermes/plugins/hermes-android
@@ -116,12 +176,12 @@ cp -r plugin ~/.hermes/plugins/hermes-android
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact) |
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
## Current State
## Current State — v0.1.0
| Phase | Status | Scope |
|-------|--------|-------|
| **Phase 0** | Complete | Compose scaffold, WSS connection, channel multiplexer, auth |
| **Phase 1** | Complete | Direct API chat, sessions, markdown, tools, personalities, tokens |
| **Phase 0** | Complete | Compose scaffold, WSS connection, channel multiplexer, auth, splash screen |
| **Phase 1** | Complete | Direct API chat, sessions, markdown, tools, personalities, slash commands, command palette, analytics, QR pairing, tool display config |
| **Phase 2** | Next | Terminal channel (xterm.js + tmux) |
| **Phase 3** | Next | Bridge channel (AccessibilityService) |
@@ -133,17 +193,16 @@ See [docs/spec.md](docs/spec.md) for the full specification and [docs/decisions.
|---|---|
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
| [Relay Server](docs/relay-server.md) | Setup, config, Docker, systemd — everything for the relay |
| [Upstream Contributions](docs/upstream-contributions.md) | Potential improvements to propose to hermes-agent |
| [Security](docs/security.md) | Auth flow, encryption, network security |
| [Privacy](docs/privacy.md) | Data handling, local storage, no telemetry |
| [Changelog](CHANGELOG.md) | Release history |
| [Dev Log](DEVLOG.md) | Session-by-session development notes |
## Related Projects
## Hermes Agent
| Project | What |
|---------|------|
| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | The agent platform (gateway, WebAPI, plugins) |
| [ARC](https://github.com/Codename-11/ARC) | Agent Runtime Control — unified CLI for agent tools |
| [ClawPort](https://github.com/Codename-11/clawport-ui) | Web dashboard for Hermes Agent |
Hermes Relay is built for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — an open-source AI agent platform by [Nous Research](https://nousresearch.com). See the [Hermes Agent docs](https://hermes-agent.nousresearch.com) for server setup, gateway configuration, and plugin development.
## License
+34 -19
View File
@@ -1,26 +1,41 @@
# v0.2.0 — Direct API Chat
# Hermes Relay v0.1.0
Chat now connects directly to your Hermes API Server — no relay server needed for conversations.
First release — a native Android client for the Hermes agent platform with direct API chat, session management, and a full Material 3 Compose UI.
## What's New
## Highlights
- **Direct API chat** — uses the Hermes Sessions API (`/api/sessions/{id}/chat/stream`) with SSE streaming
- **API key auth (optional)** — `Authorization: Bearer <key>` stored securely on device, only needed if Hermes is configured with `API_SERVER_KEY`
- **Session management** — create, list, switch, rename, and delete chat sessions via the Sessions API
- **Test Connection** — verify your API server is reachable before chatting
- **Cancel streaming** — stop button to cancel in-flight responses
## What Changed
- Onboarding now asks for API Server URL + API Key (relay URL is optional)
- Settings split into "API Server" and "Relay Server" sections
- The relay server is only needed for Bridge and Terminal features
- **Direct API chat** — connects to your Hermes API Server via SSE streaming
- **Session management** — create, switch, rename, delete sessions with full message history
- **Markdown rendering** — code blocks, bold, italic, links, lists in assistant messages
- **Reasoning display** — collapsible thinking blocks when the agent uses extended thinking
- **Token tracking** — per-message input/output token count and estimated cost
- **Personality picker** — dynamic personalities from server config, agent name on chat bubbles
- **Command palette** — searchable command browser with 29 gateway commands + server skills
- **QR code pairing** — scan `hermes-pair` QR to auto-configure connection
- **Material You theming** — dynamic colors with light/dark/auto support
- **File attachments** — attach images, documents, and other files to messages
- **Message queuing** — send follow-up messages while the agent is still responding
- **Offline detection** — graceful degradation when network connectivity is lost
- **Feature gating** — Developer Options (tap version 7x) for experimental features
- **Configurable limits** — adjustable attachment size and message length in Settings
- **In-app analytics** — Stats for Nerds with response times, token usage, peak times, reset
## Install
```bash
# Build and install debug APK
scripts/dev.bat run
```
Download from Google Play or install the APK from the release assets below.
Or download the APK from the release assets.
## Requirements
- Android 8.0+ (API 26)
- A running [Hermes agent](https://github.com/NousResearch/hermes-agent) instance
## What's Next
- Terminal channel via tmux (Phase 2)
- Bridge channel migration (Phase 3)
- Push notifications
- Agent-initiated image rendering (MEDIA: tags)
## Feedback
- Issues: https://github.com/Codename-11/hermes-relay/issues
+69 -4
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
@@ -6,26 +8,60 @@ plugins {
}
android {
namespace = "com.hermesandroid.companion"
namespace = "com.hermesandroid.relay"
compileSdk = 36
defaultConfig {
applicationId = "com.hermesandroid.companion"
applicationId = "com.hermesandroid.relay"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Feature flags — DEV_MODE enables all experimental features in debug builds
buildConfigField("boolean", "DEV_MODE", "false")
}
signingConfigs {
create("release") {
val localProps = rootProject.file("local.properties")
val props: Properties? = if (localProps.exists()) {
Properties().apply { localProps.inputStream().use { stream -> load(stream) } }
} else null
storeFile = file(
System.getenv("HERMES_KEYSTORE_PATH")
?: props?.getProperty("hermes.keystore.path")
?: "/nonexistent"
)
storePassword = System.getenv("HERMES_KEYSTORE_PASSWORD")
?: props?.getProperty("hermes.keystore.password") ?: ""
keyAlias = System.getenv("HERMES_KEY_ALIAS")
?: props?.getProperty("hermes.key.alias") ?: ""
keyPassword = System.getenv("HERMES_KEY_PASSWORD")
?: props?.getProperty("hermes.key.password") ?: ""
}
}
buildTypes {
debug {
buildConfigField("boolean", "DEV_MODE", "true")
}
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// Use release signing if keystore exists, otherwise fall back to debug signing
val releaseSigningConfig = signingConfigs.getByName("release")
signingConfig = if (releaseSigningConfig.storeFile?.exists() == true) {
releaseSigningConfig
} else {
signingConfigs.getByName("debug")
}
}
}
@@ -40,12 +76,19 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
sourceSets {
getByName("main") {
kotlin.srcDirs("src/main/kotlin")
}
getByName("test") {
kotlin.srcDirs("src/test/kotlin")
}
getByName("androidTest") {
kotlin.srcDirs("src/androidTest/kotlin")
}
}
}
@@ -84,6 +127,25 @@ dependencies {
// Networking
implementation(libs.okhttp)
implementation(libs.okhttp.sse)
// Markdown rendering
implementation(libs.markdown.renderer.m3)
implementation(libs.markdown.renderer.code)
// QR Code scanning (ML Kit + CameraX)
implementation(libs.mlkit.barcode)
implementation(libs.camera.core)
implementation(libs.camera.camera2)
implementation(libs.camera.lifecycle)
implementation(libs.camera.view)
// Haze (glassmorphism blur)
implementation(libs.haze)
implementation(libs.haze.materials)
// Window size class for responsive layout
implementation("androidx.compose.material3:material3-window-size-class")
// Serialization
implementation(libs.kotlinx.serialization.json)
@@ -99,6 +161,9 @@ dependencies {
// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kotlinx.serialization.json)
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
+23 -3
View File
@@ -11,11 +11,11 @@
}
# Keep all @Serializable data classes in our package
-keep,includedescriptorclasses class com.hermesandroid.companion.**$$serializer { *; }
-keepclassmembers class com.hermesandroid.companion.** {
-keep,includedescriptorclasses class com.hermesandroid.relay.**$$serializer { *; }
-keepclassmembers class com.hermesandroid.relay.** {
*** Companion;
}
-keepclasseswithmembers class com.hermesandroid.companion.** {
-keepclasseswithmembers class com.hermesandroid.relay.** {
kotlinx.serialization.KSerializer serializer(...);
}
@@ -27,6 +27,17 @@
-keep class okhttp3.** { *; }
-keep interface okhttp3.** { *; }
# ── OkHttp-SSE ──────────────────────────────────────────────────────
-keep class okhttp3.sse.** { *; }
-keep interface okhttp3.sse.** { *; }
-keep class okhttp3.internal.sse.** { *; }
# ── Mikepenz Markdown Renderer ────────────────────────────────────
-keep class com.mikepenz.markdown.** { *; }
-keep interface com.mikepenz.markdown.** { *; }
-keep class org.intellij.markdown.** { *; }
-keep interface org.intellij.markdown.** { *; }
# ── Compose ──────────────────────────────────────────────────────────
# Compose is mostly handled by R8 automatically, but keep stability annotations
-dontwarn androidx.compose.**
@@ -35,6 +46,15 @@
-keep class com.google.crypto.tink.** { *; }
-dontwarn com.google.crypto.tink.**
# ── ML Kit Barcode Scanning ──────────────────────────────────────────
-keep class com.google.mlkit.** { *; }
-keep class com.google.android.gms.internal.mlkit_vision_barcode.** { *; }
-dontwarn com.google.mlkit.**
# ── CameraX ─────────────────────────────────────────────────────────
-keep class androidx.camera.** { *; }
-dontwarn androidx.camera.**
# ── General ──────────────────────────────────────────────────────────
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
@@ -0,0 +1,294 @@
package com.hermesandroid.relay.ui.onboarding
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Rule
import org.junit.Test
/**
* Instrumented tests for the onboarding pager flow.
*
* These tests require an Android device or emulator because they use
* Compose UI testing APIs and interact with real Compose components.
*/
class OnboardingFlowTest {
@get:Rule
val composeTestRule = createComposeRule()
private fun setOnboardingContent() {
composeTestRule.setContent {
HermesRelayTheme {
OnboardingScreen(
onComplete = { _, _, _ -> }
)
}
}
}
// --- Page 1: Welcome ---
@Test
fun firstPage_showsHermesRelayTitle() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Hermes Relay")
.assertIsDisplayed()
}
@Test
fun firstPage_showsWelcomeDescription() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Your AI agent, in your pocket. Chat, control, and connect — all from your phone.")
.assertIsDisplayed()
}
// --- Skip button ---
@Test
fun skipButton_isAlwaysVisible_onFirstPage() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Skip")
.assertIsDisplayed()
}
// --- Navigation: Next button ---
@Test
fun nextButton_isDisplayed_onFirstPage() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Next")
.assertIsDisplayed()
}
@Test
fun nextButton_navigatesForward_toPage2() {
setOnboardingContent()
// Page 1 -> Page 2
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 2 is "Talk to Your Agent"
composeTestRule
.onNodeWithText("Talk to Your Agent")
.assertIsDisplayed()
}
@Test
fun canNavigateForward_throughAllPages() {
setOnboardingContent()
// Page 1: Hermes Relay (Welcome)
composeTestRule.onNodeWithText("Hermes Relay").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 2: Talk to Your Agent (Chat)
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 3: Remote Terminal
composeTestRule.onNodeWithText("Remote Terminal").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 4: Device Bridge
composeTestRule.onNodeWithText("Device Bridge").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 5: Connect to Hermes
composeTestRule.onNodeWithText("Connect to Hermes").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 6: Relay Server (last page)
composeTestRule.onNodeWithText("Relay Server").assertIsDisplayed()
}
// --- Back button ---
@Test
fun backButton_hiddenOnFirstPage() {
setOnboardingContent()
// On page 1, Back should not exist
composeTestRule
.onNodeWithText("Back")
.assertDoesNotExist()
}
@Test
fun backButton_visibleOnPage2() {
setOnboardingContent()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Back")
.assertIsDisplayed()
}
@Test
fun backButton_navigatesBackward() {
setOnboardingContent()
// Go to page 2
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
// Go back to page 1
composeTestRule.onNodeWithText("Back").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Hermes Relay").assertIsDisplayed()
}
// --- Page 5: Connect page ---
@Test
fun connectPage_hasApiServerUrlField() {
setOnboardingContent()
navigateToPage(4) // 0-indexed, page 5 is index 4
composeTestRule
.onNodeWithText("API Server URL")
.assertIsDisplayed()
}
@Test
fun connectPage_hasApiKeyField() {
setOnboardingContent()
navigateToPage(4)
composeTestRule
.onNodeWithText("API Key (optional)", substring = true)
.assertIsDisplayed()
}
@Test
fun connectPage_whereDoIFindThis_showsHelpDialog() {
setOnboardingContent()
navigateToPage(4)
// Tap "Where do I find this?"
composeTestRule
.onNodeWithText("Where do I find this?")
.performClick()
composeTestRule.waitForIdle()
// Dialog should show
composeTestRule
.onNodeWithText("Do I need an API key?")
.assertIsDisplayed()
}
@Test
fun connectPage_helpDialog_canBeDismissed() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Where do I find this?").performClick()
composeTestRule.waitForIdle()
// Dialog is showing
composeTestRule.onNodeWithText("Do I need an API key?").assertIsDisplayed()
// Dismiss it
composeTestRule.onNodeWithText("Got it").performClick()
composeTestRule.waitForIdle()
// Dialog should be gone
composeTestRule
.onNodeWithText("Do I need an API key?")
.assertDoesNotExist()
}
// --- Page 6: Relay page ---
@Test
fun relayPage_showsOptionalMessaging() {
setOnboardingContent()
navigateToPage(5) // Last page
composeTestRule
.onNodeWithText("This is optional", substring = true)
.assertIsDisplayed()
}
@Test
fun relayPage_showsRelayUrlField() {
setOnboardingContent()
navigateToPage(5)
composeTestRule
.onNodeWithText("Relay URL (optional)")
.assertIsDisplayed()
}
// --- Get Started button ---
@Test
fun lastPage_showsGetStartedButton() {
setOnboardingContent()
navigateToPage(5)
composeTestRule
.onNodeWithText("Get Started")
.assertIsDisplayed()
}
@Test
fun lastPage_getStartedButton_isEnabled_withDefaultUrl() {
setOnboardingContent()
navigateToPage(5)
// Default URL is "http://localhost:8642" which is non-blank
composeTestRule
.onNodeWithText("Get Started")
.assertIsEnabled()
}
// --- Skip button visibility across pages ---
@Test
fun skipButton_visibleOnAllPages() {
setOnboardingContent()
// Check skip on first page
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
// Navigate through all pages and check skip
for (i in 0 until 5) {
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
}
}
// --- Helper ---
private fun navigateToPage(pageIndex: Int) {
repeat(pageIndex) {
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
}
}
}
@@ -0,0 +1,157 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.material3.MaterialTheme
import org.junit.Rule
import org.junit.Test
/**
* Instrumented tests for Terminal and Bridge empty state screens.
*/
class EmptyStateTest {
@get:Rule
val composeTestRule = createComposeRule()
// --- Terminal Screen ---
@Test
fun terminalScreen_showsTitle() {
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
}
}
composeTestRule
.onNodeWithText("Remote Terminal")
.assertIsDisplayed()
}
@Test
fun terminalScreen_showsPhase2Chip() {
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
}
}
composeTestRule
.onNodeWithText("Coming in Phase 2")
.assertIsDisplayed()
}
@Test
fun terminalScreen_showsDescription() {
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
}
}
composeTestRule
.onNodeWithText("Secure shell access", substring = true)
.assertIsDisplayed()
}
@Test
fun terminalScreen_showsTopBarTitle() {
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
}
}
composeTestRule
.onNodeWithText("Terminal")
.assertIsDisplayed()
}
@Test
fun terminalScreen_showsPlannedFeatures() {
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
}
}
composeTestRule
.onNodeWithText("Full ANSI terminal emulator", substring = true)
.assertIsDisplayed()
composeTestRule
.onNodeWithText("tmux session management", substring = true)
.assertIsDisplayed()
}
// --- Bridge Screen ---
@Test
fun bridgeScreen_showsTitle() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Device Bridge")
.assertIsDisplayed()
}
@Test
fun bridgeScreen_showsPhase3Chip() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Coming in Phase 3")
.assertIsDisplayed()
}
@Test
fun bridgeScreen_showsDescription() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Let your Hermes agent interact with your phone", substring = true)
.assertIsDisplayed()
}
@Test
fun bridgeScreen_showsTopBarTitle() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Bridge")
.assertIsDisplayed()
}
@Test
fun bridgeScreen_showsPlannedFeatures() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Agent-controlled device interaction", substring = true)
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Permission management", substring = true)
.assertIsDisplayed()
}
}
+10 -5
View File
@@ -2,20 +2,25 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:name=".CompanionApp"
android:name=".HermesRelayApp"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.HermesCompanion">
android:theme="@style/Theme.HermesRelay">
<activity
android:name=".CompanionActivity"
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.HermesCompanion.Splash">
android:theme="@style/Theme.HermesRelay.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
+32
View File
@@ -0,0 +1,32 @@
v0.1.0 — First Release
Chat
• Direct API chat via SSE streaming
• Markdown rendering — code blocks, bold, italic, links
• Session management — create, switch, rename, delete
• Message history with auto-titles
• Reasoning display (collapsible thinking blocks)
• Personality picker with dynamic server personalities
• Command palette — 29+ commands + server skills
• Token & cost tracking per message
• File attachments — images, documents, any file type
• Message queuing — send while agent is streaming
Animation
• ASCII morphing sphere on empty chat screen
• Ambient mode — fullscreen sphere (toggle in header)
• Subtle sphere behind messages at 15% opacity
• Animation controls in Settings > Appearance
App
• Material You theming (light/dark/auto)
• QR code pairing for quick setup
• Stats for Nerds — response times, health metrics
• Offline detection with reconnect
• Developer Options — tap version 7x to unlock experimental features
• Configurable limits — attachment size, message length
Security
• API keys in EncryptedSharedPreferences
• Network security config for localhost
• Feature gating for unfinished features
@@ -1,20 +0,0 @@
package com.hermesandroid.companion
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.hermesandroid.companion.ui.CompanionApp
class CompanionActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
CompanionApp()
}
}
}
@@ -1,16 +0,0 @@
package com.hermesandroid.companion
import android.app.Application
class CompanionApp : Application() {
override fun onCreate() {
super.onCreate()
instance = this
}
companion object {
lateinit var instance: CompanionApp
private set
}
}
@@ -1,30 +0,0 @@
package com.hermesandroid.companion.data
data class ChatMessage(
val id: String,
val role: MessageRole,
val content: String,
val timestamp: Long,
val isStreaming: Boolean = false,
val toolCalls: List<ToolCall> = emptyList()
)
data class ToolCall(
val name: String,
val args: String?,
val result: String?,
val success: Boolean?,
val isComplete: Boolean = false
)
enum class MessageRole {
USER,
ASSISTANT,
SYSTEM
}
data class ChatSession(
val sessionId: String,
val title: String?,
val model: String?
)
@@ -1,224 +0,0 @@
package com.hermesandroid.companion.network.handlers
import com.hermesandroid.companion.data.ChatMessage
import com.hermesandroid.companion.data.ChatSession
import com.hermesandroid.companion.data.MessageRole
import com.hermesandroid.companion.data.ToolCall
import com.hermesandroid.companion.network.ChannelMultiplexer
import com.hermesandroid.companion.network.models.ChatCompletedPayload
import com.hermesandroid.companion.network.models.ChatDeltaPayload
import com.hermesandroid.companion.network.models.ChatErrorPayload
import com.hermesandroid.companion.network.models.ChatSessionPayload
import com.hermesandroid.companion.network.models.ChatToolCompletedPayload
import com.hermesandroid.companion.network.models.ChatToolStartedPayload
import com.hermesandroid.companion.network.models.Envelope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
/**
* Processes incoming chat channel messages and updates state.
*
* Handles:
* - chat.session — new session created
* - chat.delta — streaming text delta
* - chat.tool.started — tool execution started
* - chat.tool.completed — tool execution completed
* - chat.completed — assistant message complete
* - chat.error — error in chat
*/
class ChatHandler : ChannelMultiplexer.ChannelHandler {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
private val _isStreaming = MutableStateFlow(false)
val isStreaming: StateFlow<Boolean> = _isStreaming.asStateFlow()
private val _sessions = MutableStateFlow<List<ChatSession>>(emptyList())
val sessions: StateFlow<List<ChatSession>> = _sessions.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
override fun onMessage(envelope: Envelope) {
when (envelope.type) {
"chat.session" -> handleSession(envelope)
"chat.delta" -> handleDelta(envelope)
"chat.tool.started" -> handleToolStarted(envelope)
"chat.tool.completed" -> handleToolCompleted(envelope)
"chat.completed" -> handleCompleted(envelope)
"chat.error" -> handleError(envelope)
}
}
fun addUserMessage(message: ChatMessage) {
_messages.update { it + message }
}
fun clearError() {
_error.value = null
}
private fun handleSession(envelope: Envelope) {
try {
val payload = json.decodeFromJsonElement<ChatSessionPayload>(envelope.payload)
val session = ChatSession(
sessionId = payload.sessionId,
title = payload.title,
model = payload.model
)
_sessions.update { sessions ->
if (sessions.any { it.sessionId == session.sessionId }) {
sessions.map { if (it.sessionId == session.sessionId) session else it }
} else {
sessions + session
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun handleDelta(envelope: Envelope) {
try {
val payload = json.decodeFromJsonElement<ChatDeltaPayload>(envelope.payload)
_isStreaming.value = true
_messages.update { messages ->
val existing = messages.findLast {
it.id == payload.messageId && it.role == MessageRole.ASSISTANT
}
if (existing != null) {
// Append delta to existing streaming message
messages.map { msg ->
if (msg.id == payload.messageId) {
msg.copy(content = msg.content + payload.delta)
} else {
msg
}
}
} else {
// Create new assistant message
messages + ChatMessage(
id = payload.messageId,
role = MessageRole.ASSISTANT,
content = payload.delta,
timestamp = System.currentTimeMillis(),
isStreaming = true
)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun handleToolStarted(envelope: Envelope) {
try {
val payload = json.decodeFromJsonElement<ChatToolStartedPayload>(envelope.payload)
val toolCall = ToolCall(
name = payload.toolName,
args = payload.args?.toString(),
result = null,
success = null,
isComplete = false
)
_messages.update { messages ->
val lastAssistant = messages.findLast { it.role == MessageRole.ASSISTANT }
if (lastAssistant != null) {
messages.map { msg ->
if (msg.id == lastAssistant.id) {
msg.copy(toolCalls = msg.toolCalls + toolCall)
} else {
msg
}
}
} else {
// Create a new assistant message to hold the tool call
messages + ChatMessage(
id = java.util.UUID.randomUUID().toString(),
role = MessageRole.ASSISTANT,
content = "",
timestamp = System.currentTimeMillis(),
isStreaming = true,
toolCalls = listOf(toolCall)
)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun handleToolCompleted(envelope: Envelope) {
try {
val payload = json.decodeFromJsonElement<ChatToolCompletedPayload>(envelope.payload)
_messages.update { messages ->
messages.map { msg ->
if (msg.role == MessageRole.ASSISTANT && msg.toolCalls.any { it.name == payload.toolName && !it.isComplete }) {
val updatedCalls = msg.toolCalls.map { call ->
if (call.name == payload.toolName && !call.isComplete) {
call.copy(
result = payload.resultPreview,
success = payload.success,
isComplete = true
)
} else {
call
}
}
msg.copy(toolCalls = updatedCalls)
} else {
msg
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun handleCompleted(envelope: Envelope) {
try {
val payload = json.decodeFromJsonElement<ChatCompletedPayload>(envelope.payload)
_isStreaming.value = false
_messages.update { messages ->
messages.map { msg ->
if (msg.id == payload.messageId) {
msg.copy(
content = payload.content,
isStreaming = false
)
} else {
msg
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun handleError(envelope: Envelope) {
try {
val payload = json.decodeFromJsonElement<ChatErrorPayload>(envelope.payload)
_isStreaming.value = false
_error.value = payload.message
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -1,61 +0,0 @@
package com.hermesandroid.companion.network.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class ChatSendPayload(
val profile: String,
@SerialName("session_id")
val sessionId: String? = null,
val message: String
)
@Serializable
data class ChatDeltaPayload(
@SerialName("session_id")
val sessionId: String,
@SerialName("message_id")
val messageId: String,
val delta: String
)
@Serializable
data class ChatCompletedPayload(
@SerialName("session_id")
val sessionId: String,
@SerialName("message_id")
val messageId: String,
val content: String
)
@Serializable
data class ChatToolStartedPayload(
@SerialName("tool_name")
val toolName: String,
val preview: String? = null,
val args: JsonObject? = null
)
@Serializable
data class ChatToolCompletedPayload(
@SerialName("tool_name")
val toolName: String,
@SerialName("result_preview")
val resultPreview: String? = null,
val success: Boolean
)
@Serializable
data class ChatSessionPayload(
@SerialName("session_id")
val sessionId: String,
val title: String? = null,
val model: String? = null
)
@Serializable
data class ChatErrorPayload(
val message: String
)
@@ -1,165 +0,0 @@
package com.hermesandroid.companion.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.hermesandroid.companion.ui.onboarding.OnboardingScreen
import com.hermesandroid.companion.ui.screens.BridgeScreen
import com.hermesandroid.companion.ui.screens.ChatScreen
import com.hermesandroid.companion.ui.screens.SettingsScreen
import com.hermesandroid.companion.ui.screens.TerminalScreen
import com.hermesandroid.companion.ui.theme.HermesCompanionTheme
import com.hermesandroid.companion.viewmodel.ChatViewModel
import com.hermesandroid.companion.viewmodel.ConnectionViewModel
sealed class Screen(
val route: String,
val label: String,
val icon: ImageVector
) {
data object Onboarding : Screen("onboarding", "Onboarding", Icons.Filled.Settings)
data object Chat : Screen("chat", "Chat", Icons.Filled.Chat)
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
}
private val bottomNavScreens = listOf(
Screen.Chat,
Screen.Terminal,
Screen.Bridge,
Screen.Settings
)
@Composable
fun CompanionApp() {
val connectionViewModel: ConnectionViewModel = viewModel()
val chatViewModel: ChatViewModel = viewModel()
// Initialize ChatViewModel with networking dependencies
remember {
chatViewModel.initialize(
connectionViewModel.multiplexer,
connectionViewModel.chatHandler
)
true
}
// Observe theme preference
val themePreference by connectionViewModel.theme.collectAsState()
// Check onboarding state
val onboardingCompleted by connectionViewModel.onboardingCompleted.collectAsState()
HermesCompanionTheme(themePreference = themePreference) {
val navController = rememberNavController()
// Determine start destination based on onboarding state
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
// Track whether we're on the onboarding screen
val navBackStackEntry by navController.currentBackStackEntryAsState()
val isOnboarding = navBackStackEntry?.destination?.route == Screen.Onboarding.route
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
// Hide bottom nav during onboarding
if (!isOnboarding) {
NavigationBar {
val currentDestination = navBackStackEntry?.destination
bottomNavScreens.forEach { screen ->
NavigationBarItem(
icon = {
Icon(
imageVector = screen.icon,
contentDescription = screen.label
)
},
label = { Text(screen.label) },
selected = currentDestination?.hierarchy?.any {
it.route == screen.route
} == true,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier.padding(innerPadding)
) {
composable(Screen.Onboarding.route) {
OnboardingScreen(
onComplete = { serverUrl ->
connectionViewModel.updateServerUrl(serverUrl)
connectionViewModel.completeOnboarding()
navController.navigate(Screen.Chat.route) {
popUpTo(Screen.Onboarding.route) { inclusive = true }
}
},
onSkipToSettings = { serverUrl ->
connectionViewModel.updateServerUrl(serverUrl)
connectionViewModel.completeOnboarding()
navController.navigate(Screen.Settings.route) {
popUpTo(Screen.Onboarding.route) { inclusive = true }
}
}
)
}
composable(Screen.Chat.route) {
ChatScreen(
chatViewModel = chatViewModel,
connectionViewModel = connectionViewModel
)
}
composable(Screen.Terminal.route) {
TerminalScreen()
}
composable(Screen.Bridge.route) {
BridgeScreen()
}
composable(Screen.Settings.route) {
SettingsScreen(connectionViewModel = connectionViewModel)
}
}
}
}
}
@@ -1,131 +0,0 @@
package com.hermesandroid.companion.ui.components
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.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermesandroid.companion.data.ChatMessage
import com.hermesandroid.companion.data.MessageRole
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun MessageBubble(
message: ChatMessage,
modifier: Modifier = Modifier
) {
val isUser = message.role == MessageRole.USER
val isSystem = message.role == MessageRole.SYSTEM
val bubbleShape = when {
isUser -> RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
else -> RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
}
val bubbleColor = when {
isUser -> MaterialTheme.colorScheme.primary
isSystem -> MaterialTheme.colorScheme.tertiaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
val textColor = when {
isUser -> MaterialTheme.colorScheme.onPrimary
isSystem -> MaterialTheme.colorScheme.onTertiaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
val alignment = when {
isUser -> Alignment.End
else -> Alignment.Start
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = alignment
) {
Surface(
shape = bubbleShape,
color = bubbleColor,
modifier = Modifier.widthIn(max = 300.dp)
) {
Column(
modifier = Modifier.padding(12.dp)
) {
if (isSystem) {
Text(
text = "System",
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = 0.7f)
)
}
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = textColor
)
}
}
// Timestamp
Row(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = formatTimestamp(message.timestamp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
if (message.isStreaming) {
Text(
text = "streaming...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
)
}
}
}
}
private fun formatTimestamp(timestamp: Long): String {
val formatter = SimpleDateFormat("HH:mm", Locale.getDefault())
return formatter.format(Date(timestamp))
}
@androidx.compose.ui.tooling.preview.Preview(showBackground = true)
@Composable
private fun MessageBubblePreview() {
com.hermesandroid.companion.ui.theme.HermesCompanionTheme {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(16.dp)) {
MessageBubble(
message = ChatMessage(
id = "1", role = MessageRole.USER,
content = "Tell me about the weather today",
timestamp = System.currentTimeMillis()
)
)
MessageBubble(
message = ChatMessage(
id = "2", role = MessageRole.ASSISTANT,
content = "I'd be happy to help! Let me check the current weather conditions for you.",
timestamp = System.currentTimeMillis(),
isStreaming = true
)
)
}
}
}
@@ -1,210 +0,0 @@
package com.hermesandroid.companion.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
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.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
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.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.companion.data.ToolCall
@Composable
fun ToolProgressCard(
toolCall: ToolCall,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
val statusIcon = when {
toolCall.isComplete && toolCall.success == true -> Icons.Filled.Check
toolCall.isComplete && toolCall.success == false -> Icons.Filled.Close
else -> Icons.Filled.HourglassTop
}
val statusColor = when {
toolCall.isComplete && toolCall.success == true -> MaterialTheme.colorScheme.primary
toolCall.isComplete && toolCall.success == false -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.tertiary
}
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(modifier = Modifier.padding(12.dp)) {
// Header row — always visible
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Filled.Build,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp)
)
Text(
text = toolCall.name,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = statusIcon,
contentDescription = when {
toolCall.isComplete && toolCall.success == true -> "Success"
toolCall.isComplete && toolCall.success == false -> "Failed"
else -> "Running"
},
tint = statusColor,
modifier = Modifier.size(16.dp)
)
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
// Progress indicator for running tools
if (!toolCall.isComplete) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
}
// Expandable details
AnimatedVisibility(
visible = expanded,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column(
modifier = Modifier.padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Arguments
if (!toolCall.args.isNullOrBlank()) {
HorizontalDivider()
Text(
text = "Arguments",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = toolCall.args,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (expanded) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis
)
}
// Result
if (!toolCall.result.isNullOrBlank()) {
HorizontalDivider()
Text(
text = "Result",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = toolCall.result,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = if (toolCall.success == true) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.error
},
maxLines = if (expanded) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
}
@androidx.compose.ui.tooling.preview.Preview(showBackground = true)
@Composable
private fun ToolProgressCardPreview() {
com.hermesandroid.companion.ui.theme.HermesCompanionTheme {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp)
) {
ToolProgressCard(
toolCall = ToolCall(
name = "android_read_screen",
args = """{"include_invisible": false}""",
result = null,
success = null,
isComplete = false
)
)
ToolProgressCard(
toolCall = ToolCall(
name = "android_tap_text",
args = """{"text": "Continue"}""",
result = "Tapped element at (540, 1200)",
success = true,
isComplete = true
)
)
}
}
}
@@ -1,258 +0,0 @@
package com.hermesandroid.companion.ui.onboarding
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.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.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Stars
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.companion.ui.theme.HermesCompanionTheme
import kotlinx.coroutines.launch
private const val PAGE_COUNT = 5
private const val LAST_PAGE = PAGE_COUNT - 1
@Composable
fun OnboardingScreen(
onComplete: (serverUrl: String) -> Unit,
onSkipToSettings: (serverUrl: String) -> Unit
) {
val pagerState = rememberPagerState(pageCount = { PAGE_COUNT })
val coroutineScope = rememberCoroutineScope()
var serverUrl by rememberSaveable { mutableStateOf("wss://localhost:8767") }
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Top bar with Skip and Skip to Settings
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
// Skip to Settings - visible on pages 1-4
AnimatedVisibility(
visible = pagerState.currentPage < LAST_PAGE,
enter = fadeIn(),
exit = fadeOut()
) {
TextButton(onClick = { onSkipToSettings(serverUrl) }) {
Text(
text = "Skip to Settings",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.width(8.dp))
// Skip button - always visible
TextButton(onClick = { onComplete(serverUrl) }) {
Text(
text = "Skip",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Pager content
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f)
) { page ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
when (page) {
0 -> WelcomePage()
1 -> ChatPage()
2 -> TerminalPage()
3 -> BridgePage()
4 -> ConnectPage(
serverUrl = serverUrl,
onServerUrlChange = { serverUrl = it },
onGetStarted = { onComplete(serverUrl) }
)
}
}
}
// Bottom section: page indicator + navigation button
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(bottom = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Page indicator
PageIndicator(
pageCount = PAGE_COUNT,
currentPage = pagerState.currentPage
)
Spacer(modifier = Modifier.height(24.dp))
// Next / Get Started button
if (pagerState.currentPage < LAST_PAGE) {
Button(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Next")
}
} else {
Button(
onClick = { onComplete(serverUrl) },
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Get Started")
}
}
}
}
}
}
@Composable
private fun WelcomePage() {
OnboardingPage(
icon = Icons.Filled.Stars,
title = "Hermes Companion",
description = "Your AI agent, in your pocket. Chat, control, and connect — all from your phone."
)
}
@Composable
private fun ChatPage() {
OnboardingPage(
icon = Icons.Filled.Chat,
title = "Talk to Your Agent",
description = "Stream conversations with any Hermes profile. Ask questions, run tasks, and collaborate in real time."
)
}
@Composable
private fun TerminalPage() {
OnboardingPage(
icon = Icons.Filled.Terminal,
title = "Remote Terminal",
description = "Secure shell access to your agent's host machine, right from your phone. Coming soon."
)
}
@Composable
private fun BridgePage() {
OnboardingPage(
icon = Icons.Filled.PhoneAndroid,
title = "Device Bridge",
description = "Let your agent interact with your phone — read notifications, tap buttons, and automate workflows. Coming soon."
)
}
@Composable
private fun ConnectPage(
serverUrl: String,
onServerUrlChange: (String) -> Unit,
onGetStarted: () -> Unit
) {
OnboardingPage(
icon = Icons.Filled.Link,
title = "Let's Connect",
description = "Enter your companion relay server URL. You'll pair with a 6-character code after connecting."
) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = serverUrl,
onValueChange = onServerUrlChange,
label = { Text("Server URL") },
placeholder = { Text("wss://your-server:8767") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "A 6-digit pairing code will appear on screen after your first connection to verify the link.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onGetStarted,
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Get Started")
}
}
}
@Preview(showSystemUi = true)
@Composable
private fun OnboardingScreenPreview() {
HermesCompanionTheme {
OnboardingScreen(
onComplete = { _ -> },
onSkipToSettings = { _ -> }
)
}
}
@Preview(showSystemUi = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun OnboardingScreenDarkPreview() {
HermesCompanionTheme(themePreference = "dark") {
OnboardingScreen(
onComplete = { _ -> },
onSkipToSettings = { _ -> }
)
}
}
@@ -1,45 +0,0 @@
package com.hermesandroid.companion.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun BridgeScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Bridge",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Coming Soon",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp)
)
Text(
text = "Agent-controlled phone interaction will be available in Phase 3. " +
"This wraps the existing bridge protocol, allowing Hermes to tap, type, " +
"take screenshots, and control your device through the AccessibilityService.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp)
)
}
}
@@ -1,265 +0,0 @@
package com.hermesandroid.companion.ui.screens
import androidx.compose.animation.AnimatedVisibility
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermesandroid.companion.network.ConnectionState
import com.hermesandroid.companion.ui.components.MessageBubble
import com.hermesandroid.companion.ui.components.ToolProgressCard
import com.hermesandroid.companion.viewmodel.ChatViewModel
import com.hermesandroid.companion.viewmodel.ConnectionViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
chatViewModel: ChatViewModel,
connectionViewModel: ConnectionViewModel
) {
val messages by chatViewModel.messages.collectAsState()
val isStreaming by chatViewModel.isStreaming.collectAsState()
val currentProfile by chatViewModel.currentProfile.collectAsState()
val profiles by chatViewModel.profiles.collectAsState()
val connectionState by connectionViewModel.connectionState.collectAsState()
val error by chatViewModel.error.collectAsState()
var inputText by remember { mutableStateOf("") }
var profileMenuExpanded by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
// Auto-scroll to bottom when new messages arrive
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
) {
// Top bar with profile selector
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Hermes Chat")
// Connection indicator
Icon(
imageVector = Icons.Filled.Circle,
contentDescription = connectionState.name,
tint = when (connectionState) {
ConnectionState.Connected -> MaterialTheme.colorScheme.primary
ConnectionState.Connecting,
ConnectionState.Reconnecting -> MaterialTheme.colorScheme.tertiary
ConnectionState.Disconnected -> MaterialTheme.colorScheme.error
},
modifier = Modifier
.padding(start = 8.dp)
.size(8.dp)
)
}
},
actions = {
// Profile selector
Box {
TextButton(onClick = { profileMenuExpanded = true }) {
Text(currentProfile)
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = "Select profile"
)
}
DropdownMenu(
expanded = profileMenuExpanded,
onDismissRequest = { profileMenuExpanded = false }
) {
profiles.forEach { profile ->
DropdownMenuItem(
text = { Text(profile) },
onClick = {
chatViewModel.selectProfile(profile)
profileMenuExpanded = false
}
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
// Error banner
AnimatedVisibility(visible = error != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = error ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
)
TextButton(onClick = { chatViewModel.clearError() }) {
Text("Dismiss")
}
}
}
// Message list or empty state
if (messages.isEmpty() && !isStreaming) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Filled.Chat,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No messages yet",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Send a message to start chatting with your agent",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary
)
if (connectionState != ConnectionState.Connected) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Connect to your server first",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item { Spacer(modifier = Modifier.height(8.dp)) }
items(messages, key = { it.id }) { message ->
MessageBubble(message = message)
// Show tool progress cards for messages with tool calls
message.toolCalls.forEach { toolCall ->
Spacer(modifier = Modifier.height(4.dp))
ToolProgressCard(toolCall = toolCall)
}
}
// Streaming indicator
if (isStreaming) {
item {
Text(
text = "...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp, top = 4.dp)
)
}
}
item { Spacer(modifier = Modifier.height(8.dp)) }
}
}
// Input bar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom
) {
OutlinedTextField(
value = inputText,
onValueChange = { inputText = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Message...") },
maxLines = 4,
enabled = connectionState == ConnectionState.Connected
)
IconButton(
onClick = {
if (inputText.isNotBlank()) {
chatViewModel.sendMessage(inputText)
inputText = ""
}
},
enabled = inputText.isNotBlank() &&
connectionState == ConnectionState.Connected &&
!isStreaming
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send message",
tint = if (inputText.isNotBlank() && connectionState == ConnectionState.Connected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
}
}
@@ -1,612 +0,0 @@
package com.hermesandroid.companion.ui.screens
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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Refresh
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Switch
import com.hermesandroid.companion.auth.AuthState
import com.hermesandroid.companion.network.ConnectionState
import com.hermesandroid.companion.viewmodel.ConnectionViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
connectionViewModel: ConnectionViewModel
) {
val connectionState by connectionViewModel.connectionState.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val serverUrl by connectionViewModel.serverUrl.collectAsState()
val pairingCode by connectionViewModel.pairingCode.collectAsState()
val theme by connectionViewModel.theme.collectAsState()
val insecureMode by connectionViewModel.insecureMode.collectAsState()
val isInsecureConnection by connectionViewModel.isInsecureConnection.collectAsState()
var urlInput by remember(serverUrl) { mutableStateOf(serverUrl) }
val clipboardManager = LocalClipboardManager.current
Column(
modifier = Modifier.fillMaxSize()
) {
TopAppBar(
title = { Text("Settings") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Connection section
Text(
text = "Connection",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Server URL
OutlinedTextField(
value = urlInput,
onValueChange = { urlInput = it },
label = { Text("Server URL") },
placeholder = { Text("wss://your-server:8767") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Connect/Disconnect buttons
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val canConnect = connectionState == ConnectionState.Disconnected &&
(urlInput.startsWith("wss://") ||
(insecureMode && urlInput.startsWith("ws://")))
Button(
onClick = {
connectionViewModel.connect(urlInput)
},
enabled = canConnect
) {
Text("Connect")
}
OutlinedButton(
onClick = { connectionViewModel.disconnect() },
enabled = connectionState != ConnectionState.Disconnected
) {
Text("Disconnect")
}
}
HorizontalDivider()
// Connection status
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Filled.Circle,
contentDescription = null,
tint = when (connectionState) {
ConnectionState.Connected -> MaterialTheme.colorScheme.primary
ConnectionState.Connecting,
ConnectionState.Reconnecting -> MaterialTheme.colorScheme.tertiary
ConnectionState.Disconnected -> MaterialTheme.colorScheme.error
},
modifier = Modifier.size(12.dp)
)
Text(
text = when (connectionState) {
ConnectionState.Connected -> "Connected"
ConnectionState.Connecting -> "Connecting..."
ConnectionState.Reconnecting -> "Reconnecting..."
ConnectionState.Disconnected -> "Disconnected"
},
style = MaterialTheme.typography.bodyMedium
)
}
// Insecure connection warning
if (isInsecureConnection && connectionState == ConnectionState.Connected) {
HorizontalDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
Text(
text = "Insecure connection — traffic is not encrypted",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
HorizontalDivider()
// Insecure mode toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Allow insecure connections",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Enable ws:// for local dev/testing only",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = insecureMode,
onCheckedChange = { connectionViewModel.setInsecureMode(it) }
)
}
}
}
// Pairing section
Text(
text = "Pairing",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Pairing code display
Text(
text = "Pairing Code",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = pairingCode,
style = MaterialTheme.typography.headlineMedium.copy(
fontFamily = FontFamily.Monospace,
letterSpacing = MaterialTheme.typography.headlineMedium.fontSize * 0.15
),
color = MaterialTheme.colorScheme.onSurface
)
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(pairingCode))
}) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy pairing code"
)
}
IconButton(onClick = {
connectionViewModel.regeneratePairingCode()
}) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Generate new code"
)
}
}
HorizontalDivider()
// Session token status
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Session:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = when (authState) {
is AuthState.Paired -> "Paired"
is AuthState.Pairing -> "Pairing..."
is AuthState.Unpaired -> "Unpaired"
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
},
style = MaterialTheme.typography.bodyMedium,
color = when (authState) {
is AuthState.Paired -> MaterialTheme.colorScheme.primary
is AuthState.Failed -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
if (authState is AuthState.Paired) {
OutlinedButton(onClick = { connectionViewModel.clearSession() }) {
Text("Clear Session")
}
}
}
}
// Theme section
Text(
text = "Appearance",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Theme",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val themeOptions = listOf("auto", "light", "dark")
val themeLabels = listOf("Auto", "Light", "Dark")
val selectedIndex = themeOptions.indexOf(theme).coerceAtLeast(0)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
themeOptions.forEachIndexed { index, option ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = themeOptions.size
),
onClick = { connectionViewModel.setTheme(option) },
selected = index == selectedIndex
) {
Text(themeLabels[index])
}
}
}
}
}
// Data Management section
Text(
text = "Data",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
DataManagementSection(connectionViewModel = connectionViewModel)
// About section
Text(
text = "About",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "App Version",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "0.1.0",
style = MaterialTheme.typography.bodyMedium
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Build",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "1 (MVP)",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@Composable
private fun DataManagementSection(connectionViewModel: ConnectionViewModel) {
val context = LocalContext.current
var showResetDialog by remember { mutableStateOf(false) }
var backupJson by remember { mutableStateOf<String?>(null) }
// SAF file picker for export
val exportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json")
) { uri ->
if (uri != null && backupJson != null) {
connectionViewModel.writeBackupToUri(uri, backupJson!!) { success ->
Toast.makeText(
context,
if (success) "Settings exported" else "Export failed",
Toast.LENGTH_SHORT
).show()
backupJson = null
}
}
}
// SAF file picker for import
val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null) {
connectionViewModel.importFromUri(uri) { success ->
Toast.makeText(
context,
if (success) "Settings imported" else "Import failed — invalid file",
Toast.LENGTH_SHORT
).show()
}
}
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Reset Onboarding
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Reset Onboarding",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Show the setup guide again on next launch",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = {
connectionViewModel.resetOnboarding()
Toast.makeText(context, "Onboarding will show on next launch", Toast.LENGTH_SHORT).show()
}) {
Icon(
imageVector = Icons.Filled.RestartAlt,
contentDescription = "Reset onboarding"
)
}
}
HorizontalDivider()
// Export Settings
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Export Settings",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Save settings to a file (no tokens)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = {
connectionViewModel.exportSettings { json ->
backupJson = json
exportLauncher.launch("hermes-companion-backup.json")
}
}) {
Icon(
imageVector = Icons.Filled.FileDownload,
contentDescription = "Export settings"
)
}
}
// Import Settings
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Import Settings",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Restore settings from a backup file",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = {
importLauncher.launch(arrayOf("application/json"))
}) {
Icon(
imageVector = Icons.Filled.FileUpload,
contentDescription = "Import settings"
)
}
}
HorizontalDivider()
// Reset All Data
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Reset All Data",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
Text(
text = "Clear all settings, tokens, and cached data",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { showResetDialog = true }) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Reset all data",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
// Confirmation dialog for data reset
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("Reset All Data?") },
text = { Text("This will clear all settings, authentication tokens, and cached data. You'll need to re-pair with your server. This cannot be undone.") },
confirmButton = {
TextButton(
onClick = {
showResetDialog = false
connectionViewModel.resetAppData()
Toast.makeText(context, "App data reset", Toast.LENGTH_SHORT).show()
}
) {
Text("Reset", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) {
Text("Cancel")
}
}
)
}
}
@@ -1,45 +0,0 @@
package com.hermesandroid.companion.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun TerminalScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Terminal",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Coming Soon",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp)
)
Text(
text = "Secure remote shell access via tmux will be available in Phase 2. " +
"This will provide a full terminal emulator with xterm.js rendering, " +
"session persistence, and biometric authentication.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp)
)
}
}
@@ -1,87 +0,0 @@
package com.hermesandroid.companion.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val HermesPurple = Color(0xFF7C4DFF)
private val HermesPurpleLight = Color(0xFFB388FF)
private val HermesPurpleDark = Color(0xFF651FFF)
private val DarkColorScheme = darkColorScheme(
primary = HermesPurpleLight,
onPrimary = Color(0xFF1A0049),
primaryContainer = HermesPurpleDark,
onPrimaryContainer = Color(0xFFEADDFF),
secondary = Color(0xFFCCC2DC),
onSecondary = Color(0xFF332D41),
secondaryContainer = Color(0xFF4A4458),
onSecondaryContainer = Color(0xFFE8DEF8),
tertiary = Color(0xFFEFB8C8),
onTertiary = Color(0xFF492532),
tertiaryContainer = Color(0xFF633B48),
onTertiaryContainer = Color(0xFFFFD8E4),
background = Color(0xFF1C1B1F),
onBackground = Color(0xFFE6E1E5),
surface = Color(0xFF1C1B1F),
onSurface = Color(0xFFE6E1E5),
surfaceVariant = Color(0xFF49454F),
onSurfaceVariant = Color(0xFFCAC4D0)
)
private val LightColorScheme = lightColorScheme(
primary = HermesPurple,
onPrimary = Color.White,
primaryContainer = Color(0xFFEADDFF),
onPrimaryContainer = Color(0xFF21005D),
secondary = Color(0xFF625B71),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE8DEF8),
onSecondaryContainer = Color(0xFF1D192B),
tertiary = Color(0xFF7D5260),
onTertiary = Color.White,
tertiaryContainer = Color(0xFFFFD8E4),
onTertiaryContainer = Color(0xFF31111D),
background = Color(0xFFFFFBFE),
onBackground = Color(0xFF1C1B1F),
surface = Color(0xFFFFFBFE),
onSurface = Color(0xFF1C1B1F),
surfaceVariant = Color(0xFFE7E0EC),
onSurfaceVariant = Color(0xFF49454F)
)
@Composable
fun HermesCompanionTheme(
themePreference: String = "auto",
content: @Composable () -> Unit
) {
val useDarkTheme = when (themePreference) {
"dark" -> true
"light" -> false
else -> isSystemInDarkTheme()
}
val colorScheme = when {
// Dynamic colors available on Android 12+ (API 31)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (useDarkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
useDarkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -1,109 +0,0 @@
package com.hermesandroid.companion.viewmodel
import androidx.lifecycle.ViewModel
import com.hermesandroid.companion.data.ChatMessage
import com.hermesandroid.companion.data.ChatSession
import com.hermesandroid.companion.data.MessageRole
import com.hermesandroid.companion.network.ChannelMultiplexer
import com.hermesandroid.companion.network.handlers.ChatHandler
import com.hermesandroid.companion.network.models.ChatSendPayload
import com.hermesandroid.companion.network.models.Envelope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonObject
import java.util.UUID
class ChatViewModel : ViewModel() {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private var multiplexer: ChannelMultiplexer? = null
private var chatHandler: ChatHandler? = null
private val _currentProfile = MutableStateFlow("default")
val currentProfile: StateFlow<String> = _currentProfile.asStateFlow()
private val _profiles = MutableStateFlow<List<String>>(listOf("default"))
val profiles: StateFlow<List<String>> = _profiles.asStateFlow()
private val _currentSessionId = MutableStateFlow<String?>(null)
val currentSessionId: StateFlow<String?> = _currentSessionId.asStateFlow()
// Delegated to ChatHandler
val messages: StateFlow<List<ChatMessage>>
get() = chatHandler?.messages ?: MutableStateFlow(emptyList())
val isStreaming: StateFlow<Boolean>
get() = chatHandler?.isStreaming ?: MutableStateFlow(false)
val sessions: StateFlow<List<ChatSession>>
get() = chatHandler?.sessions ?: MutableStateFlow(emptyList())
val error: StateFlow<String?>
get() = chatHandler?.error ?: MutableStateFlow(null)
/**
* Wire up the chat handler and multiplexer.
* Called once from the UI layer after dependencies are ready.
*/
fun initialize(multiplexer: ChannelMultiplexer, chatHandler: ChatHandler) {
this.multiplexer = multiplexer
this.chatHandler = chatHandler
}
fun updateProfiles(profileList: List<String>) {
_profiles.value = profileList
if (profileList.isNotEmpty() && _currentProfile.value !in profileList) {
_currentProfile.value = profileList.first()
}
}
fun selectProfile(name: String) {
_currentProfile.value = name
}
fun sendMessage(text: String) {
if (text.isBlank()) return
val messageId = UUID.randomUUID().toString()
// Add user message locally
val userMessage = ChatMessage(
id = messageId,
role = MessageRole.USER,
content = text.trim(),
timestamp = System.currentTimeMillis()
)
chatHandler?.addUserMessage(userMessage)
// Build and send envelope
val payload = ChatSendPayload(
profile = _currentProfile.value,
sessionId = _currentSessionId.value,
message = text.trim()
)
val envelope = Envelope(
channel = "chat",
type = "chat.send",
id = messageId,
payload = json.encodeToJsonElement(payload).jsonObject
)
multiplexer?.send(envelope)
}
fun setSessionId(sessionId: String) {
_currentSessionId.value = sessionId
}
fun clearError() {
chatHandler?.clearError()
}
}
@@ -1,218 +0,0 @@
package com.hermesandroid.companion.viewmodel
import android.app.Application
import android.net.Uri
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.hermesandroid.companion.auth.AuthManager
import com.hermesandroid.companion.auth.AuthState
import com.hermesandroid.companion.data.DataManager
import com.hermesandroid.companion.data.companionDataStore
import com.hermesandroid.companion.network.ChannelMultiplexer
import com.hermesandroid.companion.network.ConnectionManager
import com.hermesandroid.companion.network.ConnectionState
import com.hermesandroid.companion.network.handlers.ChatHandler
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ConnectionViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private val KEY_SERVER_URL = stringPreferencesKey("server_url")
private val KEY_THEME = stringPreferencesKey("theme")
private val KEY_INSECURE_MODE = booleanPreferencesKey("insecure_mode")
private const val DEFAULT_URL = "wss://localhost:8767"
}
// Core networking components
val multiplexer = ChannelMultiplexer()
val chatHandler = ChatHandler()
private val connectionManager = ConnectionManager(multiplexer)
val authManager = AuthManager(application, multiplexer, viewModelScope)
// Data management
val dataManager = DataManager(application)
// Connection state
val connectionState: StateFlow<ConnectionState> = connectionManager.connectionState
val authState: StateFlow<AuthState> = authManager.authState
val insecureMode: StateFlow<Boolean> = connectionManager.insecureMode
val isInsecureConnection: StateFlow<Boolean> = connectionManager.isInsecureConnection
// Server URL
private val _serverUrl = MutableStateFlow(DEFAULT_URL)
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
// Theme preference
val theme: StateFlow<String> = application.companionDataStore.data
.map { preferences ->
preferences[KEY_THEME] ?: "auto"
}
.stateIn(viewModelScope, SharingStarted.Eagerly, "auto")
// Onboarding state
private val _onboardingCompleted = MutableStateFlow(true) // default true to avoid flash
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted.asStateFlow()
// Pairing code from AuthManager
val pairingCode: StateFlow<String> = authManager.pairingCode
init {
// Register chat handler
multiplexer.registerHandler("chat", chatHandler)
// Wire multiplexer to connection manager
multiplexer.setSendCallback { envelope ->
connectionManager.send(envelope)
}
// Auto-authenticate on connect
multiplexer.setOnConnectedCallback {
authManager.authenticate()
}
// Load saved state
viewModelScope.launch {
// Load onboarding state
_onboardingCompleted.value = dataManager.isOnboardingCompleted()
// Load saved preferences
application.companionDataStore.data.collect { preferences ->
// Restore insecure mode
val insecure = preferences[KEY_INSECURE_MODE] ?: false
connectionManager.setInsecureMode(insecure)
val savedUrl = preferences[KEY_SERVER_URL]
if (savedUrl != null) {
_serverUrl.value = savedUrl
}
}
}
}
fun connect(url: String) {
_serverUrl.value = url
viewModelScope.launch {
getApplication<Application>().companionDataStore.edit { preferences ->
preferences[KEY_SERVER_URL] = url
}
}
connectionManager.connect(url)
}
fun connect() {
connectionManager.connect(_serverUrl.value)
}
fun disconnect() {
connectionManager.disconnect()
}
fun updateServerUrl(url: String) {
_serverUrl.value = url
viewModelScope.launch {
getApplication<Application>().companionDataStore.edit { preferences ->
preferences[KEY_SERVER_URL] = url
}
}
}
fun setTheme(theme: String) {
viewModelScope.launch {
getApplication<Application>().companionDataStore.edit { preferences ->
preferences[KEY_THEME] = theme
}
}
}
fun setInsecureMode(enabled: Boolean) {
connectionManager.setInsecureMode(enabled)
viewModelScope.launch {
getApplication<Application>().companionDataStore.edit { preferences ->
preferences[KEY_INSECURE_MODE] = enabled
}
}
}
fun completeOnboarding() {
_onboardingCompleted.value = true
viewModelScope.launch {
dataManager.setOnboardingCompleted(true)
}
}
fun resetOnboarding() {
viewModelScope.launch {
dataManager.resetOnboarding()
_onboardingCompleted.value = false
}
}
fun resetAppData() {
viewModelScope.launch {
disconnect()
dataManager.resetAppData()
_serverUrl.value = DEFAULT_URL
}
}
fun exportSettings(onResult: (String) -> Unit) {
viewModelScope.launch {
val json = dataManager.exportSettings(
serverUrl = _serverUrl.value,
theme = theme.value,
onboardingCompleted = _onboardingCompleted.value,
profiles = authManager.profiles.value
)
onResult(json)
}
}
fun writeBackupToUri(uri: Uri, backup: String, onResult: (Boolean) -> Unit) {
viewModelScope.launch {
val success = dataManager.writeBackupToUri(uri, backup)
onResult(success)
}
}
fun importFromUri(uri: Uri, onResult: (Boolean) -> Unit) {
viewModelScope.launch {
val jsonString = dataManager.readBackupFromUri(uri) ?: run {
onResult(false)
return@launch
}
val backup = dataManager.importSettings(jsonString) ?: run {
onResult(false)
return@launch
}
// Apply imported settings
backup.serverUrl?.let { updateServerUrl(it) }
setTheme(backup.theme)
if (backup.onboardingCompleted) {
dataManager.setOnboardingCompleted(true)
_onboardingCompleted.value = true
}
onResult(true)
}
}
fun regeneratePairingCode() {
authManager.regeneratePairingCode()
}
fun clearSession() {
authManager.clearSession()
}
override fun onCleared() {
super.onCleared()
connectionManager.disconnect()
}
}
@@ -0,0 +1,18 @@
package com.hermesandroid.relay
import android.app.Application
import com.hermesandroid.relay.data.AppAnalytics
class HermesRelayApp : Application() {
override fun onCreate() {
super.onCreate()
instance = this
AppAnalytics.initialize(this)
}
companion object {
lateinit var instance: HermesRelayApp
private set
}
}
@@ -0,0 +1,48 @@
package com.hermesandroid.relay
import android.animation.ObjectAnimator
import android.os.Bundle
import android.view.View
import android.view.animation.DecelerateInterpolator
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.animation.doOnEnd
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.hermesandroid.relay.ui.RelayApp
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
class MainActivity : ComponentActivity() {
private val connectionViewModel: ConnectionViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
// Hold splash until DataStore is loaded and onboarding status is known
splashScreen.setKeepOnScreenCondition {
!connectionViewModel.isReady.value
}
// Smooth exit: fade out the splash screen
splashScreen.setOnExitAnimationListener { splashScreenView ->
val fadeOut = ObjectAnimator.ofFloat(
splashScreenView.view,
View.ALPHA,
1f, 0f
).apply {
duration = 400
interpolator = DecelerateInterpolator()
doOnEnd { splashScreenView.remove() }
}
fadeOut.start()
}
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
RelayApp()
}
}
}
@@ -1,17 +1,19 @@
package com.hermesandroid.companion.auth
package com.hermesandroid.relay.auth
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.hermesandroid.companion.network.ChannelMultiplexer
import com.hermesandroid.companion.network.models.Envelope
import com.hermesandroid.relay.network.ChannelMultiplexer
import com.hermesandroid.relay.network.models.Envelope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
@@ -37,27 +39,33 @@ class AuthManager(
private const val PREFS_NAME = "hermes_companion_auth"
private const val KEY_SESSION_TOKEN = "session_token"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_API_KEY = "api_server_key"
private const val PAIRING_CODE_LENGTH = 6
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
}
private val json = Json { ignoreUnknownKeys = true }
// Lazy-init crypto on first access (off main thread)
// Thread-safe lazy-init crypto on first access (off main thread)
private var _prefs: SharedPreferences? = null
private val prefsMutex = Mutex()
private suspend fun prefs(): SharedPreferences {
_prefs?.let { return it }
return withContext(Dispatchers.IO) {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
).also { _prefs = it }
return prefsMutex.withLock {
// Double-check inside lock
_prefs?.let { return it }
withContext(Dispatchers.IO) {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
).also { _prefs = it }
}
}
}
@@ -146,6 +154,25 @@ class AuthManager(
}
}
// --- API Key storage (for direct Hermes API Server auth) ---
suspend fun getApiKey(): String? {
return prefs().getString(KEY_API_KEY, null)
}
suspend fun setApiKey(key: String) {
val trimmed = key.trim()
if (trimmed.isBlank()) {
prefs().edit().remove(KEY_API_KEY).apply()
} else {
prefs().edit().putString(KEY_API_KEY, trimmed).apply()
}
}
suspend fun clearApiKey() {
prefs().edit().remove(KEY_API_KEY).apply()
}
val isPaired: Boolean
get() = _authState.value is AuthState.Paired
@@ -0,0 +1,369 @@
package com.hermesandroid.relay.data
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* Lightweight in-app analytics tracker.
*
* Tracks response times, token usage, session stats, connection health,
* and stream outcomes. Lifetime stats are persisted to DataStore; session
* stats reset when the app process restarts.
*
* All public methods are thread-safe (StateFlow + atomic updates).
* Call [initialize] once from Application.onCreate().
*/
object AppAnalytics {
private const val TAG = "AppAnalytics"
private const val MAX_RECENT_TIMES = 20
// DataStore keys for lifetime stats
private val KEY_TOTAL_MESSAGES_SENT = intPreferencesKey("analytics_total_messages_sent")
private val KEY_TOTAL_TOKENS_IN = longPreferencesKey("analytics_total_tokens_in")
private val KEY_TOTAL_TOKENS_OUT = longPreferencesKey("analytics_total_tokens_out")
private val KEY_TOTAL_RESPONSE_TIME_MS = longPreferencesKey("analytics_total_response_time_ms")
private val KEY_TOTAL_COMPLETION_TIME_MS = longPreferencesKey("analytics_total_completion_time_ms")
private val KEY_RESPONSE_TIME_COUNT = intPreferencesKey("analytics_response_time_count")
private val KEY_COMPLETION_TIME_COUNT = intPreferencesKey("analytics_completion_time_count")
private val KEY_STREAMS_COMPLETED = intPreferencesKey("analytics_streams_completed")
private val KEY_STREAMS_ERRORED = intPreferencesKey("analytics_streams_errored")
private val KEY_STREAMS_CANCELLED = intPreferencesKey("analytics_streams_cancelled")
private val KEY_HEALTH_CHECKS_TOTAL = intPreferencesKey("analytics_health_checks_total")
private val KEY_HEALTH_CHECKS_SUCCESS = intPreferencesKey("analytics_health_checks_success")
private val KEY_TOTAL_HEALTH_LATENCY_MS = longPreferencesKey("analytics_total_health_latency_ms")
private val KEY_SESSION_COUNT = intPreferencesKey("analytics_session_count")
private val KEY_RECENT_RESPONSE_TIMES = stringPreferencesKey("analytics_recent_response_times")
private val KEY_RECENT_COMPLETION_TIMES = stringPreferencesKey("analytics_recent_completion_times")
private val _stats = MutableStateFlow(AppStats())
val stats: StateFlow<AppStats> = _stats.asStateFlow()
private var context: Context? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
// Timing state for current in-flight message
private var messageSentAtMs: Long = 0L
private var firstTokenReceivedAtMs: Long = 0L
private var firstTokenReceived: Boolean = false
/**
* Initialize with application context. Loads persisted lifetime stats.
* Must be called once from Application.onCreate().
*/
fun initialize(appContext: Context) {
context = appContext.applicationContext
scope.launch { loadFromDataStore() }
}
// --- Event hooks (call from ChatViewModel / HermesApiClient) ---
/** Called when a user sends a message. Starts the response timer. */
fun onMessageSent() {
messageSentAtMs = System.currentTimeMillis()
firstTokenReceived = false
firstTokenReceivedAtMs = 0L
_stats.value = _stats.value.let { s ->
s.copy(
totalMessagesSent = s.totalMessagesSent + 1,
currentSessionMessages = s.currentSessionMessages + 1
)
}
persistAsync()
}
/** Called when the first text delta arrives. Calculates TTFT. */
fun onFirstTokenReceived() {
if (firstTokenReceived || messageSentAtMs == 0L) return
firstTokenReceived = true
firstTokenReceivedAtMs = System.currentTimeMillis()
val ttft = firstTokenReceivedAtMs - messageSentAtMs
_stats.value = _stats.value.let { s ->
val newRecent = (s.recentResponseTimesMs + ttft).takeLast(MAX_RECENT_TIMES)
val newCount = s.responseTimeCount + 1
val newTotal = s.totalResponseTimeMs + ttft
s.copy(
avgResponseTimeMs = if (newCount > 0) newTotal / newCount else 0L,
recentResponseTimesMs = newRecent,
responseTimeCount = newCount,
totalResponseTimeMs = newTotal
)
}
persistAsync()
}
/**
* Called when streaming completes successfully.
* [inputTokens] and [outputTokens] may be null if the server doesn't report them.
*/
fun onStreamComplete(inputTokens: Int?, outputTokens: Int?) {
val now = System.currentTimeMillis()
val completionTime = if (messageSentAtMs > 0L) now - messageSentAtMs else 0L
_stats.value = _stats.value.let { s ->
val tokIn = inputTokens?.toLong() ?: 0L
val tokOut = outputTokens?.toLong() ?: 0L
val newRecentCompletion = if (completionTime > 0L) {
(s.recentCompletionTimesMs + completionTime).takeLast(MAX_RECENT_TIMES)
} else {
s.recentCompletionTimesMs
}
val newCompletionCount = if (completionTime > 0L) s.completionTimeCount + 1 else s.completionTimeCount
val newCompletionTotal = s.totalCompletionTimeMs + completionTime
val newStreamsCompleted = s.streamsCompleted + 1
val totalStreams = newStreamsCompleted + s.streamsErrored + s.streamsCancelled
s.copy(
totalTokensIn = s.totalTokensIn + tokIn,
totalTokensOut = s.totalTokensOut + tokOut,
currentSessionTokensIn = s.currentSessionTokensIn + tokIn,
currentSessionTokensOut = s.currentSessionTokensOut + tokOut,
avgCompletionTimeMs = if (newCompletionCount > 0) newCompletionTotal / newCompletionCount else 0L,
recentCompletionTimesMs = newRecentCompletion,
completionTimeCount = newCompletionCount,
totalCompletionTimeMs = newCompletionTotal,
streamsCompleted = newStreamsCompleted,
streamSuccessRate = if (totalStreams > 0) newStreamsCompleted.toFloat() / totalStreams else 1f
)
}
// Reset timing state
messageSentAtMs = 0L
firstTokenReceived = false
persistAsync()
}
/** Called when streaming encounters an error. */
fun onStreamError() {
_stats.value = _stats.value.let { s ->
val newErrored = s.streamsErrored + 1
val totalStreams = s.streamsCompleted + newErrored + s.streamsCancelled
s.copy(
streamsErrored = newErrored,
streamSuccessRate = if (totalStreams > 0) s.streamsCompleted.toFloat() / totalStreams else 0f
)
}
messageSentAtMs = 0L
firstTokenReceived = false
persistAsync()
}
/** Called when a stream is intentionally cancelled by the user. */
fun onStreamCancelled() {
_stats.value = _stats.value.let { s ->
val newCancelled = s.streamsCancelled + 1
val totalStreams = s.streamsCompleted + s.streamsErrored + newCancelled
s.copy(
streamsCancelled = newCancelled,
streamSuccessRate = if (totalStreams > 0) s.streamsCompleted.toFloat() / totalStreams else 1f
)
}
messageSentAtMs = 0L
firstTokenReceived = false
persistAsync()
}
/** Called after a health check completes. */
fun onHealthCheck(success: Boolean, latencyMs: Long) {
_stats.value = _stats.value.let { s ->
val newTotal = s.healthChecksTotal + 1
val newSuccess = s.healthChecksSuccess + if (success) 1 else 0
val newLatencyTotal = s.totalHealthLatencyMs + latencyMs
s.copy(
healthChecksTotal = newTotal,
healthChecksSuccess = newSuccess,
healthCheckSuccessRate = if (newTotal > 0) newSuccess.toFloat() / newTotal else 0f,
avgHealthLatencyMs = if (newTotal > 0) newLatencyTotal / newTotal else 0L,
totalHealthLatencyMs = newLatencyTotal
)
}
persistAsync()
}
/** Called when a new session is created. */
fun onSessionCreated() {
_stats.value = _stats.value.let { s ->
s.copy(
sessionCount = s.sessionCount + 1,
currentSessionMessages = 0,
currentSessionTokensIn = 0L,
currentSessionTokensOut = 0L
)
}
persistAsync()
}
/** Reset current-session counters (e.g., when switching sessions). */
fun onSessionSwitched() {
_stats.value = _stats.value.copy(
currentSessionMessages = 0,
currentSessionTokensIn = 0L,
currentSessionTokensOut = 0L
)
}
/** Reset all analytics data (called from data reset). */
fun resetAll() {
_stats.value = AppStats()
messageSentAtMs = 0L
firstTokenReceived = false
firstTokenReceivedAtMs = 0L
persistAsync()
}
// --- Persistence ---
private fun persistAsync() {
val ctx = context ?: return
scope.launch(Dispatchers.IO) {
try {
val s = _stats.value
ctx.relayDataStore.edit { prefs ->
prefs[KEY_TOTAL_MESSAGES_SENT] = s.totalMessagesSent
prefs[KEY_TOTAL_TOKENS_IN] = s.totalTokensIn
prefs[KEY_TOTAL_TOKENS_OUT] = s.totalTokensOut
prefs[KEY_TOTAL_RESPONSE_TIME_MS] = s.totalResponseTimeMs
prefs[KEY_TOTAL_COMPLETION_TIME_MS] = s.totalCompletionTimeMs
prefs[KEY_RESPONSE_TIME_COUNT] = s.responseTimeCount
prefs[KEY_COMPLETION_TIME_COUNT] = s.completionTimeCount
prefs[KEY_STREAMS_COMPLETED] = s.streamsCompleted
prefs[KEY_STREAMS_ERRORED] = s.streamsErrored
prefs[KEY_STREAMS_CANCELLED] = s.streamsCancelled
prefs[KEY_HEALTH_CHECKS_TOTAL] = s.healthChecksTotal
prefs[KEY_HEALTH_CHECKS_SUCCESS] = s.healthChecksSuccess
prefs[KEY_TOTAL_HEALTH_LATENCY_MS] = s.totalHealthLatencyMs
prefs[KEY_SESSION_COUNT] = s.sessionCount
prefs[KEY_RECENT_RESPONSE_TIMES] = s.recentResponseTimesMs.joinToString(",")
prefs[KEY_RECENT_COMPLETION_TIMES] = s.recentCompletionTimesMs.joinToString(",")
}
} catch (e: Exception) {
Log.w(TAG, "Failed to persist analytics: ${e.message}")
}
}
}
private suspend fun loadFromDataStore() {
val ctx = context ?: return
try {
val prefs = ctx.relayDataStore.data.first()
val recentResponse = prefs[KEY_RECENT_RESPONSE_TIMES]
?.split(",")
?.mapNotNull { it.toLongOrNull() }
?.takeLast(MAX_RECENT_TIMES)
?: emptyList()
val recentCompletion = prefs[KEY_RECENT_COMPLETION_TIMES]
?.split(",")
?.mapNotNull { it.toLongOrNull() }
?.takeLast(MAX_RECENT_TIMES)
?: emptyList()
val totalMessages = prefs[KEY_TOTAL_MESSAGES_SENT] ?: 0
val totalTokensIn = prefs[KEY_TOTAL_TOKENS_IN] ?: 0L
val totalTokensOut = prefs[KEY_TOTAL_TOKENS_OUT] ?: 0L
val totalResponseTime = prefs[KEY_TOTAL_RESPONSE_TIME_MS] ?: 0L
val totalCompletionTime = prefs[KEY_TOTAL_COMPLETION_TIME_MS] ?: 0L
val responseTimeCount = prefs[KEY_RESPONSE_TIME_COUNT] ?: 0
val completionTimeCount = prefs[KEY_COMPLETION_TIME_COUNT] ?: 0
val streamsCompleted = prefs[KEY_STREAMS_COMPLETED] ?: 0
val streamsErrored = prefs[KEY_STREAMS_ERRORED] ?: 0
val streamsCancelled = prefs[KEY_STREAMS_CANCELLED] ?: 0
val healthTotal = prefs[KEY_HEALTH_CHECKS_TOTAL] ?: 0
val healthSuccess = prefs[KEY_HEALTH_CHECKS_SUCCESS] ?: 0
val totalHealthLatency = prefs[KEY_TOTAL_HEALTH_LATENCY_MS] ?: 0L
val sessionCount = prefs[KEY_SESSION_COUNT] ?: 0
val totalStreams = streamsCompleted + streamsErrored + streamsCancelled
_stats.value = AppStats(
totalMessagesSent = totalMessages,
totalTokensIn = totalTokensIn,
totalTokensOut = totalTokensOut,
avgResponseTimeMs = if (responseTimeCount > 0) totalResponseTime / responseTimeCount else 0L,
avgCompletionTimeMs = if (completionTimeCount > 0) totalCompletionTime / completionTimeCount else 0L,
streamSuccessRate = if (totalStreams > 0) streamsCompleted.toFloat() / totalStreams else 1f,
healthCheckSuccessRate = if (healthTotal > 0) healthSuccess.toFloat() / healthTotal else 0f,
avgHealthLatencyMs = if (healthTotal > 0) totalHealthLatency / healthTotal else 0L,
sessionCount = sessionCount,
currentSessionMessages = 0,
currentSessionTokensIn = 0L,
currentSessionTokensOut = 0L,
recentResponseTimesMs = recentResponse,
recentCompletionTimesMs = recentCompletion,
// Internal accumulators
totalResponseTimeMs = totalResponseTime,
totalCompletionTimeMs = totalCompletionTime,
responseTimeCount = responseTimeCount,
completionTimeCount = completionTimeCount,
streamsCompleted = streamsCompleted,
streamsErrored = streamsErrored,
streamsCancelled = streamsCancelled,
healthChecksTotal = healthTotal,
healthChecksSuccess = healthSuccess,
totalHealthLatencyMs = totalHealthLatency
)
Log.d(TAG, "Loaded analytics: $totalMessages msgs, ${totalTokensIn + totalTokensOut} tokens")
} catch (e: Exception) {
Log.w(TAG, "Failed to load analytics: ${e.message}")
}
}
}
/**
* Snapshot of all tracked analytics.
*
* UI-facing fields are the primary ones. Internal accumulators (prefixed
* descriptions say "internal") are used for running averages and persistence.
*/
data class AppStats(
// Lifetime totals
val totalMessagesSent: Int = 0,
val totalTokensIn: Long = 0L,
val totalTokensOut: Long = 0L,
val avgResponseTimeMs: Long = 0L,
val avgCompletionTimeMs: Long = 0L,
val streamSuccessRate: Float = 1f,
val healthCheckSuccessRate: Float = 0f,
val avgHealthLatencyMs: Long = 0L,
val sessionCount: Int = 0,
// Current session
val currentSessionMessages: Int = 0,
val currentSessionTokensIn: Long = 0L,
val currentSessionTokensOut: Long = 0L,
// Recent history for charting (last 20)
val recentResponseTimesMs: List<Long> = emptyList(),
val recentCompletionTimesMs: List<Long> = emptyList(),
// Internal accumulators (not displayed directly, but needed for running averages)
val totalResponseTimeMs: Long = 0L,
val totalCompletionTimeMs: Long = 0L,
val responseTimeCount: Int = 0,
val completionTimeCount: Int = 0,
val streamsCompleted: Int = 0,
val streamsErrored: Int = 0,
val streamsCancelled: Int = 0,
val healthChecksTotal: Int = 0,
val healthChecksSuccess: Int = 0,
val totalHealthLatencyMs: Long = 0L
)
@@ -0,0 +1,62 @@
package com.hermesandroid.relay.data
data class ChatMessage(
val id: String,
val role: MessageRole,
val content: String,
val timestamp: Long,
val isStreaming: Boolean = false,
val toolCalls: List<ToolCall> = emptyList(),
// Reasoning/thinking content (collapsible in UI)
val thinkingContent: String = "",
val isThinkingStreaming: Boolean = false,
// Token tracking (from content_complete event)
val inputTokens: Int? = null,
val outputTokens: Int? = null,
val totalTokens: Int? = null,
val estimatedCost: Double? = null,
// Agent/personality name for display on assistant messages
val agentName: String? = null,
// File attachments (images, documents, etc.)
val attachments: List<Attachment> = emptyList()
)
/**
* A file attachment sent with a message.
* Matches the Hermes API format: { contentType, content (base64) }
*/
data class Attachment(
val contentType: String, // MIME type (e.g. "image/png", "application/pdf", "text/plain")
val content: String, // Base64-encoded file content
val fileName: String? = null,
val fileSize: Long? = null
) {
val isImage: Boolean get() = contentType.startsWith("image/")
}
data class ToolCall(
val id: String? = null,
val name: String,
val args: String?,
val result: String?,
val success: Boolean?,
val isComplete: Boolean = false,
val error: String? = null,
// Duration tracking
val startedAt: Long = System.currentTimeMillis(),
val completedAt: Long? = null
)
enum class MessageRole {
USER,
ASSISTANT,
SYSTEM
}
data class ChatSession(
val sessionId: String,
val title: String?,
val model: String?,
val messageCount: Int = 0,
val updatedAt: Long = 0L
)
@@ -1,4 +1,4 @@
package com.hermesandroid.companion.data
package com.hermesandroid.relay.data
import android.content.Context
import android.net.Uri
@@ -44,8 +44,10 @@ class DataManager(private val context: Context) {
*/
@Serializable
data class AppBackup(
val version: Int = 1,
val serverUrl: String? = null,
val version: Int = 2,
val serverUrl: String? = null, // legacy (v1 compat)
val apiServerUrl: String? = null,
val relayUrl: String? = null,
val theme: String = "auto",
val onboardingCompleted: Boolean = false,
val profiles: List<String> = emptyList(),
@@ -60,11 +62,15 @@ class DataManager(private val context: Context) {
serverUrl: String?,
theme: String,
onboardingCompleted: Boolean,
profiles: List<String>
profiles: List<String>,
apiServerUrl: String? = null,
relayUrl: String? = null
): String {
val backup = AppBackup(
version = 1,
serverUrl = serverUrl,
version = 2,
serverUrl = serverUrl, // legacy compat
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
theme = theme,
onboardingCompleted = onboardingCompleted,
profiles = profiles,
@@ -133,11 +139,11 @@ class DataManager(private val context: Context) {
val onboarding = isOnboardingCompleted()
// Clear all DataStore preferences
context.companionDataStore.edit { it.clear() }
context.relayDataStore.edit { it.clear() }
// Restore onboarding flag so it isn't wiped
if (onboarding) {
context.companionDataStore.edit { preferences ->
context.relayDataStore.edit { preferences ->
preferences[KEY_ONBOARDING_COMPLETED] = true
}
}
@@ -171,7 +177,7 @@ class DataManager(private val context: Context) {
*/
suspend fun resetOnboarding() {
try {
context.companionDataStore.edit { preferences ->
context.relayDataStore.edit { preferences ->
preferences.remove(KEY_ONBOARDING_COMPLETED)
}
Log.d(TAG, "Onboarding flag reset")
@@ -185,7 +191,7 @@ class DataManager(private val context: Context) {
*/
suspend fun isOnboardingCompleted(): Boolean {
return try {
context.companionDataStore.data
context.relayDataStore.data
.map { preferences -> preferences[KEY_ONBOARDING_COMPLETED] ?: false }
.first()
} catch (e: Exception) {
@@ -199,7 +205,7 @@ class DataManager(private val context: Context) {
*/
suspend fun setOnboardingCompleted(completed: Boolean) {
try {
context.companionDataStore.edit { preferences ->
context.relayDataStore.edit { preferences ->
preferences[KEY_ONBOARDING_COMPLETED] = completed
}
} catch (e: Exception) {
@@ -1,4 +1,4 @@
package com.hermesandroid.companion.data
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
@@ -6,12 +6,12 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
/**
* Single DataStore instance for the companion app settings.
* Single DataStore instance for the app settings.
*
* All code that needs DataStore access should use [Context.companionDataStore]
* All code that needs DataStore access should use [Context.relayDataStore]
* rather than creating its own [preferencesDataStore] delegate (multiple
* delegates targeting the same file cause a runtime crash).
*/
internal val Context.companionDataStore: DataStore<Preferences> by preferencesDataStore(
name = "companion_settings"
internal val Context.relayDataStore: DataStore<Preferences> by preferencesDataStore(
name = "relay_settings"
)
@@ -0,0 +1,62 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import com.hermesandroid.relay.BuildConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Feature flags with compile-time defaults and runtime overrides.
*
* In debug builds, all features are unlocked by default.
* In release builds, experimental features are hidden unless the user
* enables Developer Options (tap version 7 times in Settings > About).
*
* Runtime overrides persist in DataStore so testers can toggle features
* without needing a debug APK.
*/
object FeatureFlags {
// DataStore keys
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
private val KEY_RELAY_ENABLED = booleanPreferencesKey("feature_relay_enabled")
/** Whether the app is running a debug build. */
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
/** Observe whether Developer Options have been unlocked. */
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
context.relayDataStore.data.map { prefs ->
if (isDevBuild) true else prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: false
}
/** Observe whether relay features (settings, pairing, onboarding pages) are enabled. */
fun relayEnabled(context: Context): Flow<Boolean> =
context.relayDataStore.data.map { prefs ->
if (isDevBuild) true else prefs[KEY_RELAY_ENABLED] ?: false
}
/** Unlock Developer Options. */
suspend fun unlockDevOptions(context: Context) {
context.relayDataStore.edit { prefs ->
prefs[KEY_DEV_OPTIONS_UNLOCKED] = true
}
}
/** Lock Developer Options and disable all experimental features. */
suspend fun lockDevOptions(context: Context) {
context.relayDataStore.edit { prefs ->
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false
prefs[KEY_RELAY_ENABLED] = false
}
}
/** Toggle relay features (terminal/bridge settings, pairing, onboarding relay page). */
suspend fun setRelayEnabled(context: Context, enabled: Boolean) {
context.relayDataStore.edit { prefs ->
prefs[KEY_RELAY_ENABLED] = enabled
}
}
}
@@ -1,9 +1,10 @@
package com.hermesandroid.companion.network
package com.hermesandroid.relay.network
import com.hermesandroid.companion.network.models.Envelope
import com.hermesandroid.relay.network.models.Envelope
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.concurrent.ConcurrentHashMap
/**
* Routes incoming WebSocket envelopes to the appropriate channel handler.
@@ -24,7 +25,7 @@ class ChannelMultiplexer {
fun onMessage(envelope: Envelope)
}
private val handlers = mutableMapOf<String, ChannelHandler>()
private val handlers = ConcurrentHashMap<String, ChannelHandler>()
private var onConnectedCallback: (() -> Unit)? = null
private var sendCallback: ((Envelope) -> Unit)? = null
@@ -1,7 +1,7 @@
package com.hermesandroid.companion.network
package com.hermesandroid.relay.network
import android.util.Log
import com.hermesandroid.companion.network.models.Envelope
import com.hermesandroid.relay.network.models.Envelope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -29,7 +29,8 @@ enum class ConnectionState {
class ConnectionManager(
private val multiplexer: ChannelMultiplexer
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val supervisorJob = SupervisorJob()
private val scope = CoroutineScope(supervisorJob + Dispatchers.IO)
private val json = Json {
ignoreUnknownKeys = true
@@ -100,6 +101,13 @@ class ConnectionManager(
_isInsecureConnection.value = false
}
fun shutdown() {
disconnect()
supervisorJob.cancel()
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
fun send(envelope: Envelope) {
val text = json.encodeToString(envelope)
webSocket?.send(text)
@@ -128,7 +136,7 @@ class ConnectionManager(
val envelope = json.decodeFromString<Envelope>(text)
multiplexer.route(envelope)
} catch (e: Exception) {
e.printStackTrace()
Log.w(TAG, "Malformed relay envelope: ${e.message}")
}
}
@@ -0,0 +1,50 @@
package com.hermesandroid.relay.network
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
class ConnectivityObserver(private val context: Context) {
sealed class Status {
data object Available : Status()
data object Unavailable : Status()
data object Lost : Status()
}
fun observe(): Flow<Status> = callbackFlow {
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(Status.Available)
}
override fun onLost(network: Network) {
trySend(Status.Lost)
}
override fun onUnavailable() {
trySend(Status.Unavailable)
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
// Emit current state
val activeNetwork = connectivityManager.activeNetwork
val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
val isConnected = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
trySend(if (isConnected) Status.Available else Status.Unavailable)
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}
}
@@ -0,0 +1,827 @@
package com.hermesandroid.relay.network
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.hermesandroid.relay.data.AppAnalytics
import com.hermesandroid.relay.network.models.CreateSessionRequest
import com.hermesandroid.relay.network.models.HermesSseEvent
import com.hermesandroid.relay.network.models.MessageItem
import com.hermesandroid.relay.network.models.MessageListResponse
import com.hermesandroid.relay.network.models.RenameSessionRequest
import com.hermesandroid.relay.network.models.SessionItem
import com.hermesandroid.relay.network.models.SessionListResponse
import com.hermesandroid.relay.network.models.SessionResponse
import com.hermesandroid.relay.network.models.SkillInfo
import com.hermesandroid.relay.network.models.SkillListResponse
import com.hermesandroid.relay.network.models.UsageInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources
import java.io.IOException
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
sealed interface HealthCheckResult {
data object Healthy : HealthCheckResult
data class Unhealthy(val message: String) : HealthCheckResult
}
enum class ChatMode {
/** Full Hermes Sessions API — /api/sessions/{id}/chat/stream */
ENHANCED_HERMES,
/** Only OpenAI-compatible /v1/chat/completions */
PORTABLE,
/** Cannot connect to the server */
DISCONNECTED
}
/**
* Direct HTTP/SSE client for the Hermes API Server.
*
* Session CRUD via /api/sessions REST endpoints.
* Chat streaming via /api/sessions/{id}/chat/stream SSE.
* All event callbacks dispatched to the main thread for safe StateFlow updates.
*/
class HermesApiClient(
baseUrl: String,
private val apiKey: String,
private val json: Json = Json {
ignoreUnknownKeys = true
isLenient = true
}
) {
private val baseUrl: String = baseUrl.trimEnd('/')
companion object {
private const val TAG = "HermesApiClient"
private val JSON_MEDIA = "application/json".toMediaType()
}
private val mainHandler = Handler(Looper.getMainLooper())
private val client: OkHttpClient = OkHttpClient.Builder()
.readTimeout(5, TimeUnit.MINUTES)
.connectTimeout(10, TimeUnit.SECONDS)
.build()
private val sseFactory = EventSources.createFactory(client)
// --- Health check ---
suspend fun checkHealth(): Boolean = withContext(Dispatchers.IO) {
val startMs = System.currentTimeMillis()
try {
val request = authRequest("$baseUrl/health").get().build()
client.newCall(request).execute().use { response ->
val latencyMs = System.currentTimeMillis() - startMs
val success = if (!response.isSuccessful) {
false
} else {
// Validate it's actually a JSON API, not an HTML error page
val contentType = response.header("Content-Type") ?: ""
contentType.contains("json", ignoreCase = true) ||
contentType.contains("text/plain", ignoreCase = true) ||
(response.body?.string()?.trimStart()?.startsWith("{") == true)
}
AppAnalytics.onHealthCheck(success, latencyMs)
success
}
} catch (_: Exception) {
val latencyMs = System.currentTimeMillis() - startMs
AppAnalytics.onHealthCheck(false, latencyMs)
false
}
}
suspend fun checkHealthDetailed(): HealthCheckResult = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/health").get().build()
client.newCall(request).execute().use { response ->
when {
response.isSuccessful -> HealthCheckResult.Healthy
response.code == 401 || response.code == 403 ->
HealthCheckResult.Unhealthy("Unauthorized — check your API key")
else ->
HealthCheckResult.Unhealthy("Server returned HTTP ${response.code}")
}
}
} catch (e: javax.net.ssl.SSLException) {
if (baseUrl.startsWith("https://", ignoreCase = true)) {
HealthCheckResult.Unhealthy("TLS handshake failed — try http:// if your server doesn't use HTTPS")
} else {
HealthCheckResult.Unhealthy("SSL error: ${e.message}")
}
} catch (e: java.net.ConnectException) {
HealthCheckResult.Unhealthy("Connection refused — check the URL and port")
} catch (e: java.net.UnknownHostException) {
HealthCheckResult.Unhealthy("Server not found — check the hostname")
} catch (e: java.net.SocketTimeoutException) {
HealthCheckResult.Unhealthy("Connection timed out — is the server running?")
} catch (e: IOException) {
val msg = e.message ?: ""
when {
msg.contains("tls", ignoreCase = true) || msg.contains("ssl", ignoreCase = true) ->
HealthCheckResult.Unhealthy("TLS error — try http:// if your server doesn't use HTTPS")
else -> HealthCheckResult.Unhealthy("Connection failed: $msg")
}
} catch (e: Exception) {
HealthCheckResult.Unhealthy("Unexpected error: ${e.message}")
}
}
// --- Session CRUD ---
suspend fun listSessions(limit: Int = 50): List<SessionItem> = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/api/sessions?limit=$limit").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext emptyList()
val body = response.body?.string() ?: return@withContext emptyList()
val parsed = json.decodeFromString<SessionListResponse>(body)
parsed.items ?: parsed.sessions ?: emptyList()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to list sessions: ${e.message}")
emptyList()
}
}
suspend fun createSession(title: String? = null): SessionItem? = withContext(Dispatchers.IO) {
try {
val reqBody = json.encodeToString(CreateSessionRequest(title = title))
val request = authRequest("$baseUrl/api/sessions")
.post(reqBody.toRequestBody(JSON_MEDIA))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext null
val body = response.body?.string() ?: return@withContext null
val parsed = json.decodeFromString<SessionResponse>(body)
parsed.session ?: parsed.id?.let {
SessionItem(id = it, title = parsed.title, model = parsed.model)
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to create session: ${e.message}")
null
}
}
suspend fun deleteSession(sessionId: String): Boolean = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/api/sessions/$sessionId")
.delete()
.build()
client.newCall(request).execute().use { it.isSuccessful }
} catch (e: Exception) {
Log.w(TAG, "Failed to delete session: ${e.message}")
false
}
}
suspend fun renameSession(sessionId: String, title: String): Boolean = withContext(Dispatchers.IO) {
try {
val reqBody = json.encodeToString(RenameSessionRequest(title = title))
val request = authRequest("$baseUrl/api/sessions/$sessionId")
.patch(reqBody.toRequestBody(JSON_MEDIA))
.build()
client.newCall(request).execute().use { it.isSuccessful }
} catch (e: Exception) {
Log.w(TAG, "Failed to rename session: ${e.message}")
false
}
}
suspend fun getMessages(sessionId: String): List<MessageItem> = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/api/sessions/$sessionId/messages")
.get()
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext emptyList()
val body = response.body?.string() ?: return@withContext emptyList()
val parsed = json.decodeFromString<MessageListResponse>(body)
parsed.items ?: parsed.messages ?: emptyList()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to get messages: ${e.message}")
emptyList()
}
}
// --- Skills ---
suspend fun getSkills(): List<SkillInfo> = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/api/skills").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext emptyList()
val body = response.body?.string() ?: return@withContext emptyList()
// Try structured response: { "skills": [...] } or { "items": [...] }
try {
val parsed = json.decodeFromString<SkillListResponse>(body)
val skills = parsed.skills ?: parsed.items
if (skills != null) return@withContext skills
} catch (_: Exception) { /* fall through */ }
// Try direct array: [...]
try {
return@withContext json.decodeFromString<List<SkillInfo>>(body)
} catch (_: Exception) { /* fall through */ }
emptyList()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch skills: ${e.message}")
emptyList()
}
}
// --- Server personalities ---
/**
* Personality config fetched from GET /api/config.
* Names are the keys from config.agent.personalities.
* Prompts map personality name → system prompt text.
* Default is from config.display.personality (the server's active personality).
*/
data class PersonalityConfig(
val names: List<String> = emptyList(),
val prompts: Map<String, String> = emptyMap(),
val defaultName: String = "",
val modelName: String = ""
)
suspend fun getPersonalities(): PersonalityConfig = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/api/config").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext PersonalityConfig()
val body = response.body?.string() ?: return@withContext PersonalityConfig()
val root = json.parseToJsonElement(body) as? JsonObject
?: return@withContext PersonalityConfig()
// Model name from top-level: { "model": "claude-opus-4-6", ... }
val modelName = (root["model"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: ""
val config = root["config"] as? JsonObject
?: return@withContext PersonalityConfig(modelName = modelName)
// Personalities: config.agent.personalities { name: "system prompt", ... }
val agent = config["agent"] as? JsonObject
val personalitiesObj = agent?.get("personalities") as? JsonObject
val prompts = personalitiesObj?.entries?.associate { (key, value) ->
key to ((value as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "")
} ?: emptyMap()
// Default personality: config.display.personality
val display = config["display"] as? JsonObject
val defaultName = (display?.get("personality") as? kotlinx.serialization.json.JsonPrimitive)
?.content ?: ""
PersonalityConfig(
names = prompts.keys.toList(),
prompts = prompts,
defaultName = defaultName,
modelName = modelName
)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch personalities: ${e.message}")
PersonalityConfig()
}
}
// --- Chat streaming via /api/sessions/{id}/chat/stream ---
fun sendChatStream(
sessionId: String,
message: String,
systemMessage: String? = null,
attachments: List<com.hermesandroid.relay.data.Attachment>? = null,
onSessionId: (String) -> Unit,
onMessageStarted: (String) -> Unit,
onTextDelta: (String) -> Unit,
onThinkingDelta: (String) -> Unit,
onToolCallStart: (String, String) -> Unit,
onToolCallDone: (String, String?) -> Unit,
onToolCallFailed: (String, String?) -> Unit,
onTurnComplete: () -> Unit,
onComplete: () -> Unit,
onUsage: (UsageInfo?) -> Unit,
onError: (String) -> Unit
): EventSource {
val requestPayload = buildJsonObject {
put("message", message)
if (!systemMessage.isNullOrBlank()) {
put("system_message", systemMessage)
}
if (!attachments.isNullOrEmpty()) {
putJsonArray("attachments") {
attachments.forEach { att ->
addJsonObject {
put("contentType", att.contentType)
put("content", att.content)
}
}
}
}
}
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
val request = authRequest("$baseUrl/api/sessions/$sessionId/chat/stream")
.header("Accept", "text/event-stream")
.post(requestBody.toRequestBody(JSON_MEDIA))
.build()
val completeCalled = AtomicBoolean(false)
// Notify caller of the session ID being used
mainHandler.post { onSessionId(sessionId) }
val listener = object : EventSourceListener() {
override fun onEvent(
eventSource: EventSource,
id: String?,
type: String?,
data: String
) {
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
return
}
try {
val event = json.decodeFromString<HermesSseEvent>(data)
// Check for usage data on ANY event before type resolution
// (OpenAI-format chunks have no type/event field but may carry usage)
if (event.usage != null && (event.usage.resolvedInputTokens != null || event.usage.resolvedOutputTokens != null)) {
mainHandler.post { onUsage(event.usage) }
}
val eventType = type ?: event.resolvedType ?: return
// Debug: log every SSE event type (content truncated for deltas)
if (eventType.startsWith("assistant.delta") || eventType == "tool.progress") {
Log.d(TAG, "SSE ← $eventType (${data.length} chars)")
} else {
Log.d(TAG, "SSE ← $eventType | ${data.take(300)}")
}
when (eventType) {
// --- Hermes-native events ---
"assistant.delta" -> {
val delta = event.delta
if (!delta.isNullOrEmpty()) {
mainHandler.post { onTextDelta(delta) }
}
}
"tool.progress" -> {
val delta = event.delta
if (!delta.isNullOrEmpty()) {
mainHandler.post { onThinkingDelta(delta) }
}
}
"tool.pending", "tool.started" -> {
val toolName = event.resolvedToolName ?: "unknown"
val callId = event.callId ?: event.toolCallId ?: toolName
Log.d(TAG, "SSE tool start: name=$toolName callId=$callId")
mainHandler.post { onToolCallStart(callId, toolName) }
}
"tool.completed" -> {
val callId = event.callId ?: event.toolCallId ?: event.resolvedToolName ?: ""
mainHandler.post { onToolCallDone(callId, event.resultPreview) }
}
"tool.failed" -> {
val callId = event.callId ?: event.toolCallId ?: event.resolvedToolName ?: ""
val errorMsg = event.error ?: event.messageText ?: "Tool failed"
mainHandler.post { onToolCallFailed(callId, errorMsg) }
}
// message.started — server assigns a new message ID for each turn
"message.started" -> {
val msgObj = event.message as? JsonObject
val serverMsgId = (msgObj?.get("id") as? kotlinx.serialization.json.JsonPrimitive)?.content
if (serverMsgId != null) {
mainHandler.post { onMessageStarted(serverMsgId) }
}
Log.d(TAG, "SSE message.started: id=$serverMsgId")
}
// Informational events — acknowledged but not surfaced to UI yet
"session.created", "run.started",
"memory.updated", "skill.loaded", "artifact.created" -> {
Log.d(TAG, "SSE info event: $eventType")
}
// assistant.completed — one turn finished, but run may continue with tool calls
"assistant.completed" -> {
mainHandler.post {
onUsage(event.usage)
if (event.interrupted == true) {
if (completeCalled.compareAndSet(false, true)) {
onError("Response interrupted")
}
} else {
onTurnComplete()
}
}
}
// run.completed — the entire agent loop is done (all turns + tool calls)
"run.completed" -> {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post {
onUsage(event.usage)
if (event.interrupted == true) {
onError("Run interrupted")
} else {
onComplete()
}
}
}
}
"done" -> {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
}
"error" -> {
if (completeCalled.compareAndSet(false, true)) {
val msg = event.messageText ?: event.error ?: "Unknown error"
mainHandler.post { onError(msg) }
}
}
// --- Legacy/backward-compat event names ---
"thinking_delta", "reasoning_delta", "thinking" -> {
val thinkingText = event.thinkingDelta ?: event.thinking ?: event.delta
if (!thinkingText.isNullOrEmpty()) {
mainHandler.post { onThinkingDelta(thinkingText) }
}
}
"content_delta", "delta" -> {
val thinking = event.thinking ?: event.thinkingDelta
if (!thinking.isNullOrEmpty()) {
mainHandler.post { onThinkingDelta(thinking) }
}
val delta = event.delta
if (!delta.isNullOrEmpty()) {
mainHandler.post { onTextDelta(delta) }
}
}
"tool_start", "tool_started" -> {
val toolName = event.toolName ?: event.name ?: "unknown"
val callId = event.callId ?: event.toolCallId ?: toolName
mainHandler.post { onToolCallStart(callId, toolName) }
}
"tool_result", "tool_completed" -> {
val callId = event.callId ?: event.toolCallId ?: event.toolName ?: event.name ?: ""
mainHandler.post { onToolCallDone(callId, event.resultPreview) }
}
"content_complete", "complete", "completed" -> {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post {
onUsage(event.usage)
onComplete()
}
}
}
else -> {
Log.d(TAG, "Unhandled SSE event type: $eventType | data: ${data.take(200)}")
}
}
} catch (e: Exception) {
Log.w(TAG, "Unparseable SSE event ($type): ${e.message}\nRaw: $data")
}
}
override fun onFailure(
eventSource: EventSource,
t: Throwable?,
response: Response?
) {
if (completeCalled.compareAndSet(false, true)) {
val msg = when {
response != null && !response.isSuccessful ->
"API error ${response.code}: ${response.message}"
t is IOException -> "Connection failed: ${t.message}"
t != null -> "Stream error: ${t.message}"
else -> "Unknown stream error"
}
mainHandler.post { onError(msg) }
}
}
override fun onClosed(eventSource: EventSource) {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
}
}
return sseFactory.newEventSource(request, listener)
}
// --- Run streaming via /v1/runs ---
fun sendRunStream(
message: String,
model: String? = null,
systemMessage: String? = null,
attachments: List<com.hermesandroid.relay.data.Attachment>? = null,
onSessionId: (String) -> Unit,
onMessageStarted: (String) -> Unit,
onTextDelta: (String) -> Unit,
onThinkingDelta: (String) -> Unit,
onToolCallStart: (String, String) -> Unit,
onToolCallDone: (String, String?) -> Unit,
onToolCallFailed: (String, String?) -> Unit,
onTurnComplete: () -> Unit,
onComplete: () -> Unit,
onUsage: (UsageInfo?) -> Unit,
onError: (String) -> Unit
): EventSource {
val requestPayload = buildJsonObject {
put("model", model ?: "default")
put("input", message)
put("stream", true)
if (!systemMessage.isNullOrBlank()) {
put("system_message", systemMessage)
}
if (!attachments.isNullOrEmpty()) {
putJsonArray("attachments") {
attachments.forEach { att ->
addJsonObject {
put("contentType", att.contentType)
put("content", att.content)
}
}
}
}
}
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
val request = authRequest("$baseUrl/v1/runs")
.header("Accept", "text/event-stream")
.post(requestBody.toRequestBody(JSON_MEDIA))
.build()
val completeCalled = AtomicBoolean(false)
val listener = object : EventSourceListener() {
override fun onEvent(
eventSource: EventSource,
id: String?,
type: String?,
data: String
) {
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
return
}
try {
val event = json.decodeFromString<HermesSseEvent>(data)
// Check for usage data before type resolution (catches OpenAI-format chunks)
if (event.usage != null && (event.usage.resolvedInputTokens != null || event.usage.resolvedOutputTokens != null)) {
mainHandler.post { onUsage(event.usage) }
}
val eventType = type ?: event.resolvedType ?: return
when (eventType) {
"response.created" -> {
// Extract session/run ID if available
val sid = event.sessionId ?: event.runId
if (sid != null) {
mainHandler.post { onSessionId(sid) }
}
Log.d(TAG, "Run response created")
}
"response.in_progress" -> {
Log.d(TAG, "Run response in progress")
}
"response.output_item.added",
"response.content_part.added" -> {
Log.d(TAG, "Run SSE info: $eventType")
}
"response.output_text.delta" -> {
val delta = event.delta
if (!delta.isNullOrEmpty()) {
mainHandler.post { onTextDelta(delta) }
}
}
"response.output_text.done" -> {
Log.d(TAG, "Run output text done")
}
// Tool events — Hermes /v1/runs uses "tool" field, sessions uses "tool_name"
"tool.started", "tool.pending" -> {
val toolName = event.resolvedToolName ?: "unknown"
val callId = event.callId ?: event.toolCallId ?: toolName
Log.d(TAG, "Run SSE tool start: name=$toolName callId=$callId")
mainHandler.post { onToolCallStart(callId, toolName) }
}
"tool.completed" -> {
val callId = event.callId ?: event.toolCallId ?: event.resolvedToolName ?: ""
val durationStr = event.duration?.let { String.format("%.1fs", it) }
val preview = event.resultPreview ?: durationStr
mainHandler.post { onToolCallDone(callId, preview) }
}
"tool.failed" -> {
val callId = event.callId ?: event.toolCallId ?: event.resolvedToolName ?: ""
val errorMsg = event.error ?: event.messageText ?: "Tool failed"
mainHandler.post { onToolCallFailed(callId, errorMsg) }
}
"tool.progress" -> {
val delta = event.delta
if (!delta.isNullOrEmpty()) {
mainHandler.post { onThinkingDelta(delta) }
}
}
// Reasoning — /v1/runs uses "reasoning.available" with "text" field
"reasoning.available" -> {
val reasoningText = event.text
if (!reasoningText.isNullOrEmpty()) {
mainHandler.post { onThinkingDelta(reasoningText) }
}
}
// Text deltas — /v1/runs uses "message.delta", sessions uses "assistant.delta"
"message.delta", "assistant.delta" -> {
val delta = event.delta
if (!delta.isNullOrEmpty()) {
mainHandler.post { onTextDelta(delta) }
}
}
"response.completed" -> {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post {
onUsage(event.usage)
if (event.interrupted == true) {
onError("Run interrupted")
} else {
onComplete()
}
}
}
}
// assistant.completed — one turn done, run may continue
"assistant.completed" -> {
mainHandler.post {
onUsage(event.usage)
if (event.interrupted == true) {
if (completeCalled.compareAndSet(false, true)) {
onError("Response interrupted")
}
} else {
onTurnComplete()
}
}
}
"run.completed" -> {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post {
onUsage(event.usage)
if (event.interrupted == true) {
onError("Run interrupted")
} else {
onComplete()
}
}
}
}
"done" -> {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
}
"error", "run.failed" -> {
if (completeCalled.compareAndSet(false, true)) {
val msg = event.error ?: event.messageText ?: "Unknown error"
mainHandler.post { onError(msg) }
}
}
// message.started — server assigns a new message ID for each turn
"message.started" -> {
val msgObj = event.message as? JsonObject
val serverMsgId = (msgObj?.get("id") as? kotlinx.serialization.json.JsonPrimitive)?.content
if (serverMsgId != null) {
mainHandler.post { onMessageStarted(serverMsgId) }
}
Log.d(TAG, "Run SSE message.started: id=$serverMsgId")
}
// Informational events
"session.created", "run.started",
"memory.updated", "skill.loaded", "artifact.created" -> {
Log.d(TAG, "Run SSE info event: $eventType")
}
else -> {
Log.d(TAG, "Unhandled run SSE event: $eventType | data: ${data.take(200)}")
}
}
} catch (e: Exception) {
Log.w(TAG, "Unparseable run SSE event ($type): ${e.message}\nRaw: $data")
}
}
override fun onFailure(
eventSource: EventSource,
t: Throwable?,
response: Response?
) {
if (completeCalled.compareAndSet(false, true)) {
val msg = when {
response != null && !response.isSuccessful ->
"API error ${response.code}: ${response.message}"
t is IOException -> "Connection failed: ${t.message}"
t != null -> "Stream error: ${t.message}"
else -> "Unknown stream error"
}
mainHandler.post { onError(msg) }
}
}
override fun onClosed(eventSource: EventSource) {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
}
}
return sseFactory.newEventSource(request, listener)
}
// --- Capability detection ---
/**
* Probe the server to determine which chat API is available.
* Checks /health, then /api/sessions (enhanced), then /v1/models (portable).
*/
suspend fun detectChatMode(): ChatMode = withContext(Dispatchers.IO) {
// 1. Basic connectivity
try {
val healthReq = authRequest("$baseUrl/health").get().build()
client.newCall(healthReq).execute().use { response ->
if (!response.isSuccessful) return@withContext ChatMode.DISCONNECTED
}
} catch (_: Exception) {
return@withContext ChatMode.DISCONNECTED
}
// 2. Try enhanced sessions API
try {
val sessionsReq = authRequest("$baseUrl/api/sessions?limit=1").get().build()
client.newCall(sessionsReq).execute().use { response ->
if (response.isSuccessful) return@withContext ChatMode.ENHANCED_HERMES
}
} catch (_: Exception) { /* fall through */ }
// 3. Try OpenAI-compatible models endpoint
try {
val modelsReq = authRequest("$baseUrl/v1/models").get().build()
client.newCall(modelsReq).execute().use { response ->
if (response.isSuccessful) return@withContext ChatMode.PORTABLE
}
} catch (_: Exception) { /* fall through */ }
// Server is reachable but neither API is available
ChatMode.DISCONNECTED
}
// --- Lifecycle ---
fun shutdown() {
client.dispatcher.executorService.shutdown()
try {
if (!client.dispatcher.executorService.awaitTermination(2, TimeUnit.SECONDS)) {
client.dispatcher.executorService.shutdownNow()
}
} catch (_: InterruptedException) {
client.dispatcher.executorService.shutdownNow()
}
client.connectionPool.evictAll()
}
private fun authRequest(url: String): Request.Builder {
val builder = Request.Builder().url(url)
if (apiKey.isNotBlank()) {
builder.header("Authorization", "Bearer $apiKey")
}
return builder
}
}
@@ -0,0 +1,761 @@
package com.hermesandroid.relay.network.handlers
import android.util.Log
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.network.models.MessageItem
import com.hermesandroid.relay.network.models.SessionItem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
/**
* Manages chat message state, session list, and streaming events.
*/
class ChatHandler {
companion object {
private const val TAG = "ChatHandler"
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */
private const val MAX_MESSAGES = 500
// Tool annotation patterns embedded as text markers by Hermes.
//
// Hermes /v1/chat/completions injects tool progress as inline markdown:
// `💻 terminal` — tool-type-specific emoji + tool name in backticks
// `🔍 Python docs` — some tool names have spaces
//
// The emoji varies by tool type. We match ANY non-whitespace char(s) as the
// emoji token (covers 💻🔍📝🔧⏳🔄✅✓❌✗ and future additions).
//
// We detect start vs. complete by tracking: first occurrence = start,
// second occurrence of the same tool = complete.
//
// Additionally supports bare emoji formats: 🔧 Running: terminal
//
// Format 1 (primary): `<emoji> <tool_name>` — backtick-wrapped, any emoji
private val toolAnnotationBacktickRegex = Regex(
"""`([^\s`]+)\s+([^`]+)`"""
)
// Format 2 (verbose): 🔧 Running: tool_name / ✅ Completed: tool_name / ❌ Failed: tool_name
private val toolAnnotationVerboseStartRegex = Regex(
"""([🔧⏳🔄💻🔍📝🛠️])\s+(?:Running(?:\s*:\s*|\s+))(\w[\w\s]*)"""
)
private val toolAnnotationVerboseCompleteRegex = Regex(
"""([✅✓])\s+(?:Completed(?:\s*:\s*|\s+)|Done(?:\s*:\s*|\s+))(\w[\w\s]*)"""
)
private val toolAnnotationVerboseFailedRegex = Regex(
"""([❌✗])\s+(?:Failed(?:\s*:\s*|\s+)|Error(?:\s*:\s*|\s+))(\w[\w\s]*)"""
)
// Known completion/failure emojis — if these appear in backtick format, it's a completion
private val completionEmojis = setOf("✅", "✓", "☑")
private val failureEmojis = setOf("❌", "✗", "⚠")
}
/** Whether to parse tool annotations from assistant text (for servers that don't emit tool events). */
var parseToolAnnotations: Boolean = true
/** Active personality/agent name — set by ChatViewModel before each stream. Included on new assistant messages. */
var activeAgentName: String? = null
/**
* Buffer for incomplete lines during streaming. Tool annotations are line-oriented
* (backtick + emoji + tool_name + backtick), so we accumulate text until we see a
* newline and then scan completed lines. This handles the case where a single
* annotation is split across multiple SSE deltas.
*/
private var annotationLineBuffer = StringBuilder()
/**
* Tracks which tool names currently have an active (in-progress) annotation-based
* ToolCall, keyed by "messageId:toolName" → toolCallId. This lets us match a
* completion/failure annotation back to the correct ToolCall.
*/
private val activeAnnotationTools = mutableMapOf<String, String>()
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
private val _isStreaming = MutableStateFlow(false)
val isStreaming: StateFlow<Boolean> = _isStreaming.asStateFlow()
private val _sessions = MutableStateFlow<List<ChatSession>>(emptyList())
val sessions: StateFlow<List<ChatSession>> = _sessions.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _currentSessionId = MutableStateFlow<String?>(null)
val currentSessionId: StateFlow<String?> = _currentSessionId.asStateFlow()
// --- Message management ---
fun addUserMessage(message: ChatMessage) {
_messages.update { list ->
(list + message).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
}
}
/**
* Add a placeholder assistant message immediately after the user sends,
* showing streaming dots before the first SSE delta arrives.
* Gets filled in naturally when onTextDelta finds the matching ID.
*/
fun addPlaceholderMessage(message: ChatMessage) {
_isStreaming.value = true
_messages.update { list ->
(list + message).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
}
}
fun clearMessages() {
_messages.value = emptyList()
}
fun clearError() {
_error.value = null
}
/**
* Load message history from API response into the messages list.
* Replaces current messages with the loaded history.
* Reconstructs tool calls from assistant messages' tool_calls field.
*/
fun loadMessageHistory(items: List<MessageItem>) {
// Build a map of tool result messages (role:"tool") keyed by tool_call_id
// so we can attach results back to the originating assistant message's ToolCall
val toolResults = items.filter { it.role == "tool" }
.associateBy { it.toolCallId }
val loaded = items.mapNotNull { item ->
val role = when (item.role) {
"user" -> MessageRole.USER
"assistant" -> MessageRole.ASSISTANT
"system" -> MessageRole.SYSTEM
"tool" -> return@mapNotNull null // Merged into assistant tool calls above
else -> return@mapNotNull null
}
// If > 1e12, already in milliseconds; otherwise convert from seconds
val ts = item.timestamp ?: 0.0
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
// Reconstruct tool calls from assistant messages
val toolCalls = if (role == MessageRole.ASSISTANT) {
parseToolCallsFromHistory(item.toolCalls, toolResults)
} else {
emptyList()
}
ChatMessage(
id = item.id?.toString() ?: java.util.UUID.randomUUID().toString(),
role = role,
content = item.contentText ?: "",
timestamp = timestampMs,
isStreaming = false,
toolCalls = toolCalls
)
}
_messages.value = if (loaded.size > MAX_MESSAGES) loaded.takeLast(MAX_MESSAGES) else loaded
}
/**
* Parse the tool_calls JSON from an assistant message into ToolCall objects.
* Format: array of objects with {id, type:"function", function: {name, arguments}}
* or Hermes format: {name, call_id, args, ...}
*/
private fun parseToolCallsFromHistory(
toolCallsJson: kotlinx.serialization.json.JsonElement?,
toolResults: Map<String?, MessageItem>
): List<ToolCall> {
if (toolCallsJson == null || toolCallsJson !is JsonArray) return emptyList()
return toolCallsJson.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
// Try OpenAI format: { id, type:"function", function: { name, arguments } }
val funcObj = obj["function"] as? JsonObject
val name: String
val callId: String?
val args: String?
if (funcObj != null) {
name = (funcObj["name"] as? JsonPrimitive)?.content ?: return@mapNotNull null
callId = (obj["id"] as? JsonPrimitive)?.content
args = (funcObj["arguments"] as? JsonPrimitive)?.content
} else {
// Hermes format: { name, call_id, args, ... }
name = (obj["name"] as? JsonPrimitive)?.content
?: (obj["tool_name"] as? JsonPrimitive)?.content
?: return@mapNotNull null
callId = (obj["call_id"] as? JsonPrimitive)?.content
?: (obj["id"] as? JsonPrimitive)?.content
args = obj["args"]?.toString()
}
// Check if we have a tool result for this call
val resultItem = toolResults[callId]
val resultText = resultItem?.contentText
ToolCall(
id = callId,
name = name,
args = args,
result = resultText,
success = resultText != null, // Has result → completed
isComplete = true // History items are always complete
)
}
}
// --- Session management ---
fun setSessionId(sessionId: String?) {
_currentSessionId.value = sessionId
}
/**
* Update sessions list from API response.
*/
fun updateSessions(items: List<SessionItem>) {
_sessions.value = items.map { item ->
// If > 1e12, already in milliseconds; otherwise convert from seconds
val ts = item.startedAt ?: 0.0
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
ChatSession(
sessionId = item.id,
title = item.title,
model = item.model,
messageCount = item.messageCount ?: 0,
updatedAt = timestampMs
)
}
}
/**
* Remove a session from the local list (optimistic delete).
*/
fun removeSession(sessionId: String) {
_sessions.update { sessions ->
sessions.filter { it.sessionId != sessionId }
}
if (_currentSessionId.value == sessionId) {
_currentSessionId.value = null
clearMessages()
}
}
/**
* Update a session's title in the local list (optimistic rename).
*/
fun renameSessionLocal(sessionId: String, newTitle: String) {
_sessions.update { sessions ->
sessions.map { s ->
if (s.sessionId == sessionId) s.copy(title = newTitle) else s
}
}
}
/**
* Add a newly created session to the list.
*/
fun addSession(session: ChatSession) {
_sessions.update { listOf(session) + it }
}
// --- SSE streaming event entry points ---
/**
* Tracks whether we are currently inside a `<think>`/`<thinking>` block
* in the text stream. Content inside these tags is redirected to
* thinkingContent instead of the main message content.
*/
private var insideThinkingBlock = false
fun onTextDelta(messageId: String, delta: String) {
_isStreaming.value = true
// Check for inline reasoning tags — some servers embed thinking in the text stream
val processedDelta = processInlineReasoning(messageId, delta)
// If all content was redirected to thinking, nothing left for main content
if (processedDelta.isEmpty()) return
_messages.update { messages ->
val existing = messages.findLast {
it.id == messageId && it.role == MessageRole.ASSISTANT
}
if (existing != null) {
messages.map { msg ->
if (msg.id == messageId) {
msg.copy(content = msg.content + processedDelta)
} else {
msg
}
}
} else {
messages + ChatMessage(
id = messageId,
role = MessageRole.ASSISTANT,
content = processedDelta,
timestamp = System.currentTimeMillis(),
isStreaming = true,
agentName = activeAgentName
)
}
}
// Scan for tool annotations in the text stream
if (parseToolAnnotations) {
scanForToolAnnotations(messageId, processedDelta)
}
}
/**
* Detect and extract inline `<think>`/`<thinking>` blocks from the text stream.
* Content inside these tags is redirected to onThinkingDelta.
* Returns the remaining non-thinking content.
*/
private fun processInlineReasoning(messageId: String, delta: String): String {
// Fast path: no tags in delta and not inside a block
if (!insideThinkingBlock && !delta.contains("<think", ignoreCase = true)) {
return delta
}
val result = StringBuilder()
var remaining = delta
while (remaining.isNotEmpty()) {
if (insideThinkingBlock) {
// Look for closing tag
val closeIdx = remaining.indexOfClose()
if (closeIdx != -1) {
// Extract thinking content before the close tag
val thinkingPart = remaining.substring(0, closeIdx)
if (thinkingPart.isNotEmpty()) {
onThinkingDelta(messageId, thinkingPart)
}
// Skip past the closing tag
val tagEnd = remaining.indexOf('>', closeIdx) + 1
remaining = if (tagEnd > 0) remaining.substring(tagEnd) else ""
insideThinkingBlock = false
} else {
// Entire remaining is thinking content
onThinkingDelta(messageId, remaining)
remaining = ""
}
} else {
// Look for opening tag
val openIdx = remaining.indexOfOpen()
if (openIdx != -1) {
// Content before the tag is regular text
val beforeTag = remaining.substring(0, openIdx)
result.append(beforeTag)
// Skip past the opening tag
val tagEnd = remaining.indexOf('>', openIdx) + 1
remaining = if (tagEnd > 0) remaining.substring(tagEnd) else ""
insideThinkingBlock = true
} else {
// No tags — all regular content
result.append(remaining)
remaining = ""
}
}
}
return result.toString()
}
/** Find index of `</think>` or `</thinking>` closing tag. */
private fun String.indexOfClose(): Int {
val i1 = indexOf("</think>", ignoreCase = true)
val i2 = indexOf("</thinking>", ignoreCase = true)
return when {
i1 == -1 -> i2
i2 == -1 -> i1
else -> minOf(i1, i2)
}
}
/** Find index of `<think>` or `<thinking>` opening tag. */
private fun String.indexOfOpen(): Int {
val i1 = indexOf("<think>", ignoreCase = true)
val i2 = indexOf("<thinking>", ignoreCase = true)
return when {
i1 == -1 -> i2
i2 == -1 -> i1
else -> minOf(i1, i2)
}
}
// --- Tool annotation parsing ---
/**
* Accumulate incoming text in a line buffer and scan completed lines for
* tool annotation patterns. Annotations are line-oriented, so we only
* attempt matching once we have a full line (terminated by newline).
*
* If the stream completes with a partial line still in the buffer, it is
* flushed in [onStreamComplete].
*/
private fun scanForToolAnnotations(messageId: String, delta: String) {
annotationLineBuffer.append(delta)
// Process all complete lines (newline-terminated)
while (true) {
val newlineIndex = annotationLineBuffer.indexOf('\n')
if (newlineIndex == -1) break
val line = annotationLineBuffer.substring(0, newlineIndex)
annotationLineBuffer.delete(0, newlineIndex + 1)
val trimmed = line.trim()
if (parseAnnotationLine(messageId, trimmed)) {
// Matched — strip this annotation line from the message content
stripLineFromContent(messageId, trimmed)
}
}
}
/**
* Remove a matched annotation line from the message's displayed content.
* This prevents the raw annotation text (e.g., `💻 terminal`) from showing
* in the chat bubble alongside the ToolCall card.
*/
private fun stripLineFromContent(messageId: String, line: String) {
_messages.update { messages ->
messages.map { msg ->
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
// Remove the line (with surrounding newlines) from content
val cleaned = msg.content
.replace("\n$line\n", "\n")
.replace("\n$line", "")
.replace("$line\n", "")
.replace(line, "")
.trim()
msg.copy(content = cleaned)
} else msg
}
}
}
/**
* Check a single completed line for a tool annotation pattern and
* dispatch the appropriate tool call event.
*
* Hermes injects tool progress as backtick-wrapped inline markdown:
* `💻 terminal` — first occurrence = tool start
* `💻 terminal` — second occurrence of same tool = tool complete
* `✅ terminal` — explicit completion emoji
* `❌ terminal` — explicit failure emoji
*
* Also supports verbose format:
* 🔧 Running: terminal
* ✅ Completed: terminal
* ❌ Failed: terminal
*/
/**
* Returns true if the line matched an annotation pattern (caller should strip it from content).
*/
private fun parseAnnotationLine(messageId: String, line: String): Boolean {
if (line.isEmpty()) return false
// --- Format 1: Backtick-wrapped `<emoji> <tool_name>` ---
toolAnnotationBacktickRegex.find(line)?.let { match ->
val emojiToken = match.groupValues[1]
val toolName = match.groupValues[2].trim()
if (toolName.isEmpty()) return false
val key = "$messageId:$toolName"
when {
// Explicit failure emoji
failureEmojis.any { emojiToken.contains(it) } -> {
val toolCallId = activeAnnotationTools.remove(key)
if (toolCallId != null) {
onToolCallFailed(messageId, toolCallId, null)
Log.d(TAG, "Annotation tool failed (backtick): $toolName")
}
}
// Explicit completion emoji
completionEmojis.any { emojiToken.contains(it) } -> {
val toolCallId = activeAnnotationTools.remove(key)
if (toolCallId != null) {
onToolCallComplete(messageId, toolCallId, null)
Log.d(TAG, "Annotation tool complete (backtick): $toolName")
}
}
// Tool-type emoji — first occurrence = start, second = complete
activeAnnotationTools.containsKey(key) -> {
val toolCallId = activeAnnotationTools.remove(key)
if (toolCallId != null) {
onToolCallComplete(messageId, toolCallId, null)
Log.d(TAG, "Annotation tool complete (repeat): $toolName")
}
}
else -> {
val toolCallId = "annotation-${toolName.replace(" ", "_")}-${System.currentTimeMillis()}"
activeAnnotationTools[key] = toolCallId
onToolCallStart(messageId, toolCallId, toolName)
Log.d(TAG, "Annotation tool start (backtick): $toolName [$emojiToken]")
}
}
return true
}
// --- Format 2: Verbose bare emoji ---
toolAnnotationVerboseStartRegex.find(line)?.let { match ->
val toolName = match.groupValues[2].trim()
val toolCallId = "annotation-${toolName.replace(" ", "_")}-${System.currentTimeMillis()}"
activeAnnotationTools["$messageId:$toolName"] = toolCallId
onToolCallStart(messageId, toolCallId, toolName)
Log.d(TAG, "Annotation tool start (verbose): $toolName")
return true
}
toolAnnotationVerboseCompleteRegex.find(line)?.let { match ->
val toolName = match.groupValues[2].trim()
val key = "$messageId:$toolName"
val toolCallId = activeAnnotationTools.remove(key) ?: return false
onToolCallComplete(messageId, toolCallId, null)
Log.d(TAG, "Annotation tool complete (verbose): $toolName")
return true
}
toolAnnotationVerboseFailedRegex.find(line)?.let { match ->
val toolName = match.groupValues[2].trim()
val key = "$messageId:$toolName"
val toolCallId = activeAnnotationTools.remove(key) ?: return false
onToolCallFailed(messageId, toolCallId, null)
Log.d(TAG, "Annotation tool failed (verbose): $toolName")
return true
}
return false
}
/**
* Flush any remaining partial line in the annotation buffer.
* Called when the stream ends so we don't miss annotations that
* arrived without a trailing newline.
*/
private fun flushAnnotationBuffer(messageId: String) {
if (annotationLineBuffer.isNotEmpty()) {
val remaining = annotationLineBuffer.toString().trim()
annotationLineBuffer.clear()
if (parseAnnotationLine(messageId, remaining)) {
stripLineFromContent(messageId, remaining)
}
}
// Clean up any active annotation tools for this message that never completed
val keysToRemove = activeAnnotationTools.keys.filter { it.startsWith("$messageId:") }
keysToRemove.forEach { activeAnnotationTools.remove(it) }
}
fun onToolCallStart(messageId: String, toolCallId: String, toolName: String) {
_isStreaming.value = true
val toolCall = ToolCall(
id = toolCallId,
name = toolName,
args = null,
result = null,
success = null,
isComplete = false
)
_messages.update { messages ->
val target = messages.findLast {
it.id == messageId && it.role == MessageRole.ASSISTANT
}
if (target != null) {
messages.map { msg ->
if (msg.id == messageId) {
msg.copy(toolCalls = msg.toolCalls + toolCall)
} else {
msg
}
}
} else {
messages + ChatMessage(
id = messageId,
role = MessageRole.ASSISTANT,
content = "",
timestamp = System.currentTimeMillis(),
isStreaming = true,
toolCalls = listOf(toolCall),
agentName = activeAgentName
)
}
}
}
fun onToolCallComplete(messageId: String, toolCallId: String, resultPreview: String? = null) {
_messages.update { messages ->
messages.map { msg ->
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
val updatedCalls = msg.toolCalls.map { call ->
if (call.id == toolCallId && !call.isComplete) {
call.copy(
success = true,
isComplete = true,
result = resultPreview ?: call.result,
completedAt = System.currentTimeMillis()
)
} else {
call
}
}
msg.copy(toolCalls = updatedCalls)
} else {
msg
}
}
}
}
fun onToolCallFailed(messageId: String, toolCallId: String, error: String?) {
_messages.update { messages ->
messages.map { msg ->
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
val updatedCalls = msg.toolCalls.map { call ->
if (call.id == toolCallId && !call.isComplete) {
call.copy(
success = false,
isComplete = true,
error = error,
completedAt = System.currentTimeMillis()
)
} else {
call
}
}
msg.copy(toolCalls = updatedCalls)
} else {
msg
}
}
}
}
/**
* A single assistant turn completed, but the agent run may continue
* (e.g., tool calls pending → next assistant turn). Marks the current
* message as no longer streaming but keeps the global isStreaming flag
* active so the UI continues showing progress.
*/
fun onTurnComplete(messageId: String) {
// Flush annotations for this turn
if (parseToolAnnotations) {
flushAnnotationBuffer(messageId)
}
_messages.update { messages ->
messages.map { msg ->
if (msg.id == messageId) {
msg.copy(isStreaming = false, isThinkingStreaming = false)
} else {
msg
}
}
}
// Note: do NOT set _isStreaming to false — the run is still active
}
/**
* The entire agent run is complete (run.completed / done).
* Marks the stream as finished and finalizes all messages.
*/
fun onStreamComplete(messageId: String) {
_isStreaming.value = false
insideThinkingBlock = false
// Flush any remaining annotation text that didn't end with a newline
if (parseToolAnnotations) {
flushAnnotationBuffer(messageId)
}
_messages.update { messages ->
messages.map { msg ->
if (msg.id == messageId || msg.isStreaming) {
msg.copy(isStreaming = false, isThinkingStreaming = false)
} else {
msg
}
}
}
}
fun onStreamError(message: String) {
_isStreaming.value = false
_error.value = message
// Clear streaming flag on any actively streaming message
_messages.update { messages ->
messages.map { msg ->
if (msg.isStreaming || msg.isThinkingStreaming) {
msg.copy(isStreaming = false, isThinkingStreaming = false)
} else msg
}
}
}
fun onThinkingDelta(messageId: String, delta: String) {
_isStreaming.value = true
_messages.update { messages ->
val existing = messages.findLast { it.id == messageId && it.role == MessageRole.ASSISTANT }
if (existing != null) {
messages.map { msg ->
if (msg.id == messageId) {
msg.copy(
thinkingContent = msg.thinkingContent + delta,
isThinkingStreaming = true
)
} else msg
}
} else {
messages + ChatMessage(
id = messageId,
role = MessageRole.ASSISTANT,
content = "",
thinkingContent = delta,
isThinkingStreaming = true,
timestamp = System.currentTimeMillis(),
isStreaming = true,
agentName = activeAgentName
)
}
}
}
fun onUsageReceived(messageId: String, inputTokens: Int?, outputTokens: Int?, totalTokens: Int?, cost: Double?) {
_messages.update { messages ->
messages.map { msg ->
if (msg.id == messageId) {
msg.copy(
inputTokens = inputTokens,
outputTokens = outputTokens,
totalTokens = totalTokens,
estimatedCost = cost
)
} else msg
}
}
}
// --- Retry support ---
private val _lastSentMessage = MutableStateFlow<String?>(null)
val lastSentMessage: StateFlow<String?> = _lastSentMessage.asStateFlow()
fun setLastSentMessage(text: String) {
_lastSentMessage.value = text
}
}
@@ -1,4 +1,4 @@
package com.hermesandroid.companion.network.models
package com.hermesandroid.relay.network.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@@ -0,0 +1,303 @@
package com.hermesandroid.relay.network.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
/**
* Models for the Hermes /api/sessions REST API.
* These are Hermes-native format, not OpenAI-compatible.
*/
/**
* Serializer that accepts both string and integer IDs from the server,
* normalizing them to String. Hermes returns int IDs for messages but
* string IDs for sessions.
*/
@OptIn(ExperimentalSerializationApi::class)
object FlexibleIdSerializer : KSerializer<String?> {
override val descriptor = PrimitiveSerialDescriptor("FlexibleId", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): String? {
return try {
val jsonDecoder = decoder as? JsonDecoder
?: return decoder.decodeString()
val element = jsonDecoder.decodeJsonElement()
when {
element is JsonNull -> null
element is JsonPrimitive -> element.content
else -> element.toString()
}
} catch (_: Exception) {
null
}
}
override fun serialize(encoder: Encoder, value: String?) {
if (value != null) encoder.encodeString(value) else encoder.encodeNull()
}
}
/** Non-null variant — returns empty string instead of null. Safe for primary key fields. */
object FlexibleIdNonNullSerializer : KSerializer<String> {
override val descriptor = PrimitiveSerialDescriptor("FlexibleIdNonNull", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): String {
return try {
val jsonDecoder = decoder as? JsonDecoder
?: return decoder.decodeString()
val element = jsonDecoder.decodeJsonElement()
when {
element is JsonNull -> ""
element is JsonPrimitive -> element.content
else -> element.toString()
}
} catch (_: Exception) {
""
}
}
override fun serialize(encoder: Encoder, value: String) {
encoder.encodeString(value)
}
}
// --- Session CRUD responses ---
@Serializable
data class SessionListResponse(
val items: List<SessionItem>? = null,
val sessions: List<SessionItem>? = null, // alternate key
val total: Int? = null
)
@Serializable
data class SessionResponse(
val session: SessionItem? = null,
// Flat session fields for when server returns at top level
@Serializable(with = FlexibleIdSerializer::class)
val id: String? = null,
val title: String? = null,
val model: String? = null
)
@Serializable
data class SessionItem(
@Serializable(with = FlexibleIdNonNullSerializer::class)
val id: String = "",
val title: String? = null,
val model: String? = null,
val source: String? = null,
@SerialName("started_at") val startedAt: Double? = null,
@SerialName("ended_at") val endedAt: Double? = null,
@SerialName("message_count") val messageCount: Int? = null,
@SerialName("tool_call_count") val toolCallCount: Int? = null,
@SerialName("input_tokens") val inputTokens: Int? = null,
@SerialName("output_tokens") val outputTokens: Int? = null
)
@Serializable
data class CreateSessionRequest(
val title: String? = null,
val model: String? = null
)
@Serializable
data class RenameSessionRequest(
val title: String
)
// --- Messages ---
@Serializable
data class MessageListResponse(
val items: List<MessageItem>? = null,
val messages: List<MessageItem>? = null, // alternate key
val total: Int? = null
)
@Serializable
data class MessageItem(
@Serializable(with = FlexibleIdSerializer::class)
val id: String? = null,
@SerialName("session_id")
@Serializable(with = FlexibleIdSerializer::class)
val sessionId: String? = null,
val role: String,
val content: JsonElement? = null,
@SerialName("tool_calls") val toolCalls: JsonElement? = null,
@SerialName("tool_name") val toolName: String? = null,
@SerialName("tool_call_id")
@Serializable(with = FlexibleIdSerializer::class)
val toolCallId: String? = null,
val timestamp: Double? = null,
@SerialName("finish_reason") val finishReason: String? = null
) {
/** Extract content as plain text string. Handles both string and array-of-parts formats. */
val contentText: String?
get() = when (content) {
is JsonPrimitive -> content.content
is JsonArray -> content.jsonArray
.filterIsInstance<JsonObject>()
.filter { (it["type"] as? JsonPrimitive)?.content == "text" }
.mapNotNull { (it["text"] as? JsonPrimitive)?.content }
.joinToString("")
.ifEmpty { null }
else -> null
}
/** Extract image URLs from OpenAI-format content arrays. */
val imageUrls: List<String>
get() = when (content) {
is JsonArray -> content.jsonArray
.filterIsInstance<JsonObject>()
.filter { (it["type"] as? JsonPrimitive)?.content == "image_url" }
.mapNotNull { block ->
val imageUrl = block["image_url"] as? JsonObject
(imageUrl?.get("url") as? JsonPrimitive)?.content
}
else -> emptyList()
}
}
// --- SSE streaming events from /api/sessions/{id}/chat/stream ---
//
// Hermes WebAPI event types (from server source):
// session.created — { session_id, run_id, title? }
// run.started — { session_id, run_id, user_message: { id, role, content } }
// message.started — { session_id, run_id, message: { id, role } }
// assistant.delta — { session_id, run_id, message_id, delta }
// tool.progress — { session_id, run_id, message_id, delta } (thinking/reasoning)
// tool.pending — { session_id, run_id, tool_name, call_id }
// tool.started — { session_id, run_id, tool_name, call_id, preview?, args }
// tool.completed — { session_id, run_id, tool_call_id, tool_name, args, result_preview }
// tool.failed — { session_id, run_id, call_id, tool_name, error }
// assistant.completed — { session_id, run_id, message_id, content, completed, partial, interrupted }
// run.completed — { session_id, run_id, message_id, completed, partial, interrupted, api_calls? }
// error — { message (string), error }
// done — { session_id, run_id, state: "final" }
@Serializable
data class HermesSseEvent(
// Event type — may come as "type" or "event" depending on server version
val type: String? = null,
val event: String? = null,
// Shared envelope fields
@SerialName("session_id")
@Serializable(with = FlexibleIdSerializer::class)
val sessionId: String? = null,
@SerialName("run_id")
@Serializable(with = FlexibleIdSerializer::class)
val runId: String? = null,
@SerialName("message_id")
@Serializable(with = FlexibleIdSerializer::class)
val messageId: String? = null,
val seq: Int? = null,
val ts: Double? = null,
val timestamp: Double? = null,
// assistant.delta / tool.progress / message.delta
val delta: String? = null,
// tool fields — different servers use different names
val name: String? = null,
val tool: String? = null, // /v1/runs format: "tool":"terminal"
@SerialName("tool_name") val toolName: String? = null,
val preview: String? = null,
val args: JsonObject? = null,
@SerialName("result_preview") val resultPreview: String? = null,
val duration: Double? = null, // /v1/runs tool.completed duration in seconds
val success: Boolean? = null,
@SerialName("call_id")
@Serializable(with = FlexibleIdSerializer::class)
val callId: String? = null,
@SerialName("tool_call_id")
@Serializable(with = FlexibleIdSerializer::class)
val toolCallId: String? = null,
// assistant.completed / run.completed
val content: String? = null,
val output: String? = null, // /v1/runs run.completed final text
@SerialName("final_response") val finalResponse: String? = null,
val completed: Boolean? = null,
val partial: Boolean? = null,
val interrupted: Boolean? = null,
@SerialName("api_calls") val apiCalls: Int? = null,
// session.created
val title: String? = null,
// run.started — user_message is an object
@SerialName("user_message") val userMessage: JsonObject? = null,
// message.started / error — message can be String or Object
val message: JsonElement? = null,
val error: String? = null,
// done event
val state: String? = null,
// Reasoning fields — multiple possible names across server versions
val thinking: String? = null,
@SerialName("thinking_delta") val thinkingDelta: String? = null,
val text: String? = null, // /v1/runs reasoning.available text
// Usage/token fields (on assistant.completed / run.completed)
val usage: UsageInfo? = null
) {
/** Resolve the event type from whichever field is populated. */
val resolvedType: String?
get() = type ?: event
/** Resolve tool name from whichever field the server uses. */
val resolvedToolName: String?
get() = toolName ?: tool ?: name
/** Extract message as string (returns null if message is an object, not a string). */
val messageText: String?
get() = (message as? JsonPrimitive)?.takeIf { it.isString }?.content
}
@Serializable
data class UsageInfo(
// Hermes naming
@SerialName("input_tokens") val inputTokens: Int? = null,
@SerialName("output_tokens") val outputTokens: Int? = null,
@SerialName("total_tokens") val totalTokens: Int? = null,
// OpenAI naming (fallback)
@SerialName("prompt_tokens") val promptTokens: Int? = null,
@SerialName("completion_tokens") val completionTokens: Int? = null,
// Cache tokens
@SerialName("cache_creation_input_tokens") val cacheCreationInputTokens: Int? = null,
@SerialName("cache_read_input_tokens") val cacheReadInputTokens: Int? = null
) {
/** Resolved input tokens — prefers Hermes naming, falls back to OpenAI. */
val resolvedInputTokens: Int? get() = inputTokens ?: promptTokens
/** Resolved output tokens — prefers Hermes naming, falls back to OpenAI. */
val resolvedOutputTokens: Int? get() = outputTokens ?: completionTokens
/** Resolved total tokens. */
val resolvedTotalTokens: Int? get() = totalTokens
?: if (resolvedInputTokens != null || resolvedOutputTokens != null)
(resolvedInputTokens ?: 0) + (resolvedOutputTokens ?: 0)
else null
}
// --- Skills API ---
@Serializable
data class SkillInfo(
val name: String,
val description: String? = null,
val category: String? = null,
@SerialName("usage") val usage: String? = null
)
@Serializable
data class SkillListResponse(
val skills: List<SkillInfo>? = null,
val items: List<SkillInfo>? = null
)
@@ -0,0 +1,256 @@
package com.hermesandroid.relay.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.theme.purpleGlow
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.hermesandroid.relay.ui.components.WhatsNewDialog
import com.hermesandroid.relay.ui.onboarding.OnboardingScreen
import com.hermesandroid.relay.ui.screens.BridgeScreen
import com.hermesandroid.relay.ui.screens.ChatScreen
import com.hermesandroid.relay.ui.screens.SettingsScreen
import com.hermesandroid.relay.ui.screens.TerminalScreen
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
sealed class Screen(
val route: String,
val label: String,
val icon: ImageVector
) {
data object Onboarding : Screen("onboarding", "Onboarding", Icons.Filled.Settings)
data object Chat : Screen("chat", "Chat", Icons.Filled.Chat)
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
}
private val bottomNavScreens = listOf(
Screen.Chat,
Screen.Terminal,
Screen.Bridge,
Screen.Settings
)
@Composable
fun RelayApp() {
val connectionViewModel: ConnectionViewModel = viewModel()
val chatViewModel: ChatViewModel = viewModel()
// Initialize ChatViewModel reactively when API client becomes available
val apiClient by connectionViewModel.apiClient.collectAsState()
val lastSessionId by connectionViewModel.lastSessionId.collectAsState()
var sessionResumed by remember { mutableStateOf(false) }
LaunchedEffect(apiClient) {
apiClient?.let { client ->
chatViewModel.initialize(client, connectionViewModel.chatHandler)
chatViewModel.updateApiClient(client)
// Wire session persistence callback
chatViewModel.onSessionChanged = { sessionId ->
connectionViewModel.saveLastSessionId(sessionId)
}
// Resume last session on first connection
if (!sessionResumed && lastSessionId != null) {
chatViewModel.resumeSession(lastSessionId!!)
sessionResumed = true
}
}
}
// Sync app context toggle from settings to chat
val appContextEnabled by connectionViewModel.appContextEnabled.collectAsState()
LaunchedEffect(appContextEnabled) {
chatViewModel.appContextEnabled = appContextEnabled
}
// Sync tool annotation parsing toggle to ChatHandler
val parseAnnotations by connectionViewModel.parseToolAnnotations.collectAsState()
LaunchedEffect(parseAnnotations) {
connectionViewModel.chatHandler.parseToolAnnotations = parseAnnotations
}
// Sync streaming endpoint preference to chat
val streamingEndpoint by connectionViewModel.streamingEndpoint.collectAsState()
LaunchedEffect(streamingEndpoint) {
chatViewModel.streamingEndpoint = streamingEndpoint
}
// What's New auto-show
val showWhatsNew by connectionViewModel.showWhatsNew.collectAsState()
if (showWhatsNew) {
WhatsNewDialog(onDismiss = { connectionViewModel.dismissWhatsNew() })
}
// Mark version as seen on first launch (when there's no previous version)
val onboardingCompleted by connectionViewModel.onboardingCompleted.collectAsState()
LaunchedEffect(onboardingCompleted) {
if (onboardingCompleted) {
connectionViewModel.markVersionSeen()
}
}
// Observe theme preference
val themePreference by connectionViewModel.theme.collectAsState()
HermesRelayTheme(themePreference = themePreference) {
val navController = rememberNavController()
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
val navBackStackEntry by navController.currentBackStackEntryAsState()
val isOnboarding = navBackStackEntry?.destination?.route == Screen.Onboarding.route
val isDarkTheme = isSystemInDarkTheme()
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0),
bottomBar = {
if (!isOnboarding) {
NavigationBar(
containerColor = if (isDarkTheme) {
Color(0xFF1A1A2E).copy(alpha = 0.9f)
} else {
MaterialTheme.colorScheme.surface
}
) {
val currentDestination = navBackStackEntry?.destination
bottomNavScreens.forEach { screen ->
val isSelected = currentDestination?.hierarchy?.any {
it.route == screen.route
} == true
NavigationBarItem(
icon = {
Box(
modifier = if (isSelected && isDarkTheme) {
Modifier.purpleGlow(
radius = 18.dp,
alpha = 0.4f,
isDarkTheme = true
)
} else Modifier
) {
Icon(
imageVector = screen.icon,
contentDescription = screen.label
)
}
},
label = { Text(screen.label) },
selected = isSelected,
colors = if (isDarkTheme) {
NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
NavigationBarItemDefaults.colors()
},
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier.padding(innerPadding)
) {
composable(Screen.Onboarding.route) {
OnboardingScreen(
onComplete = { apiServerUrl, apiKey, relayUrl ->
connectionViewModel.updateApiServerUrl(apiServerUrl)
if (apiKey.isNotBlank()) {
connectionViewModel.updateApiKey(apiKey)
}
if (relayUrl.isNotBlank()) {
connectionViewModel.updateRelayUrl(relayUrl)
}
connectionViewModel.completeOnboarding()
navController.navigate(Screen.Chat.route) {
popUpTo(Screen.Onboarding.route) { inclusive = true }
}
}
)
}
composable(Screen.Chat.route) {
// Responsive bubble width based on screen width
val configuration = LocalConfiguration.current
val screenWidthDp = configuration.screenWidthDp.dp
val maxBubbleWidth = when {
screenWidthDp >= 840.dp -> 600.dp // Expanded (tablet)
screenWidthDp >= 600.dp -> 480.dp // Medium (landscape / small tablet)
else -> 300.dp // Compact (phone portrait)
}
ChatScreen(
chatViewModel = chatViewModel,
connectionViewModel = connectionViewModel,
maxBubbleWidth = maxBubbleWidth
)
}
composable(Screen.Terminal.route) {
TerminalScreen()
}
composable(Screen.Bridge.route) {
BridgeScreen()
}
composable(Screen.Settings.route) {
SettingsScreen(connectionViewModel = connectionViewModel)
}
}
}
}
}
@@ -0,0 +1,372 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.FlowRowOverflow
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
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.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* A slash command entry — built-in, personality, or server skill.
*/
data class SlashCommand(
val command: String,
val description: String,
val category: String = "built-in"
)
/**
* Full-screen command palette as a bottom sheet.
* Shows all available commands grouped by category with search.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun CommandPalette(
commands: List<SlashCommand>,
onSelect: (SlashCommand) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var searchQuery by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf<String?>(null) }
var categoriesExpanded by remember { mutableStateOf(false) }
// Get unique categories in a logical order
val categories = remember(commands) {
val priorityOrder = listOf("session", "configuration", "info", "personality")
commands.map { it.category }.distinct().sortedWith(
compareBy<String> {
val idx = priorityOrder.indexOf(it)
if (idx >= 0) idx else priorityOrder.size
}.thenBy { it }
)
}
// Filter commands by search + category
val filtered = remember(commands, searchQuery, selectedCategory) {
commands.filter { cmd ->
val matchesSearch = searchQuery.isBlank() ||
cmd.command.contains(searchQuery, ignoreCase = true) ||
cmd.description.contains(searchQuery, ignoreCase = true)
val matchesCategory = selectedCategory == null || cmd.category == selectedCategory
matchesSearch && matchesCategory
}
}
// Group filtered commands by category with priority ordering
val grouped = remember(filtered, categories) {
val order = categories.withIndex().associate { (i, v) -> v to i }
filtered.groupBy { it.category }
.toSortedMap(compareBy { order[it] ?: Int.MAX_VALUE })
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 400.dp)
.padding(bottom = 16.dp)
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Commands",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "${filtered.size} available",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
// Search bar
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
placeholder = { Text("Search commands...") },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Clear search",
modifier = Modifier.size(20.dp)
)
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(8.dp))
// Category filter chips with expand/collapse
Column(
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
) {
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
maxLines = if (categoriesExpanded) Int.MAX_VALUE else 2,
overflow = FlowRowOverflow.Clip
) {
FilterChip(
selected = selectedCategory == null,
onClick = { selectedCategory = null },
label = { Text("All") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
)
)
categories.forEach { category ->
val count = commands.count { it.category == category }
FilterChip(
selected = selectedCategory == category,
onClick = {
selectedCategory = if (selectedCategory == category) null else category
},
label = {
Text(
text = "${formatCategoryName(category)} ($count)",
maxLines = 1
)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
}
if (categories.size > 6) {
TextButton(
onClick = { categoriesExpanded = !categoriesExpanded },
modifier = Modifier.padding(horizontal = 8.dp)
) {
Text(
text = if (categoriesExpanded) "Show less"
else "Show all (${categories.size})",
style = MaterialTheme.typography.labelMedium
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
// Command list grouped by category
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
grouped.forEach { (category, cmds) ->
// Category header
item(key = "header_$category") {
Text(
text = formatCategoryName(category),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(
start = 16.dp, end = 16.dp,
top = 12.dp, bottom = 4.dp
)
)
}
items(
items = cmds,
key = { "${it.category}:${it.command}" }
) { cmd ->
CommandRow(
command = cmd,
onClick = { onSelect(cmd) }
)
}
}
// Empty state
if (filtered.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "No commands match your search",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
/**
* A single command row used in both the palette and the inline autocomplete.
*/
@Composable
fun CommandRow(
command: SlashCommand,
onClick: () -> Unit,
modifier: Modifier = Modifier,
compact: Boolean = false
) {
Column(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = if (compact) 8.dp else 10.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = command.command,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
if (!compact && command.category != "built-in" && command.category != "personality") {
Spacer(modifier = Modifier.width(8.dp))
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Text(
text = command.category,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
Text(
text = command.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (compact) 1 else 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp)
)
}
}
/**
* Inline autocomplete popup for quick command entry.
* Shows filtered results as user types "/" in chat input.
*/
@Composable
fun InlineAutocomplete(
commands: List<SlashCommand>,
onSelect: (SlashCommand) -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
shadowElevation = 4.dp,
tonalElevation = 2.dp
) {
LazyColumn(
modifier = Modifier.heightIn(max = 280.dp)
) {
items(
items = commands,
key = { "${it.category}:${it.command}" }
) { cmd ->
CommandRow(
command = cmd,
onClick = { onSelect(cmd) },
compact = true
)
if (cmd != commands.last()) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
}
}
}
}
}
/** Format category name: "software-development" → "Software Development" */
private fun formatCategoryName(category: String): String {
return category.split("-").joinToString(" ") { word ->
word.replaceFirstChar { it.uppercase() }
}
}
@@ -0,0 +1,108 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ToolCall
@Composable
fun CompactToolCall(
toolCall: ToolCall,
modifier: Modifier = Modifier
) {
val statusText = when {
toolCall.isComplete && toolCall.success == true -> "completed"
toolCall.isComplete && toolCall.success == false -> "failed"
else -> "running"
}
val duration = if (toolCall.completedAt != null && toolCall.completedAt >= toolCall.startedAt) {
val seconds = (toolCall.completedAt - toolCall.startedAt) / 1000.0
String.format("%.1fs", seconds)
} else null
Row(
modifier = modifier
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 4.dp)
.semantics {
contentDescription = "Tool ${toolCall.name} $statusText${duration?.let { " in $it" } ?: ""}"
},
verticalAlignment = Alignment.CenterVertically
) {
// Tool type icon
Icon(
imageVector = toolIcon(toolCall.name),
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
// Tool name
Text(
text = toolCall.name,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
// Status indicator
when {
!toolCall.isComplete -> {
CircularProgressIndicator(
modifier = Modifier.size(10.dp),
strokeWidth = 1.5.dp,
color = MaterialTheme.colorScheme.tertiary
)
}
toolCall.success == true -> {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Completed",
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.primary
)
}
else -> {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Failed",
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
// Duration
if (duration != null) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = duration,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -0,0 +1,164 @@
package com.hermesandroid.relay.ui.components
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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Animated connection status indicator — a colored dot with an optional pulsing ring.
*
* - **Connected (green):** solid dot + slow heartbeat pulse (1.5 s)
* - **Connecting / Reconnecting (amber):** solid dot + faster pulse (0.8 s)
* - **Disconnected (red):** solid dot, no pulse
*/
@Composable
fun ConnectionStatusBadge(
isConnected: Boolean,
isConnecting: Boolean = false,
modifier: Modifier = Modifier,
size: Dp = 12.dp
) {
val dotColor: Color
val showPulse: Boolean
val pulseDurationMs: Int
val statusLabel: String
when {
isConnected -> {
dotColor = Color(0xFF4CAF50) // Material green 500
showPulse = true
pulseDurationMs = 1500
statusLabel = "Connected"
}
isConnecting -> {
dotColor = Color(0xFFFFA726) // Material amber/orange 400
showPulse = true
pulseDurationMs = 800
statusLabel = "Connecting"
}
else -> {
dotColor = MaterialTheme.colorScheme.error
showPulse = false
pulseDurationMs = 1500 // unused but required for val init
statusLabel = "Disconnected"
}
}
// Pulse animation — only runs when showPulse is true
val pulseScale: Float
val pulseAlpha: Float
if (showPulse) {
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
pulseScale = infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.8f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = pulseDurationMs, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "pulseScale"
).value
pulseAlpha = infiniteTransition.animateFloat(
initialValue = 0.35f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = pulseDurationMs, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "pulseAlpha"
).value
} else {
pulseScale = 1f
pulseAlpha = 0f
}
Box(
modifier = modifier
.size(size)
.semantics { contentDescription = statusLabel }
.drawBehind {
// Pulse ring (expanding, fading circle outline)
if (showPulse && pulseAlpha > 0f) {
val ringRadius = (this.size.minDimension / 2f) * pulseScale
drawCircle(
color = dotColor.copy(alpha = pulseAlpha),
radius = ringRadius,
style = Stroke(width = 2.dp.toPx())
)
}
// Solid inner dot
drawCircle(color = dotColor)
}
)
}
/**
* A row showing [ConnectionStatusBadge] alongside a text label and optional status text / test button.
*/
@Composable
fun ConnectionStatusRow(
label: String,
isConnected: Boolean,
isConnecting: Boolean = false,
statusText: String,
onTest: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ConnectionStatusBadge(
isConnected = isConnected,
isConnecting = isConnecting
)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f, fill = false)
)
Text(
text = statusText,
style = MaterialTheme.typography.bodyMedium,
color = when {
isConnected -> Color(0xFF4CAF50)
isConnecting -> Color(0xFFFFA726)
else -> MaterialTheme.colorScheme.error
}
)
if (onTest != null) {
Spacer(modifier = Modifier.width(4.dp))
OutlinedButton(onClick = onTest) {
Text("Test")
}
}
}
}
@@ -0,0 +1,130 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
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.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
import com.mikepenz.markdown.compose.extendedspans.ExtendedSpans
import com.mikepenz.markdown.compose.extendedspans.RoundedCornerSpanPainter
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.markdownExtendedSpans
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes
import kotlinx.coroutines.delay
@Composable
fun MarkdownContent(
content: String,
textColor: Color,
modifier: Modifier = Modifier
) {
val isDarkTheme = isSystemInDarkTheme()
val highlightsBuilder = remember(isDarkTheme) {
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
}
Markdown(
content = content,
modifier = modifier,
colors = markdownColor(
text = textColor,
codeText = MaterialTheme.colorScheme.onSurfaceVariant,
codeBackground = MaterialTheme.colorScheme.surfaceVariant,
linkText = MaterialTheme.colorScheme.primary
),
typography = markdownTypography(
text = MaterialTheme.typography.bodyMedium.copy(color = textColor),
code = MaterialTheme.typography.bodySmall.copy(
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
),
components = markdownComponents(
codeBlock = {
MarkdownCodeBlock(it.content, it.node) { code, language ->
CodeBlockWithCopyButton(code, language, highlightsBuilder)
}
},
codeFence = {
MarkdownCodeFence(it.content, it.node) { code, language ->
CodeBlockWithCopyButton(code, language, highlightsBuilder)
}
}
),
extendedSpans = markdownExtendedSpans {
remember { ExtendedSpans(RoundedCornerSpanPainter()) }
}
)
}
@Composable
private fun CodeBlockWithCopyButton(
code: String,
language: String?,
highlightsBuilder: Highlights.Builder
) {
val clipboardManager = LocalClipboardManager.current
val haptic = LocalHapticFeedback.current
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
delay(2000)
copied = false
}
}
Box {
MarkdownHighlightedCode(code, language, highlightsBuilder)
IconButton(
onClick = {
clipboardManager.setText(AnnotatedString(code))
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
copied = true
},
modifier = Modifier
.size(28.dp)
.align(Alignment.TopEnd)
.padding(4.dp),
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
)
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = if (copied) "Copied" else "Copy code",
modifier = Modifier.size(14.dp),
tint = if (copied) Color(0xFF4CAF50)
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -0,0 +1,287 @@
package com.hermesandroid.relay.ui.components
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.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.unit.sp
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.ui.theme.leftEdgeGlow
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(
message: ChatMessage,
modifier: Modifier = Modifier,
maxBubbleWidth: Dp = 300.dp,
showThinking: Boolean = true,
isFirstInGroup: Boolean = true,
isLastInGroup: Boolean = true,
onCopyMessage: (String) -> Unit = {}
) {
val isUser = message.role == MessageRole.USER
val isSystem = message.role == MessageRole.SYSTEM
val backgroundColor = when (message.role) {
MessageRole.USER -> MaterialTheme.colorScheme.primary
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.surfaceVariant
MessageRole.SYSTEM -> MaterialTheme.colorScheme.tertiaryContainer
}
val textColor = when (message.role) {
MessageRole.USER -> MaterialTheme.colorScheme.onPrimary
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurfaceVariant
MessageRole.SYSTEM -> MaterialTheme.colorScheme.onTertiaryContainer
}
// Grouped bubble shapes — flat edges where consecutive messages meet
val topStart = if (isUser) { if (isFirstInGroup) 16.dp else 16.dp } else { if (isFirstInGroup) 16.dp else 4.dp }
val topEnd = if (isUser) { if (isFirstInGroup) 16.dp else 4.dp } else { if (isFirstInGroup) 16.dp else 16.dp }
val bottomStart = if (isUser) 16.dp else 4.dp // tail side always small
val bottomEnd = if (isUser) 4.dp else 16.dp // tail side always small
val bubbleShape = when (message.role) {
MessageRole.USER -> RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)
MessageRole.ASSISTANT -> RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)
MessageRole.SYSTEM -> RoundedCornerShape(12.dp)
}
val alignment = if (isUser) Alignment.End else Alignment.Start
val timeFormat = SimpleDateFormat("h:mm a", Locale.getDefault())
val a11yDescription = "${message.role.name.lowercase()} message: ${message.content.take(100)}"
val isDarkTheme = isSystemInDarkTheme()
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = alignment
) {
// Agent name label (above assistant bubbles, only first in group)
if (!isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
Text(
text = message.agentName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 2.dp, start = 4.dp)
)
}
// Thinking block (above the bubble, only for assistant messages)
if (!isUser && showThinking && message.thinkingContent.isNotEmpty()) {
ThinkingBlock(
thinkingContent = message.thinkingContent,
isStreaming = message.isThinkingStreaming,
modifier = Modifier
.widthIn(max = maxBubbleWidth)
.padding(bottom = 4.dp)
)
}
// Message bubble
Surface(
shape = bubbleShape,
color = backgroundColor,
modifier = Modifier
.widthIn(max = maxBubbleWidth)
.then(
if (!isUser && !isSystem && isDarkTheme) {
Modifier.leftEdgeGlow(
alpha = 0.12f,
width = 28.dp,
isDarkTheme = true
)
} else Modifier
)
.combinedClickable(
onClick = {},
onLongClick = { onCopyMessage(message.content) }
)
.semantics { contentDescription = a11yDescription }
) {
Column(modifier = Modifier.padding(12.dp)) {
SelectionContainer {
if (isUser || isSystem) {
// Plain text for user and system messages
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = textColor
)
} else {
// Markdown for assistant messages
if (message.content.isNotEmpty()) {
MarkdownContent(
content = message.content,
textColor = textColor
)
}
}
}
// Attachments
if (message.attachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
message.attachments.forEach { attachment ->
if (attachment.isImage) {
val imageBitmap = remember(attachment.content) {
try {
val bytes = android.util.Base64.decode(attachment.content, android.util.Base64.DEFAULT)
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?.asImageBitmap()
} catch (_: Exception) { null as androidx.compose.ui.graphics.ImageBitmap? }
}
if (imageBitmap != null) {
androidx.compose.foundation.Image(
bitmap = imageBitmap,
contentDescription = attachment.fileName,
modifier = Modifier
.widthIn(max = maxBubbleWidth - 24.dp)
.clip(RoundedCornerShape(8.dp))
.padding(vertical = 2.dp),
contentScale = androidx.compose.ui.layout.ContentScale.FillWidth
)
}
} else {
Row(
modifier = Modifier.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "\uD83D\uDCC4",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = attachment.fileName ?: attachment.contentType,
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = 0.8f)
)
}
}
}
}
// Streaming indicator
if (message.isStreaming) {
StreamingDots(
color = textColor.copy(alpha = 0.6f),
modifier = Modifier.padding(top = 4.dp)
)
}
// Timestamp
Spacer(modifier = Modifier.height(2.dp))
Text(
text = timeFormat.format(Date(message.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = 0.5f)
)
// Token display (assistant messages only)
if (!isUser && (message.inputTokens != null || message.outputTokens != null)) {
Spacer(modifier = Modifier.height(2.dp))
TokenDisplay(
inputTokens = message.inputTokens,
outputTokens = message.outputTokens
)
}
}
}
}
}
/**
* Three dots that animate opacity in sequence to indicate streaming is in progress.
*/
@Composable
fun StreamingDots(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
) {
val transition = rememberInfiniteTransition(label = "streaming")
val dot1Alpha by transition.animateFloat(
initialValue = 0.2f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 600),
repeatMode = RepeatMode.Reverse
),
label = "dot1"
)
val dot2Alpha by transition.animateFloat(
initialValue = 0.2f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 600, delayMillis = 200),
repeatMode = RepeatMode.Reverse
),
label = "dot2"
)
val dot3Alpha by transition.animateFloat(
initialValue = 0.2f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 600, delayMillis = 400),
repeatMode = RepeatMode.Reverse
),
label = "dot3"
)
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\u2022",
fontSize = 14.sp,
color = color.copy(alpha = dot1Alpha)
)
Spacer(modifier = Modifier.width(3.dp))
Text(
text = "\u2022",
fontSize = 14.sp,
color = color.copy(alpha = dot2Alpha)
)
Spacer(modifier = Modifier.width(3.dp))
Text(
text = "\u2022",
fontSize = 14.sp,
color = color.copy(alpha = dot3Alpha)
)
}
}
@@ -0,0 +1,166 @@
package com.hermesandroid.relay.ui.components
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.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.nativeCanvas
import android.graphics.Paint
import android.graphics.Typeface
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* ASCII art sphere inspired by the AMP Code CLI.
*
* Renders rows of monospace characters (`. : - = + * # % @`) arranged
* in a sphere shape. Characters cycle organically over time and the
* sphere boundary subtly morphs. Pure Compose Canvas — no OpenGL.
*/
@Composable
fun MorphingSphere(
modifier: Modifier = Modifier
) {
val transition = rememberInfiniteTransition(label = "sphere")
val time by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1_000_000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "time"
)
// Slow color pulse: green → purple → green
val colorPhase by transition.animateFloat(
initialValue = 0f,
targetValue = 6.2832f, // 2π
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 8000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "colorPulse"
)
val density = " .:-=+*#%@"
// Monospace chars are ~1.8x taller than wide. Use more columns to compensate
// so the sphere appears circular, not egg-shaped.
val cols = 52
val rows = 30
val paint = remember {
Paint().apply {
typeface = Typeface.MONOSPACE
isAntiAlias = true
}
}
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
val canvasW = size.width
val canvasH = size.height
// Size each character cell to fit the grid centered in the canvas
val cellW = canvasW / cols
val cellH = canvasH / rows
// Size characters to fill cells tightly — less gap between rows
val charSize = (cellW * 0.95f).coerceAtMost(cellH * 0.85f)
paint.textSize = charSize
// Sphere is centered in the grid
val cx = cols / 2f
val cy = rows / 2f
// Character cell aspect ratio (width / height) — monospace chars are tall
val charAspect = cellW / cellH
// Radius in row-units (vertical). The sphere is measured in rows,
// and column-extent is scaled by charAspect to make it circular on screen.
val baseRadius = (rows / 2f) * 0.85f
// Color pulse: interpolate green → purple
val pulse = (sin(colorPhase) * 0.5f + 0.5f) // 0..1
val r = lerp(0.25f, 0.61f, pulse) // green→purple R
val g = lerp(0.85f, 0.42f, pulse) // green→purple G
val b = lerp(0.40f, 0.94f, pulse) // green→purple B
val t = time
for (row in 0 until rows) {
val ny = (row - cy) / baseRadius // normalized Y: -1..1
// Sphere radius at this Y slice (circle equation)
val sliceRadiusSq = 1f - ny * ny
if (sliceRadiusSq <= 0f) continue
val sliceRadius = sqrt(sliceRadiusSq)
// Subtle boundary morph per row
val morph = 1f + 0.03f * sin(t * 0.5f + row * 0.4f) +
0.02f * sin(t * 0.3f + row * 0.7f)
// Slice radius in column-units, corrected for character aspect ratio
val sliceCols = sliceRadius * baseRadius * morph / charAspect
for (col in 0 until cols) {
val dx = col - cx // distance from center in columns
if (kotlin.math.abs(dx) > sliceCols) continue
// Normalized X on sphere surface (corrected for aspect)
val nxNorm = dx * charAspect / baseRadius
// Approximate Z from sphere surface: z = sqrt(1 - x² - y²)
val zSq = 1f - nxNorm * nxNorm - ny * ny
val nz = if (zSq > 0f) sqrt(zSq) else 0f
// Simple diffuse lighting (light from upper-right-front)
val lightDot = (nxNorm * 0.3f + ny * (-0.4f) + nz * 0.86f)
.coerceIn(0f, 1f)
// Map brightness to ASCII density (boosted for denser fill)
val brightness = lightDot * 0.6f + 0.3f // higher ambient = denser characters
// Time-based character cycling: shift the character selection
val charNoise = sin(t * 0.4f + col * 1.3f + row * 0.9f) * 0.12f +
sin(t * 0.25f + col * 0.7f - row * 1.1f) * 0.08f
val charIndex = ((brightness + charNoise) * (density.length - 1))
.toInt().coerceIn(0, density.length - 1)
val ch = density[charIndex]
if (ch == ' ') continue
// Position on canvas
val px = col * cellW
val py = row * cellH + cellH * 0.8f // baseline offset
// Brightness-based alpha: brighter chars more opaque
val alpha = (brightness * 0.6f + 0.35f).coerceIn(0.3f, 0.95f)
// Edge fade: only the very outermost characters fade slightly
val edgeDist = kotlin.math.abs(dx) / sliceCols
val edgeFade = if (edgeDist > 0.92f) 0.5f + 0.5f * (1f - (edgeDist - 0.92f) / 0.08f) else 1f
paint.color = android.graphics.Color.argb(
(alpha * edgeFade * 255).toInt().coerceIn(0, 255),
(r * 255).toInt(),
(g * 255).toInt(),
(b * 255).toInt()
)
drawContext.canvas.nativeCanvas.drawText(
ch.toString(), px, py, paint
)
}
}
}
}
private fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t
@@ -0,0 +1,95 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Modifier
import androidx.compose.ui.text.style.TextOverflow
/**
* Personality picker — shows server-configured personalities from GET /api/config.
* "Default" uses the server's active personality (config.display.personality).
* Other entries are from config.agent.personalities.
*/
@Composable
fun PersonalityPicker(
selected: String,
personalities: List<String>,
defaultName: String,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
val displayName = if (selected == "default") {
if (defaultName.isNotBlank()) {
defaultName.replaceFirstChar { it.uppercase() }
} else "Default"
} else {
selected.replaceFirstChar { it.uppercase() }
}
Box(modifier = modifier) {
TextButton(onClick = { expanded = true }) {
Text(
text = displayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = "Select personality"
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
// Default (server's active personality)
DropdownMenuItem(
text = {
Text(
text = if (defaultName.isNotBlank()) {
"${defaultName.replaceFirstChar { it.uppercase() }} (default)"
} else "Default",
style = MaterialTheme.typography.bodyMedium
)
},
onClick = {
onSelect("default")
expanded = false
}
)
if (personalities.isNotEmpty()) {
HorizontalDivider()
personalities.filter { it != defaultName }.forEach { personality ->
DropdownMenuItem(
text = {
Text(
text = personality.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodyMedium
)
},
onClick = {
onSelect(personality)
expanded = false
}
)
}
}
}
}
}
@@ -0,0 +1,263 @@
package com.hermesandroid.relay.ui.components
import android.util.Log
import android.view.ViewGroup
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
import java.util.concurrent.Executors
/**
* Parsed result from a Hermes pairing QR code.
*
* QR payload format:
* ```json
* {"hermes":1,"host":"172.16.24.250","port":8642,"key":"bearer-token","tls":false}
* ```
*/
@Serializable
data class HermesPairingPayload(
val hermes: Int,
val host: String,
val port: Int = 8642,
val key: String = "",
val tls: Boolean = false
) {
/** Build the full server URL from host, port, and tls flag. */
val serverUrl: String
get() = "${if (tls) "https" else "http"}://$host:$port"
}
private val json = Json { ignoreUnknownKeys = true }
/**
* Try to parse a scanned string as a Hermes pairing QR payload.
* Returns null if it's not a valid Hermes QR (no "hermes":1 field).
*/
fun parseHermesPairingQr(raw: String): HermesPairingPayload? {
return try {
// Quick check: must contain "hermes" key with value 1
val obj = json.decodeFromString<JsonObject>(raw)
val version = obj["hermes"]?.jsonPrimitive?.int ?: return null
if (version != 1) return null
json.decodeFromString<HermesPairingPayload>(raw)
} catch (_: Exception) {
null
}
}
/**
* Full-screen QR code scanner overlay.
* Detects Hermes pairing QR codes and calls [onPairingDetected] with the parsed payload.
*/
@Composable
fun QrPairingScanner(
onPairingDetected: (HermesPairingPayload) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// AtomicBoolean for thread-safe detection flag (accessed from camera executor thread)
val hasDetected = remember { AtomicBoolean(false) }
val cameraProviderRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
DisposableEffect(Unit) {
onDispose {
cameraProviderRef.value?.unbindAll()
cameraExecutor.shutdown()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.95f))
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header bar
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
IconButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.CenterStart)
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close scanner",
tint = MaterialTheme.colorScheme.onSurface
)
}
Text(
text = "Scan Hermes QR",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.Center)
)
}
Spacer(modifier = Modifier.height(32.dp))
// Camera preview
Box(
modifier = Modifier
.size(280.dp)
.clip(RoundedCornerShape(16.dp)),
contentAlignment = Alignment.Center
) {
AndroidView(
factory = { ctx ->
val previewView = PreviewView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
scaleType = PreviewView.ScaleType.FILL_CENTER
}
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
cameraProviderRef.value = cameraProvider
val preview = Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val barcodeScanner = BarcodeScanning.getClient()
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { analysis ->
analysis.setAnalyzer(cameraExecutor) { imageProxy ->
val mediaImage = imageProxy.image
if (mediaImage != null && !hasDetected.get()) {
val inputImage = InputImage.fromMediaImage(
mediaImage,
imageProxy.imageInfo.rotationDegrees
)
barcodeScanner.process(inputImage)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
if (barcode.valueType == Barcode.TYPE_TEXT ||
barcode.valueType == Barcode.TYPE_UNKNOWN
) {
val rawValue = barcode.rawValue ?: continue
val payload = parseHermesPairingQr(rawValue)
if (payload != null && hasDetected.compareAndSet(false, true)) {
onPairingDetected(payload)
return@addOnSuccessListener
}
}
}
}
.addOnCompleteListener {
imageProxy.close()
}
} else {
imageProxy.close()
}
}
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageAnalysis
)
} catch (e: Exception) {
Log.e("QrPairingScanner", "Camera bind failed", e)
}
}, ContextCompat.getMainExecutor(ctx))
previewView
},
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.height(24.dp))
// Instructions
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 32.dp)
) {
Icon(
imageVector = Icons.Filled.QrCodeScanner,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
Text(
text = "Point at a Hermes pairing QR code",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Text(
text = "Generate one on your server with: hermes-pair",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
}
@@ -0,0 +1,251 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.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.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ChatSession
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun SessionDrawerContent(
sessions: List<ChatSession>,
currentSessionId: String?,
onNewChat: () -> Unit,
onSelectSession: (String) -> Unit,
onDeleteSession: (String) -> Unit,
onRenameSession: (String, String) -> Unit
) {
var renameDialogSession by remember { mutableStateOf<ChatSession?>(null) }
var deleteDialogSession by remember { mutableStateOf<ChatSession?>(null) }
ModalDrawerSheet(modifier = Modifier.width(300.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
// Header
Text(
text = "Sessions",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(8.dp))
// New Chat button
Button(
onClick = onNewChat,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("New Chat")
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
}
if (sessions.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No sessions yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Start a conversation to see it here",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn {
items(sessions, key = { it.sessionId }) { session ->
SessionItem(
session = session,
isActive = session.sessionId == currentSessionId,
onClick = { onSelectSession(session.sessionId) },
onRename = { renameDialogSession = session },
onDelete = { deleteDialogSession = session }
)
}
}
}
}
// Rename dialog
renameDialogSession?.let { session ->
var newTitle by remember(session) { mutableStateOf(session.title ?: "") }
AlertDialog(
onDismissRequest = { renameDialogSession = null },
title = { Text("Rename Session") },
text = {
OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
label = { Text("Title") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(onClick = {
if (newTitle.isNotBlank()) {
onRenameSession(session.sessionId, newTitle)
}
renameDialogSession = null
}) {
Text("Rename")
}
},
dismissButton = {
TextButton(onClick = { renameDialogSession = null }) {
Text("Cancel")
}
}
)
}
// Delete confirmation dialog
deleteDialogSession?.let { session ->
AlertDialog(
onDismissRequest = { deleteDialogSession = null },
title = { Text("Delete Session?") },
text = {
Text("This will permanently delete \"${session.title ?: "Untitled"}\" and its message history.")
},
confirmButton = {
TextButton(onClick = {
onDeleteSession(session.sessionId)
deleteDialogSession = null
}) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { deleteDialogSession = null }) {
Text("Cancel")
}
}
)
}
}
@Composable
private fun SessionItem(
session: ChatSession,
isActive: Boolean,
onClick: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit
) {
val backgroundColor = if (isActive) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surface
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = session.title ?: "Untitled",
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (isActive) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (session.updatedAt > 0) {
Text(
text = formatTimestamp(session.updatedAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (session.messageCount > 0) {
Text(
text = "${session.messageCount} msgs",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
IconButton(onClick = onRename, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Filled.Edit,
contentDescription = "Rename",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onDelete, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Filled.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
)
}
}
}
private fun formatTimestamp(millis: Long): String {
val now = System.currentTimeMillis()
val diff = now - millis
return when {
diff < 60_000 -> "Just now"
diff < 3_600_000 -> "${diff / 60_000}m ago"
diff < 86_400_000 -> SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(millis))
else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(Date(millis))
}
}
@@ -0,0 +1,630 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Canvas
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.hermesandroid.relay.data.AppAnalytics
import com.hermesandroid.relay.data.AppStats
// Brand gradient colors
private val GradientStart = Color(0xFF9B6BF0)
private val GradientEnd = Color(0xFF6B35E8)
@Composable
fun StatsForNerds() {
val appStats by AppAnalytics.stats.collectAsState()
var expanded by rememberSaveable { mutableStateOf(false) }
var showResetDialog by rememberSaveable { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
// Header row — tap to expand/collapse
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Analytics",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
Row {
if (expanded) {
TextButton(onClick = { showResetDialog = true }) {
Text("Reset", color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall)
}
}
TextButton(onClick = { expanded = !expanded }) {
Text(if (expanded) "Collapse" else "Expand")
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
modifier = Modifier.size(18.dp)
)
}
}
}
// Summary line always visible
val totalTokens = appStats.totalTokensIn + appStats.totalTokensOut
val tokensPerMsg = if (appStats.totalMessagesSent > 0)
totalTokens / appStats.totalMessagesSent else 0L
Text(
text = "${appStats.totalMessagesSent} messages | " +
"${formatTokenCount(totalTokens)} tokens" +
(if (tokensPerMsg > 0) " (~${formatTokenCount(tokensPerMsg)}/msg)" else "") +
" | ${appStats.sessionCount} sessions",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
AnimatedVisibility(
visible = expanded,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(top = 12.dp)
) {
// -- Response Time Chart (TTFT) --
if (appStats.recentResponseTimesMs.isNotEmpty()) {
ChartSection(
title = "Time to First Token",
data = appStats.recentResponseTimesMs,
unit = "ms"
)
}
// -- Completion Time Chart --
if (appStats.recentCompletionTimesMs.isNotEmpty()) {
ChartSection(
title = "Completion Time",
data = appStats.recentCompletionTimesMs,
unit = "ms"
)
}
HorizontalDivider()
// -- Token Usage --
TokenUsageSection(appStats)
HorizontalDivider()
// -- Connection Health --
ConnectionHealthSection(appStats)
HorizontalDivider()
// -- Stream Stats --
StreamStatsSection(appStats)
}
}
}
}
// Reset confirmation dialog
if (showResetDialog) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("Reset Analytics?") },
text = { Text("This will clear all recorded stats including token counts, response times, and stream history. This cannot be undone.") },
confirmButton = {
TextButton(onClick = {
AppAnalytics.resetAll()
showResetDialog = false
}) {
Text("Reset", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) {
Text("Cancel")
}
}
)
}
}
// --- Chart ---
@Composable
private fun ChartSection(
title: String,
data: List<Long>,
unit: String
) {
val minVal = data.minOrNull() ?: 0L
val maxVal = data.maxOrNull() ?: 0L
val avgVal = if (data.isNotEmpty()) data.sum() / data.size else 0L
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
Column {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(4.dp))
// Min / Avg / Max labels
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
StatLabel("Min", formatMsWithSeconds(minVal))
StatLabel("Avg", formatMsWithSeconds(avgVal))
StatLabel("Max", formatMsWithSeconds(maxVal))
}
Spacer(modifier = Modifier.height(8.dp))
// Bar chart drawn with Canvas
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
) {
val barCount = data.size
if (barCount == 0) return@Canvas
val chartMax = maxVal.coerceAtLeast(1L)
val barSpacing = 2.dp.toPx()
val totalSpacing = barSpacing * (barCount - 1).coerceAtLeast(0)
val barWidth = ((size.width - totalSpacing) / barCount).coerceAtLeast(2f)
val bottomPadding = 16.dp.toPx()
val chartHeight = size.height - bottomPadding
val gradient = Brush.verticalGradient(
colors = listOf(GradientStart, GradientEnd),
startY = 0f,
endY = chartHeight
)
// Draw bars
data.forEachIndexed { index, value ->
val barHeight = (value.toFloat() / chartMax) * chartHeight
val x = index * (barWidth + barSpacing)
val y = chartHeight - barHeight
drawRect(
brush = gradient,
topLeft = Offset(x, y),
size = Size(barWidth, barHeight)
)
}
// Draw average line
val avgY = chartHeight - (avgVal.toFloat() / chartMax) * chartHeight
drawLine(
color = GradientStart.copy(alpha = 0.5f),
start = Offset(0f, avgY),
end = Offset(size.width, avgY),
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
pathEffect = androidx.compose.ui.graphics.PathEffect.dashPathEffect(
floatArrayOf(8f, 6f), 0f
)
)
// X-axis label area
val textPaint = android.graphics.Paint().apply {
color = labelColor.hashCode()
textSize = 9.sp.toPx()
textAlign = android.graphics.Paint.Align.CENTER
isAntiAlias = true
}
// Draw a few index labels (first, middle, last)
val indicesToLabel = when {
barCount <= 5 -> data.indices.toList()
else -> listOf(0, barCount / 2, barCount - 1)
}
indicesToLabel.forEach { idx ->
val x = idx * (barWidth + barSpacing) + barWidth / 2
drawContext.canvas.nativeCanvas.drawText(
"${idx + 1}",
x,
size.height,
textPaint
)
}
}
}
}
@Composable
private fun StatLabel(label: String, value: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// --- Token Usage ---
@Composable
private fun TokenUsageSection(stats: AppStats) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Token Usage",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
)
// Current session
Text(
text = "Current Session",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TokenStat("In", formatTokenCount(stats.currentSessionTokensIn))
TokenStat("Out", formatTokenCount(stats.currentSessionTokensOut))
TokenStat("Total", formatTokenCount(stats.currentSessionTokensIn + stats.currentSessionTokensOut))
}
// Lifetime
Text(
text = "Lifetime",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TokenStat("In", formatTokenCount(stats.totalTokensIn))
TokenStat("Out", formatTokenCount(stats.totalTokensOut))
TokenStat("Total", formatTokenCount(stats.totalTokensIn + stats.totalTokensOut))
}
}
}
@Composable
private fun TokenStat(label: String, value: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// --- Connection Health ---
@Composable
private fun ConnectionHealthSection(stats: AppStats) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Connection Health",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
)
if (stats.healthChecksTotal == 0) {
Text(
text = "No health checks recorded yet",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
// Success rate bar
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Success rate",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${(stats.healthCheckSuccessRate * 100).toInt()}%",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = when {
stats.healthCheckSuccessRate >= 0.9f -> MaterialTheme.colorScheme.primary
stats.healthCheckSuccessRate >= 0.5f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
)
}
LinearProgressIndicator(
progress = { stats.healthCheckSuccessRate },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
// Avg latency
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Avg latency",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatMsWithSeconds(stats.avgHealthLatencyMs),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
}
// Total checks
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Total checks",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${stats.healthChecksTotal}",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
// --- Stream Stats ---
@Composable
private fun StreamStatsSection(stats: AppStats) {
val totalStreams = stats.streamsCompleted + stats.streamsErrored + stats.streamsCancelled
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Stream Stats",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
)
if (totalStreams == 0) {
Text(
text = "No streams recorded yet",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
// Success rate bar
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Success rate",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${(stats.streamSuccessRate * 100).toInt()}%",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = when {
stats.streamSuccessRate >= 0.9f -> MaterialTheme.colorScheme.primary
stats.streamSuccessRate >= 0.5f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
)
}
LinearProgressIndicator(
progress = { stats.streamSuccessRate },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
Spacer(modifier = Modifier.height(4.dp))
// Breakdown
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
StreamCounter("Completed", stats.streamsCompleted, MaterialTheme.colorScheme.primary)
StreamCounter("Errored", stats.streamsErrored, MaterialTheme.colorScheme.error)
StreamCounter("Cancelled", stats.streamsCancelled, MaterialTheme.colorScheme.onSurfaceVariant)
}
// Avg times
if (stats.avgResponseTimeMs > 0) {
val peakTtft = stats.recentResponseTimesMs.maxOrNull() ?: 0L
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Avg TTFT",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatMsWithSeconds(stats.avgResponseTimeMs),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
}
if (peakTtft > stats.avgResponseTimeMs) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Peak TTFT",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatMsWithSeconds(peakTtft),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.tertiary
)
}
}
}
if (stats.avgCompletionTimeMs > 0) {
val worstCompletion = stats.recentCompletionTimesMs.maxOrNull() ?: 0L
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Avg completion",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatMsWithSeconds(stats.avgCompletionTimeMs),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
}
if (worstCompletion > stats.avgCompletionTimeMs) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Slowest",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatMsWithSeconds(worstCompletion),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.tertiary
)
}
}
}
}
}
}
@Composable
private fun StreamCounter(label: String, count: Int, color: Color) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "$count",
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = color
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// --- Formatting helpers ---
private fun formatTokenCount(tokens: Long): String = when {
tokens >= 1_000_000 -> "${tokens / 1_000_000}.${(tokens % 1_000_000) / 100_000}M"
tokens >= 1_000 -> "${tokens / 1_000}.${(tokens % 1_000) / 100}k"
else -> "$tokens"
}
private fun formatDuration(ms: Long): String = when {
ms >= 60_000 -> "${ms / 60_000}m ${(ms % 60_000) / 1_000}s"
ms >= 1_000 -> "${ms / 1_000}.${(ms % 1_000) / 100}s"
else -> "${ms}ms"
}
/** Format ms with seconds subtext: "1234ms (1.2s)" */
private fun formatMsWithSeconds(ms: Long): String = when {
ms >= 1_000 -> "${ms}ms (${"%.1f".format(ms / 1000.0)}s)"
else -> "${ms}ms"
}
@@ -0,0 +1,96 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Psychology
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.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.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun ThinkingBlock(
thinkingContent: String,
isStreaming: Boolean,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(isStreaming) }
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(modifier = Modifier.padding(8.dp)) {
// Header row
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Psychology,
contentDescription = "Thinking",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = if (isStreaming) "Thinking..." else "Thought process",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary
)
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Collapsible content
AnimatedVisibility(
visible = expanded,
enter = expandVertically(),
exit = shrinkVertically()
) {
Text(
text = thinkingContent,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Default,
color = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier.padding(top = 4.dp),
maxLines = if (isStreaming) Int.MAX_VALUE else 50,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@@ -0,0 +1,46 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun TokenDisplay(
inputTokens: Int?,
outputTokens: Int?,
modifier: Modifier = Modifier
) {
if (inputTokens == null && outputTokens == null) return
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
val parts = mutableListOf<String>()
if (inputTokens != null) {
parts.add("↑${formatTokens(inputTokens)}")
}
if (outputTokens != null) {
parts.add("↓${formatTokens(outputTokens)}")
}
Text(
text = parts.joinToString(" ") + " tokens",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
private fun formatTokens(count: Int): String {
return when {
count >= 1_000_000 -> "${String.format("%.1f", count / 1_000_000.0)}M"
count >= 1_000 -> "${String.format("%.1f", count / 1_000.0)}K"
else -> count.toString()
}
}
@@ -0,0 +1,228 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.TouchApp
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
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.graphics.vector.ImageVector
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ToolCall
@Composable
fun ToolProgressCard(
toolCall: ToolCall,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(!toolCall.isComplete) }
// Auto-collapse when tool completes
LaunchedEffect(toolCall.isComplete) {
if (toolCall.isComplete) expanded = false
}
val statusIcon: ImageVector
val statusColor = when {
toolCall.isComplete && toolCall.success == true -> {
statusIcon = Icons.Filled.Check
MaterialTheme.colorScheme.primary
}
toolCall.isComplete && toolCall.success == false -> {
statusIcon = Icons.Filled.Close
MaterialTheme.colorScheme.error
}
else -> {
statusIcon = Icons.Filled.HourglassTop
MaterialTheme.colorScheme.tertiary
}
}
val toolIcon = toolIcon(toolCall.name)
val statusText = when {
toolCall.isComplete && toolCall.success == true -> "completed"
toolCall.isComplete && toolCall.success == false -> "failed"
else -> "running"
}
val duration = if (toolCall.completedAt != null && toolCall.completedAt >= toolCall.startedAt) {
val seconds = (toolCall.completedAt - toolCall.startedAt) / 1000.0
String.format("%.1fs", seconds)
} else null
Card(
modifier = modifier
.fillMaxWidth()
.semantics {
contentDescription = "Tool ${toolCall.name} $statusText${duration?.let { " in $it" } ?: ""}"
},
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
)
) {
Column(modifier = Modifier.padding(12.dp)) {
// Header row
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
verticalAlignment = Alignment.CenterVertically
) {
// Tool type icon
Icon(
imageVector = toolIcon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
// Tool name
Text(
text = toolCall.name,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.weight(1f)
)
// Duration
if (duration != null) {
Text(
text = duration,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
}
// Status icon
Icon(
imageVector = statusIcon,
contentDescription = statusText,
tint = statusColor,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
// Expand/collapse
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Progress bar while running
if (!toolCall.isComplete) {
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.tertiary
)
}
// Expandable details
AnimatedVisibility(
visible = expanded,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column(modifier = Modifier.padding(top = 8.dp)) {
// Arguments
toolCall.args?.let { args ->
Text(
text = "Arguments:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = args,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.padding(top = 2.dp)
)
}
// Error (tool.failed)
toolCall.error?.let { error ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Error:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
Text(
text = error,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 2.dp)
)
}
// Result
toolCall.result?.let { result ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Result:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = result,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
}
}
}
internal fun toolIcon(toolName: String): ImageVector = when {
toolName.contains("screenshot") || toolName.contains("read_screen") || toolName.contains("vision") -> Icons.Filled.Description
toolName.contains("tap") || toolName.contains("swipe") || toolName.contains("click") || toolName.contains("scroll") -> Icons.Filled.TouchApp
toolName.contains("type") || toolName.contains("input") || toolName.contains("keyboard") -> Icons.Filled.Keyboard
toolName.contains("launch") || toolName.contains("open") -> Icons.Filled.OpenInNew
toolName.contains("bash") || toolName.contains("terminal") || toolName.contains("execute") || toolName.contains("shell") -> Icons.Filled.Code
toolName.contains("file") || toolName.contains("read") || toolName.contains("write") -> Icons.Filled.Description
toolName.contains("search") || toolName.contains("web") -> Icons.Filled.Search
else -> Icons.Filled.Build
}
@@ -0,0 +1,55 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
@Composable
fun WhatsNewDialog(
onDismiss: () -> Unit
) {
val context = LocalContext.current
val changelogText = remember { loadWhatsNew(context) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("What's New") },
text = {
Text(
text = changelogText,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Default
),
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 400.dp)
.verticalScroll(rememberScrollState())
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Got it")
}
}
)
}
private fun loadWhatsNew(context: Context): String {
return try {
context.assets.open("whats_new.txt").bufferedReader().readText()
} catch (_: Exception) {
"No release notes available."
}
}
@@ -1,4 +1,4 @@
package com.hermesandroid.companion.ui.onboarding
package com.hermesandroid.relay.ui.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -20,7 +20,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.companion.ui.theme.HermesCompanionTheme
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
@Composable
fun OnboardingPage(
@@ -71,7 +71,7 @@ fun OnboardingPage(
@Preview(showBackground = true)
@Composable
private fun OnboardingPagePreview() {
HermesCompanionTheme {
HermesRelayTheme {
OnboardingPage(
icon = Icons.Filled.Chat,
title = "Talk to Your Agent",
@@ -83,11 +83,11 @@ private fun OnboardingPagePreview() {
@Preview(showBackground = true)
@Composable
private fun OnboardingPageWithContentPreview() {
HermesCompanionTheme {
HermesRelayTheme {
OnboardingPage(
icon = Icons.Filled.Chat,
title = "Let's Connect",
description = "Enter your companion relay server URL to get started."
description = "Enter your relay server URL to get started."
) {
Text(
text = "Custom content slot",
@@ -0,0 +1,681 @@
package com.hermesandroid.relay.ui.onboarding
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.outlined.Dns
import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.Hub
import androidx.compose.material.icons.outlined.PhonelinkSetup
import androidx.compose.material.icons.outlined.RocketLaunch
import androidx.compose.material.icons.outlined.Terminal
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
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.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.text.font.FontFamily
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.collectAsState
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.ui.components.ConnectionStatusBadge
import com.hermesandroid.relay.ui.components.QrPairingScanner
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
import java.util.concurrent.TimeUnit
private sealed interface TestResult {
data object Success : TestResult
data class Failure(val message: String) : TestResult
}
/** Page identifiers for dynamic onboarding flow. */
private enum class OnboardingPage { Welcome, Chat, Terminal, Bridge, Connect, Relay }
@Composable
fun OnboardingScreen(
onComplete: (apiServerUrl: String, apiKey: String, relayUrl: String) -> Unit
) {
val context = LocalContext.current
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
// Build page list dynamically based on feature flags
val pages = remember(relayEnabled) {
buildList {
add(OnboardingPage.Welcome)
add(OnboardingPage.Chat)
if (relayEnabled) {
add(OnboardingPage.Terminal)
add(OnboardingPage.Bridge)
}
add(OnboardingPage.Connect)
if (relayEnabled) {
add(OnboardingPage.Relay)
}
}
}
val pageCount = pages.size
val lastPage = pageCount - 1
val pagerState = rememberPagerState(pageCount = { pageCount })
val coroutineScope = rememberCoroutineScope()
var apiServerUrl by rememberSaveable { mutableStateOf("http://localhost:8642") }
var apiKey by rememberSaveable { mutableStateOf("") }
var relayUrl by rememberSaveable { mutableStateOf("wss://localhost:8767") }
var showSkipConfirm by rememberSaveable { mutableStateOf(false) }
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
if (showSkipConfirm) {
AlertDialog(
onDismissRequest = { showSkipConfirm = false },
title = { Text("Skip setup?") },
text = {
Text("You can configure your server connection later in Settings. Without an API key, your connection will not be authenticated.")
},
confirmButton = {
TextButton(onClick = {
showSkipConfirm = false
onComplete(apiServerUrl, apiKey, relayUrl)
}) {
Text("Skip anyway")
}
},
dismissButton = {
TextButton(onClick = { showSkipConfirm = false }) {
Text("Go back")
}
}
)
}
Column(
modifier = Modifier.fillMaxSize()
) {
// Top bar with Skip
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
// Skip button - always visible
TextButton(onClick = { showSkipConfirm = true }) {
Text(
text = "Skip",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Pager content
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f)
) { pageIndex ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
when (pages[pageIndex]) {
OnboardingPage.Welcome -> WelcomePage()
OnboardingPage.Chat -> ChatPage()
OnboardingPage.Terminal -> TerminalPage()
OnboardingPage.Bridge -> BridgePage()
OnboardingPage.Connect -> ConnectPage(
apiServerUrl = apiServerUrl,
onApiServerUrlChange = { apiServerUrl = it },
apiKey = apiKey,
onApiKeyChange = { apiKey = it }
)
OnboardingPage.Relay -> RelayPage(
relayUrl = relayUrl,
onRelayUrlChange = { relayUrl = it }
)
}
}
}
// Bottom section: page indicator + navigation buttons
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(bottom = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Page indicator
PageIndicator(
pageCount = pageCount,
currentPage = pagerState.currentPage
)
Spacer(modifier = Modifier.height(24.dp))
// Navigation buttons row: Back (left) + Next/Get Started (right)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Back button — only visible when not on first page
AnimatedVisibility(
visible = pagerState.currentPage > 0,
enter = fadeIn(),
exit = fadeOut()
) {
TextButton(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
}
) {
Text(text = "Back")
}
}
// Invisible spacer when Back is hidden so Next/Get Started stays right-aligned
if (pagerState.currentPage == 0) {
Spacer(modifier = Modifier.width(1.dp))
}
// Next / Get Started button
if (pagerState.currentPage < lastPage) {
Button(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
) {
Text(text = "Next")
}
} else {
Button(
onClick = { onComplete(apiServerUrl, apiKey, relayUrl) },
enabled = apiServerUrl.isNotBlank()
) {
Text(text = "Get Started")
}
}
}
}
}
}
}
@Composable
private fun WelcomePage() {
val context = LocalContext.current
OnboardingPage(
icon = Icons.Outlined.RocketLaunch,
title = "Hermes Relay",
description = "Your Hermes agent, in your pocket."
) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "hermes-agent.nousresearch.com",
style = MaterialTheme.typography.bodySmall.copy(
textDecoration = TextDecoration.Underline
),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com")))
}
)
}
}
@Composable
private fun ChatPage() {
OnboardingPage(
icon = Icons.Outlined.Forum,
title = "Chat",
description = "Talk to any Hermes agent profile with real-time streaming responses, tool progress, and full markdown."
)
}
@Composable
private fun TerminalPage() {
OnboardingPage(
icon = Icons.Outlined.Terminal,
title = "Terminal",
description = "Secure remote shell access to your server via tmux. Coming soon."
)
}
@Composable
private fun BridgePage() {
OnboardingPage(
icon = Icons.Outlined.PhonelinkSetup,
title = "Bridge",
description = "Let your agent control your device — taps, typing, screenshots, and automation. Coming soon."
)
}
@Composable
private fun ConnectPage(
apiServerUrl: String,
onApiServerUrlChange: (String) -> Unit,
apiKey: String,
onApiKeyChange: (String) -> Unit
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var apiKeyVisible by rememberSaveable { mutableStateOf(false) }
var testResult by remember { mutableStateOf<TestResult?>(null) }
var isTesting by rememberSaveable { mutableStateOf(false) }
var showApiKeyHelp by rememberSaveable { mutableStateOf(false) }
var showQrScanner by remember { mutableStateOf(false) }
val cameraPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
showQrScanner = true
} else {
Toast.makeText(context, "Camera permission needed to scan QR codes", Toast.LENGTH_SHORT).show()
}
}
// API Key help dialog
if (showApiKeyHelp) {
AlertDialog(
onDismissRequest = { showApiKeyHelp = false },
title = { Text("Where to find your API key") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Your API key is the API_SERVER_KEY value from your Hermes server configuration.",
style = MaterialTheme.typography.bodyMedium
)
Text(
"Location: ~/.hermes/.env",
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace
)
Text(
"If you haven't set a key yet, add one to your server config — it secures all API communication.",
style = MaterialTheme.typography.bodyMedium
)
Text(
"Default port: 8642",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
val helpContext = LocalContext.current
Text(
text = "View full setup guide",
style = MaterialTheme.typography.bodySmall.copy(
textDecoration = TextDecoration.Underline
),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable {
helpContext.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com"))
)
}
)
}
},
confirmButton = {
TextButton(onClick = { showApiKeyHelp = false }) {
Text("Got it")
}
}
)
}
OnboardingPage(
icon = Icons.Outlined.Dns,
title = "Connect",
description = "Enter your Hermes server address and API key to connect securely."
) {
Spacer(modifier = Modifier.height(8.dp))
// API Server URL
OutlinedTextField(
value = apiServerUrl,
onValueChange = {
onApiServerUrlChange(it)
testResult = null
},
label = { Text("API Server URL") },
placeholder = { Text("http://your-server:8642") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// API Key with help icon
OutlinedTextField(
value = apiKey,
onValueChange = {
onApiKeyChange(it)
testResult = null
},
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("API Key")
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.AutoMirrored.Filled.HelpOutline,
contentDescription = "API key help",
modifier = Modifier
.size(18.dp)
.clickable { showApiKeyHelp = true },
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
placeholder = { Text("Your API_SERVER_KEY value") },
singleLine = true,
visualTransformation = if (apiKeyVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { apiKeyVisible = !apiKeyVisible }) {
Icon(
imageVector = if (apiKeyVisible) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = if (apiKeyVisible) "Hide" else "Show"
)
}
},
modifier = Modifier.fillMaxWidth()
)
// "How do I set this up?" clickable text
Text(
text = "How do I set this up?",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.align(Alignment.Start)
.clickable { showApiKeyHelp = true }
.padding(top = 4.dp)
)
Spacer(modifier = Modifier.height(12.dp))
// Test Connection button + result
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = {
if (apiServerUrl.isNotBlank()) {
isTesting = true
testResult = null
coroutineScope.launch {
testResult = performHealthCheck(apiServerUrl, apiKey)
isTesting = false
}
}
},
enabled = apiServerUrl.isNotBlank() && !isTesting
) {
Text("Test Connection")
}
if (isTesting) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
testResult?.let { result ->
when (result) {
is TestResult.Success -> {
ConnectionStatusBadge(
isConnected = true,
modifier = Modifier.size(14.dp),
size = 14.dp
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Connected",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
is TestResult.Failure -> {
ConnectionStatusBadge(
isConnected = false,
modifier = Modifier.size(14.dp),
size = 14.dp
)
}
}
}
}
// Error message text below the row
testResult?.let { result ->
if (result is TestResult.Failure) {
Text(
text = result.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.align(Alignment.Start)
.padding(top = 4.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// QR code scanning option
OutlinedButton(
onClick = {
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Filled.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Scan QR Code")
}
Text(
text = "Run hermes-pair on your server to generate a QR code",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.Start)
)
}
// QR Scanner overlay
if (showQrScanner) {
QrPairingScanner(
onPairingDetected = { payload ->
onApiServerUrlChange(payload.serverUrl)
onApiKeyChange(payload.key)
showQrScanner = false
// Auto-trigger test
isTesting = true
testResult = null
coroutineScope.launch {
testResult = performHealthCheck(payload.serverUrl, payload.key)
isTesting = false
}
},
onDismiss = { showQrScanner = false }
)
}
}
@Composable
private fun RelayPage(
relayUrl: String,
onRelayUrlChange: (String) -> Unit
) {
OnboardingPage(
icon = Icons.Outlined.Hub,
title = "Relay Server",
description = "Optional — for Bridge and Terminal features. You can set this up later in Settings."
) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = relayUrl,
onValueChange = onRelayUrlChange,
label = { Text("Relay URL (optional)") },
placeholder = { Text("wss://your-server:8767") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Needed for Bridge and Terminal features",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* Performs a direct OkHttp health check against the Hermes API server.
* Returns a [TestResult] with either success or a descriptive error message.
*/
private suspend fun performHealthCheck(apiServerUrl: String, apiKey: String): TestResult =
withContext(Dispatchers.IO) {
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
try {
val builder = Request.Builder()
.url("$apiServerUrl/health")
.get()
if (apiKey.isNotBlank()) {
builder.header("Authorization", "Bearer $apiKey")
}
val request = builder.build()
client.newCall(request).execute().use { response ->
when {
response.isSuccessful -> TestResult.Success
response.code == 401 -> TestResult.Failure("Unauthorized \u2014 check your API key")
else -> TestResult.Failure("Server returned HTTP ${response.code}")
}
}
} catch (e: SSLException) {
// User entered https:// but server runs plain HTTP, or vice versa
if (apiServerUrl.startsWith("https://", ignoreCase = true)) {
TestResult.Failure("TLS handshake failed \u2014 try http:// if your server doesn't use HTTPS")
} else {
TestResult.Failure("SSL error: ${e.message}")
}
} catch (e: ConnectException) {
TestResult.Failure("Connection refused \u2014 check the URL and port")
} catch (e: UnknownHostException) {
TestResult.Failure("Server not found \u2014 check the hostname")
} catch (e: SocketTimeoutException) {
TestResult.Failure("Connection timed out \u2014 is the server running?")
} catch (e: IOException) {
val msg = e.message ?: ""
when {
msg.contains("401") -> TestResult.Failure("Unauthorized \u2014 check your API key")
msg.contains("tls", ignoreCase = true) || msg.contains("ssl", ignoreCase = true) ->
TestResult.Failure("TLS error \u2014 try http:// if your server doesn't use HTTPS")
else -> TestResult.Failure("Connection failed: $msg")
}
} catch (e: Exception) {
TestResult.Failure("Connection failed: ${e.message}")
} finally {
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
}
@Preview(showSystemUi = true)
@Composable
private fun OnboardingScreenPreview() {
HermesRelayTheme {
OnboardingScreen(
onComplete = { _, _, _ -> }
)
}
}
@Preview(showSystemUi = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun OnboardingScreenDarkPreview() {
HermesRelayTheme(themePreference = "dark") {
OnboardingScreen(
onComplete = { _, _, _ -> }
)
}
}
@@ -1,4 +1,4 @@
package com.hermesandroid.companion.ui.onboarding
package com.hermesandroid.relay.ui.onboarding
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
@@ -17,7 +17,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.companion.ui.theme.HermesCompanionTheme
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
@Composable
fun PageIndicator(
@@ -62,7 +62,7 @@ fun PageIndicator(
@Preview(showBackground = true)
@Composable
private fun PageIndicatorPreview() {
HermesCompanionTheme {
HermesRelayTheme {
PageIndicator(pageCount = 5, currentPage = 2)
}
}
@@ -70,7 +70,7 @@ private fun PageIndicatorPreview() {
@Preview(showBackground = true)
@Composable
private fun PageIndicatorFirstPagePreview() {
HermesCompanionTheme {
HermesRelayTheme {
PageIndicator(pageCount = 5, currentPage = 0)
}
}
@@ -0,0 +1,128 @@
package com.hermesandroid.relay.ui.screens
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AssistChip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BridgeScreen() {
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Bridge") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.widthIn(max = 300.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.PhoneAndroid,
contentDescription = null,
modifier = Modifier
.size(64.dp)
.alpha(0.6f),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Device Bridge",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Let your Hermes agent interact with your phone \u2014 " +
"tap, type, read the screen, and automate workflows.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
AssistChip(
onClick = { },
label = {
Text(
text = "Coming Soon",
style = MaterialTheme.typography.labelSmall
)
},
leadingIcon = {
Icon(
imageVector = Icons.Filled.Schedule,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
Spacer(modifier = Modifier.height(20.dp))
val plannedFeatures = listOf(
"Agent-controlled device interaction",
"Accessibility service integration",
"Activity log and command history",
"Permission management"
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
plannedFeatures.forEach { feature ->
Text(
text = "\u2022 $feature",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun BridgeScreenPreview() {
MaterialTheme {
BridgeScreen()
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
package com.hermesandroid.relay.ui.screens
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.AssistChip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TerminalScreen() {
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Terminal") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.widthIn(max = 300.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.Terminal,
contentDescription = null,
modifier = Modifier
.size(64.dp)
.alpha(0.6f),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Remote Terminal",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Secure shell access to your Hermes server via tmux. " +
"Connect, run commands, and manage sessions \u2014 right from your phone.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
AssistChip(
onClick = { },
label = {
Text(
text = "Coming Soon",
style = MaterialTheme.typography.labelSmall
)
},
leadingIcon = {
Icon(
imageVector = Icons.Filled.Schedule,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
Spacer(modifier = Modifier.height(20.dp))
val plannedFeatures = listOf(
"Full ANSI terminal emulator",
"tmux session management",
"Biometric authentication",
"Soft keyboard shortcuts"
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
plannedFeatures.forEach { feature ->
Text(
text = "\u2022 $feature",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun TerminalScreenPreview() {
MaterialTheme {
TerminalScreen()
}
}
@@ -0,0 +1,137 @@
package com.hermesandroid.relay.ui.theme
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
// Brand colors for glow effects
private val GlowPurple = Color(0xFF9B6BF0)
private val GlowPurpleDark = Color(0xFF6B35E8)
private val NavySurface = Color(0xFF1E1E34)
private val NavyDeep = Color(0xFF12121E)
/**
* Purple glow behind an element. Uses a radial gradient from purple to transparent.
* Only applies in dark theme — in light theme this is a no-op.
*/
fun Modifier.purpleGlow(
radius: Dp = 20.dp,
alpha: Float = 0.3f,
isDarkTheme: Boolean = true
): Modifier {
if (!isDarkTheme) return this
return this.drawBehind {
val radiusPx = radius.toPx()
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
GlowPurple.copy(alpha = alpha),
GlowPurpleDark.copy(alpha = alpha * 0.4f),
Color.Transparent
),
center = center,
radius = radiusPx
),
radius = radiusPx,
center = center
)
}
}
/**
* Gradient border on a card/surface. Uses a linear gradient from light purple to dark purple.
* Only applies in dark theme — in light theme this is a no-op.
*/
fun Modifier.gradientBorder(
width: Dp = 1.dp,
shape: Shape = RoundedCornerShape(16.dp),
colors: List<Color> = listOf(GlowPurple, GlowPurpleDark),
isDarkTheme: Boolean = true
): Modifier {
if (!isDarkTheme) return this
return this.border(
width = width,
brush = Brush.linearGradient(colors),
shape = shape
)
}
/**
* Radial background gradient — dark center, darker edges.
* Provides subtle depth instead of flat color.
* Only applies in dark theme — in light theme this is a no-op.
*/
fun Modifier.radialNavyBackground(
isDarkTheme: Boolean = true
): Modifier {
if (!isDarkTheme) return this
return this.drawBehind {
drawRect(
brush = Brush.radialGradient(
colors = listOf(NavySurface, NavyDeep),
center = Offset(size.width / 2f, size.height / 3f),
radius = size.maxDimension * 0.8f
)
)
}
}
/**
* Small purple glow on the left edge of an element (used for assistant bubbles).
* Only applies in dark theme.
*/
fun Modifier.leftEdgeGlow(
alpha: Float = 0.15f,
width: Dp = 24.dp,
isDarkTheme: Boolean = true
): Modifier {
if (!isDarkTheme) return this
return this.drawBehind {
val widthPx = width.toPx()
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(
GlowPurple.copy(alpha = alpha),
Color.Transparent
),
startX = 0f,
endX = widthPx
)
)
}
}
/**
* Composable helper to check dark theme and apply glow modifiers.
* Use this in @Composable contexts where isSystemInDarkTheme() is available.
*/
@Composable
fun Modifier.purpleGlowAuto(
radius: Dp = 20.dp,
alpha: Float = 0.3f
): Modifier = purpleGlow(radius = radius, alpha = alpha, isDarkTheme = isSystemInDarkTheme())
@Composable
fun Modifier.gradientBorderAuto(
width: Dp = 1.dp,
shape: Shape = RoundedCornerShape(16.dp),
colors: List<Color> = listOf(GlowPurple, GlowPurpleDark)
): Modifier = gradientBorder(width = width, shape = shape, colors = colors, isDarkTheme = isSystemInDarkTheme())
@Composable
fun Modifier.radialNavyBackgroundAuto(): Modifier = radialNavyBackground(isDarkTheme = isSystemInDarkTheme())
@Composable
fun Modifier.leftEdgeGlowAuto(
alpha: Float = 0.15f,
width: Dp = 24.dp
): Modifier = leftEdgeGlow(alpha = alpha, width = width, isDarkTheme = isSystemInDarkTheme())
@@ -0,0 +1,105 @@
package com.hermesandroid.relay.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
// Brand palette — derived from assets/logo.svg
private val HermesPrimary = Color(0xFF6B35E8) // Logo primary purple
private val HermesPrimaryLight = Color(0xFF9B6BF0) // Logo accent purple
private val HermesPrimaryDark = Color(0xFF4A1DB8) // Deeper variant for containers
private val HermesNavy = Color(0xFF1A1A2E) // Logo background navy
private val HermesNavySurface = Color(0xFF1E1E34) // Slightly lifted surface
private val HermesNavyVariant = Color(0xFF2A2A44) // Card/surface variant
private val DarkColorScheme = darkColorScheme(
primary = HermesPrimaryLight,
onPrimary = Color(0xFF1A0049),
primaryContainer = HermesPrimary,
onPrimaryContainer = Color(0xFFE8DEFF),
secondary = Color(0xFFB8AACC),
onSecondary = Color(0xFF2B2040),
secondaryContainer = Color(0xFF413558),
onSecondaryContainer = Color(0xFFE8DEFF),
tertiary = Color(0xFF9B6BF0),
onTertiary = Color(0xFF1A0049),
tertiaryContainer = Color(0xFF3D1F8C),
onTertiaryContainer = Color(0xFFE8DEFF),
background = HermesNavy,
onBackground = Color(0xFFE4E1E9),
surface = HermesNavy,
onSurface = Color(0xFFE4E1E9),
surfaceVariant = HermesNavyVariant,
onSurfaceVariant = Color(0xFFC9C3D4),
surfaceContainerLowest = Color(0xFF151524),
surfaceContainerLow = Color(0xFF1C1C30),
surfaceContainer = HermesNavySurface,
surfaceContainerHigh = Color(0xFF24243C),
surfaceContainerHighest = Color(0xFF2E2E48),
outline = Color(0xFF5A5470),
outlineVariant = Color(0xFF3D3854)
)
private val LightColorScheme = lightColorScheme(
primary = HermesPrimary,
onPrimary = Color.White,
primaryContainer = Color(0xFFE8DEFF),
onPrimaryContainer = Color(0xFF1A0049),
secondary = Color(0xFF5E5474),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE8DEFF),
onSecondaryContainer = Color(0xFF1B1030),
tertiary = HermesPrimaryDark,
onTertiary = Color.White,
tertiaryContainer = Color(0xFFE8DEFF),
onTertiaryContainer = Color(0xFF1A0049),
background = Color(0xFFFCF8FF),
onBackground = Color(0xFF1B1B22),
surface = Color(0xFFFCF8FF),
onSurface = Color(0xFF1B1B22),
surfaceVariant = Color(0xFFEAE4F2),
onSurfaceVariant = Color(0xFF48444E),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF7F2FC),
surfaceContainer = Color(0xFFF1ECF6),
surfaceContainerHigh = Color(0xFFEBE6F0),
surfaceContainerHighest = Color(0xFFE5E0EA),
outline = Color(0xFF79747E),
outlineVariant = Color(0xFFCBC4D0)
)
@Composable
fun HermesRelayTheme(
themePreference: String = "auto",
content: @Composable () -> Unit
) {
val useDarkTheme = when (themePreference) {
"dark" -> true
"light" -> false
else -> isSystemInDarkTheme()
}
val colorScheme = when {
// Dynamic colors available on Android 12+ (API 31)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (useDarkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
useDarkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -1,4 +1,4 @@
package com.hermesandroid.companion.ui.theme
package com.hermesandroid.relay.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
@@ -0,0 +1,517 @@
package com.hermesandroid.relay.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hermesandroid.relay.data.AppAnalytics
import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.network.HermesApiClient
import com.hermesandroid.relay.network.handlers.ChatHandler
import com.hermesandroid.relay.network.models.SkillInfo
import com.hermesandroid.relay.network.models.UsageInfo
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 okhttp3.sse.EventSource
import java.util.UUID
class ChatViewModel : ViewModel() {
private var apiClient: HermesApiClient? = null
private var chatHandler: ChatHandler? = null
private var activeStream: EventSource? = null
private var intentionallyCancelled = false
private var firstTokenNotified = false
/** Callback to persist session ID — set by RelayApp */
var onSessionChanged: ((String?) -> Unit)? = null
// --- Message queue ---
private val _queuedMessages = MutableStateFlow<List<String>>(emptyList())
val queuedMessages: StateFlow<List<String>> = _queuedMessages.asStateFlow()
// --- Pending attachments ---
private val _pendingAttachments = MutableStateFlow<List<Attachment>>(emptyList())
val pendingAttachments: StateFlow<List<Attachment>> = _pendingAttachments.asStateFlow()
fun addAttachment(attachment: Attachment) {
_pendingAttachments.update { it + attachment }
}
fun removeAttachment(index: Int) {
_pendingAttachments.update { list ->
list.filterIndexed { i, _ -> i != index }
}
}
fun clearAttachments() {
_pendingAttachments.value = emptyList()
}
companion object {
/** Brief app context sent as system_message when enabled in settings. */
const val APP_CONTEXT_PROMPT = "The user is chatting via the Hermes Relay Android app. Keep responses mobile-friendly and concise when possible."
}
// Server-side personality selection
private val _selectedPersonality = MutableStateFlow("default")
val selectedPersonality: StateFlow<String> = _selectedPersonality.asStateFlow()
private val _personalityNames = MutableStateFlow<List<String>>(emptyList())
val personalityNames: StateFlow<List<String>> = _personalityNames.asStateFlow()
/** Default personality name from server (config.display.personality) */
private val _defaultPersonality = MutableStateFlow("")
val defaultPersonality: StateFlow<String> = _defaultPersonality.asStateFlow()
/** Personality name → system prompt. Used to send the right prompt when switching. */
private var personalityPrompts: Map<String, String> = emptyMap()
/** Model name from the server's /api/config response (e.g. "claude-opus-4-6") */
private val _serverModelName = MutableStateFlow("")
val serverModelName: StateFlow<String> = _serverModelName.asStateFlow()
/** Whether to include the brief app context system message */
var appContextEnabled: Boolean = true
/** Streaming endpoint: "sessions" or "runs" */
var streamingEndpoint: String = "sessions"
fun selectPersonality(name: String) {
_selectedPersonality.value = name
}
/** The display name of the currently active personality (for chat bubbles). */
val activePersonalityName: String
get() {
val selected = _selectedPersonality.value
return if (selected == "default") _defaultPersonality.value else selected
}
private val _isLoadingHistory = MutableStateFlow(false)
val isLoadingHistory: StateFlow<Boolean> = _isLoadingHistory.asStateFlow()
private val _availableSkills = MutableStateFlow<List<SkillInfo>>(emptyList())
val availableSkills: StateFlow<List<SkillInfo>> = _availableSkills.asStateFlow()
// Cached fallback StateFlows to avoid creating new instances on each access
private val _emptyMessages = MutableStateFlow<List<ChatMessage>>(emptyList())
private val _emptyStreaming = MutableStateFlow(false)
private val _emptySessions = MutableStateFlow<List<ChatSession>>(emptyList())
private val _emptyError = MutableStateFlow<String?>(null)
private val _emptySessionId = MutableStateFlow<String?>(null)
// Delegated to ChatHandler
val messages: StateFlow<List<ChatMessage>>
get() = chatHandler?.messages ?: _emptyMessages
val isStreaming: StateFlow<Boolean>
get() = chatHandler?.isStreaming ?: _emptyStreaming
val sessions: StateFlow<List<ChatSession>>
get() = chatHandler?.sessions ?: _emptySessions
val error: StateFlow<String?>
get() = chatHandler?.error ?: _emptyError
val currentSessionId: StateFlow<String?>
get() = chatHandler?.currentSessionId ?: _emptySessionId
fun initialize(apiClient: HermesApiClient, chatHandler: ChatHandler) {
this.apiClient = apiClient
this.chatHandler = chatHandler
fetchSkills()
fetchPersonalities()
}
fun fetchSkills() {
val client = apiClient ?: return
viewModelScope.launch {
val skills = client.getSkills()
_availableSkills.value = skills
}
}
fun updateApiClient(client: HermesApiClient) {
activeStream?.cancel()
activeStream = null
this.apiClient = client
fetchSkills()
fetchPersonalities()
}
private fun fetchPersonalities() {
val client = apiClient ?: return
viewModelScope.launch {
val config = client.getPersonalities()
_personalityNames.value = config.names
_defaultPersonality.value = config.defaultName
personalityPrompts = config.prompts
_serverModelName.value = config.modelName
}
}
// --- Session management ---
fun refreshSessions() {
val client = apiClient ?: return
val handler = chatHandler ?: return
viewModelScope.launch {
val sessions = client.listSessions()
handler.updateSessions(sessions)
}
}
fun createNewChat() {
val client = apiClient ?: return
val handler = chatHandler ?: return
// Cancel any in-flight stream
activeStream?.cancel()
activeStream = null
viewModelScope.launch {
val session = client.createSession()
if (session != null) {
val chatSession = ChatSession(
sessionId = session.id,
title = session.title ?: "New Chat",
model = session.model
)
handler.addSession(chatSession)
handler.setSessionId(session.id)
handler.clearMessages()
onSessionChanged?.invoke(session.id)
AppAnalytics.onSessionCreated()
}
}
}
fun switchSession(sessionId: String) {
val client = apiClient ?: return
val handler = chatHandler ?: return
// Cancel any in-flight stream
intentionallyCancelled = true
activeStream?.cancel()
activeStream = null
handler.setSessionId(sessionId)
handler.clearMessages()
onSessionChanged?.invoke(sessionId)
AppAnalytics.onSessionSwitched()
// Load message history
_isLoadingHistory.value = true
viewModelScope.launch {
val messages = client.getMessages(sessionId)
handler.loadMessageHistory(messages)
_isLoadingHistory.value = false
}
}
/**
* Resume a session by ID (e.g., from persisted last session).
* Only loads history, doesn't create a new session.
*/
fun resumeSession(sessionId: String) {
switchSession(sessionId)
}
fun deleteSession(sessionId: String) {
val client = apiClient ?: return
val handler = chatHandler ?: return
// Save reference before removing (for rollback on failure)
val removedSession = handler.sessions.value.find { it.sessionId == sessionId }
// Optimistic removal
handler.removeSession(sessionId)
if (handler.currentSessionId.value == null) {
onSessionChanged?.invoke(null)
}
viewModelScope.launch {
val success = client.deleteSession(sessionId)
if (!success && removedSession != null) {
// Restore on failure
handler.addSession(removedSession)
}
}
}
fun renameSession(sessionId: String, newTitle: String) {
val client = apiClient ?: return
val handler = chatHandler ?: return
// Optimistic rename
handler.renameSessionLocal(sessionId, newTitle)
viewModelScope.launch {
client.renameSession(sessionId, newTitle)
}
}
// --- Message sending ---
fun sendMessage(text: String) {
if (text.isBlank()) return
val client = apiClient ?: return
val handler = chatHandler ?: return
// If currently streaming, queue the message instead of cancelling
if (activeStream != null) {
_queuedMessages.update { it + text.trim() }
return
}
sendMessageInternal(client, handler, text)
}
fun clearQueue() {
_queuedMessages.value = emptyList()
}
private fun drainQueue() {
val client = apiClient ?: return
val handler = chatHandler ?: return
val next = _queuedMessages.value.firstOrNull() ?: return
_queuedMessages.update { it.drop(1) }
sendMessageInternal(client, handler, next)
}
private fun sendMessageInternal(client: HermesApiClient, handler: ChatHandler, text: String) {
AppAnalytics.onMessageSent()
// Snapshot and clear pending attachments
val attachments = _pendingAttachments.value.ifEmpty { null }
_pendingAttachments.value = emptyList()
val messageId = UUID.randomUUID().toString()
// Add user message locally (with attachments for display)
handler.addUserMessage(
ChatMessage(
id = messageId,
role = MessageRole.USER,
content = text.trim(),
timestamp = System.currentTimeMillis(),
attachments = attachments ?: emptyList()
)
)
handler.setLastSentMessage(text.trim())
val assistantMessageId = UUID.randomUUID().toString()
val sessionId = handler.currentSessionId.value
if (streamingEndpoint == "runs") {
startStream(client, handler, sessionId ?: "", text.trim(), assistantMessageId, attachments)
} else if (sessionId != null) {
startStream(client, handler, sessionId, text.trim(), assistantMessageId, attachments)
} else {
viewModelScope.launch {
val session = client.createSession()
if (session != null) {
val chatSession = ChatSession(
sessionId = session.id,
title = null,
model = session.model
)
handler.addSession(chatSession)
handler.setSessionId(session.id)
onSessionChanged?.invoke(session.id)
startStream(client, handler, session.id, text.trim(), assistantMessageId, attachments)
// Auto-title: use first ~50 chars of user message
val autoTitle = text.trim().take(50).let {
if (text.length > 50) "$it..." else it
}
client.renameSession(session.id, autoTitle)
handler.renameSessionLocal(session.id, autoTitle)
} else {
handler.onStreamError("Failed to create chat session")
}
}
}
}
private fun startStream(
client: HermesApiClient,
handler: ChatHandler,
sessionId: String,
message: String,
assistantMessageId: String,
attachments: List<Attachment>? = null
) {
// Build system_message from personality prompt + app context
val selected = _selectedPersonality.value
val personalityPrompt = if (selected != "default" && selected != _defaultPersonality.value) {
// Non-default personality selected — send its system prompt to override server default
personalityPrompts[selected]
} else null
val appContext = if (appContextEnabled) APP_CONTEXT_PROMPT else null
val systemMsg = listOfNotNull(personalityPrompt, appContext)
.joinToString("\n\n")
.ifBlank { null }
// Set agent name for display on chat bubbles
handler.activeAgentName = activePersonalityName.replaceFirstChar { it.uppercase() }
.ifBlank { null }
firstTokenNotified = false
var lastInputTokens: Int? = null
var lastOutputTokens: Int? = null
// Track the current message ID — starts with our generated ID,
// but updates when the server sends message.started with its own ID.
var currentMessageId = assistantMessageId
// Show placeholder "thinking" message immediately — filled when first delta arrives
handler.addPlaceholderMessage(
ChatMessage(
id = assistantMessageId,
role = MessageRole.ASSISTANT,
content = "",
timestamp = System.currentTimeMillis(),
isStreaming = true,
agentName = handler.activeAgentName
)
)
// Shared callbacks for both endpoints
val onMessageStartedCb = { serverMsgId: String ->
// Server assigned a new message ID for this turn — update tracking
currentMessageId = serverMsgId
}
val onTextDeltaCb = { delta: String ->
if (!firstTokenNotified) {
firstTokenNotified = true
AppAnalytics.onFirstTokenReceived()
}
handler.onTextDelta(currentMessageId, delta)
}
val onThinkingDeltaCb = { delta: String ->
handler.onThinkingDelta(currentMessageId, delta)
}
val onToolCallStartCb = { toolCallId: String, toolName: String ->
handler.onToolCallStart(currentMessageId, toolCallId, toolName)
}
val onToolCallDoneCb = { toolCallId: String, resultPreview: String? ->
handler.onToolCallComplete(currentMessageId, toolCallId, resultPreview)
}
val onToolCallFailedCb = { toolCallId: String, errorMsg: String? ->
handler.onToolCallFailed(currentMessageId, toolCallId, errorMsg)
}
// Turn complete — one assistant message finished, but the run may continue
val onTurnCompleteCb = {
handler.onTurnComplete(currentMessageId)
}
val onCompleteCb = {
handler.onStreamComplete(currentMessageId)
AppAnalytics.onStreamComplete(lastInputTokens, lastOutputTokens)
activeStream = null
drainQueue()
}
val onUsageCb = { usage: UsageInfo? ->
if (usage != null) {
val tokIn = usage.resolvedInputTokens
val tokOut = usage.resolvedOutputTokens
if (tokIn != null || tokOut != null) {
lastInputTokens = tokIn
lastOutputTokens = tokOut
handler.onUsageReceived(
currentMessageId,
tokIn,
tokOut,
usage.resolvedTotalTokens,
null
)
}
}
}
val onErrorCb = { errorMsg: String ->
if (intentionallyCancelled) {
intentionallyCancelled = false
// Don't surface cancellation errors
} else {
AppAnalytics.onStreamError()
handler.onStreamError(errorMsg)
}
activeStream = null
_queuedMessages.value = emptyList()
}
activeStream = if (streamingEndpoint == "runs") {
client.sendRunStream(
message = message,
systemMessage = systemMsg,
attachments = attachments,
onSessionId = { sid ->
handler.setSessionId(sid)
onSessionChanged?.invoke(sid)
},
onMessageStarted = onMessageStartedCb,
onTextDelta = onTextDeltaCb,
onThinkingDelta = onThinkingDeltaCb,
onToolCallStart = onToolCallStartCb,
onToolCallDone = onToolCallDoneCb,
onToolCallFailed = onToolCallFailedCb,
onTurnComplete = onTurnCompleteCb,
onComplete = onCompleteCb,
onUsage = onUsageCb,
onError = onErrorCb
)
} else {
client.sendChatStream(
sessionId = sessionId,
message = message,
systemMessage = systemMsg,
attachments = attachments,
onSessionId = { /* already set */ },
onMessageStarted = onMessageStartedCb,
onTextDelta = onTextDeltaCb,
onThinkingDelta = onThinkingDeltaCb,
onToolCallStart = onToolCallStartCb,
onToolCallDone = onToolCallDoneCb,
onToolCallFailed = onToolCallFailedCb,
onTurnComplete = onTurnCompleteCb,
onComplete = onCompleteCb,
onUsage = onUsageCb,
onError = onErrorCb
)
}
}
fun cancelStream() {
intentionallyCancelled = true
activeStream?.cancel()
activeStream = null
_queuedMessages.value = emptyList()
AppAnalytics.onStreamCancelled()
chatHandler?.let { handler ->
val streamingMsg = handler.messages.value.findLast { it.isStreaming }
if (streamingMsg != null) {
handler.onStreamComplete(streamingMsg.id)
}
}
}
fun clearError() {
chatHandler?.clearError()
}
fun retryLastMessage() {
val lastMsg = chatHandler?.lastSentMessage?.value ?: return
sendMessage(lastMsg)
}
override fun onCleared() {
super.onCleared()
activeStream?.cancel()
}
}
@@ -0,0 +1,582 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import android.net.Uri
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.hermesandroid.relay.auth.AuthManager
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.data.DataManager
import com.hermesandroid.relay.data.relayDataStore
import com.hermesandroid.relay.network.ChannelMultiplexer
import com.hermesandroid.relay.network.ConnectivityObserver
import com.hermesandroid.relay.network.ChatMode
import com.hermesandroid.relay.network.ConnectionManager
import com.hermesandroid.relay.network.ConnectionState
import com.hermesandroid.relay.network.HermesApiClient
import com.hermesandroid.relay.network.handlers.ChatHandler
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ConnectionViewModel(application: Application) : AndroidViewModel(application) {
companion object {
// API Server (direct chat)
private val KEY_API_SERVER_URL = stringPreferencesKey("api_server_url")
private const val DEFAULT_API_URL = "http://localhost:8642"
// Relay Server (bridge/terminal)
private val KEY_RELAY_URL = stringPreferencesKey("relay_url")
private val KEY_SERVER_URL = stringPreferencesKey("server_url") // legacy migration
private const val DEFAULT_RELAY_URL = "wss://localhost:8767"
// Shared
private val KEY_THEME = stringPreferencesKey("theme")
private val KEY_INSECURE_MODE = booleanPreferencesKey("insecure_mode")
private val KEY_LAST_SEEN_VERSION = stringPreferencesKey("last_seen_version")
private val KEY_LAST_SESSION_ID = stringPreferencesKey("last_session_id")
private val KEY_SHOW_THINKING = booleanPreferencesKey("show_thinking")
private val KEY_TOOL_DISPLAY = stringPreferencesKey("tool_display")
private val KEY_APP_CONTEXT = booleanPreferencesKey("app_context_prompt")
private val KEY_STREAMING_ENDPOINT = stringPreferencesKey("streaming_endpoint")
private val KEY_PARSE_TOOL_ANNOTATIONS = booleanPreferencesKey("parse_tool_annotations")
private val KEY_MAX_ATTACHMENT_MB = intPreferencesKey("max_attachment_mb")
private val KEY_MAX_MESSAGE_LENGTH = intPreferencesKey("max_message_length")
// Animation
private val KEY_ANIMATION_ENABLED = booleanPreferencesKey("animation_enabled")
private val KEY_ANIMATION_BEHIND_CHAT = booleanPreferencesKey("animation_behind_chat")
}
// --- Core networking components ---
// Relay (bridge/terminal)
val multiplexer = ChannelMultiplexer()
val chatHandler = ChatHandler()
private val connectionManager = ConnectionManager(multiplexer)
val authManager = AuthManager(application, multiplexer, viewModelScope)
// Data management
val dataManager = DataManager(application)
// --- Relay connection state ---
val relayConnectionState: StateFlow<ConnectionState> = connectionManager.connectionState
val authState: StateFlow<AuthState> = authManager.authState
val insecureMode: StateFlow<Boolean> = connectionManager.insecureMode
val isInsecureConnection: StateFlow<Boolean> = connectionManager.isInsecureConnection
// --- API Server state ---
private val _apiServerUrl = MutableStateFlow(DEFAULT_API_URL)
val apiServerUrl: StateFlow<String> = _apiServerUrl.asStateFlow()
private val _apiServerReachable = MutableStateFlow(false)
val apiServerReachable: StateFlow<Boolean> = _apiServerReachable.asStateFlow()
private val _apiClient = MutableStateFlow<HermesApiClient?>(null)
val apiClient: StateFlow<HermesApiClient?> = _apiClient.asStateFlow()
private val _chatMode = MutableStateFlow(ChatMode.DISCONNECTED)
val chatMode: StateFlow<ChatMode> = _chatMode.asStateFlow()
// Chat is ready when API client exists and server is reachable
val chatReady: StateFlow<Boolean> = combine(_apiClient, _apiServerReachable) { client, reachable ->
client != null && reachable
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
// --- Relay URL ---
private val _relayUrl = MutableStateFlow(DEFAULT_RELAY_URL)
val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()
// Backward compat: expose as serverUrl for any remaining references
@Deprecated("Use relayUrl or apiServerUrl", replaceWith = ReplaceWith("relayUrl"))
val serverUrl: StateFlow<String> = _relayUrl
// Backward compat: expose relay state as connectionState
@Deprecated("Use relayConnectionState", replaceWith = ReplaceWith("relayConnectionState"))
val connectionState: StateFlow<ConnectionState> = relayConnectionState
// Theme preference
val theme: StateFlow<String> = application.relayDataStore.data
.map { preferences ->
preferences[KEY_THEME] ?: "auto"
}
.stateIn(viewModelScope, SharingStarted.Eagerly, "auto")
// Onboarding state
private val _onboardingCompleted = MutableStateFlow(true) // default true to avoid flash
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted.asStateFlow()
// Pairing code from AuthManager
val pairingCode: StateFlow<String> = authManager.pairingCode
// What's New tracking
private val _showWhatsNew = MutableStateFlow(false)
val showWhatsNew: StateFlow<Boolean> = _showWhatsNew.asStateFlow()
// Last session ID persistence
private val _lastSessionId = MutableStateFlow<String?>(null)
val lastSessionId: StateFlow<String?> = _lastSessionId.asStateFlow()
// Connectivity
private val connectivityObserver = ConnectivityObserver(application)
val networkStatus: StateFlow<ConnectivityObserver.Status> = connectivityObserver.observe()
.stateIn(viewModelScope, SharingStarted.Eagerly, ConnectivityObserver.Status.Available)
// Splash readiness — true once initial DataStore load + onboarding check is done
private val _isReady = MutableStateFlow(false)
val isReady: StateFlow<Boolean> = _isReady.asStateFlow()
// Show thinking toggle
val showThinking: StateFlow<Boolean> = application.relayDataStore.data
.map { it[KEY_SHOW_THINKING] ?: true }
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setShowThinking(enabled: Boolean) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_SHOW_THINKING] = enabled
}
}
}
// Tool call display mode: "off", "compact", "detailed"
val toolDisplay: StateFlow<String> = application.relayDataStore.data
.map { it[KEY_TOOL_DISPLAY] ?: "detailed" }
.stateIn(viewModelScope, SharingStarted.Eagerly, "detailed")
fun setToolDisplay(mode: String) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_TOOL_DISPLAY] = mode
}
}
}
// App context prompt toggle
val appContextEnabled: StateFlow<Boolean> = application.relayDataStore.data
.map { it[KEY_APP_CONTEXT] ?: true }
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setAppContext(enabled: Boolean) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_APP_CONTEXT] = enabled
}
}
}
// Streaming endpoint: "sessions" = /api/sessions/{id}/chat/stream, "runs" = /v1/runs
val streamingEndpoint: StateFlow<String> = application.relayDataStore.data
.map { it[KEY_STREAMING_ENDPOINT] ?: "sessions" }
.stateIn(viewModelScope, SharingStarted.Eagerly, "sessions")
fun setStreamingEndpoint(endpoint: String) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_STREAMING_ENDPOINT] = endpoint
}
}
}
// Parse tool annotations from text markers toggle
val parseToolAnnotations: StateFlow<Boolean> = application.relayDataStore.data
.map { it[KEY_PARSE_TOOL_ANNOTATIONS] ?: true }
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setParseToolAnnotations(enabled: Boolean) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_PARSE_TOOL_ANNOTATIONS] = enabled
}
}
}
// Animation settings
val animationEnabled: StateFlow<Boolean> = application.relayDataStore.data
.map { it[KEY_ANIMATION_ENABLED] ?: true }
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
val animationBehindChat: StateFlow<Boolean> = application.relayDataStore.data
.map { it[KEY_ANIMATION_BEHIND_CHAT] ?: true }
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setAnimationEnabled(enabled: Boolean) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_ANIMATION_ENABLED] = enabled
}
}
}
fun setAnimationBehindChat(enabled: Boolean) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_ANIMATION_BEHIND_CHAT] = enabled
}
}
}
// Max attachment size in MB (default 10)
val maxAttachmentMb: StateFlow<Int> = application.relayDataStore.data
.map { it[KEY_MAX_ATTACHMENT_MB] ?: 10 }
.stateIn(viewModelScope, SharingStarted.Eagerly, 10)
fun setMaxAttachmentMb(mb: Int) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_MAX_ATTACHMENT_MB] = mb
}
}
}
// Max message character length (default 4096)
val maxMessageLength: StateFlow<Int> = application.relayDataStore.data
.map { it[KEY_MAX_MESSAGE_LENGTH] ?: 4096 }
.stateIn(viewModelScope, SharingStarted.Eagerly, 4096)
fun setMaxMessageLength(length: Int) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_MAX_MESSAGE_LENGTH] = length
}
}
}
init {
// Wire multiplexer to connection manager (for relay/bridge/terminal)
multiplexer.setSendCallback { envelope ->
connectionManager.send(envelope)
}
// Auto-authenticate on relay connect
multiplexer.setOnConnectedCallback {
authManager.authenticate()
}
// Load saved state — split into fast (UI-blocking) and slow (network) paths
viewModelScope.launch {
_onboardingCompleted.value = dataManager.isOnboardingCompleted()
var prevApiUrl: String? = null
var prevApiKey: String? = null
application.relayDataStore.data.collect { preferences ->
// Restore insecure mode
val insecure = preferences[KEY_INSECURE_MODE] ?: false
connectionManager.setInsecureMode(insecure)
// Load API server URL
val savedApiUrl = preferences[KEY_API_SERVER_URL]
if (savedApiUrl != null) {
_apiServerUrl.value = savedApiUrl
}
// Load relay URL (with migration from old server_url key)
val savedRelayUrl = preferences[KEY_RELAY_URL]
?: preferences[KEY_SERVER_URL] // legacy migration
if (savedRelayUrl != null) {
_relayUrl.value = savedRelayUrl
}
// Load last session ID
val savedSessionId = preferences[KEY_LAST_SESSION_ID]
if (savedSessionId != null) {
_lastSessionId.value = savedSessionId
}
// Check if this is a new version → show What's New
val currentVersion = getAppVersionName()
val lastSeen = preferences[KEY_LAST_SEEN_VERSION]
if (lastSeen != null && lastSeen != currentVersion) {
_showWhatsNew.value = true
}
// Mark ready after first DataStore emission (UI can render)
if (!_isReady.value) {
_isReady.value = true
}
// Rebuild API client in a separate coroutine so it doesn't block
// the DataStore flow (getApiKey() awaits Tink crypto init on first call)
val currentUrl = _apiServerUrl.value
launch {
val currentKey = authManager.getApiKey() ?: ""
if (currentUrl != prevApiUrl || currentKey != prevApiKey) {
prevApiUrl = currentUrl
prevApiKey = currentKey
rebuildApiClient()
}
}
}
}
// Periodic health check — only runs when an API client is configured
viewModelScope.launch {
while (true) {
delay(30_000)
if (_apiClient.value != null) {
checkApiHealth()
}
}
}
}
// --- API Server methods ---
fun updateApiServerUrl(url: String) {
_apiServerUrl.value = url
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { preferences ->
preferences[KEY_API_SERVER_URL] = url
}
rebuildApiClient()
}
}
fun updateApiKey(key: String) {
viewModelScope.launch {
authManager.setApiKey(key)
rebuildApiClient()
}
}
fun checkApiHealth() {
viewModelScope.launch {
val client = _apiClient.value
_apiServerReachable.value = client?.checkHealth() == true
}
}
fun testApiConnection(onResult: (Boolean) -> Unit) {
viewModelScope.launch {
val client = _apiClient.value
val reachable = client?.checkHealth() == true
_apiServerReachable.value = reachable
onResult(reachable)
}
}
private suspend fun rebuildApiClient() {
val url = _apiServerUrl.value
val key = authManager.getApiKey() ?: ""
val oldClient = _apiClient.value
if (url.isNotBlank()) {
val client = HermesApiClient(baseUrl = url, apiKey = key)
_apiClient.value = client
oldClient?.shutdown()
_apiServerReachable.value = client.checkHealth()
// Detect chat mode
val mode = client.detectChatMode()
_chatMode.value = mode
} else {
_apiClient.value = null
oldClient?.shutdown()
_apiServerReachable.value = false
_chatMode.value = ChatMode.DISCONNECTED
}
}
// --- Relay methods ---
fun connectRelay(url: String) {
_relayUrl.value = url
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { preferences ->
preferences[KEY_RELAY_URL] = url
}
}
connectionManager.connect(url)
}
fun connectRelay() {
connectionManager.connect(_relayUrl.value)
}
fun disconnectRelay() {
connectionManager.disconnect()
}
fun updateRelayUrl(url: String) {
_relayUrl.value = url
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { preferences ->
preferences[KEY_RELAY_URL] = url
}
}
}
// Backward compat wrappers
@Deprecated("Use connectRelay", replaceWith = ReplaceWith("connectRelay(url)"))
fun connect(url: String) = connectRelay(url)
@Deprecated("Use connectRelay", replaceWith = ReplaceWith("connectRelay()"))
fun connect() = connectRelay()
@Deprecated("Use disconnectRelay", replaceWith = ReplaceWith("disconnectRelay()"))
fun disconnect() = disconnectRelay()
@Deprecated("Use updateRelayUrl", replaceWith = ReplaceWith("updateRelayUrl(url)"))
fun updateServerUrl(url: String) = updateRelayUrl(url)
// --- What's New + Version tracking ---
fun dismissWhatsNew() {
_showWhatsNew.value = false
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { preferences ->
preferences[KEY_LAST_SEEN_VERSION] = getAppVersionName()
}
}
}
fun markVersionSeen() {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { preferences ->
preferences[KEY_LAST_SEEN_VERSION] = getAppVersionName()
}
}
}
private fun getAppVersionName(): String {
return try {
val app = getApplication<Application>()
app.packageManager.getPackageInfo(app.packageName, 0).versionName ?: "0.0.0"
} catch (_: Exception) {
"0.0.0"
}
}
// --- Session persistence ---
fun saveLastSessionId(sessionId: String?) {
_lastSessionId.value = sessionId
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { preferences ->
if (sessionId != null) {
preferences[KEY_LAST_SESSION_ID] = sessionId
} else {
preferences.remove(KEY_LAST_SESSION_ID)
}
}
}
}
// --- Shared methods ---
fun setTheme(theme: String) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { preferences ->
preferences[KEY_THEME] = theme
}
}
}
fun setInsecureMode(enabled: Boolean) {
connectionManager.setInsecureMode(enabled)
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { preferences ->
preferences[KEY_INSECURE_MODE] = enabled
}
}
}
fun completeOnboarding() {
_onboardingCompleted.value = true
viewModelScope.launch {
dataManager.setOnboardingCompleted(true)
}
}
fun resetOnboarding() {
viewModelScope.launch {
dataManager.resetOnboarding()
_onboardingCompleted.value = false
}
}
fun resetAppData() {
viewModelScope.launch {
disconnectRelay()
authManager.clearApiKey()
dataManager.resetAppData()
_apiServerUrl.value = DEFAULT_API_URL
_relayUrl.value = DEFAULT_RELAY_URL
_apiClient.value?.shutdown()
_apiClient.value = null
_apiServerReachable.value = false
}
}
fun exportSettings(onResult: (String) -> Unit) {
viewModelScope.launch {
val json = dataManager.exportSettings(
serverUrl = _relayUrl.value,
theme = theme.value,
onboardingCompleted = _onboardingCompleted.value,
profiles = authManager.profiles.value,
apiServerUrl = _apiServerUrl.value,
relayUrl = _relayUrl.value
)
onResult(json)
}
}
fun writeBackupToUri(uri: Uri, backup: String, onResult: (Boolean) -> Unit) {
viewModelScope.launch {
val success = dataManager.writeBackupToUri(uri, backup)
onResult(success)
}
}
fun importFromUri(uri: Uri, onResult: (Boolean) -> Unit) {
viewModelScope.launch {
val jsonString = dataManager.readBackupFromUri(uri) ?: run {
onResult(false)
return@launch
}
val backup = dataManager.importSettings(jsonString) ?: run {
onResult(false)
return@launch
}
// Apply imported settings
// Prefer v2 fields, fall back to v1 serverUrl for relay
val importedRelayUrl = backup.relayUrl ?: backup.serverUrl
importedRelayUrl?.let { updateRelayUrl(it) }
backup.apiServerUrl?.let { updateApiServerUrl(it) }
setTheme(backup.theme)
if (backup.onboardingCompleted) {
dataManager.setOnboardingCompleted(true)
_onboardingCompleted.value = true
}
onResult(true)
}
}
fun regeneratePairingCode() {
authManager.regeneratePairingCode()
}
fun clearSession() {
authManager.clearSession()
}
override fun onCleared() {
super.onCleared()
connectionManager.shutdown()
_apiClient.value?.shutdown()
}
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Splash icon animation: scale up from 0.7x with overshoot + fade in -->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:ordering="together">
<objectAnimator
android:propertyName="scaleX"
android:valueFrom="0.7"
android:valueTo="1.0"
android:valueType="floatType"
android:duration="600"
android:interpolator="@android:interpolator/overshoot"/>
<objectAnimator
android:propertyName="scaleY"
android:valueFrom="0.7"
android:valueTo="1.0"
android:valueType="floatType"
android:duration="600"
android:interpolator="@android:interpolator/overshoot"/>
<objectAnimator
android:propertyName="alpha"
android:valueFrom="0"
android:valueTo="1"
android:valueType="floatType"
android:duration="300"
android:interpolator="@android:interpolator/decelerate_cubic"/>
</set>
@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Hermes Companion foreground icon — Wing-H monogram
Viewport 108x108, content within 72dp safe zone (18dp inset).
Coordinates scaled from 512 SVG viewport: factor = 108/512 = 0.2109375
Hermes Relay foreground icon — Chevron Compass with ghost V-crossbar + feathers
Viewport 108x108. Scaled to 75% centered at (54,54) to fit within the 72dp
adaptive icon safe zone with breathing room.
Source: assets/logo.svg
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
@@ -11,135 +12,143 @@
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Left vertical of the H -->
<path
android:fillColor="#6B35E8"
android:pathData="
M39.66,31.22
L44.72,31.22
L44.72,76.78
L39.66,76.78
Z"/>
<!-- Scale artwork to 75% around center (54,54) for adaptive icon safe zone -->
<group
android:scaleX="0.75"
android:scaleY="0.75"
android:pivotX="54"
android:pivotY="54">
<!-- Right vertical of the H -->
<path
android:fillColor="#6B35E8"
android:pathData="
M63.28,31.22
L68.34,31.22
L68.34,76.78
L63.28,76.78
Z"/>
<!-- ═══ Ghost layer: V-crossbar + diagonal feathers (no verticals) ═══ -->
<!-- Crossbar of the H -->
<path
android:fillColor="#6B35E8"
android:pathData="
M44.72,51.47
L63.28,51.47
L63.28,56.53
L44.72,56.53
Z"/>
<!-- Crossbar + wings (V shape) -->
<path
android:pathData="M17.6,36.8 L37.8,57 L70.2,57 L90.4,36.8"
android:strokeWidth="4.5"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="0"
android:startY="0"
android:endX="108"
android:endY="108"
android:startColor="#596B35E8"
android:endColor="#0D9B6BF0"/>
</aapt:attr>
</path>
<!-- Left wing — primary feather (longest) -->
<path
android:fillColor="#6B35E8"
android:pathData="
M44.72,51.47
L44.72,48.94
L21.09,40.50
L20.25,43.88
Z"/>
<!-- Top-left feather -->
<path
android:pathData="M17.6,18.6 L37.8,38.8"
android:strokeWidth="4.5"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="0"
android:startY="0"
android:endX="108"
android:endY="108"
android:startColor="#596B35E8"
android:endColor="#0D9B6BF0"/>
</aapt:attr>
</path>
<!-- Left wing — secondary feather -->
<path
android:pathData="
M44.72,53.16
L44.72,51.05
L24.47,46.0
L23.63,49.37
Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="23.63"
android:startY="49.37"
android:endX="44.72"
android:endY="51.05"
android:startColor="#9B6BF0"
android:endColor="#6B35E8"/>
</aapt:attr>
</path>
<!-- Top-right feather -->
<path
android:pathData="M90.4,18.6 L70.2,38.8"
android:strokeWidth="4.5"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="0"
android:startY="0"
android:endX="108"
android:endY="108"
android:startColor="#596B35E8"
android:endColor="#0D9B6BF0"/>
</aapt:attr>
</path>
<!-- Left wing — tertiary feather -->
<path
android:fillAlpha="0.8"
android:pathData="
M44.72,55.69
L44.72,53.58
L27.84,50.63
L27.42,53.58
Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="27.42"
android:startY="53.58"
android:endX="44.72"
android:endY="53.58"
android:startColor="#9B6BF0"
android:endColor="#6B35E8"/>
</aapt:attr>
</path>
<!-- ═══ Drop shadow (foreground offset +1.5dp Y, 20% black) ═══ -->
<!-- Right wing — primary feather (longest) -->
<path
android:fillColor="#6B35E8"
android:pathData="
M63.28,51.47
L63.28,48.94
L86.91,40.50
L87.75,43.88
Z"/>
<path
android:pathData="M54,43.5 L66,55.5 L54,67.5 L42,55.5 Z"
android:strokeColor="#33000000"
android:strokeWidth="7.5"
android:strokeLineCap="round"
android:strokeLineJoin="round"/>
<path
android:pathData="M30,43.5 L54,19.5 L78,43.5"
android:strokeColor="#33000000"
android:strokeWidth="7.5"
android:strokeLineCap="round"
android:strokeLineJoin="round"/>
<path
android:pathData="M30,67.5 L54,91.5 L78,67.5"
android:strokeColor="#33000000"
android:strokeWidth="7.5"
android:strokeLineCap="round"
android:strokeLineJoin="round"/>
<!-- Right wing — secondary feather -->
<path
android:pathData="
M63.28,53.16
L63.28,51.05
L83.53,46.0
L84.38,49.37
Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="63.28"
android:startY="51.05"
android:endX="84.38"
android:endY="49.37"
android:startColor="#6B35E8"
android:endColor="#9B6BF0"/>
</aapt:attr>
</path>
<!-- ═══ Foreground: Chevron Compass ═══ -->
<!-- Right wing — tertiary feather -->
<path
android:fillAlpha="0.8"
android:pathData="
M63.28,55.69
L63.28,53.58
L80.16,50.63
L80.58,53.58
Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="63.28"
android:startY="53.58"
android:endX="80.58"
android:endY="53.58"
android:startColor="#6B35E8"
android:endColor="#9B6BF0"/>
</aapt:attr>
</path>
<!-- Central diamond -->
<path
android:pathData="M54,42 L66,54 L54,66 L42,54 Z"
android:strokeWidth="6.75"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="42"
android:startY="42"
android:endX="66"
android:endY="66"
android:startColor="#9B6BF0"
android:endColor="#6B35E8"/>
</aapt:attr>
</path>
<!-- Upper chevron -->
<path
android:pathData="M30,42 L54,18 L78,42"
android:strokeWidth="6.75"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="30"
android:startY="18"
android:endX="78"
android:endY="42"
android:startColor="#9B6BF0"
android:endColor="#6B35E8"/>
</aapt:attr>
</path>
<!-- Lower chevron -->
<path
android:pathData="M30,66 L54,90 L78,66"
android:strokeWidth="6.75"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="30"
android:startY="66"
android:endX="78"
android:endY="90"
android:startColor="#9B6BF0"
android:endColor="#6B35E8"/>
</aapt:attr>
</path>
</group>
</vector>
+108
View File
@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Hermes Relay splash screen icon — 0.9x scale with named group for animation.
-->
<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">
<group
android:name="main_group"
android:pivotX="54"
android:pivotY="54">
<!-- Ghost layer -->
<path
android:pathData="M21.2,38.5 L39.4,56.7 L68.6,56.7 L86.8,38.5"
android:strokeWidth="4"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="0" android:startY="0"
android:endX="108" android:endY="108"
android:startColor="#596B35E8"
android:endColor="#0D9B6BF0"/>
</aapt:attr>
</path>
<path
android:pathData="M21.2,22.1 L39.4,40.3"
android:strokeWidth="4"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="0" android:startY="0"
android:endX="108" android:endY="108"
android:startColor="#596B35E8"
android:endColor="#0D9B6BF0"/>
</aapt:attr>
</path>
<path
android:pathData="M86.8,22.1 L68.6,40.3"
android:strokeWidth="4"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="0" android:startY="0"
android:endX="108" android:endY="108"
android:startColor="#596B35E8"
android:endColor="#0D9B6BF0"/>
</aapt:attr>
</path>
<!-- Drop shadow -->
<path android:pathData="M54,44.6 L64.8,55.4 L54,66.2 L43.2,55.4 Z"
android:strokeColor="#33000000" android:strokeWidth="6.75"
android:strokeLineCap="round" android:strokeLineJoin="round"/>
<path android:pathData="M32.4,44.6 L54,23 L75.6,44.6"
android:strokeColor="#33000000" android:strokeWidth="6.75"
android:strokeLineCap="round" android:strokeLineJoin="round"/>
<path android:pathData="M32.4,66.2 L54,87.8 L75.6,66.2"
android:strokeColor="#33000000" android:strokeWidth="6.75"
android:strokeLineCap="round" android:strokeLineJoin="round"/>
<!-- Foreground: Chevron Compass -->
<path
android:pathData="M54,43.2 L64.8,54 L54,64.8 L43.2,54 Z"
android:strokeWidth="6.1"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="43.2" android:startY="43.2"
android:endX="64.8" android:endY="64.8"
android:startColor="#9B6BF0" android:endColor="#6B35E8"/>
</aapt:attr>
</path>
<path
android:pathData="M32.4,43.2 L54,21.6 L75.6,43.2"
android:strokeWidth="6.1"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="32.4" android:startY="21.6"
android:endX="75.6" android:endY="43.2"
android:startColor="#9B6BF0" android:endColor="#6B35E8"/>
</aapt:attr>
</path>
<path
android:pathData="M32.4,64.8 L54,86.4 L75.6,64.8"
android:strokeWidth="6.1"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="32.4" android:startY="64.8"
android:endX="75.6" android:endY="86.4"
android:startColor="#9B6BF0" android:endColor="#6B35E8"/>
</aapt:attr>
</path>
</group>
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Animated splash icon: scales in with overshoot + fades in -->
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/splash_icon">
<target
android:name="main_group"
android:animation="@animator/splash_anim"/>
</animated-vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

+1
View File
@@ -3,4 +3,5 @@
<color name="purple_primary">#6B35E8</color>
<color name="purple_light">#9B6BF0</color>
<color name="dark_background">#1A1A2E</color>
<color name="splash_icon_bg">#252540</color>
</resources>
+4 -3
View File
@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HermesCompanion.Splash" parent="Theme.SplashScreen">
<style name="Theme.HermesRelay.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/dark_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<item name="postSplashScreenTheme">@style/Theme.HermesCompanion</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon_animated</item>
<item name="windowSplashScreenIconBackgroundColor">@color/splash_icon_bg</item>
<item name="postSplashScreenTheme">@style/Theme.HermesRelay</item>
</style>
</resources>
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hermes Companion</string>
<string name="app_name">Hermes Relay</string>
</resources>
+1 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Minimal theme — Compose handles all theming via Material 3 -->
<style name="Theme.HermesCompanion" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.HermesRelay" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Hermes Relay connects to user-configured servers on arbitrary LAN IPs/hostnames.
Cleartext (HTTP) is permitted because most local Hermes installs don't use TLS
and Android's network-security-config doesn't support IP range restrictions.
Security is enforced at the application layer instead:
- "Allow insecure connections" toggle in Settings (default: off for relay WSS)
- Visible warning badge when connected over http:// or ws://
- Users are encouraged to set up TLS for production deployments
The app makes NO connections to external services — only user-configured endpoints.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,222 @@
package com.hermesandroid.relay.auth
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for AuthManager's pure logic: pairing code generation and AuthState types.
*
* AuthManager itself requires Android Context (EncryptedSharedPreferences) and
* ChannelMultiplexer, so we cannot instantiate it in JVM tests. Instead, we test
* the pairing code algorithm and the AuthState sealed class directly.
*/
class AuthManagerTest {
// --- Pairing code generation ---
// Mirror the companion object constants from AuthManager:
// PAIRING_CODE_LENGTH = 6, PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
private val codeLength = 6
private val allowedChars = (('A'..'Z') + ('0'..'9')).toSet()
// Excluded ambiguous characters that should NOT be in PAIRING_CODE_CHARS
// Note: The current AuthManager uses ALL A-Z and 0-9. This test documents
// that behavior. If ambiguous chars (0, O, 1, I) should be excluded,
// the implementation would need updating.
private val ambiguousChars = setOf('0', 'O', '1', 'I')
private fun generatePairingCode(): String {
val chars = ('A'..'Z') + ('0'..'9')
return (1..codeLength)
.map { chars.random() }
.joinToString("")
}
@Test
fun pairingCode_hasCorrectLength() {
val code = generatePairingCode()
assertEquals(codeLength, code.length)
}
@Test
fun pairingCode_onlyContainsAllowedCharacters() {
repeat(50) {
val code = generatePairingCode()
for (char in code) {
assertTrue(
"Character '$char' not in allowed set",
char in allowedChars
)
}
}
}
@Test
fun pairingCode_doesNotContainLowercase() {
repeat(50) {
val code = generatePairingCode()
for (char in code) {
assertFalse(
"Code should not contain lowercase: $char",
char.isLowerCase()
)
}
}
}
@Test
fun pairingCode_doesNotContainSpecialCharacters() {
repeat(50) {
val code = generatePairingCode()
for (char in code) {
assertTrue(
"Code should only contain alphanumeric: $char",
char.isLetterOrDigit()
)
}
}
}
@Test
fun pairingCode_regeneration_producesDifferentCode() {
// With 36^6 = ~2.2 billion combinations, collision probability is negligible
val codes = (1..20).map { generatePairingCode() }.toSet()
assertTrue(
"Expected multiple unique codes out of 20 generated, got ${codes.size}",
codes.size > 1
)
}
@Test
fun pairingCode_allCharactersInRange() {
// Generate many codes and verify all characters are within expected bounds
val allChars = (1..100).flatMap { generatePairingCode().toList() }.toSet()
for (char in allChars) {
assertTrue(
"Character '$char' outside allowed range",
char in 'A'..'Z' || char in '0'..'9'
)
}
}
// --- AuthState sealed class ---
@Test
fun authState_unpaired_isCorrectType() {
val state: AuthState = AuthState.Unpaired
assertTrue(state is AuthState.Unpaired)
}
@Test
fun authState_pairing_isCorrectType() {
val state: AuthState = AuthState.Pairing
assertTrue(state is AuthState.Pairing)
}
@Test
fun authState_paired_holdsToken() {
val state = AuthState.Paired("my-secret-token")
assertTrue(state is AuthState.Paired)
assertEquals("my-secret-token", state.token)
}
@Test
fun authState_failed_holdsReason() {
val state = AuthState.Failed("Invalid pairing code")
assertTrue(state is AuthState.Failed)
assertEquals("Invalid pairing code", state.reason)
}
@Test
fun authState_allTypesAreDistinct() {
val states: List<AuthState> = listOf(
AuthState.Unpaired,
AuthState.Pairing,
AuthState.Paired("token"),
AuthState.Failed("reason")
)
assertEquals(4, states.map { it::class }.distinct().size)
}
@Test
fun authState_unpaired_singletonEquality() {
val a = AuthState.Unpaired
val b = AuthState.Unpaired
assertEquals(a, b)
assertTrue(a === b)
}
@Test
fun authState_pairing_singletonEquality() {
val a = AuthState.Pairing
val b = AuthState.Pairing
assertEquals(a, b)
assertTrue(a === b)
}
@Test
fun authState_paired_dataClassEquality() {
val a = AuthState.Paired("token123")
val b = AuthState.Paired("token123")
assertEquals(a, b)
}
@Test
fun authState_paired_differentTokens_notEqual() {
val a = AuthState.Paired("token-A")
val b = AuthState.Paired("token-B")
assertNotEquals(a, b)
}
@Test
fun authState_failed_dataClassEquality() {
val a = AuthState.Failed("bad code")
val b = AuthState.Failed("bad code")
assertEquals(a, b)
}
@Test
fun authState_failed_differentReasons_notEqual() {
val a = AuthState.Failed("reason A")
val b = AuthState.Failed("reason B")
assertNotEquals(a, b)
}
// --- State transition patterns ---
@Test
fun authState_transitionSequence_unpairedToPairingToPaired() {
var state: AuthState = AuthState.Unpaired
assertTrue(state is AuthState.Unpaired)
state = AuthState.Pairing
assertTrue(state is AuthState.Pairing)
state = AuthState.Paired("session-token-abc")
assertTrue(state is AuthState.Paired)
assertEquals("session-token-abc", (state as AuthState.Paired).token)
}
@Test
fun authState_transitionSequence_unpairedToFailed() {
var state: AuthState = AuthState.Unpaired
assertTrue(state is AuthState.Unpaired)
state = AuthState.Failed("Server unreachable")
assertTrue(state is AuthState.Failed)
assertEquals("Server unreachable", (state as AuthState.Failed).reason)
}
@Test
fun authState_transitionSequence_pairingToFailed() {
var state: AuthState = AuthState.Pairing
assertTrue(state is AuthState.Pairing)
state = AuthState.Failed("Code expired")
assertTrue(state is AuthState.Failed)
}
}
@@ -0,0 +1,337 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for ChatMessage, ToolCall, MessageRole, and ChatSession data models.
*/
class ChatMessageTest {
// --- ChatMessage creation with defaults ---
@Test
fun chatMessage_creation_withRequiredFields() {
val msg = ChatMessage(
id = "msg-1",
role = MessageRole.USER,
content = "Hello",
timestamp = 1700000000L
)
assertEquals("msg-1", msg.id)
assertEquals(MessageRole.USER, msg.role)
assertEquals("Hello", msg.content)
assertEquals(1700000000L, msg.timestamp)
}
@Test
fun chatMessage_defaults_isStreamingFalse() {
val msg = ChatMessage(
id = "1", role = MessageRole.USER, content = "", timestamp = 0L
)
assertFalse(msg.isStreaming)
}
@Test
fun chatMessage_defaults_emptyToolCalls() {
val msg = ChatMessage(
id = "1", role = MessageRole.USER, content = "", timestamp = 0L
)
assertTrue(msg.toolCalls.isEmpty())
}
@Test
fun chatMessage_defaults_emptyThinkingContent() {
val msg = ChatMessage(
id = "1", role = MessageRole.USER, content = "", timestamp = 0L
)
assertEquals("", msg.thinkingContent)
}
@Test
fun chatMessage_defaults_isThinkingStreamingFalse() {
val msg = ChatMessage(
id = "1", role = MessageRole.USER, content = "", timestamp = 0L
)
assertFalse(msg.isThinkingStreaming)
}
@Test
fun chatMessage_defaults_nullTokenFields() {
val msg = ChatMessage(
id = "1", role = MessageRole.USER, content = "", timestamp = 0L
)
assertNull(msg.inputTokens)
assertNull(msg.outputTokens)
assertNull(msg.totalTokens)
assertNull(msg.estimatedCost)
}
@Test
fun chatMessage_withAllFields() {
val toolCalls = listOf(
ToolCall(id = "tc-1", name = "read_file", args = "{}", result = "contents", success = true, isComplete = true)
)
val msg = ChatMessage(
id = "msg-2",
role = MessageRole.ASSISTANT,
content = "Here is the file",
timestamp = 1700000000L,
isStreaming = true,
toolCalls = toolCalls,
thinkingContent = "Let me check...",
isThinkingStreaming = true,
inputTokens = 50,
outputTokens = 100,
totalTokens = 150,
estimatedCost = 0.003
)
assertTrue(msg.isStreaming)
assertEquals(1, msg.toolCalls.size)
assertEquals("Let me check...", msg.thinkingContent)
assertTrue(msg.isThinkingStreaming)
assertEquals(50, msg.inputTokens)
assertEquals(100, msg.outputTokens)
assertEquals(150, msg.totalTokens)
assertEquals(0.003, msg.estimatedCost!!, 0.0001)
}
@Test
fun chatMessage_copy_preservesFields() {
val original = ChatMessage(
id = "msg-1",
role = MessageRole.ASSISTANT,
content = "Hello",
timestamp = 1000L,
isStreaming = true
)
val copy = original.copy(content = "Hello world", isStreaming = false)
assertEquals("msg-1", copy.id)
assertEquals(MessageRole.ASSISTANT, copy.role)
assertEquals("Hello world", copy.content)
assertEquals(1000L, copy.timestamp)
assertFalse(copy.isStreaming)
}
// --- MessageRole enum ---
@Test
fun messageRole_hasAllExpectedValues() {
val values = MessageRole.values()
assertEquals(3, values.size)
}
@Test
fun messageRole_user() {
assertEquals(MessageRole.USER, MessageRole.valueOf("USER"))
}
@Test
fun messageRole_assistant() {
assertEquals(MessageRole.ASSISTANT, MessageRole.valueOf("ASSISTANT"))
}
@Test
fun messageRole_system() {
assertEquals(MessageRole.SYSTEM, MessageRole.valueOf("SYSTEM"))
}
// --- ToolCall ---
@Test
fun toolCall_creation_withDefaults() {
val tc = ToolCall(
name = "read_file",
args = null,
result = null,
success = null
)
assertNull(tc.id)
assertEquals("read_file", tc.name)
assertNull(tc.args)
assertNull(tc.result)
assertNull(tc.success)
assertFalse(tc.isComplete)
assertNull(tc.error)
assertNotNull(tc.startedAt) // Default to System.currentTimeMillis()
assertNull(tc.completedAt)
}
@Test
fun toolCall_pending_state() {
val tc = ToolCall(
id = "call-1",
name = "write_file",
args = """{"path": "test.txt"}""",
result = null,
success = null,
isComplete = false
)
assertFalse(tc.isComplete)
assertNull(tc.success)
assertNull(tc.result)
}
@Test
fun toolCall_completed_state() {
val now = System.currentTimeMillis()
val tc = ToolCall(
id = "call-1",
name = "read_file",
args = """{"path": "data.json"}""",
result = "file contents here",
success = true,
isComplete = true,
completedAt = now
)
assertTrue(tc.isComplete)
assertEquals(true, tc.success)
assertEquals("file contents here", tc.result)
assertEquals(now, tc.completedAt)
assertNull(tc.error)
}
@Test
fun toolCall_failed_state() {
val tc = ToolCall(
id = "call-2",
name = "dangerous_operation",
args = null,
result = null,
success = false,
isComplete = true,
error = "Permission denied",
completedAt = System.currentTimeMillis()
)
assertTrue(tc.isComplete)
assertEquals(false, tc.success)
assertEquals("Permission denied", tc.error)
}
@Test
fun toolCall_copy_updatesCompletion() {
val pending = ToolCall(
id = "call-1",
name = "tool",
args = null,
result = null,
success = null,
isComplete = false
)
val completed = pending.copy(
success = true,
isComplete = true,
result = "done",
completedAt = System.currentTimeMillis()
)
assertFalse(pending.isComplete)
assertTrue(completed.isComplete)
assertEquals(true, completed.success)
assertEquals("done", completed.result)
}
@Test
fun toolCall_startedAt_defaultsToCurrentTime() {
val before = System.currentTimeMillis()
val tc = ToolCall(name = "tool", args = null, result = null, success = null)
val after = System.currentTimeMillis()
assertTrue(tc.startedAt >= before)
assertTrue(tc.startedAt <= after)
}
@Test
fun toolCall_duration_calculable() {
val startTime = 1000L
val endTime = 2500L
val tc = ToolCall(
name = "slow_tool",
args = null,
result = "done",
success = true,
isComplete = true,
startedAt = startTime,
completedAt = endTime
)
val duration = tc.completedAt!! - tc.startedAt
assertEquals(1500L, duration)
}
// --- ChatSession ---
@Test
fun chatSession_creation_withAllFields() {
val session = ChatSession(
sessionId = "sess-1",
title = "My Session",
model = "gpt-4",
messageCount = 10,
updatedAt = 1700000000L
)
assertEquals("sess-1", session.sessionId)
assertEquals("My Session", session.title)
assertEquals("gpt-4", session.model)
assertEquals(10, session.messageCount)
assertEquals(1700000000L, session.updatedAt)
}
@Test
fun chatSession_defaults() {
val session = ChatSession(
sessionId = "sess-2",
title = null,
model = null
)
assertNull(session.title)
assertNull(session.model)
assertEquals(0, session.messageCount)
assertEquals(0L, session.updatedAt)
}
@Test
fun chatSession_nullableFields() {
val session = ChatSession(
sessionId = "s1",
title = null,
model = null,
messageCount = 5
)
assertNull(session.title)
assertNull(session.model)
}
@Test
fun chatSession_copy_updatesTitle() {
val original = ChatSession(sessionId = "s1", title = "Old", model = "gpt-4")
val renamed = original.copy(title = "New Title")
assertEquals("New Title", renamed.title)
assertEquals("s1", renamed.sessionId)
assertEquals("gpt-4", renamed.model)
}
@Test
fun chatSession_equality() {
val a = ChatSession(sessionId = "s1", title = "Test", model = "gpt-4")
val b = ChatSession(sessionId = "s1", title = "Test", model = "gpt-4")
assertEquals(a, b)
}
}
@@ -0,0 +1,330 @@
package com.hermesandroid.relay.data
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Unit tests for DataManager.AppBackup serialization and import logic.
*
* DataManager itself requires Android Context, but AppBackup is a
* kotlinx.serialization data class and importSettings() is pure logic,
* so we can test the serialization round-trip and field validation on JVM.
*/
class DataManagerTest {
private lateinit var json: Json
@Before
fun setUp() {
json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}
}
// --- Serialization produces valid JSON ---
@Test
fun backup_serialization_producesValidJson() {
val backup = DataManager.AppBackup(
apiServerUrl = "http://localhost:8642",
relayUrl = "wss://localhost:8767",
theme = "dark"
)
val jsonStr = json.encodeToString(backup)
assertNotNull(jsonStr)
assertTrue(jsonStr.isNotEmpty())
// Should be parseable back
val parsed = json.decodeFromString<DataManager.AppBackup>(jsonStr)
assertNotNull(parsed)
}
// --- Backup contains expected keys ---
@Test
fun backup_containsVersionKey() {
val backup = DataManager.AppBackup()
val jsonStr = json.encodeToString(backup)
assertTrue("JSON should contain 'version'", jsonStr.contains("\"version\""))
}
@Test
fun backup_containsApiServerUrlKey() {
val backup = DataManager.AppBackup(apiServerUrl = "http://localhost:8642")
val jsonStr = json.encodeToString(backup)
assertTrue("JSON should contain 'apiServerUrl'", jsonStr.contains("\"apiServerUrl\""))
}
@Test
fun backup_containsRelayUrlKey() {
val backup = DataManager.AppBackup(relayUrl = "wss://localhost:8767")
val jsonStr = json.encodeToString(backup)
assertTrue("JSON should contain 'relayUrl'", jsonStr.contains("\"relayUrl\""))
}
@Test
fun backup_containsThemeKey() {
val backup = DataManager.AppBackup(theme = "dark")
val jsonStr = json.encodeToString(backup)
assertTrue("JSON should contain 'theme'", jsonStr.contains("\"theme\""))
}
@Test
fun backup_containsExportedAtKey() {
val backup = DataManager.AppBackup()
val jsonStr = json.encodeToString(backup)
assertTrue("JSON should contain 'exportedAt'", jsonStr.contains("\"exportedAt\""))
}
@Test
fun backup_containsOnboardingCompletedKey() {
val backup = DataManager.AppBackup()
val jsonStr = json.encodeToString(backup)
assertTrue("JSON should contain 'onboardingCompleted'", jsonStr.contains("\"onboardingCompleted\""))
}
// --- Backup does NOT contain sensitive data ---
@Test
fun backup_doesNotContainApiKey() {
val backup = DataManager.AppBackup(
apiServerUrl = "http://localhost:8642",
theme = "auto"
)
val jsonStr = json.encodeToString(backup)
assertFalse("Backup should not contain 'apiKey'", jsonStr.contains("\"apiKey\""))
assertFalse("Backup should not contain 'api_key'", jsonStr.contains("\"api_key\""))
}
@Test
fun backup_doesNotContainSessionToken() {
val backup = DataManager.AppBackup()
val jsonStr = json.encodeToString(backup)
assertFalse("Backup should not contain 'session_token'", jsonStr.contains("\"session_token\""))
assertFalse("Backup should not contain 'sessionToken'", jsonStr.contains("\"sessionToken\""))
}
@Test
fun backup_doesNotContainDeviceId() {
val backup = DataManager.AppBackup()
val jsonStr = json.encodeToString(backup)
assertFalse("Backup should not contain 'device_id'", jsonStr.contains("\"device_id\""))
assertFalse("Backup should not contain 'deviceId'", jsonStr.contains("\"deviceId\""))
}
@Test
fun backup_doesNotContainBearerToken() {
val backup = DataManager.AppBackup()
val jsonStr = json.encodeToString(backup)
assertFalse("Backup should not contain 'token'", jsonStr.contains("\"token\""))
assertFalse("Backup should not contain 'bearer'", jsonStr.lowercase().contains("\"bearer\""))
}
// --- Serialization round-trip ---
@Test
fun backup_roundTrip_preservesAllFields() {
val original = DataManager.AppBackup(
version = 2,
serverUrl = "http://old-server:8642",
apiServerUrl = "http://localhost:8642",
relayUrl = "wss://localhost:8767",
theme = "dark",
onboardingCompleted = true,
profiles = listOf("default", "coder"),
exportedAt = 1700000000000L
)
val jsonStr = json.encodeToString(original)
val restored = json.decodeFromString<DataManager.AppBackup>(jsonStr)
assertEquals(original.version, restored.version)
assertEquals(original.serverUrl, restored.serverUrl)
assertEquals(original.apiServerUrl, restored.apiServerUrl)
assertEquals(original.relayUrl, restored.relayUrl)
assertEquals(original.theme, restored.theme)
assertEquals(original.onboardingCompleted, restored.onboardingCompleted)
assertEquals(original.profiles, restored.profiles)
assertEquals(original.exportedAt, restored.exportedAt)
}
// --- Restore from valid JSON ---
@Test
fun importSettings_validJson_returnsBackup() {
val jsonStr = """
{
"version": 2,
"apiServerUrl": "http://myserver:8642",
"relayUrl": "wss://myserver:8767",
"theme": "light",
"onboardingCompleted": true,
"profiles": ["default"],
"exportedAt": 1700000000000
}
""".trimIndent()
val result = json.decodeFromString<DataManager.AppBackup>(jsonStr)
assertNotNull(result)
assertEquals(2, result.version)
assertEquals("http://myserver:8642", result.apiServerUrl)
assertEquals("wss://myserver:8767", result.relayUrl)
assertEquals("light", result.theme)
assertTrue(result.onboardingCompleted)
assertEquals(listOf("default"), result.profiles)
}
// --- Restore from malformed JSON ---
@Test
fun importSettings_malformedJson_returnsNull() {
val malformed = "this is not json {{"
val result = try {
json.decodeFromString<DataManager.AppBackup>(malformed)
} catch (e: Exception) {
null
}
assertNull(result)
}
@Test
fun importSettings_emptyString_returnsNull() {
val result = try {
json.decodeFromString<DataManager.AppBackup>("")
} catch (e: Exception) {
null
}
assertNull(result)
}
@Test
fun importSettings_emptyJsonObject_usesDefaults() {
val jsonStr = "{}"
val result = json.decodeFromString<DataManager.AppBackup>(jsonStr)
assertNotNull(result)
assertEquals(2, result.version)
assertNull(result.serverUrl)
assertNull(result.apiServerUrl)
assertNull(result.relayUrl)
assertEquals("auto", result.theme)
assertFalse(result.onboardingCompleted)
assertTrue(result.profiles.isEmpty())
}
// --- Unknown fields are ignored ---
@Test
fun importSettings_unknownFields_ignored() {
val jsonStr = """
{
"version": 2,
"apiServerUrl": "http://localhost:8642",
"theme": "auto",
"unknownField": "should be ignored",
"anotherUnknown": 42
}
""".trimIndent()
val result = json.decodeFromString<DataManager.AppBackup>(jsonStr)
assertNotNull(result)
assertEquals("http://localhost:8642", result.apiServerUrl)
}
// --- Format version handling ---
@Test
fun backup_defaultVersion_isTwo() {
val backup = DataManager.AppBackup()
assertEquals(2, backup.version)
}
@Test
fun importSettings_version1_compat() {
// v1 used 'serverUrl' instead of 'apiServerUrl'/'relayUrl'
val jsonStr = """
{
"version": 1,
"serverUrl": "wss://oldserver:8767",
"theme": "auto"
}
""".trimIndent()
val result = json.decodeFromString<DataManager.AppBackup>(jsonStr)
assertEquals(1, result.version)
assertEquals("wss://oldserver:8767", result.serverUrl)
assertNull(result.apiServerUrl) // Not present in v1
}
// --- Default values ---
@Test
fun backup_defaultTheme_isAuto() {
val backup = DataManager.AppBackup()
assertEquals("auto", backup.theme)
}
@Test
fun backup_defaultOnboardingCompleted_isFalse() {
val backup = DataManager.AppBackup()
assertFalse(backup.onboardingCompleted)
}
@Test
fun backup_defaultProfiles_isEmpty() {
val backup = DataManager.AppBackup()
assertTrue(backup.profiles.isEmpty())
}
@Test
fun backup_nullableFields_defaultToNull() {
val backup = DataManager.AppBackup()
assertNull(backup.serverUrl)
assertNull(backup.apiServerUrl)
assertNull(backup.relayUrl)
}
// --- Profiles list serialization ---
@Test
fun backup_profilesList_roundTrip() {
val profiles = listOf("default", "coder", "researcher", "creative")
val backup = DataManager.AppBackup(profiles = profiles)
val jsonStr = json.encodeToString(backup)
val restored = json.decodeFromString<DataManager.AppBackup>(jsonStr)
assertEquals(profiles, restored.profiles)
}
@Test
fun backup_emptyProfilesList_roundTrip() {
val backup = DataManager.AppBackup(profiles = emptyList())
val jsonStr = json.encodeToString(backup)
val restored = json.decodeFromString<DataManager.AppBackup>(jsonStr)
assertTrue(restored.profiles.isEmpty())
}
}

Some files were not shown because too many files have changed in this diff Show More