Initial commit: Hermes Android native remote client MVP (Contract v1)
This commit is contained in:
commit
4fbb95c6c4
46 changed files with 5753 additions and 0 deletions
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal 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
122
README.md
Normal 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
106
app/build.gradle.kts
Normal 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
2
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Proguard rules for Hermes Mobile
|
||||||
|
-keepattributes *Annotation*,Signature,InnerClasses,EnclosingMethod
|
||||||
30
app/src/main/AndroidManifest.xml
Normal file
30
app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
139
app/src/main/java/app/hermes/mobile/MainActivity.kt
Normal file
139
app/src/main/java/app/hermes/mobile/MainActivity.kt
Normal 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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/src/main/java/app/hermes/mobile/core/model/AuthTokens.kt
Normal file
19
app/src/main/java/app/hermes/mobile/core/model/AuthTokens.kt
Normal 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 = ""
|
||||||
|
)
|
||||||
13
app/src/main/java/app/hermes/mobile/core/model/Connection.kt
Normal file
13
app/src/main/java/app/hermes/mobile/core/model/Connection.kt
Normal 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()
|
||||||
|
)
|
||||||
365
app/src/main/java/app/hermes/mobile/core/model/GatewayEvents.kt
Normal file
365
app/src/main/java/app/hermes/mobile/core/model/GatewayEvents.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
144
app/src/main/java/app/hermes/mobile/feature/chat/ApprovalCard.kt
Normal file
144
app/src/main/java/app/hermes/mobile/feature/chat/ApprovalCard.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
458
app/src/main/java/app/hermes/mobile/feature/chat/ChatScreen.kt
Normal file
458
app/src/main/java/app/hermes/mobile/feature/chat/ChatScreen.kt
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/src/main/java/app/hermes/mobile/ui/theme/Color.kt
Normal file
32
app/src/main/java/app/hermes/mobile/ui/theme/Color.kt
Normal 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)
|
||||||
59
app/src/main/java/app/hermes/mobile/ui/theme/Theme.kt
Normal file
59
app/src/main/java/app/hermes/mobile/ui/theme/Theme.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
36
app/src/main/java/app/hermes/mobile/ui/theme/Type.kt
Normal file
36
app/src/main/java/app/hermes/mobile/ui/theme/Type.kt
Normal 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
|
||||||
|
)
|
||||||
|
)
|
||||||
10
app/src/main/res/values/colors.xml
Normal file
10
app/src/main/res/values/colors.xml
Normal 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>
|
||||||
19
app/src/main/res/values/strings.xml
Normal file
19
app/src/main/res/values/strings.xml
Normal 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>
|
||||||
6
app/src/main/res/values/themes.xml
Normal file
6
app/src/main/res/values/themes.xml
Normal 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>
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
6
build.gradle.kts
Normal 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
4
gradle.properties
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
251
gradlew
vendored
Normal 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
94
gradlew.bat
vendored
Normal 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
23
settings.gradle.kts
Normal 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")
|
||||||
Loading…
Reference in a new issue