Initial commit: Hermes Android native remote client MVP (Contract v1)

This commit is contained in:
Ochenstarik 2026-08-23 22:46:58 +07:00
commit 4fbb95c6c4
46 changed files with 5753 additions and 0 deletions

39
.gitignore vendored Normal file
View file

@ -0,0 +1,39 @@
# Built application files
*.apk
*.aar
*.aab
*.dex
*.class
# Output and build directories
build/
app/build/
**/build/
.cxx/
captures/
# Gradle files
.gradle/
# Local configuration file (sdk path, secrets, etc.)
local.properties
# Android Studio / IntelliJ IDEA files
.idea/
*.iml
.idea/caches
.idea/libraries
.idea/modules.xml
.idea/workspace.xml
.idea/navEditor.xml
.idea/assetWizardSettings.xml
# Kotlin
.kotlin/
# Log files
*.log
# OS & temporary files
.DS_Store
Thumbs.db

122
README.md Normal file
View file

@ -0,0 +1,122 @@
# Hermes Android Native Remote Client
A production-grade, native Android client application for **Hermes**, implementing Protocol & Architecture Contract v1.
Built with **Kotlin**, **Jetpack Compose (Material 3)**, **Coroutines**, **OkHttp**, and **Android Keystore (EncryptedSharedPreferences)**.
---
## 🌟 Architecture Overview
```
┌─────────────────────────────────────────────────────────┐
│ Hermes Android Client │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Jetpack Compose UI (M3) │ │
│ │ • Connections • Sessions • Chat & Approvals │ │
│ └─────────────────────────┬─────────────────────────┘ │
│ │ StateFlow / Actions │
│ ┌─────────────────────────▼─────────────────────────┐ │
│ │ Hermes Gateway Layer │ │
│ │ • Reconnection Loop with Exponential Backoff │ │
│ │ • Session State Reconciliation │ │
│ │ • Event Stream Dispatcher │ │
│ └──────────┬──────────────────────────┬─────────────┘ │
│ │ JSON-RPC / Ticket │ PKCE Auth │
│ ┌──────────▼──────────┐ ┌──────────▼──────────────┐ │
│ │ OkHttp WebSocket │ │ Loopback Auth Server │ │
│ │ (Single-Use Auth) │ │ (127.0.0.1:<port>) │ │
│ └──────────┬──────────┘ └──────────┬──────────────┘ │
└─────────────┼──────────────────────────┼─────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Hermes Host │
│ (`hermes serve`) │
│ │
│ • `GET /api/status``GET /auth/native/...`
│ • `POST /api/auth/ws-ticket``WS /ws?ticket=...`
└─────────────────────────────────────────────────────────┘
```
---
## 🚀 Getting Started & Host Setup
### 1. Windows Host Setup
Run the Hermes server binding to all interfaces (or your LAN / Tailscale IP):
```powershell
hermes serve --host 0.0.0.0 --port 9119
```
To configure GitHub authentication:
```powershell
$env:HERMES_AUTH_REQUIRED="true"
$env:HERMES_AUTH_PROVIDERS="github"
$env:HERMES_AUTH_GITHUB_CLIENT_ID="<your_client_id>"
$env:HERMES_AUTH_GITHUB_CLIENT_SECRET="<your_client_secret>"
hermes serve --host 0.0.0.0 --port 9119
```
### 2. Linux Host Setup
```bash
export HERMES_AUTH_REQUIRED="true"
export HERMES_AUTH_PROVIDERS="github"
export HERMES_AUTH_GITHUB_CLIENT_ID="<your_client_id>"
export HERMES_AUTH_GITHUB_CLIENT_SECRET="<your_client_secret>"
hermes serve --host 0.0.0.0 --port 9119
```
---
## 📱 Android Client Features
1. **Host Connection Manager**:
- Save multiple Hermes host endpoints.
- Live endpoint verification (`GET /api/status`).
- Cleartext HTTP toggling with explicit security warning badges for local development.
2. **Native PKCE Authentication**:
- RFC 7636 & RFC 8252 compliant PKCE loopback authentication on `127.0.0.1:<ephemeral_port>`.
- Single-use WebSocket tickets with 30s TTL.
- Credentials securely stored via Android Keystore & `EncryptedSharedPreferences`. Zero token logging.
3. **Session Management**:
- Resume durable sessions (`DurableSessionId`) or create new sessions.
- Dynamic reconciliation across network disconnects.
4. **Real-time Chat Experience**:
- Streaming token deltas (`message.delta`).
- Collapsible reasoning & chain-of-thought section (`thinking.delta`).
- Real-time tool execution tracking cards (`tool.start`, `tool.progress`, `tool.complete`).
- **Interactive Approvals**: Immediate in-stream approval card for dangerous commands (`Allow Once`, `Allow Always`, `Deny`).
- **Clarifications & Sudo**: Masked dialogs for `sudo.request`, `secret.request`, and `clarify.request`.
- Interrupt / Stop execution control.
---
## 🔒 Security Best Practices for Remote Access
- **Do NOT expose cleartext HTTP directly to the public internet.**
- **Recommended**: Connect via **Tailscale**, **WireGuard**, or a TLS Reverse Proxy (Caddy / Nginx) with HTTPS & WSS.
- The Android client strictly enforces `usesCleartextTraffic="false"` at the manifest level by default.
---
## 🧪 Testing & Verification
Run all unit tests via Gradle:
```powershell
.\gradlew testDebugUnitTest
```
Build the release or debug APK:
```powershell
.\gradlew assembleDebug
```

106
app/build.gradle.kts Normal file
View file

@ -0,0 +1,106 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "app.hermes.mobile"
compileSdk = 35
defaultConfig {
applicationId = "app.hermes.mobile"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
applicationIdSuffix = ".debug"
isDebuggable = true
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs += listOf(
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api"
)
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Jetpack Compose BOM
val composeBom = platform("androidx.compose:compose-bom:2025.02.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.foundation:foundation")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// AndroidX & Lifecycle
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.navigation:navigation-compose:2.8.8")
implementation("androidx.browser:browser:1.8.0")
implementation("androidx.datastore:datastore-preferences:1.1.2")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
// Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
// Network (OkHttp & WebSocket)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation("app.cash.turbine:turbine:1.2.0")
testImplementation("org.json:json:20240303")
}

2
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,2 @@
# Proguard rules for Hermes Mobile
-keepattributes *Annotation*,Signature,InnerClasses,EnclosingMethod

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="false"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:theme="@style/Theme.HermesAndroid"
android:usesCleartextTraffic="false">
<activity
android:name="app.hermes.mobile.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.HermesAndroid"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,139 @@
package app.hermes.mobile
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import app.hermes.mobile.core.auth.PkceLoopbackAuthManager
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.network.JsonRpcGatewayClient
import app.hermes.mobile.core.repository.ConnectionRepository
import app.hermes.mobile.core.repository.HermesGatewayRepository
import app.hermes.mobile.core.security.EncryptedTokenVault
import app.hermes.mobile.feature.chat.ChatScreen
import app.hermes.mobile.feature.chat.ChatViewModel
import app.hermes.mobile.feature.connections.ConnectionsScreen
import app.hermes.mobile.feature.connections.ConnectionsViewModel
import app.hermes.mobile.feature.sessions.SessionsScreen
import app.hermes.mobile.feature.sessions.SessionsViewModel
import app.hermes.mobile.feature.settings.SettingsScreen
import app.hermes.mobile.ui.theme.HermesAndroidTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val tokenVault = EncryptedTokenVault(applicationContext)
val restClient = HermesRestClient()
val gatewayClient = JsonRpcGatewayClient()
val pkceAuthManager = PkceLoopbackAuthManager(restClient, tokenVault)
val connectionRepo = ConnectionRepository(applicationContext)
val gatewayRepo = HermesGatewayRepository(restClient, gatewayClient, tokenVault)
setContent {
HermesAndroidTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
HermesAppNavigation(
connectionRepo = connectionRepo,
gatewayRepo = gatewayRepo,
tokenVault = tokenVault,
pkceAuthManager = pkceAuthManager
)
}
}
}
}
}
@Composable
fun HermesAppNavigation(
connectionRepo: ConnectionRepository,
gatewayRepo: HermesGatewayRepository,
tokenVault: EncryptedTokenVault,
pkceAuthManager: PkceLoopbackAuthManager
) {
val navController = rememberNavController()
val connectionsViewModel = remember {
ConnectionsViewModel(connectionRepo, gatewayRepo, tokenVault, pkceAuthManager)
}
val sessionsViewModel = remember {
SessionsViewModel(gatewayRepo)
}
val chatViewModel = remember {
ChatViewModel(gatewayRepo)
}
NavHost(
navController = navController,
startDestination = "connections"
) {
composable("connections") {
ConnectionsScreen(
viewModel = connectionsViewModel,
onNavigateToSessions = { connId ->
sessionsViewModel.loadSessions()
navController.navigate("sessions/$connId")
}
)
}
composable(
route = "sessions/{connectionId}",
arguments = listOf(navArgument("connectionId") { type = NavType.StringType })
) { backStackEntry ->
val connId = backStackEntry.arguments?.getString("connectionId") ?: ""
SessionsScreen(
viewModel = sessionsViewModel,
connectionId = connId,
onNavigateBack = {
navController.popBackStack()
},
onNavigateToChat = { durableSessionId ->
navController.navigate("chat/$connId/$durableSessionId")
}
)
}
composable(
route = "chat/{connectionId}/{durableSessionId}",
arguments = listOf(
navArgument("connectionId") { type = NavType.StringType },
navArgument("durableSessionId") { type = NavType.StringType }
)
) { backStackEntry ->
val durableSessionId = backStackEntry.arguments?.getString("durableSessionId") ?: ""
ChatScreen(
viewModel = chatViewModel,
durableSessionId = durableSessionId,
onNavigateBack = {
navController.popBackStack()
}
)
}
composable("settings") {
SettingsScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
}
}

View file

@ -0,0 +1,42 @@
package app.hermes.mobile.core.auth
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.security.SecureRandom
data class PkceChallenge(
val codeVerifier: String,
val codeChallenge: String,
val method: String = "S256"
) {
companion object {
private val SECURE_RANDOM = SecureRandom()
private const val PKCE_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
fun generate(length: Int = 64): PkceChallenge {
require(length in 43..128) { "PKCE code_verifier length must be between 43 and 128 characters" }
val sb = StringBuilder(length)
for (i in 0 until length) {
val index = SECURE_RANDOM.nextInt(PKCE_CHARSET.length)
sb.append(PKCE_CHARSET[index])
}
val verifier = sb.toString()
val challenge = computeChallenge(verifier)
return PkceChallenge(
codeVerifier = verifier,
codeChallenge = challenge,
method = "S256"
)
}
fun computeChallenge(verifier: String): String {
val bytes = verifier.toByteArray(StandardCharsets.US_ASCII)
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
return base64UrlEncodeNoPadding(digest)
}
fun base64UrlEncodeNoPadding(input: ByteArray): String {
return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(input)
}
}
}

View file

@ -0,0 +1,195 @@
package app.hermes.mobile.core.auth
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import app.hermes.mobile.core.model.NativeAuthTokens
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.security.TokenVault
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
class PkceLoopbackAuthManager(
private val restClient: HermesRestClient,
private val tokenVault: TokenVault
) {
suspend fun startAuthFlow(
context: Context?,
connectionId: String,
baseUrl: String,
provider: String = "github",
allowCleartext: Boolean = false,
onAuthUrlReady: ((String) -> Unit)? = null
): Result<NativeAuthTokens> = withContext(Dispatchers.IO) {
var serverSocket: ServerSocket? = null
try {
serverSocket = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))
val port = serverSocket.localPort
serverSocket.soTimeout = 180_000 // 3 minutes timeout
val state = UUID.randomUUID().toString()
val challenge = PkceChallenge.generate()
val redirectUri = "http://127.0.0.1:$port/callback"
val encodedRedirect = URLEncoder.encode(redirectUri, StandardCharsets.UTF_8.name())
val encodedChallenge = URLEncoder.encode(challenge.codeChallenge, StandardCharsets.UTF_8.name())
val encodedState = URLEncoder.encode(state, StandardCharsets.UTF_8.name())
val encodedProvider = URLEncoder.encode(provider, StandardCharsets.UTF_8.name())
val cleanBase = baseUrl.trimEnd('/')
val authUrl = "$cleanBase/auth/native/authorize?" +
"provider=$encodedProvider" +
"&code_challenge=$encodedChallenge" +
"&code_challenge_method=S256" +
"&redirect_uri=$encodedRedirect" +
"&state=$encodedState"
if (onAuthUrlReady != null) {
onAuthUrlReady(authUrl)
} else if (context != null) {
openBrowser(context, authUrl)
}
val socket: Socket = serverSocket.accept()
val authCode = handleCallbackSocket(socket, state)
val exchangeResult = restClient.exchangeNativeToken(
baseUrl = cleanBase,
code = authCode,
codeVerifier = challenge.codeVerifier,
allowCleartext = allowCleartext
)
if (exchangeResult.isSuccess) {
val tokens = exchangeResult.getOrThrow()
tokenVault.saveTokens(connectionId, tokens)
Result.success(tokens)
} else {
Result.failure(exchangeResult.exceptionOrNull() ?: Exception("Token exchange failed"))
}
} catch (e: Exception) {
Result.failure(e)
} finally {
try {
serverSocket?.close()
} catch (_: Exception) {
}
}
}
private fun handleCallbackSocket(socket: Socket, expectedState: String): String {
socket.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream()))
val firstLine = reader.readLine() ?: throw IllegalStateException("Empty HTTP request received")
val parts = firstLine.split(" ")
if (parts.size < 2 || parts[0] != "GET") {
throw IllegalStateException("Invalid HTTP request method: $firstLine")
}
val pathAndQuery = parts[1]
val queryIndex = pathAndQuery.indexOf('?')
if (queryIndex == -1) {
sendHtmlResponse(s, 400, "Missing authorization parameters")
throw IllegalStateException("Missing query parameters in callback URL: $pathAndQuery")
}
val query = pathAndQuery.substring(queryIndex + 1)
val queryParams = parseQueryParams(query)
val returnedState = queryParams["state"]
val authCode = queryParams["code"]
val error = queryParams["error"]
if (error != null) {
sendHtmlResponse(s, 400, "Authorization Error: $error")
throw IllegalStateException("Server returned authorization error: $error")
}
if (returnedState != expectedState) {
sendHtmlResponse(s, 400, "State mismatch error")
throw SecurityException("PKCE State mismatch! Possible CSRF attempt.")
}
if (authCode.isNullOrEmpty()) {
sendHtmlResponse(s, 400, "Missing authorization code")
throw IllegalStateException("Authorization code missing in response")
}
sendHtmlResponse(s, 200, "Authentication Successful! You can return to Hermes.")
return authCode
}
}
private fun parseQueryParams(query: String): Map<String, String> {
val map = mutableMapOf<String, String>()
for (pair in query.split("&")) {
val idx = pair.indexOf("=")
if (idx > 0) {
val key = pair.substring(0, idx)
val value = pair.substring(idx + 1)
map[key] = java.net.URLDecoder.decode(value, StandardCharsets.UTF_8.name())
}
}
return map
}
private fun sendHtmlResponse(socket: Socket, statusCode: Int, message: String) {
val html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hermes Authentication</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; text-align: center; padding: 40px 20px; background: #0f172a; color: #f8fafc; }
.card { background: #1e293b; max-width: 420px; margin: 0 auto; padding: 32px; border-radius: 16px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); }
h2 { margin-top: 0; color: #38bdf8; }
p { color: #94a3b8; font-size: 16px; line-height: 1.5; }
</style>
</head>
<body>
<div class="card">
<h2>Hermes Authentication</h2>
<p>$message</p>
</div>
</body>
</html>
""".trimIndent()
val statusText = if (statusCode == 200) "OK" else "Bad Request"
val response = "HTTP/1.1 $statusCode $statusText\r\n" +
"Content-Type: text/html; charset=utf-8\r\n" +
"Content-Length: ${html.toByteArray(StandardCharsets.UTF_8).size}\r\n" +
"Connection: close\r\n\r\n" +
html
val output = socket.getOutputStream()
output.write(response.toByteArray(StandardCharsets.UTF_8))
output.flush()
}
private fun openBrowser(context: Context, url: String) {
try {
val customTabsIntent = CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
customTabsIntent.launchUrl(context, Uri.parse(url))
} catch (e: Exception) {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(browserIntent)
}
}
}

View file

@ -0,0 +1,19 @@
package app.hermes.mobile.core.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class NativeAuthTokens(
@SerialName("access_token")
val accessToken: String,
@SerialName("refresh_token")
val refreshToken: String = "",
@SerialName("token_type")
val tokenType: String = "Bearer",
@SerialName("expires_at")
val expiresAt: Long = 0L,
val provider: String = "",
@SerialName("user_id")
val userId: String = ""
)

View file

@ -0,0 +1,13 @@
package app.hermes.mobile.core.model
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class HermesConnection(
val id: String = UUID.randomUUID().toString(),
val name: String,
val baseUrl: String,
val allowCleartext: Boolean = false,
val createdAt: Long = System.currentTimeMillis()
)

View file

@ -0,0 +1,365 @@
package app.hermes.mobile.core.model
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
sealed class GatewayEvent {
abstract val rawPayload: JsonObject
data class GatewayReadyEvent(
val version: String,
val sessionCount: Int = 0,
override val rawPayload: JsonObject
) : GatewayEvent()
data class MessageStartEvent(
val messageId: String,
val role: String = "assistant",
override val rawPayload: JsonObject
) : GatewayEvent()
data class MessageDeltaEvent(
val messageId: String,
val delta: String,
override val rawPayload: JsonObject
) : GatewayEvent()
data class MessageInterimEvent(
val messageId: String,
val content: String,
override val rawPayload: JsonObject
) : GatewayEvent()
data class MessageCompleteEvent(
val messageId: String,
val content: String,
override val rawPayload: JsonObject
) : GatewayEvent()
data class ThinkingDeltaEvent(
val messageId: String,
val delta: String,
override val rawPayload: JsonObject
) : GatewayEvent()
data class ReasoningDeltaEvent(
val messageId: String,
val delta: String,
override val rawPayload: JsonObject
) : GatewayEvent()
data class ReasoningAvailableEvent(
val messageId: String,
val reasoning: String,
override val rawPayload: JsonObject
) : GatewayEvent()
data class ToolStartEvent(
val toolId: String,
val name: String,
val input: JsonElement? = null,
override val rawPayload: JsonObject
) : GatewayEvent()
data class ToolProgressEvent(
val toolId: String,
val progress: String,
override val rawPayload: JsonObject
) : GatewayEvent()
data class ToolGeneratingEvent(
val toolId: String,
val name: String,
override val rawPayload: JsonObject
) : GatewayEvent()
data class ToolCompleteEvent(
val toolId: String,
val result: String,
val isError: Boolean = false,
override val rawPayload: JsonObject
) : GatewayEvent()
data class ApprovalRequestEvent(
val requestId: String,
val command: String? = null,
val description: String? = null,
val choices: List<String> = listOf("once", "deny"),
val sessionKey: String? = null,
override val rawPayload: JsonObject
) : GatewayEvent()
data class ClarifyRequestEvent(
val requestId: String,
val questionId: String? = null,
val question: String,
val promptType: ClarifyType = ClarifyType.CLARIFY,
override val rawPayload: JsonObject
) : GatewayEvent()
data class SudoRequestEvent(
val requestId: String,
val question: String,
override val rawPayload: JsonObject
) : GatewayEvent()
data class SecretRequestEvent(
val requestId: String,
val question: String,
override val rawPayload: JsonObject
) : GatewayEvent()
data class StatusUpdateEvent(
val status: String,
val message: String? = null,
override val rawPayload: JsonObject
) : GatewayEvent()
data class SessionUsageEvent(
val inputTokens: Long = 0,
val outputTokens: Long = 0,
val totalTokens: Long = 0,
override val rawPayload: JsonObject
) : GatewayEvent()
data class SessionInfoEvent(
val info: SessionInfo,
override val rawPayload: JsonObject
) : GatewayEvent()
data class BackgroundCompleteEvent(
val taskId: String,
val result: String? = null,
override val rawPayload: JsonObject
) : GatewayEvent()
data class ErrorEvent(
val code: Int = -1,
val message: String,
override val rawPayload: JsonObject
) : GatewayEvent()
data class UnknownGatewayEvent(
val eventType: String,
override val rawPayload: JsonObject
) : GatewayEvent()
companion object {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
fun parse(root: JsonObject): GatewayEvent {
// Find event name and data container
var eventType = ""
var dataObj: JsonObject = root
if (root.containsKey("method")) {
val method = root["method"]?.jsonPrimitive?.content ?: ""
if (method == "event" && root.containsKey("params")) {
val params = root["params"]?.jsonObject ?: JsonObject(emptyMap())
eventType = params["event"]?.jsonPrimitive?.content
?: params["type"]?.jsonPrimitive?.content
?: ""
dataObj = params["data"]?.jsonObject
?: params["payload"]?.jsonObject
?: params
} else if (method.isNotEmpty()) {
eventType = method
dataObj = root["params"]?.jsonObject ?: root
}
}
if (eventType.isEmpty()) {
eventType = root["event"]?.jsonPrimitive?.content
?: root["type"]?.jsonPrimitive?.content
?: ""
if (root.containsKey("data") && root["data"] is JsonObject) {
dataObj = root["data"]!!.jsonObject
} else if (root.containsKey("payload") && root["payload"] is JsonObject) {
dataObj = root["payload"]!!.jsonObject
}
}
fun getString(vararg keys: String): String {
for (k in keys) {
val v = dataObj[k]?.jsonPrimitive?.content ?: root[k]?.jsonPrimitive?.content
if (v != null) return v
}
return ""
}
fun getNullableString(vararg keys: String): String? {
for (k in keys) {
val el = dataObj[k] ?: root[k]
val v = el?.jsonPrimitive?.content
if (v != null) return v
}
return null
}
fun getLong(vararg keys: String): Long {
for (k in keys) {
val v = (dataObj[k]?.jsonPrimitive ?: root[k]?.jsonPrimitive)?.longOrNull
if (v != null) return v
}
return 0L
}
fun getInt(vararg keys: String): Int {
for (k in keys) {
val v = (dataObj[k]?.jsonPrimitive ?: root[k]?.jsonPrimitive)?.intOrNull
if (v != null) return v
}
return 0
}
fun getBoolean(vararg keys: String): Boolean {
for (k in keys) {
val v = (dataObj[k]?.jsonPrimitive ?: root[k]?.jsonPrimitive)?.booleanOrNull
if (v != null) return v
}
return false
}
fun getStringList(key: String): List<String> {
val array = (dataObj[key] ?: root[key]) as? JsonArray ?: return emptyList()
return array.mapNotNull { it.jsonPrimitive.content }
}
return when (eventType) {
"gateway.ready" -> GatewayReadyEvent(
version = getString("version", "server_version"),
sessionCount = getInt("session_count", "sessions"),
rawPayload = root
)
"message.start" -> MessageStartEvent(
messageId = getString("message_id", "id"),
role = getString("role").ifEmpty { "assistant" },
rawPayload = root
)
"message.delta" -> MessageDeltaEvent(
messageId = getString("message_id", "id"),
delta = getString("delta", "text", "chunk"),
rawPayload = root
)
"message.interim" -> MessageInterimEvent(
messageId = getString("message_id", "id"),
content = getString("content", "text"),
rawPayload = root
)
"message.complete" -> MessageCompleteEvent(
messageId = getString("message_id", "id"),
content = getString("content", "text"),
rawPayload = root
)
"thinking.delta" -> ThinkingDeltaEvent(
messageId = getString("message_id", "id"),
delta = getString("delta", "text", "chunk"),
rawPayload = root
)
"reasoning.delta" -> ReasoningDeltaEvent(
messageId = getString("message_id", "id"),
delta = getString("delta", "text", "chunk"),
rawPayload = root
)
"reasoning.available" -> ReasoningAvailableEvent(
messageId = getString("message_id", "id"),
reasoning = getString("reasoning", "content"),
rawPayload = root
)
"tool.start" -> ToolStartEvent(
toolId = getString("tool_id", "id"),
name = getString("name", "tool_name"),
input = dataObj["input"] ?: root["input"],
rawPayload = root
)
"tool.progress" -> ToolProgressEvent(
toolId = getString("tool_id", "id"),
progress = getString("progress", "message"),
rawPayload = root
)
"tool.generating" -> ToolGeneratingEvent(
toolId = getString("tool_id", "id"),
name = getString("name", "tool_name"),
rawPayload = root
)
"tool.complete" -> ToolCompleteEvent(
toolId = getString("tool_id", "id"),
result = getString("result", "output"),
isError = getBoolean("is_error", "error"),
rawPayload = root
)
"approval.request" -> {
val choices = getStringList("choices")
ApprovalRequestEvent(
requestId = getString("request_id", "id"),
command = getNullableString("command"),
description = getNullableString("description", "prompt"),
choices = if (choices.isNotEmpty()) choices else listOf("once", "deny"),
sessionKey = getNullableString("session_key", "sessionKey"),
rawPayload = root
)
}
"clarify.request" -> ClarifyRequestEvent(
requestId = getString("request_id", "id"),
questionId = getNullableString("question_id", "questionId"),
question = getString("question", "prompt"),
promptType = ClarifyType.CLARIFY,
rawPayload = root
)
"sudo.request" -> SudoRequestEvent(
requestId = getString("request_id", "id"),
question = getString("question", "prompt").ifEmpty { "Administrator password required:" },
rawPayload = root
)
"secret.request" -> SecretRequestEvent(
requestId = getString("request_id", "id"),
question = getString("question", "prompt").ifEmpty { "Secret / Token required:" },
rawPayload = root
)
"status.update" -> StatusUpdateEvent(
status = getString("status"),
message = getNullableString("message"),
rawPayload = root
)
"session.usage" -> SessionUsageEvent(
inputTokens = getLong("input_tokens", "prompt_tokens"),
outputTokens = getLong("output_tokens", "completion_tokens"),
totalTokens = getLong("total_tokens"),
rawPayload = root
)
"session.info" -> SessionInfoEvent(
info = SessionInfo(
model = getNullableString("model"),
provider = getNullableString("provider"),
cwd = getNullableString("cwd"),
branch = getNullableString("branch"),
project = getNullableString("project")
),
rawPayload = root
)
"background.complete" -> BackgroundCompleteEvent(
taskId = getString("task_id", "id"),
result = getNullableString("result"),
rawPayload = root
)
"error" -> ErrorEvent(
code = getInt("code"),
message = getString("message").ifEmpty { "Unknown error" },
rawPayload = root
)
else -> UnknownGatewayEvent(
eventType = eventType.ifEmpty { "unknown" },
rawPayload = root
)
}
}
}
}

View file

@ -0,0 +1,29 @@
package app.hermes.mobile.core.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
@Serializable
data class JsonRpcRequest(
val jsonrpc: String = "2.0",
val id: String,
val method: String,
val params: JsonObject = buildJsonObject {}
)
@Serializable
data class JsonRpcResponse(
val jsonrpc: String = "2.0",
val id: String? = null,
val result: JsonElement? = null,
val error: JsonRpcError? = null
)
@Serializable
data class JsonRpcError(
val code: Int,
val message: String,
val data: JsonElement? = null
)

View file

@ -0,0 +1,16 @@
package app.hermes.mobile.core.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class HermesServerStatus(
val status: String = "ok",
@SerialName("auth_required")
val authRequired: Boolean = false,
@SerialName("auth_providers")
val authProviders: List<String> = emptyList(),
@SerialName("auth_flows")
val authFlows: List<String> = emptyList(),
val version: String? = null
)

View file

@ -0,0 +1,92 @@
package app.hermes.mobile.core.model
import kotlinx.serialization.Serializable
@Serializable
data class DurableSessionId(val value: String)
@Serializable
data class RuntimeSessionId(val value: String)
@Serializable
data class SessionSummary(
val id: DurableSessionId,
val title: String = "",
val preview: String = "",
val startedAt: Long = 0L,
val messageCount: Int = 0,
val source: String = "android"
)
@Serializable
data class SessionInfo(
val model: String? = null,
val provider: String? = null,
val cwd: String? = null,
val branch: String? = null,
val project: String? = null
)
enum class MessageRole {
USER,
ASSISTANT,
SYSTEM
}
@Serializable
data class ToolActivity(
val id: String,
val name: String,
val status: String,
val progress: String? = null,
val result: String? = null,
val isError: Boolean = false
)
@Serializable
data class HermesMessage(
val id: String,
val role: MessageRole,
val content: String,
val thinking: String? = null,
val tools: List<ToolActivity> = emptyList(),
val isStreaming: Boolean = false,
val timestamp: Long = System.currentTimeMillis()
)
@Serializable
data class HermesApproval(
val requestId: String,
val command: String? = null,
val description: String? = null,
val choices: List<String> = listOf("once", "deny")
)
enum class ClarifyType {
CLARIFY,
SUDO,
SECRET
}
@Serializable
data class HermesClarifyRequest(
val requestId: String,
val questionId: String? = null,
val question: String,
val promptType: ClarifyType = ClarifyType.CLARIFY
)
data class CreateSessionResult(
val durableId: DurableSessionId,
val runtimeId: RuntimeSessionId
)
data class ResumeSessionResult(
val durableId: DurableSessionId,
val runtimeId: RuntimeSessionId
)
data class PromptSubmitResult(
val turnId: String? = null,
val accepted: Boolean = true
)

View file

@ -0,0 +1,183 @@
package app.hermes.mobile.core.network
import app.hermes.mobile.core.model.HermesServerStatus
import app.hermes.mobile.core.model.NativeAuthTokens
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
class HermesRestClient(
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build(),
private val json: Json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
) {
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
private fun normalizeBaseUrl(baseUrl: String): String {
return baseUrl.trim().trimEnd('/')
}
private fun validateUrlScheme(url: String, allowCleartext: Boolean) {
if (!allowCleartext && url.startsWith("http://", ignoreCase = true)) {
throw SecurityException("Cleartext HTTP is not allowed unless explicitly permitted in connection settings.")
}
}
suspend fun getStatus(baseUrl: String, allowCleartext: Boolean = false): Result<HermesServerStatus> =
withContext(Dispatchers.IO) {
try {
val base = normalizeBaseUrl(baseUrl)
validateUrlScheme(base, allowCleartext)
val url = "$base/api/status"
val request = Request.Builder()
.url(url)
.get()
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(
IOException("HTTP ${response.code}: ${response.message}")
)
}
val body = response.body?.string() ?: "{}"
val status = json.decodeFromString<HermesServerStatus>(body)
Result.success(status)
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun exchangeNativeToken(
baseUrl: String,
code: String,
codeVerifier: String,
allowCleartext: Boolean = false
): Result<NativeAuthTokens> = withContext(Dispatchers.IO) {
try {
val base = normalizeBaseUrl(baseUrl)
validateUrlScheme(base, allowCleartext)
val url = "$base/auth/native/token"
val payload = buildJsonObject {
put("code", code)
put("code_verifier", codeVerifier)
}
val requestBody = payload.toString().toRequestBody(jsonMediaType)
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
val errBody = response.body?.string() ?: ""
return@withContext Result.failure(
IOException("HTTP ${response.code}: ${response.message} - $errBody")
)
}
val body = response.body?.string() ?: "{}"
val tokens = json.decodeFromString<NativeAuthTokens>(body)
Result.success(tokens)
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun refreshNativeToken(
baseUrl: String,
refreshToken: String,
provider: String = "",
allowCleartext: Boolean = false
): Result<NativeAuthTokens> = withContext(Dispatchers.IO) {
try {
val base = normalizeBaseUrl(baseUrl)
validateUrlScheme(base, allowCleartext)
val url = "$base/auth/native/refresh"
val payload = buildJsonObject {
put("refresh_token", refreshToken)
if (provider.isNotEmpty()) {
put("provider", provider)
}
}
val requestBody = payload.toString().toRequestBody(jsonMediaType)
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(
IOException("HTTP ${response.code}: ${response.message}")
)
}
val body = response.body?.string() ?: "{}"
val tokens = json.decodeFromString<NativeAuthTokens>(body)
Result.success(tokens)
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun mintWsTicket(
baseUrl: String,
accessToken: String,
allowCleartext: Boolean = false
): Result<String> = withContext(Dispatchers.IO) {
try {
val base = normalizeBaseUrl(baseUrl)
validateUrlScheme(base, allowCleartext)
val url = "$base/api/auth/ws-ticket"
val emptyBody = "{}".toRequestBody(jsonMediaType)
val request = Request.Builder()
.url(url)
.header("Authorization", "Bearer $accessToken")
.post(emptyBody)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(
IOException("HTTP ${response.code}: ${response.message}")
)
}
val body = response.body?.string() ?: "{}"
val root = json.decodeFromString<JsonObject>(body)
val ticket = root["ticket"]?.jsonPrimitive?.content
?: root["ws_ticket"]?.jsonPrimitive?.content
if (ticket != null) {
Result.success(ticket)
} else {
Result.failure(IOException("No ticket returned in response"))
}
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View file

@ -0,0 +1,418 @@
package app.hermes.mobile.core.network
import app.hermes.mobile.core.model.CreateSessionResult
import app.hermes.mobile.core.model.DurableSessionId
import app.hermes.mobile.core.model.GatewayEvent
import app.hermes.mobile.core.model.JsonRpcError
import app.hermes.mobile.core.model.JsonRpcRequest
import app.hermes.mobile.core.model.JsonRpcResponse
import app.hermes.mobile.core.model.PromptSubmitResult
import app.hermes.mobile.core.model.ResumeSessionResult
import app.hermes.mobile.core.model.RuntimeSessionId
import app.hermes.mobile.core.model.SessionSummary
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
sealed class ConnectionState {
object Disconnected : ConnectionState()
object Connecting : ConnectionState()
object Connected : ConnectionState()
data class Reconnecting(val attempt: Int) : ConnectionState()
data class Failed(val error: Throwable) : ConnectionState()
data class AuthExpired(val message: String = "Session expired. Please sign in again.") : ConnectionState()
}
class JsonRpcGatewayClient(
private val client: OkHttpClient = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) // infinite for websockets
.pingInterval(30, TimeUnit.SECONDS)
.build(),
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
) {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
encodeDefaults = true
}
private val reqCounter = AtomicInteger(0)
private val pendingRequests = ConcurrentHashMap<String, CompletableDeferred<JsonRpcResponse>>()
private var gatewayReadyDeferred = CompletableDeferred<Unit>()
private var activeWebSocket: WebSocket? = null
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _events = MutableSharedFlow<GatewayEvent>(extraBufferCapacity = 64)
val events: SharedFlow<GatewayEvent> = _events.asSharedFlow()
private fun nextId(): String = "a${reqCounter.incrementAndGet()}"
fun connect(wsUrl: String, ticket: String? = null, allowCleartext: Boolean = false) {
if (!allowCleartext && wsUrl.startsWith("ws://", ignoreCase = true)) {
_connectionState.value = ConnectionState.Failed(
SecurityException("Cleartext WebSocket is not allowed unless explicitly permitted in connection settings.")
)
return
}
gatewayReadyDeferred = CompletableDeferred()
_connectionState.value = ConnectionState.Connecting
val fullUrl = if (!ticket.isNullOrEmpty()) {
val sep = if (wsUrl.contains("?")) "&" else "?"
"$wsUrl${sep}ticket=$ticket"
} else {
wsUrl
}
val request = Request.Builder()
.url(fullUrl)
.build()
activeWebSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
// Keep state as Connecting until gateway.ready event is received
_connectionState.value = ConnectionState.Connecting
}
override fun onMessage(webSocket: WebSocket, text: String) {
handleIncomingMessage(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(code, reason)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
_connectionState.value = ConnectionState.Disconnected
if (!gatewayReadyDeferred.isCompleted) {
gatewayReadyDeferred.completeExceptionally(IOException("WebSocket closed: $code $reason"))
}
failPendingRequests(IOException("WebSocket closed: $code $reason"))
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
_connectionState.value = ConnectionState.Failed(t)
if (!gatewayReadyDeferred.isCompleted) {
gatewayReadyDeferred.completeExceptionally(t)
}
failPendingRequests(t)
}
})
}
suspend fun awaitGatewayReady(timeoutMs: Long = 10_000) {
if (_connectionState.value is ConnectionState.Connected) return
withTimeout(timeoutMs) {
gatewayReadyDeferred.await()
}
}
fun setAuthExpired(message: String = "Session expired. Please sign in again.") {
_connectionState.value = ConnectionState.AuthExpired(message)
if (!gatewayReadyDeferred.isCompleted) {
gatewayReadyDeferred.completeExceptionally(IOException(message))
}
failPendingRequests(IOException(message))
}
fun disconnect() {
try {
activeWebSocket?.close(1000, "Client initiated disconnect")
activeWebSocket?.cancel()
} catch (_: Exception) {
}
activeWebSocket = null
_connectionState.value = ConnectionState.Disconnected
if (!gatewayReadyDeferred.isCompleted) {
gatewayReadyDeferred.completeExceptionally(IOException("Client disconnected"))
}
failPendingRequests(IOException("Client disconnected"))
}
private fun failPendingRequests(t: Throwable) {
for ((_, deferred) in pendingRequests) {
deferred.completeExceptionally(t)
}
pendingRequests.clear()
}
fun handleIncomingMessage(text: String) {
try {
val root = json.decodeFromString<JsonObject>(text)
// 1. Is this a JSON-RPC response with id matching pending request?
val id = root["id"]?.jsonPrimitive?.content
if (!id.isNullOrEmpty() && pendingRequests.containsKey(id)) {
val deferred = pendingRequests.remove(id)
val response = try {
json.decodeFromString<JsonRpcResponse>(text)
} catch (e: Exception) {
val isErr = root.containsKey("error")
if (isErr) {
JsonRpcResponse(
jsonrpc = "2.0",
id = id,
error = JsonRpcError(
code = -32000,
message = root["error"]?.toString() ?: "Unknown error"
)
)
} else {
JsonRpcResponse(jsonrpc = "2.0", id = id, result = root["result"])
}
}
deferred?.complete(response)
return
}
// 2. Otherwise, treat as Gateway Event / Notification
val event = GatewayEvent.parse(root)
if (event is GatewayEvent.GatewayReadyEvent) {
_connectionState.value = ConnectionState.Connected
if (!gatewayReadyDeferred.isCompleted) {
gatewayReadyDeferred.complete(Unit)
}
}
scope.launch {
_events.emit(event)
}
} catch (e: Exception) {
// Ignore corrupted frames gracefully or log if debug
}
}
suspend fun sendRequest(
method: String,
params: JsonObject = buildJsonObject {},
timeoutMs: Long = 120_000
): JsonRpcResponse {
val ws = activeWebSocket ?: throw IOException("WebSocket is not connected")
val reqId = nextId()
val request = JsonRpcRequest(id = reqId, method = method, params = params)
val jsonString = json.encodeToString(request)
val deferred = CompletableDeferred<JsonRpcResponse>()
pendingRequests[reqId] = deferred
return try {
val sent = ws.send(jsonString)
if (!sent) {
pendingRequests.remove(reqId)
throw IOException("Failed to send message over WebSocket")
}
withTimeout(timeoutMs) {
deferred.await()
}
} catch (e: Exception) {
pendingRequests.remove(reqId)
throw e
}
}
suspend fun listSessions(limit: Int = 200): List<SessionSummary> {
val params = buildJsonObject { put("limit", limit) }
val response = sendRequest("session.list", params)
if (response.error != null) {
throw IOException("RPC Error [${response.error.code}]: ${response.error.message}")
}
val result = response.result ?: return emptyList()
val list = mutableListOf<SessionSummary>()
val sessionsArray = when (result) {
is JsonArray -> result
is JsonObject -> result["sessions"] as? JsonArray ?: JsonArray(emptyList())
else -> JsonArray(emptyList())
}
for (item in sessionsArray) {
if (item is JsonObject) {
val durableVal = item["stored_session_id"]?.jsonPrimitive?.content
?: item["durable_id"]?.jsonPrimitive?.content
?: item["id"]?.jsonPrimitive?.content
?: item["durable_session_id"]?.jsonPrimitive?.content
?: ""
if (durableVal.isNotEmpty()) {
list.add(
SessionSummary(
id = DurableSessionId(durableVal),
title = item["title"]?.jsonPrimitive?.content ?: "Session ${durableVal.take(8)}",
preview = item["preview"]?.jsonPrimitive?.content ?: "",
startedAt = item["started_at"]?.jsonPrimitive?.longOrNull
?: item["createdAt"]?.jsonPrimitive?.longOrNull
?: System.currentTimeMillis(),
messageCount = item["message_count"]?.jsonPrimitive?.intOrNull ?: 0,
source = item["source"]?.jsonPrimitive?.content ?: "android"
)
)
}
}
}
return list
}
suspend fun createSession(cols: Int = 100, source: String = "android"): CreateSessionResult {
val params = buildJsonObject {
put("cols", cols)
put("source", source)
}
val response = sendRequest("session.create", params)
if (response.error != null) {
throw IOException("RPC Error [${response.error.code}]: ${response.error.message}")
}
val result = response.result as? JsonObject
?: throw IOException("Invalid response format for session.create")
val durable = result["stored_session_id"]?.jsonPrimitive?.content
?: result["durable_id"]?.jsonPrimitive?.content
?: result["durable_session_id"]?.jsonPrimitive?.content
?: result["id"]?.jsonPrimitive?.content
?: result["session_id"]?.jsonPrimitive?.content
?: throw IOException("Missing stored_session_id/durable_id in session.create result")
val runtime = result["session_id"]?.jsonPrimitive?.content
?: result["runtime_id"]?.jsonPrimitive?.content
?: result["runtime_session_id"]?.jsonPrimitive?.content
?: durable
return CreateSessionResult(
durableId = DurableSessionId(durable),
runtimeId = RuntimeSessionId(runtime)
)
}
suspend fun resumeSession(durableId: DurableSessionId, source: String = "android"): ResumeSessionResult {
val params = buildJsonObject {
put("session_id", durableId.value)
put("source", source)
}
val response = sendRequest("session.resume", params)
if (response.error != null) {
throw IOException("RPC Error [${response.error.code}]: ${response.error.message}")
}
val result = response.result as? JsonObject
?: throw IOException("Invalid response format for session.resume")
val durable = result["stored_session_id"]?.jsonPrimitive?.content
?: result["durable_id"]?.jsonPrimitive?.content
?: result["durable_session_id"]?.jsonPrimitive?.content
?: durableId.value
val runtime = result["session_id"]?.jsonPrimitive?.content
?: result["runtime_id"]?.jsonPrimitive?.content
?: result["runtime_session_id"]?.jsonPrimitive?.content
?: durable
return ResumeSessionResult(
durableId = DurableSessionId(durable),
runtimeId = RuntimeSessionId(runtime)
)
}
suspend fun submitPrompt(runtimeId: RuntimeSessionId, text: String): PromptSubmitResult {
val params = buildJsonObject {
put("session_id", runtimeId.value)
put("text", text)
}
val response = sendRequest("prompt.submit", params)
if (response.error != null) {
throw IOException("RPC Error [${response.error.code}]: ${response.error.message}")
}
val result = response.result as? JsonObject
val turnId = result?.get("turn_id")?.jsonPrimitive?.content
?: result?.get("turnId")?.jsonPrimitive?.content
return PromptSubmitResult(turnId = turnId, accepted = true)
}
suspend fun interruptSession(runtimeId: RuntimeSessionId): Boolean {
val params = buildJsonObject {
put("session_id", runtimeId.value)
}
val response = sendRequest("session.interrupt", params)
return response.error == null
}
suspend fun respondApproval(
sessionKey: String,
requestId: String,
choice: String,
all: Boolean = false
): Boolean {
val params = buildJsonObject {
put("session_id", sessionKey)
put("request_id", requestId)
put("choice", choice)
put("all", all)
}
val response = sendRequest("approval.respond", params)
return response.error == null
}
suspend fun respondClarify(
requestId: String,
answer: String,
questionId: String? = null
): Boolean {
val params = buildJsonObject {
put("request_id", requestId)
put("answer", answer)
if (!questionId.isNullOrEmpty()) {
put("question_id", questionId)
}
}
val response = sendRequest("clarify.respond", params)
return response.error == null
}
suspend fun respondSudo(requestId: String, password: String): Boolean {
val params = buildJsonObject {
put("request_id", requestId)
put("password", password)
}
val response = sendRequest("sudo.respond", params)
return response.error == null
}
suspend fun respondSecret(requestId: String, value: String): Boolean {
val params = buildJsonObject {
put("request_id", requestId)
put("value", value)
}
val response = sendRequest("secret.respond", params)
return response.error == null
}
}

View file

@ -0,0 +1,72 @@
package app.hermes.mobile.core.repository
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import app.hermes.mobile.core.model.HermesConnection
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "hermes_connections")
class ConnectionRepository(
private val context: Context
) {
private val json = Json { ignoreUnknownKeys = true }
private val connectionsKey = stringPreferencesKey("saved_connections")
val connections: Flow<List<HermesConnection>> = context.dataStore.data.map { preferences ->
val raw = preferences[connectionsKey] ?: return@map emptyList()
try {
json.decodeFromString<List<HermesConnection>>(raw)
} catch (e: Exception) {
emptyList()
}
}
suspend fun saveConnection(connection: HermesConnection) {
context.dataStore.edit { preferences ->
val raw = preferences[connectionsKey]
val currentList = if (!raw.isNullOrEmpty()) {
try {
json.decodeFromString<List<HermesConnection>>(raw).toMutableList()
} catch (e: Exception) {
mutableListOf()
}
} else {
mutableListOf()
}
val index = currentList.indexOfFirst { it.id == connection.id }
if (index >= 0) {
currentList[index] = connection
} else {
currentList.add(connection)
}
preferences[connectionsKey] = json.encodeToString(currentList)
}
}
suspend fun removeConnection(connectionId: String) {
context.dataStore.edit { preferences ->
val raw = preferences[connectionsKey] ?: return@edit
try {
val currentList = json.decodeFromString<List<HermesConnection>>(raw).toMutableList()
currentList.removeAll { it.id == connectionId }
preferences[connectionsKey] = json.encodeToString(currentList)
} catch (_: Exception) {
}
}
}
suspend fun getConnection(connectionId: String): HermesConnection? {
return connections.firstOrNull()?.find { it.id == connectionId }
}
}

View file

@ -0,0 +1,553 @@
package app.hermes.mobile.core.repository
import app.hermes.mobile.core.model.*
import app.hermes.mobile.core.network.ConnectionState
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.network.JsonRpcGatewayClient
import app.hermes.mobile.core.security.TokenVault
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.IOException
import java.util.UUID
import kotlin.math.min
import kotlin.random.Random
class HermesGatewayRepository(
val restClient: HermesRestClient,
val gatewayClient: JsonRpcGatewayClient,
val tokenVault: TokenVault,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
) {
private val _activeConnection = MutableStateFlow<HermesConnection?>(null)
val activeConnection: StateFlow<HermesConnection?> = _activeConnection.asStateFlow()
private val _serverStatus = MutableStateFlow<HermesServerStatus?>(null)
val serverStatus: StateFlow<HermesServerStatus?> = _serverStatus.asStateFlow()
val connectionState: StateFlow<ConnectionState> = gatewayClient.connectionState
private val _activeDurableId = MutableStateFlow<DurableSessionId?>(null)
val activeDurableId: StateFlow<DurableSessionId?> = _activeDurableId.asStateFlow()
private val _activeRuntimeId = MutableStateFlow<RuntimeSessionId?>(null)
val activeRuntimeId: StateFlow<RuntimeSessionId?> = _activeRuntimeId.asStateFlow()
private val _messages = MutableStateFlow<List<HermesMessage>>(emptyList())
val messages: StateFlow<List<HermesMessage>> = _messages.asStateFlow()
private val _activeApprovals = MutableStateFlow<List<HermesApproval>>(emptyList())
val activeApprovals: StateFlow<List<HermesApproval>> = _activeApprovals.asStateFlow()
private val _activeClarify = MutableStateFlow<HermesClarifyRequest?>(null)
val activeClarify: StateFlow<HermesClarifyRequest?> = _activeClarify.asStateFlow()
private val _sessionInfo = MutableStateFlow<SessionInfo?>(null)
val sessionInfo: StateFlow<SessionInfo?> = _sessionInfo.asStateFlow()
private val _isExecuting = MutableStateFlow(false)
val isExecuting: StateFlow<Boolean> = _isExecuting.asStateFlow()
private var reconnectJob: Job? = null
private var autoReconnectEnabled = true
private var reconnectAttempt = 0
init {
scope.launch {
gatewayClient.events.collect { event ->
handleGatewayEvent(event)
}
}
scope.launch {
gatewayClient.connectionState.collect { state ->
when (state) {
is ConnectionState.Connected -> {
reconnectAttempt = 0
reconnectJob?.cancel()
// Re-resume session if we had an active durable session
val durable = _activeDurableId.value
if (durable != null) {
try {
val resumeRes = gatewayClient.resumeSession(durable)
_activeRuntimeId.value = resumeRes.runtimeId
} catch (_: Exception) {
}
}
}
is ConnectionState.AuthExpired -> {
autoReconnectEnabled = false
reconnectJob?.cancel()
}
is ConnectionState.Disconnected, is ConnectionState.Failed -> {
if (autoReconnectEnabled && _activeConnection.value != null) {
scheduleReconnect()
}
}
else -> {}
}
}
}
}
private fun scheduleReconnect() {
if (reconnectJob?.isActive == true) return
reconnectJob = scope.launch {
val baseDelay = min(30_000L, (1000L * (1 shl min(reconnectAttempt, 5))))
val jitter = Random.nextLong(0, 1000)
val totalDelay = baseDelay + jitter
reconnectAttempt++
delay(totalDelay)
val connection = _activeConnection.value ?: return@launch
try {
connectInternal(connection)
} catch (_: Exception) {
// Retry will be scheduled on next disconnect/failure
}
}
}
suspend fun checkStatus(connection: HermesConnection): Result<HermesServerStatus> {
val result = restClient.getStatus(connection.baseUrl, connection.allowCleartext)
if (result.isSuccess) {
_serverStatus.value = result.getOrNull()
}
return result
}
suspend fun connect(connection: HermesConnection): Result<Unit> {
autoReconnectEnabled = true
_activeConnection.value = connection
return connectInternal(connection)
}
private suspend fun connectInternal(connection: HermesConnection): Result<Unit> {
return try {
val statusResult = restClient.getStatus(connection.baseUrl, connection.allowCleartext)
val status = statusResult.getOrNull() ?: HermesServerStatus()
_serverStatus.value = status
var ticket: String? = null
if (status.authRequired) {
var tokens = tokenVault.getTokens(connection.id)
?: return Result.failure(IllegalStateException("Authentication required for this server"))
val nowSeconds = System.currentTimeMillis() / 1000
val isExpiring = tokens.expiresAt > 0 && nowSeconds >= (tokens.expiresAt - 60)
if (isExpiring && tokens.refreshToken.isNotEmpty()) {
val refreshRes = restClient.refreshNativeToken(
baseUrl = connection.baseUrl,
refreshToken = tokens.refreshToken,
provider = tokens.provider,
allowCleartext = connection.allowCleartext
)
if (refreshRes.isSuccess) {
val newTokens = refreshRes.getOrThrow()
tokenVault.saveTokens(connection.id, newTokens)
tokens = newTokens
} else {
val err = refreshRes.exceptionOrNull()
val errMsg = err?.message ?: ""
if (errMsg.contains("401") || errMsg.contains("session_expired") || errMsg.contains("invalid_grant")) {
tokenVault.clearTokens(connection.id)
gatewayClient.setAuthExpired("Session expired. Please sign in again.")
return Result.failure(IllegalStateException("Session expired. Please sign in again."))
}
}
}
var ticketResult = restClient.mintWsTicket(
baseUrl = connection.baseUrl,
accessToken = tokens.accessToken,
allowCleartext = connection.allowCleartext
)
if (ticketResult.isFailure) {
val err = ticketResult.exceptionOrNull()
val errMsg = err?.message ?: ""
if (errMsg.contains("401") && tokens.refreshToken.isNotEmpty()) {
val refreshRes = restClient.refreshNativeToken(
baseUrl = connection.baseUrl,
refreshToken = tokens.refreshToken,
provider = tokens.provider,
allowCleartext = connection.allowCleartext
)
if (refreshRes.isSuccess) {
val newTokens = refreshRes.getOrThrow()
tokenVault.saveTokens(connection.id, newTokens)
tokens = newTokens
ticketResult = restClient.mintWsTicket(
baseUrl = connection.baseUrl,
accessToken = tokens.accessToken,
allowCleartext = connection.allowCleartext
)
} else {
tokenVault.clearTokens(connection.id)
gatewayClient.setAuthExpired("Session expired. Please sign in again.")
return Result.failure(IllegalStateException("Session expired. Please sign in again."))
}
}
if (ticketResult.isFailure) {
val finalErr = ticketResult.exceptionOrNull()
if (finalErr?.message?.contains("401") == true) {
tokenVault.clearTokens(connection.id)
gatewayClient.setAuthExpired("Session expired. Please sign in again.")
return Result.failure(IllegalStateException("Session expired. Please sign in again."))
}
return Result.failure(
finalErr ?: IOException("Failed to mint WebSocket ticket")
)
}
}
ticket = ticketResult.getOrNull()
}
val wsUrl = convertHttpToWsUrl(connection.baseUrl)
gatewayClient.connect(
wsUrl = wsUrl,
ticket = ticket,
allowCleartext = connection.allowCleartext
)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
fun disconnect() {
autoReconnectEnabled = false
reconnectJob?.cancel()
gatewayClient.disconnect()
_activeConnection.value = null
_activeDurableId.value = null
_activeRuntimeId.value = null
_messages.value = emptyList()
_activeApprovals.value = emptyList()
_activeClarify.value = null
_isExecuting.value = false
}
private fun convertHttpToWsUrl(baseUrl: String): String {
val trimmed = baseUrl.trim().trimEnd('/')
val wsBase = when {
trimmed.startsWith("https://", ignoreCase = true) -> "wss://" + trimmed.substring(8)
trimmed.startsWith("http://", ignoreCase = true) -> "ws://" + trimmed.substring(7)
trimmed.startsWith("wss://", ignoreCase = true) || trimmed.startsWith("ws://", ignoreCase = true) -> trimmed
else -> "ws://$trimmed"
}
return when {
wsBase.endsWith("/api/ws") -> wsBase
wsBase.endsWith("/ws") -> wsBase.removeSuffix("/ws") + "/api/ws"
else -> "$wsBase/api/ws"
}
}
suspend fun listSessions(limit: Int = 200): List<SessionSummary> {
return gatewayClient.listSessions(limit)
}
suspend fun startNewSession(): CreateSessionResult {
val result = gatewayClient.createSession(source = "android")
_activeDurableId.value = result.durableId
_activeRuntimeId.value = result.runtimeId
_messages.value = emptyList()
_activeApprovals.value = emptyList()
_activeClarify.value = null
_isExecuting.value = false
return result
}
suspend fun openSession(durableId: DurableSessionId): ResumeSessionResult {
val result = gatewayClient.resumeSession(durableId, source = "android")
_activeDurableId.value = result.durableId
_activeRuntimeId.value = result.runtimeId
_messages.value = emptyList()
_activeApprovals.value = emptyList()
_activeClarify.value = null
_isExecuting.value = false
return result
}
suspend fun sendUserPrompt(text: String): PromptSubmitResult {
val runtimeId = _activeRuntimeId.value
?: throw IllegalStateException("No active runtime session")
val userMessage = HermesMessage(
id = UUID.randomUUID().toString(),
role = MessageRole.USER,
content = text,
isStreaming = false
)
_messages.value = _messages.value + userMessage
_isExecuting.value = true
return try {
val result = gatewayClient.submitPrompt(runtimeId, text)
result
} catch (e: Exception) {
_isExecuting.value = false
throw e
}
}
suspend fun interruptSession(): Boolean {
val runtimeId = _activeRuntimeId.value ?: return false
val success = gatewayClient.interruptSession(runtimeId)
if (success) {
_isExecuting.value = false
}
return success
}
suspend fun respondApproval(requestId: String, choice: String, all: Boolean = false): Boolean {
val sessionKey = _activeRuntimeId.value?.value ?: _activeDurableId.value?.value ?: ""
val success = gatewayClient.respondApproval(sessionKey, requestId, choice, all)
if (success) {
_activeApprovals.value = _activeApprovals.value.filterNot { it.requestId == requestId }
}
return success
}
suspend fun respondClarify(requestId: String, answer: String, questionId: String? = null): Boolean {
val success = gatewayClient.respondClarify(requestId, answer, questionId)
if (success) {
_activeClarify.value = null
}
return success
}
suspend fun respondSudo(requestId: String, password: String): Boolean {
val success = gatewayClient.respondSudo(requestId, password)
if (success) {
_activeClarify.value = null
}
return success
}
suspend fun respondSecret(requestId: String, value: String): Boolean {
val success = gatewayClient.respondSecret(requestId, value)
if (success) {
_activeClarify.value = null
}
return success
}
private fun handleGatewayEvent(event: GatewayEvent) {
when (event) {
is GatewayEvent.MessageStartEvent -> {
_isExecuting.value = true
val existing = _messages.value.find { it.id == event.messageId }
if (existing == null) {
val role = if (event.role.equals("user", ignoreCase = true)) MessageRole.USER else MessageRole.ASSISTANT
val newMsg = HermesMessage(
id = event.messageId,
role = role,
content = "",
isStreaming = true
)
_messages.value = _messages.value + newMsg
}
}
is GatewayEvent.MessageDeltaEvent -> {
_isExecuting.value = true
val list = _messages.value.toMutableList()
val idx = list.indexOfFirst { it.id == event.messageId }
if (idx >= 0) {
val current = list[idx]
list[idx] = current.copy(
content = current.content + event.delta,
isStreaming = true
)
_messages.value = list
} else {
// Message wasn't explicitly started, create streaming assistant message
val newMsg = HermesMessage(
id = event.messageId,
role = MessageRole.ASSISTANT,
content = event.delta,
isStreaming = true
)
_messages.value = list + newMsg
}
}
is GatewayEvent.MessageInterimEvent -> {
val list = _messages.value.toMutableList()
val idx = list.indexOfFirst { it.id == event.messageId }
if (idx >= 0) {
list[idx] = list[idx].copy(content = event.content, isStreaming = true)
_messages.value = list
}
}
is GatewayEvent.MessageCompleteEvent -> {
_isExecuting.value = false
val list = _messages.value.toMutableList()
val idx = list.indexOfFirst { it.id == event.messageId }
if (idx >= 0) {
list[idx] = list[idx].copy(
content = if (event.content.isNotEmpty()) event.content else list[idx].content,
isStreaming = false
)
_messages.value = list
} else if (event.content.isNotEmpty()) {
val newMsg = HermesMessage(
id = event.messageId,
role = MessageRole.ASSISTANT,
content = event.content,
isStreaming = false
)
_messages.value = list + newMsg
}
}
is GatewayEvent.ThinkingDeltaEvent -> {
val list = _messages.value.toMutableList()
val idx = list.indexOfLast { it.role == MessageRole.ASSISTANT }
if (idx >= 0) {
val current = list[idx]
list[idx] = current.copy(
thinking = (current.thinking ?: "") + event.delta
)
_messages.value = list
}
}
is GatewayEvent.ReasoningDeltaEvent -> {
val list = _messages.value.toMutableList()
val idx = list.indexOfLast { it.role == MessageRole.ASSISTANT }
if (idx >= 0) {
val current = list[idx]
list[idx] = current.copy(
thinking = (current.thinking ?: "") + event.delta
)
_messages.value = list
}
}
is GatewayEvent.ReasoningAvailableEvent -> {
val list = _messages.value.toMutableList()
val idx = list.indexOfLast { it.role == MessageRole.ASSISTANT }
if (idx >= 0) {
val current = list[idx]
list[idx] = current.copy(thinking = event.reasoning)
_messages.value = list
}
}
is GatewayEvent.ToolStartEvent -> {
val tool = ToolActivity(
id = event.toolId,
name = event.name,
status = "running"
)
attachToolToLastAssistantMessage(tool)
}
is GatewayEvent.ToolProgressEvent -> {
updateToolInLastAssistantMessage(event.toolId) { it.copy(progress = event.progress) }
}
is GatewayEvent.ToolGeneratingEvent -> {
updateToolInLastAssistantMessage(event.toolId) { it.copy(status = "generating") }
}
is GatewayEvent.ToolCompleteEvent -> {
updateToolInLastAssistantMessage(event.toolId) {
it.copy(
status = if (event.isError) "failed" else "completed",
result = event.result,
isError = event.isError
)
}
}
is GatewayEvent.ApprovalRequestEvent -> {
val approval = HermesApproval(
requestId = event.requestId,
command = event.command,
description = event.description,
choices = event.choices
)
_activeApprovals.value = _activeApprovals.value.filterNot { it.requestId == event.requestId } + approval
}
is GatewayEvent.ClarifyRequestEvent -> {
_activeClarify.value = HermesClarifyRequest(
requestId = event.requestId,
questionId = event.questionId,
question = event.question,
promptType = ClarifyType.CLARIFY
)
}
is GatewayEvent.SudoRequestEvent -> {
_activeClarify.value = HermesClarifyRequest(
requestId = event.requestId,
question = event.question,
promptType = ClarifyType.SUDO
)
}
is GatewayEvent.SecretRequestEvent -> {
_activeClarify.value = HermesClarifyRequest(
requestId = event.requestId,
question = event.question,
promptType = ClarifyType.SECRET
)
}
is GatewayEvent.SessionInfoEvent -> {
_sessionInfo.value = event.info
}
is GatewayEvent.ErrorEvent -> {
_isExecuting.value = false
}
else -> {}
}
}
private fun attachToolToLastAssistantMessage(tool: ToolActivity) {
val list = _messages.value.toMutableList()
val idx = list.indexOfLast { it.role == MessageRole.ASSISTANT }
if (idx >= 0) {
val current = list[idx]
val updatedTools = current.tools.filterNot { it.id == tool.id } + tool
list[idx] = current.copy(tools = updatedTools)
_messages.value = list
} else {
// Create a message containing this tool
val newMsg = HermesMessage(
id = UUID.randomUUID().toString(),
role = MessageRole.ASSISTANT,
content = "",
tools = listOf(tool),
isStreaming = true
)
_messages.value = list + newMsg
}
}
private fun updateToolInLastAssistantMessage(toolId: String, transform: (ToolActivity) -> ToolActivity) {
val list = _messages.value.toMutableList()
val idx = list.indexOfLast { msg -> msg.tools.any { it.id == toolId } }
if (idx >= 0) {
val current = list[idx]
val updatedTools = current.tools.map { if (it.id == toolId) transform(it) else it }
list[idx] = current.copy(tools = updatedTools)
_messages.value = list
}
}
}

View file

@ -0,0 +1,81 @@
package app.hermes.mobile.core.security
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import app.hermes.mobile.core.model.NativeAuthTokens
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.concurrent.ConcurrentHashMap
interface TokenVault {
fun saveTokens(connectionId: String, tokens: NativeAuthTokens)
fun getTokens(connectionId: String): NativeAuthTokens?
fun clearTokens(connectionId: String)
fun getAllConnectionIds(): Set<String>
}
class EncryptedTokenVault(context: Context) : TokenVault {
private val json = Json { ignoreUnknownKeys = true }
private val prefs: SharedPreferences = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"hermes_secure_tokens",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
throw SecurityException("Keystore encryption required for token storage", e)
}
override fun saveTokens(connectionId: String, tokens: NativeAuthTokens) {
val serialized = json.encodeToString(tokens)
prefs.edit().putString("conn_$connectionId", serialized).apply()
}
override fun getTokens(connectionId: String): NativeAuthTokens? {
val raw = prefs.getString("conn_$connectionId", null) ?: return null
return try {
json.decodeFromString<NativeAuthTokens>(raw)
} catch (e: Exception) {
null
}
}
override fun clearTokens(connectionId: String) {
prefs.edit().remove("conn_$connectionId").apply()
}
override fun getAllConnectionIds(): Set<String> {
return prefs.all.keys
.filter { it.startsWith("conn_") }
.map { it.removePrefix("conn_") }
.toSet()
}
}
class InMemoryTokenVault : TokenVault {
private val storage = ConcurrentHashMap<String, NativeAuthTokens>()
override fun saveTokens(connectionId: String, tokens: NativeAuthTokens) {
storage[connectionId] = tokens
}
override fun getTokens(connectionId: String): NativeAuthTokens? {
return storage[connectionId]
}
override fun clearTokens(connectionId: String) {
storage.remove(connectionId)
}
override fun getAllConnectionIds(): Set<String> {
return storage.keys.toSet()
}
}

View file

@ -0,0 +1,144 @@
package app.hermes.mobile.feature.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.hermes.mobile.core.model.HermesApproval
@Composable
fun ApprovalCard(
approval: HermesApproval,
onRespond: (choice: String, all: Boolean) -> Unit
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF1E293B)
),
modifier = Modifier
.fillMaxWidth()
.border(1.5.dp, Color(0xFFF59E0B), RoundedCornerShape(16.dp))
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Shield,
contentDescription = null,
tint = Color(0xFFF59E0B),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Action Authorization Required",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = Color(0xFFF59E0B)
)
}
if (!approval.description.isNullOrBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = approval.description,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFFF8FAFC)
)
}
if (!approval.command.isNullOrBlank()) {
Spacer(modifier = Modifier.height(10.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF0F172A))
.padding(12.dp)
) {
Row(verticalAlignment = Alignment.Top) {
Icon(
Icons.Default.Terminal,
contentDescription = null,
tint = Color(0xFF38BDF8),
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = approval.command,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = Color(0xFF38BDF8)
)
}
}
}
Spacer(modifier = Modifier.height(14.dp))
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = { onRespond("deny", false) },
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color(0xFFEF4444)
),
shape = RoundedCornerShape(8.dp)
) {
Text("Deny", fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.width(8.dp))
OutlinedButton(
onClick = { onRespond("once", false) },
shape = RoundedCornerShape(8.dp)
) {
Text("Allow Once")
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { onRespond("always", true) },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF10B981)
),
shape = RoundedCornerShape(8.dp)
) {
Text("Allow Always", fontWeight = FontWeight.Bold)
}
}
}
}
}

View file

@ -0,0 +1,458 @@
package app.hermes.mobile.feature.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Lightbulb
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.hermes.mobile.core.model.HermesMessage
import app.hermes.mobile.core.model.MessageRole
import app.hermes.mobile.core.model.ToolActivity
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
viewModel: ChatViewModel,
durableSessionId: String,
onNavigateBack: () -> Unit
) {
val messages by viewModel.messages.collectAsState()
val approvals by viewModel.activeApprovals.collectAsState()
val activeClarify by viewModel.activeClarify.collectAsState()
val sessionInfo by viewModel.sessionInfo.collectAsState()
val isExecuting by viewModel.isExecuting.collectAsState()
val uiState by viewModel.uiState.collectAsState()
val activeConn by viewModel.activeConnection.collectAsState()
val listState = rememberLazyListState()
LaunchedEffect(messages.size, messages.lastOrNull()?.content?.length, approvals.size) {
if (messages.isNotEmpty() || approvals.isNotEmpty()) {
val totalCount = messages.size + approvals.size
listState.animateScrollToItem(totalCount)
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
text = sessionInfo?.model ?: activeConn?.name ?: "Hermes Chat",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Session: ${durableSessionId.take(8)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
if (isExecuting) {
Button(
onClick = { viewModel.interruptSession() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEF4444)),
shape = RoundedCornerShape(8.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
modifier = Modifier.padding(end = 8.dp)
) {
Icon(Icons.Default.Stop, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Stop", fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
}
)
},
bottomBar = {
ChatInputBar(
text = uiState.inputText,
onTextChange = { viewModel.updateInputText(it) },
onSend = { viewModel.submitPrompt() },
isExecuting = isExecuting,
onStop = { viewModel.interruptSession() }
)
}
) { padding ->
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 12.dp)
) {
items(messages, key = { it.id }) { message ->
MessageItem(message = message)
}
items(approvals, key = { it.requestId }) { approval ->
ApprovalCard(
approval = approval,
onRespond = { choice, all ->
viewModel.respondApproval(approval.requestId, choice, all)
}
)
}
if (isExecuting && messages.lastOrNull()?.isStreaming != true && approvals.isEmpty()) {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Hermes is thinking…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
if (activeClarify != null) {
ClarifyDialog(
request = activeClarify!!,
onDismiss = { viewModel.dismissClarify() },
onSubmit = { value ->
viewModel.respondClarify(activeClarify!!, value)
}
)
}
}
}
@Composable
fun MessageItem(message: HermesMessage) {
val isUser = message.role == MessageRole.USER
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start
) {
// Thinking Collapsible
if (!message.thinking.isNullOrBlank()) {
ThinkingSection(thinking = message.thinking)
Spacer(modifier = Modifier.height(6.dp))
}
// Tools Invocation Cards
if (message.tools.isNotEmpty()) {
for (tool in message.tools) {
ToolActivityCard(tool = tool)
Spacer(modifier = Modifier.height(6.dp))
}
}
// Message Content Bubble
if (message.content.isNotBlank() || (message.isStreaming && message.tools.isEmpty())) {
Card(
shape = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (isUser) 16.dp else 4.dp,
bottomEnd = if (isUser) 4.dp else 16.dp
),
colors = CardDefaults.cardColors(
containerColor = if (isUser) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth(0.9f)
) {
Column(modifier = Modifier.padding(14.dp)) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = if (isUser) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurfaceVariant
)
if (message.isStreaming) {
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
}
}
}
}
}
}
@Composable
fun ThinkingSection(thinking: String) {
var expanded by remember { mutableStateOf(false) }
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
),
modifier = Modifier
.fillMaxWidth(0.9f)
.clickable { expanded = !expanded }
) {
Column(modifier = Modifier.padding(10.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Lightbulb,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(0xFFF59E0B)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
"Reasoning & Thoughts",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = Color(0xFFF59E0B)
)
}
Icon(
if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
AnimatedVisibility(visible = expanded) {
Column {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = thinking,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
fun ToolActivityCard(tool: ToolActivity) {
val isRunning = tool.status == "running" || tool.status == "generating"
val isCompleted = tool.status == "completed"
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF0F172A)),
modifier = Modifier.fillMaxWidth(0.9f)
) {
Column(modifier = Modifier.padding(10.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Build,
contentDescription = null,
tint = Color(0xFF38BDF8),
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
tool.name,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF38BDF8)
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (isRunning) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp,
color = Color(0xFF38BDF8)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Running", fontSize = 10.sp, color = Color(0xFF38BDF8))
} else if (isCompleted) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = Color(0xFF10B981),
modifier = Modifier.size(12.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Done", fontSize = 10.sp, color = Color(0xFF10B981))
}
}
}
if (!tool.progress.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = tool.progress,
fontSize = 11.sp,
color = Color(0xFF94A3B8)
)
}
if (!tool.result.isNullOrBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = tool.result,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = if (tool.isError) Color(0xFFEF4444) else Color(0xFFE2E8F0),
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
fun ChatInputBar(
text: String,
onTextChange: (String) -> Unit,
onSend: () -> Unit,
isExecuting: Boolean,
onStop: () -> Unit
) {
Card(
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
modifier = Modifier
.fillMaxWidth()
.imePadding()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = text,
onValueChange = onTextChange,
placeholder = { Text("Message Hermes…") },
maxLines = 5,
shape = RoundedCornerShape(24.dp),
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
)
if (isExecuting) {
IconButton(
onClick = onStop,
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(Color(0xFFEF4444))
) {
Icon(
Icons.Default.Stop,
contentDescription = "Stop",
tint = Color.White
)
}
} else {
IconButton(
onClick = onSend,
enabled = text.isNotBlank(),
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(
if (text.isNotBlank()) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = if (text.isNotBlank()) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
}
}
}
}

View file

@ -0,0 +1,96 @@
package app.hermes.mobile.feature.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.hermes.mobile.core.model.ClarifyType
import app.hermes.mobile.core.model.DurableSessionId
import app.hermes.mobile.core.model.HermesApproval
import app.hermes.mobile.core.model.HermesClarifyRequest
import app.hermes.mobile.core.model.HermesMessage
import app.hermes.mobile.core.model.SessionInfo
import app.hermes.mobile.core.network.ConnectionState
import app.hermes.mobile.core.repository.HermesGatewayRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class ChatUiState(
val error: String? = null,
val inputText: String = ""
)
class ChatViewModel(
private val gatewayRepo: HermesGatewayRepository
) : ViewModel() {
val messages: StateFlow<List<HermesMessage>> = gatewayRepo.messages
val activeApprovals: StateFlow<List<HermesApproval>> = gatewayRepo.activeApprovals
val activeClarify: StateFlow<HermesClarifyRequest?> = gatewayRepo.activeClarify
val sessionInfo: StateFlow<SessionInfo?> = gatewayRepo.sessionInfo
val isExecuting: StateFlow<Boolean> = gatewayRepo.isExecuting
val connectionState: StateFlow<ConnectionState> = gatewayRepo.connectionState
val activeConnection = gatewayRepo.activeConnection
val activeDurableId: StateFlow<DurableSessionId?> = gatewayRepo.activeDurableId
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
fun updateInputText(text: String) {
_uiState.value = _uiState.value.copy(inputText = text)
}
fun submitPrompt() {
val text = _uiState.value.inputText.trim()
if (text.isEmpty()) return
_uiState.value = _uiState.value.copy(inputText = "", error = null)
viewModelScope.launch {
try {
gatewayRepo.sendUserPrompt(text)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = e.localizedMessage ?: "Failed to submit prompt"
)
}
}
}
fun interruptSession() {
viewModelScope.launch {
gatewayRepo.interruptSession()
}
}
fun respondApproval(requestId: String, choice: String, all: Boolean = false) {
viewModelScope.launch {
try {
gatewayRepo.respondApproval(requestId, choice, all)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = e.localizedMessage ?: "Failed to respond to approval"
)
}
}
}
fun respondClarify(request: HermesClarifyRequest, answer: String) {
viewModelScope.launch {
try {
when (request.promptType) {
ClarifyType.CLARIFY -> gatewayRepo.respondClarify(request.requestId, answer, request.questionId)
ClarifyType.SUDO -> gatewayRepo.respondSudo(request.requestId, answer)
ClarifyType.SECRET -> gatewayRepo.respondSecret(request.requestId, answer)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = e.localizedMessage ?: "Failed to respond to clarification"
)
}
}
}
fun dismissClarify() {
// Can be cancelled or handled
}
}

View file

@ -0,0 +1,95 @@
package app.hermes.mobile.feature.chat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import app.hermes.mobile.core.model.ClarifyType
import app.hermes.mobile.core.model.HermesClarifyRequest
@Composable
fun ClarifyDialog(
request: HermesClarifyRequest,
onDismiss: () -> Unit,
onSubmit: (value: String) -> Unit
) {
var input by remember { mutableStateOf("") }
val isMasked = request.promptType == ClarifyType.SUDO || request.promptType == ClarifyType.SECRET
val title = when (request.promptType) {
ClarifyType.SUDO -> "Sudo Password Required"
ClarifyType.SECRET -> "Secret / API Key Required"
ClarifyType.CLARIFY -> "Clarification Requested"
}
val icon = when (request.promptType) {
ClarifyType.SUDO -> Icons.Default.Lock
ClarifyType.SECRET -> Icons.Default.Key
ClarifyType.CLARIFY -> Icons.AutoMirrored.Filled.HelpOutline
}
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
title = { Text(title, fontWeight = FontWeight.Bold) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = request.question,
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = input,
onValueChange = { input = it },
label = {
Text(
if (isMasked) "Password / Secret" else "Your Answer"
)
},
visualTransformation = if (isMasked) PasswordVisualTransformation() else VisualTransformation.None,
singleLine = isMasked,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(
onClick = {
if (input.isNotBlank()) {
onSubmit(input)
}
},
enabled = input.isNotBlank()
) {
Text("Submit")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View file

@ -0,0 +1,435 @@
package app.hermes.mobile.feature.connections
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Lan
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.hermes.mobile.core.model.HermesConnection
import app.hermes.mobile.core.network.ConnectionState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectionsScreen(
viewModel: ConnectionsViewModel,
onNavigateToSessions: (String) -> Unit
) {
val context = LocalContext.current
val connections by viewModel.connections.collectAsState()
val activeConn by viewModel.activeConnection.collectAsState()
val connState by viewModel.connectionState.collectAsState()
val uiState by viewModel.uiState.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Hermes Connections", fontWeight = FontWeight.Bold) }
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { showAddDialog = true },
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(Icons.Default.Add, contentDescription = "Add Connection")
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
if (uiState.authError != null) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = uiState.authError ?: "",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
if (connections.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Lan,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"No connections configured",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Tap '+' to connect to a Hermes host instance.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize()
) {
items(connections, key = { it.id }) { conn ->
val isConnected = activeConn?.id == conn.id && connState is ConnectionState.Connected
val isAuthenticated = viewModel.isConnectionAuthenticated(conn.id)
ConnectionCard(
connection = conn,
isConnected = isConnected,
isAuthenticated = isAuthenticated,
isAuthenticating = uiState.isAuthenticating,
onConnect = {
viewModel.connectTo(conn) {
onNavigateToSessions(conn.id)
}
},
onSignIn = {
viewModel.startSignIn(context, conn) {
viewModel.connectTo(conn) {
onNavigateToSessions(conn.id)
}
}
},
onDelete = {
viewModel.removeConnection(conn.id)
}
)
}
}
}
}
if (showAddDialog) {
AddConnectionDialog(
uiState = uiState,
onDismiss = { showAddDialog = false },
onTest = { url, cleartext ->
viewModel.testConnection(url, cleartext)
},
onSave = { name, url, cleartext ->
viewModel.saveConnection(name, url, cleartext)
showAddDialog = false
}
)
}
}
}
@Composable
fun ConnectionCard(
connection: HermesConnection,
isConnected: Boolean,
isAuthenticated: Boolean,
isAuthenticating: Boolean,
onConnect: () -> Unit,
onSignIn: () -> Unit,
onDelete: () -> Unit
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = if (isConnected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
else MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = connection.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = connection.baseUrl,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (connection.allowCleartext) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFFF59E0B).copy(alpha = 0.2f))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text("LAN / HTTP", fontSize = 10.sp, color = Color(0xFFD97706), fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.width(6.dp))
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val badgeColor = when {
isConnected -> Color(0xFF10B981)
isAuthenticated -> Color(0xFF38BDF8)
else -> Color(0xFF94A3B8)
}
val badgeText = when {
isConnected -> "Connected"
isAuthenticated -> "Ready (Auth Saved)"
else -> "Configured"
}
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(badgeColor)
)
Spacer(modifier = Modifier.width(6.dp))
Text(badgeText, style = MaterialTheme.typography.bodySmall, color = badgeColor, fontWeight = FontWeight.Medium)
}
Row {
OutlinedButton(
onClick = onSignIn,
enabled = !isAuthenticating,
shape = RoundedCornerShape(8.dp)
) {
if (isAuthenticating) {
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(4.dp))
} else {
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(4.dp))
}
Text("Sign In", fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = onConnect,
shape = RoundedCornerShape(8.dp)
) {
Text(if (isConnected) "Open" else "Connect", fontSize = 12.sp)
}
}
}
}
}
}
@Composable
fun AddConnectionDialog(
uiState: ConnectionUiState,
onDismiss: () -> Unit,
onTest: (String, Boolean) -> Unit,
onSave: (String, String, Boolean) -> Unit
) {
var name by remember { mutableStateOf("") }
var baseUrl by remember { mutableStateOf("http://10.0.2.2:9119") }
var allowCleartext by remember { mutableStateOf(true) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add Host Connection", fontWeight = FontWeight.Bold) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Friendly Name (e.g. Workstation)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it },
label = { Text("Server Base URL (http://... or https://...)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text("Allow Cleartext HTTP (LAN)", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
Text(
"Required for local IP connections without TLS.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = allowCleartext,
onCheckedChange = { allowCleartext = it }
)
}
if (allowCleartext) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"⚠️ Security Notice: Unencrypted traffic may be intercepted on untrusted networks.",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFD97706)
)
}
Spacer(modifier = Modifier.height(12.dp))
// Test Connection Button & Status
OutlinedButton(
onClick = { onTest(baseUrl, allowCleartext) },
enabled = !uiState.isTesting && baseUrl.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
if (uiState.isTesting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
Text("Testing...")
} else {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Test Connection (/api/status)")
}
}
if (uiState.testStatus != null) {
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Color(0xFF10B981), modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(
"Status OK (v${uiState.testStatus.version ?: "1.0"}, Auth: ${if (uiState.testStatus.authRequired) "Required" else "None"})",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF10B981)
)
}
}
if (uiState.testError != null) {
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(
uiState.testError ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
},
confirmButton = {
Button(
onClick = {
if (baseUrl.isNotBlank()) {
onSave(name, baseUrl, allowCleartext)
}
},
enabled = baseUrl.isNotBlank()
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View file

@ -0,0 +1,122 @@
package app.hermes.mobile.feature.connections
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.hermes.mobile.core.auth.PkceLoopbackAuthManager
import app.hermes.mobile.core.model.HermesConnection
import app.hermes.mobile.core.model.HermesServerStatus
import app.hermes.mobile.core.repository.ConnectionRepository
import app.hermes.mobile.core.repository.HermesGatewayRepository
import app.hermes.mobile.core.security.TokenVault
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class ConnectionUiState(
val isTesting: Boolean = false,
val testStatus: HermesServerStatus? = null,
val testError: String? = null,
val isAuthenticating: Boolean = false,
val authError: String? = null
)
class ConnectionsViewModel(
private val connectionRepo: ConnectionRepository,
private val gatewayRepo: HermesGatewayRepository,
private val tokenVault: TokenVault,
private val pkceAuthManager: PkceLoopbackAuthManager
) : ViewModel() {
val connections: StateFlow<List<HermesConnection>> = connectionRepo.connections
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val activeConnection = gatewayRepo.activeConnection
val connectionState = gatewayRepo.connectionState
private val _uiState = MutableStateFlow(ConnectionUiState())
val uiState: StateFlow<ConnectionUiState> = _uiState.asStateFlow()
fun isConnectionAuthenticated(connectionId: String): Boolean {
return tokenVault.getTokens(connectionId) != null
}
fun saveConnection(name: String, baseUrl: String, allowCleartext: Boolean) {
viewModelScope.launch {
val connection = HermesConnection(
name = name.ifBlank { baseUrl },
baseUrl = baseUrl.trim(),
allowCleartext = allowCleartext
)
connectionRepo.saveConnection(connection)
}
}
fun removeConnection(connectionId: String) {
viewModelScope.launch {
tokenVault.clearTokens(connectionId)
connectionRepo.removeConnection(connectionId)
}
}
fun testConnection(baseUrl: String, allowCleartext: Boolean) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isTesting = true, testError = null, testStatus = null)
val result = gatewayRepo.restClient.getStatus(baseUrl, allowCleartext)
if (result.isSuccess) {
_uiState.value = _uiState.value.copy(
isTesting = false,
testStatus = result.getOrNull(),
testError = null
)
} else {
_uiState.value = _uiState.value.copy(
isTesting = false,
testError = result.exceptionOrNull()?.localizedMessage ?: "Connection failed"
)
}
}
}
fun startSignIn(context: Context, connection: HermesConnection, provider: String = "github", onComplete: () -> Unit) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isAuthenticating = true, authError = null)
val result = pkceAuthManager.startAuthFlow(
context = context,
connectionId = connection.id,
baseUrl = connection.baseUrl,
provider = provider,
allowCleartext = connection.allowCleartext
)
if (result.isSuccess) {
_uiState.value = _uiState.value.copy(isAuthenticating = false, authError = null)
onComplete()
} else {
_uiState.value = _uiState.value.copy(
isAuthenticating = false,
authError = result.exceptionOrNull()?.localizedMessage ?: "Authentication failed"
)
}
}
}
fun connectTo(connection: HermesConnection, onSuccess: () -> Unit) {
viewModelScope.launch {
val result = gatewayRepo.connect(connection)
if (result.isSuccess) {
onSuccess()
} else {
_uiState.value = _uiState.value.copy(
authError = result.exceptionOrNull()?.localizedMessage ?: "Failed to connect"
)
}
}
}
fun disconnect() {
gatewayRepo.disconnect()
}
}

View file

@ -0,0 +1,271 @@
package app.hermes.mobile.feature.sessions
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.hermes.mobile.core.model.DurableSessionId
import app.hermes.mobile.core.model.SessionSummary
import app.hermes.mobile.core.network.ConnectionState
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionsScreen(
viewModel: SessionsViewModel,
connectionId: String,
onNavigateBack: () -> Unit,
onNavigateToChat: (String) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val activeConn by viewModel.activeConnection.collectAsState()
val connState by viewModel.connectionState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
activeConn?.name ?: "Sessions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Row(verticalAlignment = Alignment.CenterVertically) {
val isConn = connState is ConnectionState.Connected
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(if (isConn) Color(0xFF10B981) else Color(0xFFEF4444))
)
Spacer(modifier = Modifier.width(4.dp))
Text(
if (isConn) "Connected" else "Disconnected",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.loadSessions() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {
viewModel.createNewSession { durableId ->
onNavigateToChat(durableId)
}
},
containerColor = MaterialTheme.colorScheme.primary
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("New Chat", fontWeight = FontWeight.Bold)
}
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
if (uiState.isLoading && uiState.sessions.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (uiState.sessions.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Forum,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"No active sessions",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Tap 'New Chat' to start a Hermes agent session.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize()
) {
items(uiState.sessions, key = { it.id.value }) { session ->
SessionItemCard(
session = session,
onClick = {
viewModel.resumeSession(session.id) { durableId ->
onNavigateToChat(durableId)
}
}
)
}
}
}
}
}
}
@Composable
fun SessionItemCard(
session: SessionSummary,
onClick: () -> Unit
) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.AutoMirrored.Filled.Chat,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = session.title.ifEmpty { "Session ${session.id.value.take(8)}" },
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(
text = session.source,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
if (session.preview.isNotBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = session.preview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "${session.messageCount} messages",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
Text(
text = formatTimestamp(session.startedAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}
}
private fun formatTimestamp(timestamp: Long): String {
if (timestamp <= 0) return ""
val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}

View file

@ -0,0 +1,82 @@
package app.hermes.mobile.feature.sessions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.hermes.mobile.core.model.*
import app.hermes.mobile.core.repository.HermesGatewayRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class SessionsUiState(
val isLoading: Boolean = false,
val sessions: List<SessionSummary> = emptyList(),
val error: String? = null
)
class SessionsViewModel(
private val gatewayRepo: HermesGatewayRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SessionsUiState())
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
val activeConnection = gatewayRepo.activeConnection
val connectionState = gatewayRepo.connectionState
init {
loadSessions()
}
fun loadSessions() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
try {
val list = gatewayRepo.listSessions()
_uiState.value = _uiState.value.copy(
isLoading = false,
sessions = list.sortedByDescending { it.startedAt },
error = null
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.localizedMessage ?: "Failed to load sessions"
)
}
}
}
fun createNewSession(onSuccess: (String) -> Unit) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
try {
val res = gatewayRepo.startNewSession()
_uiState.value = _uiState.value.copy(isLoading = false)
onSuccess(res.durableId.value)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.localizedMessage ?: "Failed to create session"
)
}
}
}
fun resumeSession(durableId: DurableSessionId, onSuccess: (String) -> Unit) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
try {
val res = gatewayRepo.openSession(durableId)
_uiState.value = _uiState.value.copy(isLoading = false)
onSuccess(res.durableId.value)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.localizedMessage ?: "Failed to resume session"
)
}
}
}
}

View file

@ -0,0 +1,94 @@
package app.hermes.mobile.feature.settings
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings & Security", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Security & Authentication", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Text(
"• PKCE (RFC 7636 / RFC 8252) ephemeral loopback on 127.0.0.1.\n" +
"• Single-use WebSocket tickets with 30s TTL.\n" +
"• Encrypted credentials stored in Android Keystore / EncryptedSharedPreferences.\n" +
"• Cleartext HTTP strictly disabled by default unless explicitly enabled per connection for local LAN debugging.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Hermes Client Info", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Text(
"App: Hermes Android Client\n" +
"Version: 1.0.0 (Protocol Contract v1)\n" +
"Architecture: Direct WebSocket JSON-RPC & PKCE Loopback Auth",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}

View file

@ -0,0 +1,32 @@
package app.hermes.mobile.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// Dark Theme Colors
val BackgroundDark = Color(0xFF0F172A)
val SurfaceDark = Color(0xFF1E293B)
val SurfaceVariantDark = Color(0xFF334155)
val PrimaryDark = Color(0xFF38BDF8)
val OnPrimaryDark = Color(0xFF0F172A)
val AccentAmber = Color(0xFFF59E0B)
val AccentGreen = Color(0xFF10B981)
val AccentRed = Color(0xFFEF4444)
val TextPrimaryDark = Color(0xFFF8FAFC)
val TextSecondaryDark = Color(0xFF94A3B8)
// Light Theme Colors
val BackgroundLight = Color(0xFFF8FAFC)
val SurfaceLight = Color(0xFFFFFFFF)
val SurfaceVariantLight = Color(0xFFF1F5F9)
val PrimaryLight = Color(0xFF0284C7)
val OnPrimaryLight = Color(0xFFFFFFFF)
val TextPrimaryLight = Color(0xFF0F172A)
val TextSecondaryLight = Color(0xFF64748B)

View file

@ -0,0 +1,59 @@
package app.hermes.mobile.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.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = PrimaryDark,
onPrimary = OnPrimaryDark,
secondary = PurpleGrey80,
tertiary = Pink80,
background = BackgroundDark,
surface = SurfaceDark,
surfaceVariant = SurfaceVariantDark,
onBackground = TextPrimaryDark,
onSurface = TextPrimaryDark,
onSurfaceVariant = TextSecondaryDark
)
private val LightColorScheme = lightColorScheme(
primary = PrimaryLight,
onPrimary = OnPrimaryLight,
secondary = PurpleGrey40,
tertiary = Pink40,
background = BackgroundLight,
surface = SurfaceLight,
surfaceVariant = SurfaceVariantLight,
onBackground = TextPrimaryLight,
onSurface = TextPrimaryLight,
onSurfaceVariant = TextSecondaryLight
)
@Composable
fun HermesAndroidTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View file

@ -0,0 +1,36 @@
package app.hermes.mobile.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
lineHeight = 28.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp
)
)

View file

@ -0,0 +1,10 @@
<resources>
<color name="primary">#6750A4</color>
<color name="on_primary">#FFFFFF</color>
<color name="primary_container">#EADDFF</color>
<color name="on_primary_container">#21005D</color>
<color name="background">#FEF7FF</color>
<color name="on_background">#1D1B20</color>
<color name="surface">#FEF7FF</color>
<color name="on_surface">#1D1B20</color>
</resources>

View file

@ -0,0 +1,19 @@
<resources>
<string name="app_name">Hermes</string>
<string name="connections">Connections</string>
<string name="sessions">Sessions</string>
<string name="chat">Chat</string>
<string name="settings">Settings</string>
<string name="new_chat">New Chat</string>
<string name="add_connection">Add Connection</string>
<string name="test_connection">Test Connection</string>
<string name="sign_in">Sign In</string>
<string name="disconnect">Disconnect</string>
<string name="connected">Connected</string>
<string name="connecting">Connecting…</string>
<string name="disconnected">Disconnected</string>
<string name="reconnecting">Reconnecting…</string>
<string name="failed">Failed</string>
<string name="authenticated">Authenticated</string>
<string name="auth_required">Auth Required</string>
</resources>

View file

@ -0,0 +1,6 @@
<resources>
<style name="Theme.HermesAndroid" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/background</item>
<item name="android:navigationBarColor">@color/background</item>
</style>
</resources>

View file

@ -0,0 +1,32 @@
package app.hermes.mobile.core.auth
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PkceChallengeTest {
@Test
fun testRfc7636AppendixBTestVector() {
// RFC 7636 Appendix B test vector:
val codeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
val expectedChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
val computedChallenge = PkceChallenge.computeChallenge(codeVerifier)
assertEquals(expectedChallenge, computedChallenge)
}
@Test
fun testGenerateProducesValidLengthAndCharset() {
val challenge = PkceChallenge.generate(64)
assertNotNull(challenge.codeVerifier)
assertNotNull(challenge.codeChallenge)
assertEquals(64, challenge.codeVerifier.length)
assertEquals("S256", challenge.method)
// Verifier must only contain unreserved characters: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
val validRegex = Regex("^[A-Za-z0-9\\-._~]+$")
assertTrue(challenge.codeVerifier.matches(validRegex))
}
}

View file

@ -0,0 +1,276 @@
package app.hermes.mobile.core.network
import app.hermes.mobile.core.model.DurableSessionId
import app.hermes.mobile.core.model.HermesConnection
import app.hermes.mobile.core.model.MessageRole
import app.hermes.mobile.core.repository.HermesGatewayRepository
import app.hermes.mobile.core.security.InMemoryTokenVault
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class EndToEndContractScenarioTest {
private lateinit var server: MockWebServer
private lateinit var restClient: HermesRestClient
private lateinit var gatewayClient: JsonRpcGatewayClient
private lateinit var tokenVault: InMemoryTokenVault
private lateinit var repository: HermesGatewayRepository
private var serverWs: WebSocket? = null
private val wsConnectedLatch = CountDownLatch(1)
@Before
fun setUp() {
server = MockWebServer()
restClient = HermesRestClient()
gatewayClient = JsonRpcGatewayClient()
tokenVault = InMemoryTokenVault()
repository = HermesGatewayRepository(restClient, gatewayClient, tokenVault)
}
@After
fun tearDown() {
serverWs?.close(1000, "done")
repository.disconnect()
try {
server.shutdown()
} catch (_: Exception) {
}
}
@Test
fun testFullContractScenarioLifecycle() = runBlocking {
val serverUrl = server.url("").toString().removeSuffix("/")
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path ?: ""
return when {
path == "/api/status" -> {
MockResponse().setResponseCode(200).setBody(
"""{"status":"ok","auth_required":true,"auth_providers":["github"],"version":"1.0.0"}"""
)
}
path == "/auth/native/token" -> {
MockResponse().setResponseCode(200).setBody(
"""{"access_token":"jwt_access_123","refresh_token":"rt_456","token_type":"Bearer","expires_at":2000000000,"user_id":"hermes_user"}"""
)
}
path == "/api/auth/ws-ticket" -> {
val authHeader = request.getHeader("Authorization")
if (authHeader == "Bearer jwt_access_123") {
MockResponse().setResponseCode(200).setBody(
"""{"ticket":"ticket_xyz_789","ttl_seconds":30}"""
)
} else {
MockResponse().setResponseCode(401).setBody("""{"error":"Unauthorized"}""")
}
}
path.startsWith("/api/ws") || path.startsWith("/ws") -> {
MockResponse().withWebSocketUpgrade(object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
serverWs = webSocket
wsConnectedLatch.countDown()
// Send gateway.ready
webSocket.send("""{"event":"gateway.ready","data":{"version":"1.0.0","session_count":1}}""")
}
override fun onMessage(webSocket: WebSocket, text: String) {
if (text.contains("session.list")) {
webSocket.send("""{"jsonrpc":"2.0","id":"a1","result":[{"id":"durable_100","title":"Existing Session","preview":"Hello!","started_at":1700000000,"message_count":2,"source":"android"}]}""")
} else if (text.contains("session.create")) {
webSocket.send("""{"jsonrpc":"2.0","id":"a2","result":{"stored_session_id":"durable_101","session_id":"runtime_202"}}""")
} else if (text.contains("prompt.submit")) {
webSocket.send("""{"jsonrpc":"2.0","id":"a3","result":{"turn_id":"turn_001"}}""")
// Emit streaming events
webSocket.send("""{"event":"message.start","data":{"message_id":"msg_resp_1","role":"assistant"}}""")
webSocket.send("""{"event":"message.delta","data":{"message_id":"msg_resp_1","delta":"Sure, I can "}}""")
webSocket.send("""{"event":"message.delta","data":{"message_id":"msg_resp_1","delta":"run that tool."}}""")
webSocket.send("""{"event":"tool.start","data":{"tool_id":"t_exec","name":"run_command"}}""")
webSocket.send("""{"event":"tool.progress","data":{"tool_id":"t_exec","progress":"Executing ls..."}}""")
webSocket.send("""{"event":"tool.complete","data":{"tool_id":"t_exec","result":"file1.txt\nfile2.txt","is_error":false}}""")
webSocket.send("""{"event":"approval.request","data":{"request_id":"app_req_1","command":"git status","description":"Run git status","choices":["once","deny"]}}""")
} else if (text.contains("approval.respond")) {
webSocket.send("""{"jsonrpc":"2.0","id":"a4","result":{"accepted":true}}""")
webSocket.send("""{"event":"message.complete","data":{"message_id":"msg_resp_1","content":"Sure, I can run that tool. Done!"}}""")
}
}
})
}
else -> MockResponse().setResponseCode(404)
}
}
}
val conn = HermesConnection(
id = "test_conn_1",
name = "Test Server",
baseUrl = serverUrl,
allowCleartext = true
)
// 1. Status check
val statusRes = repository.checkStatus(conn)
assertTrue(statusRes.isSuccess)
val status = statusRes.getOrThrow()
assertTrue(status.authRequired)
// 2. Token exchange fixture
val exchangeRes = restClient.exchangeNativeToken(
baseUrl = conn.baseUrl,
code = "auth_code_123",
codeVerifier = "code_verifier_123",
allowCleartext = true
)
assertTrue(exchangeRes.isSuccess)
val tokens = exchangeRes.getOrThrow()
tokenVault.saveTokens(conn.id, tokens)
// 3 & 4. Connect repository
val connectRes = repository.connect(conn)
assertTrue(connectRes.isSuccess)
assertTrue(wsConnectedLatch.await(5, TimeUnit.SECONDS))
val state = withTimeout(5000) {
repository.connectionState.first { it is ConnectionState.Connected }
}
assertTrue(state is ConnectionState.Connected)
// 6. List sessions
val sessionList = repository.listSessions()
assertEquals(1, sessionList.size)
assertEquals(DurableSessionId("durable_100"), sessionList[0].id)
// 7. Create new session
val createResult = repository.startNewSession()
assertEquals(DurableSessionId("durable_101"), createResult.durableId)
assertEquals(repository.activeDurableId.value, DurableSessionId("durable_101"))
// 8. Submit user prompt
val submitRes = repository.sendUserPrompt("Run git status")
assertTrue(submitRes.accepted)
// Wait for streaming delta and approval request
withTimeout(5000) {
while (repository.messages.value.none { it.role == MessageRole.ASSISTANT && it.content.isNotEmpty() }) {
kotlinx.coroutines.delay(50)
}
}
val assistantMsg = repository.messages.value.find { it.role == MessageRole.ASSISTANT }
assertNotNull(assistantMsg)
assertTrue(assistantMsg!!.content.contains("Sure, I can"))
withTimeout(5000) {
while (repository.activeApprovals.value.isEmpty()) {
kotlinx.coroutines.delay(50)
}
}
assertEquals(1, repository.activeApprovals.value.size)
val approval = repository.activeApprovals.value[0]
assertEquals("app_req_1", approval.requestId)
assertEquals("git status", approval.command)
// 10. Respond to approval
val approvalRes = repository.respondApproval("app_req_1", "once", false)
assertTrue(approvalRes)
assertTrue(repository.activeApprovals.value.isEmpty())
// 11. Wait for completion
withTimeout(5000) {
while (repository.isExecuting.value) {
kotlinx.coroutines.delay(50)
}
}
assertFalse(repository.isExecuting.value)
val completedMsg = repository.messages.value.find { it.role == MessageRole.ASSISTANT }
assertNotNull(completedMsg)
assertEquals("Sure, I can run that tool. Done!", completedMsg!!.content)
assertFalse(completedMsg.isStreaming)
}
@Test
fun testTokenRefreshOnExpiringToken() = runBlocking {
val serverUrl = server.url("").toString().removeSuffix("/")
var refreshed = false
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path ?: ""
return when {
path == "/api/status" -> {
MockResponse().setResponseCode(200).setBody(
"""{"status":"ok","auth_required":true,"auth_providers":["github"],"version":"1.0.0"}"""
)
}
path == "/auth/native/refresh" -> {
refreshed = true
MockResponse().setResponseCode(200).setBody(
"""{"access_token":"refreshed_access_token","refresh_token":"rt_789","token_type":"Bearer","expires_at":2500000000,"user_id":"hermes_user"}"""
)
}
path == "/api/auth/ws-ticket" -> {
val authHeader = request.getHeader("Authorization")
if (authHeader == "Bearer refreshed_access_token") {
MockResponse().setResponseCode(200).setBody(
"""{"ticket":"fresh_ticket_999","ttl_seconds":30}"""
)
} else {
MockResponse().setResponseCode(401).setBody("""{"error":"Unauthorized"}""")
}
}
path.startsWith("/api/ws") || path.startsWith("/ws") -> {
MockResponse().withWebSocketUpgrade(object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send("""{"event":"gateway.ready","data":{"version":"1.0.0","session_count":0}}""")
}
})
}
else -> MockResponse().setResponseCode(404)
}
}
}
val conn = HermesConnection(
id = "refresh_conn_1",
name = "Refresh Server",
baseUrl = serverUrl,
allowCleartext = true
)
// Expired token (expiresAt = 1000, current time is > 1000)
tokenVault.saveTokens(
conn.id,
app.hermes.mobile.core.model.NativeAuthTokens(
accessToken = "expired_token",
refreshToken = "rt_initial",
expiresAt = 1000L
)
)
val connectRes = repository.connect(conn)
assertTrue(connectRes.isSuccess)
assertTrue(refreshed)
val newTokens = tokenVault.getTokens(conn.id)
assertNotNull(newTokens)
assertEquals("refreshed_access_token", newTokens?.accessToken)
}
}

View file

@ -0,0 +1,201 @@
package app.hermes.mobile.core.network
import app.hermes.mobile.core.model.DurableSessionId
import app.hermes.mobile.core.model.GatewayEvent
import app.hermes.mobile.core.model.RuntimeSessionId
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class JsonRpcGatewayClientTest {
private lateinit var server: MockWebServer
private lateinit var client: JsonRpcGatewayClient
@Before
fun setUp() {
server = MockWebServer()
server.start()
client = JsonRpcGatewayClient()
}
@After
fun tearDown() {
client.disconnect()
try {
server.shutdown()
} catch (_: Exception) {
}
}
@Test
fun testWebSocketConnectAndRpcExchange() = runBlocking {
var serverWebSocket: WebSocket? = null
server.enqueue(
MockResponse().withWebSocketUpgrade(object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
serverWebSocket = webSocket
webSocket.send("""{"event":"gateway.ready","data":{"version":"1.0.0","session_count":0}}""")
}
override fun onMessage(webSocket: WebSocket, text: String) {
// When receiving session.create, respond with result
if (text.contains("session.create")) {
webSocket.send("""{"jsonrpc":"2.0","id":"a1","result":{"stored_session_id":"durable_123","session_id":"runtime_456"}}""")
}
}
})
)
val wsUrl = "ws://${server.hostName}:${server.port}/api/ws"
client.connect(wsUrl, ticket = "sample_ticket_123", allowCleartext = true)
client.awaitGatewayReady(5000)
assertEquals(ConnectionState.Connected, client.connectionState.value)
val res = client.createSession(source = "android")
assertEquals(DurableSessionId("durable_123"), res.durableId)
assertEquals(RuntimeSessionId("runtime_456"), res.runtimeId)
serverWebSocket?.close(1000, "done")
client.disconnect()
}
@Test
fun testGatewayReadyStateTransition() = runBlocking {
var serverWebSocket: WebSocket? = null
server.enqueue(
MockResponse().withWebSocketUpgrade(object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
serverWebSocket = webSocket
// Do not send gateway.ready immediately
}
override fun onMessage(webSocket: WebSocket, text: String) {
}
})
)
val wsUrl = "ws://${server.hostName}:${server.port}/api/ws"
client.connect(wsUrl, allowCleartext = true)
// Give WS a moment to open transport
kotlinx.coroutines.delay(100)
// Must still be Connecting before gateway.ready is received
assertEquals(ConnectionState.Connecting, client.connectionState.value)
// Send gateway.ready
serverWebSocket?.send("""{"event":"gateway.ready","data":{"version":"1.0.0","session_count":1}}""")
client.awaitGatewayReady(5000)
assertEquals(ConnectionState.Connected, client.connectionState.value)
serverWebSocket?.close(1000, "done")
client.disconnect()
}
@Test
fun testEventDispatchingFromWebSocket() = runBlocking {
var serverWebSocket: WebSocket? = null
server.enqueue(
MockResponse().withWebSocketUpgrade(object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
serverWebSocket = webSocket
// Send an incoming server notification/event
webSocket.send("""{"event":"message.delta","data":{"message_id":"m100","delta":"Streaming token"}}""")
}
})
)
val wsUrl = "ws://${server.hostName}:${server.port}/api/ws"
client.connect(wsUrl, allowCleartext = true)
val event = withTimeout(5000) {
client.events.first()
}
assertTrue(event is GatewayEvent.MessageDeltaEvent)
val deltaEvent = event as GatewayEvent.MessageDeltaEvent
assertEquals("m100", deltaEvent.messageId)
assertEquals("Streaming token", deltaEvent.delta)
serverWebSocket?.close(1000, "done")
client.disconnect()
}
@Test
fun testRpcMethodsSendSessionId() = runBlocking {
var serverWebSocket: WebSocket? = null
val receivedTexts = mutableListOf<String>()
server.enqueue(
MockResponse().withWebSocketUpgrade(object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
serverWebSocket = webSocket
webSocket.send("""{"event":"gateway.ready","data":{"version":"1.0.0","session_count":0}}""")
}
override fun onMessage(webSocket: WebSocket, text: String) {
receivedTexts.add(text)
if (text.contains("session.resume")) {
webSocket.send("""{"jsonrpc":"2.0","id":"a1","result":{"stored_session_id":"dur_1","session_id":"rt_1"}}""")
} else if (text.contains("prompt.submit")) {
webSocket.send("""{"jsonrpc":"2.0","id":"a2","result":{"turn_id":"t_1"}}""")
} else if (text.contains("session.interrupt")) {
webSocket.send("""{"jsonrpc":"2.0","id":"a3","result":{"status":"ok"}}""")
} else if (text.contains("approval.respond")) {
webSocket.send("""{"jsonrpc":"2.0","id":"a4","result":{"status":"ok"}}""")
} else if (text.contains("clarify.respond")) {
webSocket.send("""{"jsonrpc":"2.0","id":"a5","result":{"status":"ok"}}""")
}
}
})
)
val wsUrl = "ws://${server.hostName}:${server.port}/api/ws"
client.connect(wsUrl, allowCleartext = true)
client.awaitGatewayReady(5000)
val resumeRes = client.resumeSession(DurableSessionId("dur_1"))
assertEquals(DurableSessionId("dur_1"), resumeRes.durableId)
assertEquals(RuntimeSessionId("rt_1"), resumeRes.runtimeId)
val promptRes = client.submitPrompt(RuntimeSessionId("rt_1"), "Hello")
assertEquals("t_1", promptRes.turnId)
val interruptRes = client.interruptSession(RuntimeSessionId("rt_1"))
assertTrue(interruptRes)
val approvalRes = client.respondApproval("rt_1", "req_1", "once", false)
assertTrue(approvalRes)
val clarifyRes = client.respondClarify("req_2", "42", "q_1")
assertTrue(clarifyRes)
// Verify wire contents sent over WebSocket
assertTrue(receivedTexts[0].contains("\"session_id\":\"dur_1\""))
assertTrue(receivedTexts[1].contains("\"session_id\":\"rt_1\""))
assertTrue(receivedTexts[1].contains("\"text\":\"Hello\""))
assertTrue(receivedTexts[2].contains("\"session_id\":\"rt_1\""))
assertTrue(receivedTexts[3].contains("\"session_id\":\"rt_1\""))
assertTrue(receivedTexts[3].contains("\"request_id\":\"req_1\""))
assertTrue(receivedTexts[4].contains("\"question_id\":\"q_1\""))
serverWebSocket?.close(1000, "done")
client.disconnect()
}
}

View file

@ -0,0 +1,292 @@
package app.hermes.mobile.core.network
import app.hermes.mobile.core.model.ClarifyType
import app.hermes.mobile.core.model.GatewayEvent
import app.hermes.mobile.core.model.JsonRpcError
import app.hermes.mobile.core.model.JsonRpcRequest
import app.hermes.mobile.core.model.JsonRpcResponse
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class JsonRpcWireFormatTest {
private val json = Json { ignoreUnknownKeys = true; isLenient = true; encodeDefaults = true }
@Test
fun testRequestSerialization() {
val request = JsonRpcRequest(
id = "a1",
method = "session.create",
params = buildJsonObject {
put("source", "android")
put("cols", 100)
}
)
val serialized = json.encodeToString(request)
val parsed = json.decodeFromString<JsonObject>(serialized)
assertEquals("2.0", parsed["jsonrpc"]?.jsonPrimitive?.content)
assertEquals("a1", parsed["id"]?.jsonPrimitive?.content)
assertEquals("session.create", parsed["method"]?.jsonPrimitive?.content)
}
@Test
fun testResponseDeserializationSuccess() {
val raw = """
{
"jsonrpc": "2.0",
"id": "a1",
"result": {
"durable_id": "sess_12345",
"runtime_id": "rt_67890"
}
}
""".trimIndent()
val response = json.decodeFromString<JsonRpcResponse>(raw)
assertEquals("2.0", response.jsonrpc)
assertEquals("a1", response.id)
assertNotNull(response.result)
assertNull(response.error)
}
@Test
fun testResponseDeserializationError() {
val raw = """
{
"jsonrpc": "2.0",
"id": "a2",
"error": {
"code": -32601,
"message": "Method not found"
}
}
""".trimIndent()
val response = json.decodeFromString<JsonRpcResponse>(raw)
assertEquals("a2", response.id)
assertNotNull(response.error)
assertEquals(-32601, response.error?.code)
assertEquals("Method not found", response.error?.message)
}
@Test
fun testAllGatewayEventParsers() {
// 1. Gateway Ready
val readyJson = json.decodeFromString<JsonObject>("""{"event":"gateway.ready","data":{"version":"1.0.0","session_count":3}}""")
val readyEvent = GatewayEvent.parse(readyJson) as GatewayEvent.GatewayReadyEvent
assertEquals("1.0.0", readyEvent.version)
assertEquals(3, readyEvent.sessionCount)
// 2. Message Start
val msgStartJson = json.decodeFromString<JsonObject>("""{"event":"message.start","data":{"message_id":"msg_1","role":"assistant"}}""")
val msgStart = GatewayEvent.parse(msgStartJson) as GatewayEvent.MessageStartEvent
assertEquals("msg_1", msgStart.messageId)
assertEquals("assistant", msgStart.role)
// 3. Message Delta
val msgDeltaJson = json.decodeFromString<JsonObject>("""{"event":"message.delta","data":{"message_id":"msg_1","delta":"Hello world"}}""")
val msgDelta = GatewayEvent.parse(msgDeltaJson) as GatewayEvent.MessageDeltaEvent
assertEquals("msg_1", msgDelta.messageId)
assertEquals("Hello world", msgDelta.delta)
// 4. Message Complete
val msgCompleteJson = json.decodeFromString<JsonObject>("""{"event":"message.complete","data":{"message_id":"msg_1","content":"Final answer"}}""")
val msgComplete = GatewayEvent.parse(msgCompleteJson) as GatewayEvent.MessageCompleteEvent
assertEquals("msg_1", msgComplete.messageId)
assertEquals("Final answer", msgComplete.content)
// 5. Thinking Delta
val thinkJson = json.decodeFromString<JsonObject>("""{"event":"thinking.delta","data":{"message_id":"msg_1","delta":"Analyzing requirements..."}}""")
val think = GatewayEvent.parse(thinkJson) as GatewayEvent.ThinkingDeltaEvent
assertEquals("Analyzing requirements...", think.delta)
// 6. Tool Lifecycle
val toolStartJson = json.decodeFromString<JsonObject>("""{"event":"tool.start","data":{"tool_id":"t1","name":"exec_command"}}""")
val toolStart = GatewayEvent.parse(toolStartJson) as GatewayEvent.ToolStartEvent
assertEquals("t1", toolStart.toolId)
assertEquals("exec_command", toolStart.name)
val toolProgressJson = json.decodeFromString<JsonObject>("""{"event":"tool.progress","data":{"tool_id":"t1","progress":"Running build..."}}""")
val toolProgress = GatewayEvent.parse(toolProgressJson) as GatewayEvent.ToolProgressEvent
assertEquals("Running build...", toolProgress.progress)
val toolCompleteJson = json.decodeFromString<JsonObject>("""{"event":"tool.complete","data":{"tool_id":"t1","result":"Success","is_error":false}}""")
val toolComplete = GatewayEvent.parse(toolCompleteJson) as GatewayEvent.ToolCompleteEvent
assertEquals("Success", toolComplete.result)
assertEquals(false, toolComplete.isError)
// 7. Approval Request
val approvalJson = json.decodeFromString<JsonObject>("""{"event":"approval.request","data":{"request_id":"req_app","command":"rm -rf /tmp/cache","description":"Clear cache directory","choices":["once","deny"]}}""")
val approval = GatewayEvent.parse(approvalJson) as GatewayEvent.ApprovalRequestEvent
assertEquals("req_app", approval.requestId)
assertEquals("rm -rf /tmp/cache", approval.command)
assertEquals(2, approval.choices.size)
// 8. Clarify, Sudo, Secret
val clarifyJson = json.decodeFromString<JsonObject>("""{"event":"clarify.request","data":{"request_id":"c1","question":"Which port?"}}""")
val clarify = GatewayEvent.parse(clarifyJson) as GatewayEvent.ClarifyRequestEvent
assertEquals("Which port?", clarify.question)
assertEquals(ClarifyType.CLARIFY, clarify.promptType)
val sudoJson = json.decodeFromString<JsonObject>("""{"event":"sudo.request","data":{"request_id":"s1","question":"Root password required:"}}""")
val sudo = GatewayEvent.parse(sudoJson) as GatewayEvent.SudoRequestEvent
assertEquals("Root password required:", sudo.question)
val secretJson = json.decodeFromString<JsonObject>("""{"event":"secret.request","data":{"request_id":"sec1","question":"OpenAI API Key:"}}""")
val secret = GatewayEvent.parse(secretJson) as GatewayEvent.SecretRequestEvent
assertEquals("OpenAI API Key:", secret.question)
// 9. Session Info & Usage
val infoJson = json.decodeFromString<JsonObject>("""{"event":"session.info","data":{"model":"claude-3-5-sonnet","provider":"anthropic","branch":"main"}}""")
val info = GatewayEvent.parse(infoJson) as GatewayEvent.SessionInfoEvent
assertEquals("claude-3-5-sonnet", info.info.model)
assertEquals("main", info.info.branch)
val usageJson = json.decodeFromString<JsonObject>("""{"event":"session.usage","data":{"input_tokens":1200,"output_tokens":350,"total_tokens":1550}}""")
val usage = GatewayEvent.parse(usageJson) as GatewayEvent.SessionUsageEvent
assertEquals(1200L, usage.inputTokens)
assertEquals(350L, usage.outputTokens)
assertEquals(1550L, usage.totalTokens)
}
@Test
fun testUnknownEventToleranceWithoutCrash() {
val rawUnknown = """
{
"event": "custom.future.event.v99",
"data": {
"some_new_field": 42
}
}
""".trimIndent()
val root = json.decodeFromString<JsonObject>(rawUnknown)
val event = GatewayEvent.parse(root)
assertTrue(event is GatewayEvent.UnknownGatewayEvent)
val unknown = event as GatewayEvent.UnknownGatewayEvent
assertEquals("custom.future.event.v99", unknown.eventType)
}
@Test
fun testPromptSubmitWireParams() {
val request = JsonRpcRequest(
id = "a10",
method = "prompt.submit",
params = buildJsonObject {
put("session_id", "rt_session_123")
put("text", "Execute query")
}
)
val serialized = json.encodeToString(request)
val parsed = json.decodeFromString<JsonObject>(serialized)
val params = parsed["params"] as JsonObject
assertEquals("rt_session_123", params["session_id"]?.jsonPrimitive?.content)
assertEquals("Execute query", params["text"]?.jsonPrimitive?.content)
}
@Test
fun testSessionResumeWireParams() {
val request = JsonRpcRequest(
id = "a11",
method = "session.resume",
params = buildJsonObject {
put("session_id", "durable_session_456")
put("source", "android")
}
)
val serialized = json.encodeToString(request)
val parsed = json.decodeFromString<JsonObject>(serialized)
val params = parsed["params"] as JsonObject
assertEquals("durable_session_456", params["session_id"]?.jsonPrimitive?.content)
assertEquals("android", params["source"]?.jsonPrimitive?.content)
}
@Test
fun testSessionInterruptWireParams() {
val request = JsonRpcRequest(
id = "a12",
method = "session.interrupt",
params = buildJsonObject {
put("session_id", "rt_session_123")
}
)
val serialized = json.encodeToString(request)
val parsed = json.decodeFromString<JsonObject>(serialized)
val params = parsed["params"] as JsonObject
assertEquals("rt_session_123", params["session_id"]?.jsonPrimitive?.content)
}
@Test
fun testApprovalRespondWireParams() {
val request = JsonRpcRequest(
id = "a13",
method = "approval.respond",
params = buildJsonObject {
put("session_id", "rt_session_123")
put("request_id", "app_req_99")
put("choice", "once")
put("all", false)
}
)
val serialized = json.encodeToString(request)
val parsed = json.decodeFromString<JsonObject>(serialized)
val params = parsed["params"] as JsonObject
assertEquals("rt_session_123", params["session_id"]?.jsonPrimitive?.content)
assertEquals("app_req_99", params["request_id"]?.jsonPrimitive?.content)
assertEquals("once", params["choice"]?.jsonPrimitive?.content)
assertEquals("false", params["all"]?.jsonPrimitive?.content)
}
@Test
fun testClarifyRespondWireParamsWithQuestionId() {
val request = JsonRpcRequest(
id = "a14",
method = "clarify.respond",
params = buildJsonObject {
put("request_id", "c1")
put("answer", "port 8080")
put("question_id", "q_port")
}
)
val serialized = json.encodeToString(request)
val parsed = json.decodeFromString<JsonObject>(serialized)
val params = parsed["params"] as JsonObject
assertEquals("c1", params["request_id"]?.jsonPrimitive?.content)
assertEquals("port 8080", params["answer"]?.jsonPrimitive?.content)
assertEquals("q_port", params["question_id"]?.jsonPrimitive?.content)
}
@Test
fun testSessionCreateResponseWithStoredSessionId() {
val raw = """
{
"jsonrpc": "2.0",
"id": "a15",
"result": {
"stored_session_id": "dur_sess_999",
"session_id": "rt_sess_888"
}
}
""".trimIndent()
val response = json.decodeFromString<JsonRpcResponse>(raw)
val result = response.result as JsonObject
assertEquals("dur_sess_999", result["stored_session_id"]?.jsonPrimitive?.content)
assertEquals("rt_sess_888", result["session_id"]?.jsonPrimitive?.content)
}
}

View file

@ -0,0 +1,92 @@
package app.hermes.mobile.feature.chat
import app.hermes.mobile.core.model.DurableSessionId
import app.hermes.mobile.core.model.HermesApproval
import app.hermes.mobile.core.model.HermesMessage
import app.hermes.mobile.core.model.MessageRole
import app.hermes.mobile.core.model.RuntimeSessionId
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.network.JsonRpcGatewayClient
import app.hermes.mobile.core.repository.HermesGatewayRepository
import app.hermes.mobile.core.security.InMemoryTokenVault
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var restClient: HermesRestClient
private lateinit var gatewayClient: JsonRpcGatewayClient
private lateinit var tokenVault: InMemoryTokenVault
private lateinit var repository: HermesGatewayRepository
private lateinit var viewModel: ChatViewModel
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
restClient = HermesRestClient()
gatewayClient = JsonRpcGatewayClient()
tokenVault = InMemoryTokenVault()
repository = HermesGatewayRepository(restClient, gatewayClient, tokenVault)
viewModel = ChatViewModel(repository)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testUpdateInputText() {
assertEquals("", viewModel.uiState.value.inputText)
viewModel.updateInputText("Hello agent")
assertEquals("Hello agent", viewModel.uiState.value.inputText)
}
@Test
fun testSubmitEmptyPromptDoesNothing() {
viewModel.updateInputText(" ")
viewModel.submitPrompt()
assertEquals(" ", viewModel.uiState.value.inputText)
}
@Test
fun testMessageHandlingStateFlow() {
val initialMessages = viewModel.messages.value
assertEquals(0, initialMessages.size)
}
@Test
fun testClarifyRequestHandling() = runTest(testDispatcher) {
val clarifyReq = app.hermes.mobile.core.model.HermesClarifyRequest(
requestId = "req_101",
questionId = "q_param",
question = "Which database?",
promptType = app.hermes.mobile.core.model.ClarifyType.CLARIFY
)
viewModel.respondClarify(clarifyReq, "PostgreSQL")
// No crash, handled gracefully when disconnected
}
@Test
fun testApprovalHandling() = runTest(testDispatcher) {
viewModel.respondApproval("req_app_1", "once", false)
// Handled gracefully
}
@Test
fun testInterruptHandling() = runTest(testDispatcher) {
viewModel.interruptSession()
// Handled gracefully
}
}

6
build.gradle.kts Normal file
View file

@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.8.2" apply false
id("org.jetbrains.kotlin.android") version "2.1.10" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10" apply false
}

4
gradle.properties Normal file
View file

@ -0,0 +1,4 @@
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
kotlin.code.style=official
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Normal file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

23
settings.gradle.kts Normal file
View file

@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "hermes-android"
include(":app")