From 4fbb95c6c47e366fcc169c6cb34831d97a675a62 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Sun, 23 Aug 2026 22:46:58 +0700 Subject: [PATCH] Initial commit: Hermes Android native remote client MVP (Contract v1) --- .gitignore | 39 ++ README.md | 122 ++++ app/build.gradle.kts | 106 ++++ app/proguard-rules.pro | 2 + app/src/main/AndroidManifest.xml | 30 + .../java/app/hermes/mobile/MainActivity.kt | 139 +++++ .../hermes/mobile/core/auth/PkceChallenge.kt | 42 ++ .../core/auth/PkceLoopbackAuthManager.kt | 195 ++++++ .../hermes/mobile/core/model/AuthTokens.kt | 19 + .../hermes/mobile/core/model/Connection.kt | 13 + .../hermes/mobile/core/model/GatewayEvents.kt | 365 ++++++++++++ .../hermes/mobile/core/model/JsonRpcModels.kt | 29 + .../hermes/mobile/core/model/ServerStatus.kt | 16 + .../hermes/mobile/core/model/SessionModels.kt | 92 +++ .../mobile/core/network/HermesRestClient.kt | 183 ++++++ .../core/network/JsonRpcGatewayClient.kt | 418 +++++++++++++ .../core/repository/ConnectionRepository.kt | 72 +++ .../repository/HermesGatewayRepository.kt | 553 ++++++++++++++++++ .../hermes/mobile/core/security/TokenVault.kt | 81 +++ .../mobile/feature/chat/ApprovalCard.kt | 144 +++++ .../hermes/mobile/feature/chat/ChatScreen.kt | 458 +++++++++++++++ .../mobile/feature/chat/ChatViewModel.kt | 96 +++ .../mobile/feature/chat/ClarifyDialog.kt | 95 +++ .../feature/connections/ConnectionsScreen.kt | 435 ++++++++++++++ .../connections/ConnectionsViewModel.kt | 122 ++++ .../mobile/feature/sessions/SessionsScreen.kt | 271 +++++++++ .../feature/sessions/SessionsViewModel.kt | 82 +++ .../mobile/feature/settings/SettingsScreen.kt | 94 +++ .../java/app/hermes/mobile/ui/theme/Color.kt | 32 + .../java/app/hermes/mobile/ui/theme/Theme.kt | 59 ++ .../java/app/hermes/mobile/ui/theme/Type.kt | 36 ++ app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 19 + app/src/main/res/values/themes.xml | 6 + .../mobile/core/auth/PkceChallengeTest.kt | 32 + .../network/EndToEndContractScenarioTest.kt | 276 +++++++++ .../core/network/JsonRpcGatewayClientTest.kt | 201 +++++++ .../core/network/JsonRpcWireFormatTest.kt | 292 +++++++++ .../mobile/feature/chat/ChatViewModelTest.kt | 92 +++ build.gradle.kts | 6 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43705 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++ gradlew.bat | 94 +++ settings.gradle.kts | 23 + 46 files changed, 5753 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/app/hermes/mobile/MainActivity.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/auth/PkceChallenge.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/auth/PkceLoopbackAuthManager.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/model/AuthTokens.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/model/Connection.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/model/GatewayEvents.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/model/JsonRpcModels.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/model/ServerStatus.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/model/SessionModels.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/network/HermesRestClient.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/network/JsonRpcGatewayClient.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/repository/ConnectionRepository.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/repository/HermesGatewayRepository.kt create mode 100644 app/src/main/java/app/hermes/mobile/core/security/TokenVault.kt create mode 100644 app/src/main/java/app/hermes/mobile/feature/chat/ApprovalCard.kt create mode 100644 app/src/main/java/app/hermes/mobile/feature/chat/ChatScreen.kt create mode 100644 app/src/main/java/app/hermes/mobile/feature/chat/ChatViewModel.kt create mode 100644 app/src/main/java/app/hermes/mobile/feature/chat/ClarifyDialog.kt create mode 100644 app/src/main/java/app/hermes/mobile/feature/connections/ConnectionsScreen.kt create mode 100644 app/src/main/java/app/hermes/mobile/feature/connections/ConnectionsViewModel.kt create mode 100644 app/src/main/java/app/hermes/mobile/feature/sessions/SessionsScreen.kt create mode 100644 app/src/main/java/app/hermes/mobile/feature/sessions/SessionsViewModel.kt create mode 100644 app/src/main/java/app/hermes/mobile/feature/settings/SettingsScreen.kt create mode 100644 app/src/main/java/app/hermes/mobile/ui/theme/Color.kt create mode 100644 app/src/main/java/app/hermes/mobile/ui/theme/Theme.kt create mode 100644 app/src/main/java/app/hermes/mobile/ui/theme/Type.kt create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/test/java/app/hermes/mobile/core/auth/PkceChallengeTest.kt create mode 100644 app/src/test/java/app/hermes/mobile/core/network/EndToEndContractScenarioTest.kt create mode 100644 app/src/test/java/app/hermes/mobile/core/network/JsonRpcGatewayClientTest.kt create mode 100644 app/src/test/java/app/hermes/mobile/core/network/JsonRpcWireFormatTest.kt create mode 100644 app/src/test/java/app/hermes/mobile/feature/chat/ChatViewModelTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e18fb5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd05ca5 --- /dev/null +++ b/README.md @@ -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:) β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ 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="" +$env:HERMES_AUTH_GITHUB_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="" +export HERMES_AUTH_GITHUB_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:`. + - 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 +``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..92d4f45 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..46af752 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Proguard rules for Hermes Mobile +-keepattributes *Annotation*,Signature,InnerClasses,EnclosingMethod diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..de4254e --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/app/hermes/mobile/MainActivity.kt b/app/src/main/java/app/hermes/mobile/MainActivity.kt new file mode 100644 index 0000000..e12c4c8 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/MainActivity.kt @@ -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() + } + ) + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/core/auth/PkceChallenge.kt b/app/src/main/java/app/hermes/mobile/core/auth/PkceChallenge.kt new file mode 100644 index 0000000..19d7703 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/auth/PkceChallenge.kt @@ -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) + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/core/auth/PkceLoopbackAuthManager.kt b/app/src/main/java/app/hermes/mobile/core/auth/PkceLoopbackAuthManager.kt new file mode 100644 index 0000000..cbc900c --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/auth/PkceLoopbackAuthManager.kt @@ -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 = 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 { + val map = mutableMapOf() + 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 = """ + + + + + + Hermes Authentication + + + +
+

Hermes Authentication

+

$message

+
+ + + """.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) + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/core/model/AuthTokens.kt b/app/src/main/java/app/hermes/mobile/core/model/AuthTokens.kt new file mode 100644 index 0000000..182c317 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/model/AuthTokens.kt @@ -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 = "" +) diff --git a/app/src/main/java/app/hermes/mobile/core/model/Connection.kt b/app/src/main/java/app/hermes/mobile/core/model/Connection.kt new file mode 100644 index 0000000..f73ed63 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/model/Connection.kt @@ -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() +) diff --git a/app/src/main/java/app/hermes/mobile/core/model/GatewayEvents.kt b/app/src/main/java/app/hermes/mobile/core/model/GatewayEvents.kt new file mode 100644 index 0000000..1e30703 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/model/GatewayEvents.kt @@ -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 = 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 { + 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 + ) + } + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/core/model/JsonRpcModels.kt b/app/src/main/java/app/hermes/mobile/core/model/JsonRpcModels.kt new file mode 100644 index 0000000..f98cf11 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/model/JsonRpcModels.kt @@ -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 +) diff --git a/app/src/main/java/app/hermes/mobile/core/model/ServerStatus.kt b/app/src/main/java/app/hermes/mobile/core/model/ServerStatus.kt new file mode 100644 index 0000000..750f46b --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/model/ServerStatus.kt @@ -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 = emptyList(), + @SerialName("auth_flows") + val authFlows: List = emptyList(), + val version: String? = null +) diff --git a/app/src/main/java/app/hermes/mobile/core/model/SessionModels.kt b/app/src/main/java/app/hermes/mobile/core/model/SessionModels.kt new file mode 100644 index 0000000..8aa43f9 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/model/SessionModels.kt @@ -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 = 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 = 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 +) diff --git a/app/src/main/java/app/hermes/mobile/core/network/HermesRestClient.kt b/app/src/main/java/app/hermes/mobile/core/network/HermesRestClient.kt new file mode 100644 index 0000000..3bef4e2 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/network/HermesRestClient.kt @@ -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 = + 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(body) + Result.success(status) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun exchangeNativeToken( + baseUrl: String, + code: String, + codeVerifier: String, + allowCleartext: Boolean = false + ): Result = 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(body) + Result.success(tokens) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun refreshNativeToken( + baseUrl: String, + refreshToken: String, + provider: String = "", + allowCleartext: Boolean = false + ): Result = 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(body) + Result.success(tokens) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun mintWsTicket( + baseUrl: String, + accessToken: String, + allowCleartext: Boolean = false + ): Result = 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(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) + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/core/network/JsonRpcGatewayClient.kt b/app/src/main/java/app/hermes/mobile/core/network/JsonRpcGatewayClient.kt new file mode 100644 index 0000000..3c26ae0 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/network/JsonRpcGatewayClient.kt @@ -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>() + private var gatewayReadyDeferred = CompletableDeferred() + + private var activeWebSocket: WebSocket? = null + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _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(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(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() + 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 { + 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() + 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 + } +} diff --git a/app/src/main/java/app/hermes/mobile/core/repository/ConnectionRepository.kt b/app/src/main/java/app/hermes/mobile/core/repository/ConnectionRepository.kt new file mode 100644 index 0000000..6c0cfcb --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/repository/ConnectionRepository.kt @@ -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 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> = context.dataStore.data.map { preferences -> + val raw = preferences[connectionsKey] ?: return@map emptyList() + try { + json.decodeFromString>(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>(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>(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 } + } +} diff --git a/app/src/main/java/app/hermes/mobile/core/repository/HermesGatewayRepository.kt b/app/src/main/java/app/hermes/mobile/core/repository/HermesGatewayRepository.kt new file mode 100644 index 0000000..d29f6ac --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/repository/HermesGatewayRepository.kt @@ -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(null) + val activeConnection: StateFlow = _activeConnection.asStateFlow() + + private val _serverStatus = MutableStateFlow(null) + val serverStatus: StateFlow = _serverStatus.asStateFlow() + + val connectionState: StateFlow = gatewayClient.connectionState + + private val _activeDurableId = MutableStateFlow(null) + val activeDurableId: StateFlow = _activeDurableId.asStateFlow() + + private val _activeRuntimeId = MutableStateFlow(null) + val activeRuntimeId: StateFlow = _activeRuntimeId.asStateFlow() + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + private val _activeApprovals = MutableStateFlow>(emptyList()) + val activeApprovals: StateFlow> = _activeApprovals.asStateFlow() + + private val _activeClarify = MutableStateFlow(null) + val activeClarify: StateFlow = _activeClarify.asStateFlow() + + private val _sessionInfo = MutableStateFlow(null) + val sessionInfo: StateFlow = _sessionInfo.asStateFlow() + + private val _isExecuting = MutableStateFlow(false) + val isExecuting: StateFlow = _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 { + val result = restClient.getStatus(connection.baseUrl, connection.allowCleartext) + if (result.isSuccess) { + _serverStatus.value = result.getOrNull() + } + return result + } + + suspend fun connect(connection: HermesConnection): Result { + autoReconnectEnabled = true + _activeConnection.value = connection + return connectInternal(connection) + } + + private suspend fun connectInternal(connection: HermesConnection): Result { + 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 { + 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 + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/core/security/TokenVault.kt b/app/src/main/java/app/hermes/mobile/core/security/TokenVault.kt new file mode 100644 index 0000000..0ce2304 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/core/security/TokenVault.kt @@ -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 +} + +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(raw) + } catch (e: Exception) { + null + } + } + + override fun clearTokens(connectionId: String) { + prefs.edit().remove("conn_$connectionId").apply() + } + + override fun getAllConnectionIds(): Set { + return prefs.all.keys + .filter { it.startsWith("conn_") } + .map { it.removePrefix("conn_") } + .toSet() + } +} + +class InMemoryTokenVault : TokenVault { + private val storage = ConcurrentHashMap() + + 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 { + return storage.keys.toSet() + } +} diff --git a/app/src/main/java/app/hermes/mobile/feature/chat/ApprovalCard.kt b/app/src/main/java/app/hermes/mobile/feature/chat/ApprovalCard.kt new file mode 100644 index 0000000..14f751a --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/chat/ApprovalCard.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/feature/chat/ChatScreen.kt b/app/src/main/java/app/hermes/mobile/feature/chat/ChatScreen.kt new file mode 100644 index 0000000..1b48c1a --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/chat/ChatScreen.kt @@ -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) + ) + } + } + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/feature/chat/ChatViewModel.kt b/app/src/main/java/app/hermes/mobile/feature/chat/ChatViewModel.kt new file mode 100644 index 0000000..15b5380 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/chat/ChatViewModel.kt @@ -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> = gatewayRepo.messages + val activeApprovals: StateFlow> = gatewayRepo.activeApprovals + val activeClarify: StateFlow = gatewayRepo.activeClarify + val sessionInfo: StateFlow = gatewayRepo.sessionInfo + val isExecuting: StateFlow = gatewayRepo.isExecuting + val connectionState: StateFlow = gatewayRepo.connectionState + val activeConnection = gatewayRepo.activeConnection + val activeDurableId: StateFlow = gatewayRepo.activeDurableId + + private val _uiState = MutableStateFlow(ChatUiState()) + val uiState: StateFlow = _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 + } +} diff --git a/app/src/main/java/app/hermes/mobile/feature/chat/ClarifyDialog.kt b/app/src/main/java/app/hermes/mobile/feature/chat/ClarifyDialog.kt new file mode 100644 index 0000000..b6a1410 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/chat/ClarifyDialog.kt @@ -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") + } + } + ) +} diff --git a/app/src/main/java/app/hermes/mobile/feature/connections/ConnectionsScreen.kt b/app/src/main/java/app/hermes/mobile/feature/connections/ConnectionsScreen.kt new file mode 100644 index 0000000..86cf76e --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/connections/ConnectionsScreen.kt @@ -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") + } + } + ) +} diff --git a/app/src/main/java/app/hermes/mobile/feature/connections/ConnectionsViewModel.kt b/app/src/main/java/app/hermes/mobile/feature/connections/ConnectionsViewModel.kt new file mode 100644 index 0000000..4565eac --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/connections/ConnectionsViewModel.kt @@ -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> = connectionRepo.connections + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val activeConnection = gatewayRepo.activeConnection + val connectionState = gatewayRepo.connectionState + + private val _uiState = MutableStateFlow(ConnectionUiState()) + val uiState: StateFlow = _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() + } +} diff --git a/app/src/main/java/app/hermes/mobile/feature/sessions/SessionsScreen.kt b/app/src/main/java/app/hermes/mobile/feature/sessions/SessionsScreen.kt new file mode 100644 index 0000000..29bccc2 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/sessions/SessionsScreen.kt @@ -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)) +} diff --git a/app/src/main/java/app/hermes/mobile/feature/sessions/SessionsViewModel.kt b/app/src/main/java/app/hermes/mobile/feature/sessions/SessionsViewModel.kt new file mode 100644 index 0000000..ccc97f9 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/sessions/SessionsViewModel.kt @@ -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 = emptyList(), + val error: String? = null +) + +class SessionsViewModel( + private val gatewayRepo: HermesGatewayRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(SessionsUiState()) + val uiState: StateFlow = _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" + ) + } + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/feature/settings/SettingsScreen.kt b/app/src/main/java/app/hermes/mobile/feature/settings/SettingsScreen.kt new file mode 100644 index 0000000..f95bd45 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/feature/settings/SettingsScreen.kt @@ -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 + ) + } + } + } + } +} diff --git a/app/src/main/java/app/hermes/mobile/ui/theme/Color.kt b/app/src/main/java/app/hermes/mobile/ui/theme/Color.kt new file mode 100644 index 0000000..be0f1ff --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/ui/theme/Color.kt @@ -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) diff --git a/app/src/main/java/app/hermes/mobile/ui/theme/Theme.kt b/app/src/main/java/app/hermes/mobile/ui/theme/Theme.kt new file mode 100644 index 0000000..e42f796 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/java/app/hermes/mobile/ui/theme/Type.kt b/app/src/main/java/app/hermes/mobile/ui/theme/Type.kt new file mode 100644 index 0000000..ff490c1 --- /dev/null +++ b/app/src/main/java/app/hermes/mobile/ui/theme/Type.kt @@ -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 + ) +) diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..fa331d7 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + #6750A4 + #FFFFFF + #EADDFF + #21005D + #FEF7FF + #1D1B20 + #FEF7FF + #1D1B20 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d93eeb8 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + Hermes + Connections + Sessions + Chat + Settings + New Chat + Add Connection + Test Connection + Sign In + Disconnect + Connected + Connecting… + Disconnected + Reconnecting… + Failed + Authenticated + Auth Required + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..6e871d0 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/test/java/app/hermes/mobile/core/auth/PkceChallengeTest.kt b/app/src/test/java/app/hermes/mobile/core/auth/PkceChallengeTest.kt new file mode 100644 index 0000000..0082100 --- /dev/null +++ b/app/src/test/java/app/hermes/mobile/core/auth/PkceChallengeTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/app/hermes/mobile/core/network/EndToEndContractScenarioTest.kt b/app/src/test/java/app/hermes/mobile/core/network/EndToEndContractScenarioTest.kt new file mode 100644 index 0000000..0f3ae74 --- /dev/null +++ b/app/src/test/java/app/hermes/mobile/core/network/EndToEndContractScenarioTest.kt @@ -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) + } +} diff --git a/app/src/test/java/app/hermes/mobile/core/network/JsonRpcGatewayClientTest.kt b/app/src/test/java/app/hermes/mobile/core/network/JsonRpcGatewayClientTest.kt new file mode 100644 index 0000000..bb2653e --- /dev/null +++ b/app/src/test/java/app/hermes/mobile/core/network/JsonRpcGatewayClientTest.kt @@ -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() + + 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() + } +} diff --git a/app/src/test/java/app/hermes/mobile/core/network/JsonRpcWireFormatTest.kt b/app/src/test/java/app/hermes/mobile/core/network/JsonRpcWireFormatTest.kt new file mode 100644 index 0000000..f8e9e7f --- /dev/null +++ b/app/src/test/java/app/hermes/mobile/core/network/JsonRpcWireFormatTest.kt @@ -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(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(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(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("""{"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("""{"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("""{"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("""{"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("""{"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("""{"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("""{"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("""{"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("""{"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("""{"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("""{"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("""{"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("""{"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("""{"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(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(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(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(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(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(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(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) + } +} diff --git a/app/src/test/java/app/hermes/mobile/feature/chat/ChatViewModelTest.kt b/app/src/test/java/app/hermes/mobile/feature/chat/ChatViewModelTest.kt new file mode 100644 index 0000000..b47e8ca --- /dev/null +++ b/app/src/test/java/app/hermes/mobile/feature/chat/ChatViewModelTest.kt @@ -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 + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..6eeba4e --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2a5aa01 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..9bbc975c742b298b441bfb90dbc124400a3751b9 GIT binary patch literal 43705 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 DHx0+F literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..dcb1e6e --- /dev/null +++ b/settings.gradle.kts @@ -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")