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