commit 4fbb95c6c47e366fcc169c6cb34831d97a675a62 Author: Ochenstarik Date: Sun Aug 23 22:46:58 2026 +0700 Initial commit: Hermes Android native remote client MVP (Contract v1) 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 0000000..9bbc975 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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")