feat: add local-first gateway routing

This commit is contained in:
Hermes Agent
2026-07-24 11:26:52 +00:00
parent 25a83e0b6f
commit e5cc27ebc7
12 changed files with 720 additions and 402 deletions
+11
View File
@@ -5,6 +5,9 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
## Unreleased ## Unreleased
### Added ### Added
- Added one securely stored Hermes gateway profile with optional Local HTTPS and required Remote HTTPS routes, bearer/no-auth configuration, and visible active-route diagnostics.
- Added bounded, redirect-disabled `GET /health` probes plus authenticated `GET /v1/models` compatibility checks and deterministic route, retry, connection-state, and HTTP-header tests.
- Added foreground, explicit reconnect, connectivity-change, and failed-request re-evaluation hooks backed by immutable per-request route leases.
- Added `docs/DIRECT_GATEWAY_ARCHITECTURE.md` to define the direct upstream Hermes gateway architecture, documented endpoints, unverified assumptions, and legacy companion compatibility policy. - Added `docs/DIRECT_GATEWAY_ARCHITECTURE.md` to define the direct upstream Hermes gateway architecture, documented endpoints, unverified assumptions, and legacy companion compatibility policy.
- Added transport DTOs and domain repository contracts for server profiles, authentication, sessions, messages, stream events, tool results, and artifacts. - Added transport DTOs and domain repository contracts for server profiles, authentication, sessions, messages, stream events, tool results, and artifacts.
- Added DTO-to-domain/UI mappers, stream reducer behavior, and a fake Hermes repository with unit coverage for mappings, stream reduction, and repository fixtures. - Added DTO-to-domain/UI mappers, stream reducer behavior, and a fake Hermes repository with unit coverage for mappings, stream reduction, and repository fixtures.
@@ -17,6 +20,9 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
- Added `PROJECT_PLAN.md` as the project-controlled milestone plan. - Added `PROJECT_PLAN.md` as the project-controlled milestone plan.
### Changed ### Changed
- Changed Android gateway routing to prefer a compatible Local route and deterministically fall back to Remote without switching an in-flight lease.
- Changed the Android shell to stop calling undocumented legacy companion `/api/*` routes; unavailable workspace/chat utilities now remain gated until direct upstream contracts are verified.
- Changed Android networking to reject cleartext, credential-bearing, path-bearing, duplicate, or missing gateway URLs and disabled application cleartext traffic.
- Pivoted planning and Android connection/domain terminology toward direct gateway auth, sessions, streaming, and later legacy companion removal. - Pivoted planning and Android connection/domain terminology toward direct gateway auth, sessions, streaming, and later legacy companion removal.
- Changed Android transport/domain capability models and fake repository data to describe direct gateway support first, with legacy companion compatibility explicitly flagged. - Changed Android transport/domain capability models and fake repository data to describe direct gateway support first, with legacy companion compatibility explicitly flagged.
- Changed the fake chat seed path to reuse the fake Hermes repository preview state. - Changed the fake chat seed path to reuse the fake Hermes repository preview state.
@@ -27,9 +33,14 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
- Ignored `.gradle-user/` and `.dev/` alongside existing generated build artifacts. - Ignored `.gradle-user/` and `.dev/` alongside existing generated build artifacts.
### Fixed ### Fixed
- Fixed bearer handling so `/health` is checked without credentials and the token is sent only to the documented authenticated `/v1/models` compatibility route.
- Fixed legacy stored HTTP URLs so they cannot become active routes, and sanitized connection/request diagnostics so tokens and internal URLs are not emitted.
- Fixed companion/mobile lint issues from missing Node globals and an unused React settings value. - Fixed companion/mobile lint issues from missing Node globals and an unused React settings value.
### Verification ### Verification
- Direct Kotlin/JUnit execution of `ConnectionStateTest` and `GatewayHttpProbeTest` — passed, 12 tests.
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process :app:compileDebugUnitTestKotlin :app:assembleDebug` from `apps/mobile/android` — passed with temporary restored sandbox-only Gradle socket shims; 37 tasks, 6 executed.
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:testDebugUnitTest` — test sources compiled, but Gradle's forked test worker could not open its sandbox-prohibited TCP control socket; the same compiled test classes passed through direct JUnit execution.
- `git diff --check` — passed for direct gateway planning/contract milestone. - `git diff --check` — passed for direct gateway planning/contract milestone.
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:testDebugUnitTest :app:assembleDebug` from `apps/mobile/android` — passed for direct gateway planning/contract milestone. - `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:testDebugUnitTest :app:assembleDebug` from `apps/mobile/android` — passed for direct gateway planning/contract milestone.
- Parent preflight immediately before B1 completion — script PASS; Android `:app:assembleDebug` BUILD SUCCESSFUL, 35 tasks total and 6 executed. - Parent preflight immediately before B1 completion — script PASS; Android `:app:assembleDebug` BUILD SUCCESSFUL, 35 tasks total and 6 executed.
+6 -4
View File
@@ -26,10 +26,12 @@ Complete the Android/client contract pivot before implementing backend behavior.
## Beta path — upstream-connected MVP ## Beta path — upstream-connected MVP
- [ ] **B2 — Direct gateway connection and authentication UX** - [ ] **B2 — Direct gateway connection, authentication, and local-first routing UX**
- Server connection screen with validation, `GET /health`, bearer/no-auth configuration, optional `GET /v1/models` auth check, offline/unauthorized/retry states. - One logical Hermes gateway profile with optional local URL + required remote fallback URL.
- Store sensitive values using Android-appropriate secure storage; never log secrets. - Validate URLs, store bearer/no-auth configuration securely, probe `GET /health`, and use optional `GET /v1/models` auth check.
- Verification: connection-state tests and manual unhappy-path walkthrough. - Automatically prefer the local URL when reachable; immediately fail over to remote when it is not. Re-evaluate on foreground, explicit reconnect, connectivity change, and request failure; never switch a stream mid-flight.
- Show active Local/Remote route and clear offline/unauthorized/retry state without logging tokens or silently sending credentials to untrusted LAN hosts.
- Verification: deterministic route-selection/retry tests, connection-state tests, and manual unhappy-path walkthrough.
- [ ] **B3 — Direct gateway session contract and durable chat state** - [ ] **B3 — Direct gateway session contract and durable chat state**
- Fetch/list/search/select/create sessions through repository contracts after upstream session routes are verified. - Fetch/list/search/select/create sessions through repository contracts after upstream session routes are verified.
@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
@@ -2,6 +2,9 @@ package cloud.molberg.hermesmobile
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.os.Bundle import android.os.Bundle
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
@@ -85,6 +88,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@@ -103,26 +107,22 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import cloud.molberg.hermesmobile.connection.ConnectionAuthMode import cloud.molberg.hermesmobile.connection.ConnectionAuthMode
import cloud.molberg.hermesmobile.connection.ConnectionCheckResult
import cloud.molberg.hermesmobile.connection.ConnectionForm import cloud.molberg.hermesmobile.connection.ConnectionForm
import cloud.molberg.hermesmobile.connection.ConnectionReevaluationTrigger
import cloud.molberg.hermesmobile.connection.ConnectionStateReducer import cloud.molberg.hermesmobile.connection.ConnectionStateReducer
import cloud.molberg.hermesmobile.connection.ConnectionStatus import cloud.molberg.hermesmobile.connection.ConnectionStatus
import cloud.molberg.hermesmobile.connection.ConnectionUiState import cloud.molberg.hermesmobile.connection.ConnectionUiState
import cloud.molberg.hermesmobile.connection.ConnectionValidator import cloud.molberg.hermesmobile.connection.GatewayHttpProbe
import cloud.molberg.hermesmobile.connection.GatewayRouteCoordinator
import cloud.molberg.hermesmobile.connection.GatewayRouteSelector
import cloud.molberg.hermesmobile.connection.SecureConnectionStorage import cloud.molberg.hermesmobile.connection.SecureConnectionStorage
import cloud.molberg.hermesmobile.connection.StoredConnectionRepository import cloud.molberg.hermesmobile.connection.StoredConnectionRepository
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
enum class Screen(val label: String, val icon: ImageVector, val title: String) { enum class Screen(val label: String, val icon: ImageVector, val title: String) {
Inbox("Chat", Icons.Filled.ChatBubble, "Chat"), Inbox("Chat", Icons.Filled.ChatBubble, "Chat"),
@@ -173,21 +173,18 @@ data class HealthState(
class CompanionApi(context: Context) { class CompanionApi(context: Context) {
val connectionStorage = SecureConnectionStorage(context) val connectionStorage = SecureConnectionStorage(context)
private val connectionRepository = StoredConnectionRepository(connectionStorage) { checkConnection() } private val routeCoordinator = GatewayRouteCoordinator(GatewayRouteSelector(GatewayHttpProbe()))
private val connectionRepository = StoredConnectionRepository(connectionStorage, routeCoordinator)
private val prefs = context.getSharedPreferences("companion", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("companion", Context.MODE_PRIVATE)
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
init { init {
migrateLegacyConnectionPrefs() migrateLegacyConnectionPrefs()
} }
var baseUrl: String var baseUrl: String
get() = connectionStorage.serverUrl get() = connectionStorage.remoteUrl
set(value) { set(value) {
connectionStorage.serverUrl = value connectionStorage.remoteUrl = value
} }
var accessKey: String var accessKey: String
@@ -227,149 +224,48 @@ class CompanionApi(context: Context) {
fun saveConnection(form: ConnectionForm): Result<ConnectionUiState> = fun saveConnection(form: ConnectionForm): Result<ConnectionUiState> =
connectionRepository.save(form) connectionRepository.save(form)
suspend fun checkConnectionState(): ConnectionUiState = suspend fun checkConnectionState(
connectionRepository.check() trigger: ConnectionReevaluationTrigger = ConnectionReevaluationTrigger.ExplicitReconnect
): ConnectionUiState = connectionRepository.check(trigger)
private fun request(path: String, method: String = "GET", body: JSONObject? = null): JSONObject { suspend fun health(trigger: ConnectionReevaluationTrigger = ConnectionReevaluationTrigger.ExplicitReconnect): HealthState {
val normalizedUrl = ConnectionValidator.normalizeServerUrl(baseUrl) val state = checkConnectionState(trigger)
.getOrElse { throw IllegalStateException(it.message ?: "Enter a valid server URL in Settings.") } return HealthState(
val mediaType = "application/json; charset=utf-8".toMediaType() reachable = state.connected,
val builder = Request.Builder() status = state.noticeText,
.url(normalizedUrl + path) hermesCli = "Managed by upstream Hermes",
.header("Accept", "application/json") workspace = "Not exposed by documented gateway routes",
if (connectionStorage.authMode == ConnectionAuthMode.BearerToken && accessKey.isNotBlank()) { mode = state.activeRouteLabel
builder.header("Authorization", "Bearer $accessKey")
}
val request = when (method) {
"POST" -> builder.post((body ?: JSONObject()).toString().toRequestBody(mediaType)).build()
"PUT" -> builder.put((body ?: JSONObject()).toString().toRequestBody(mediaType)).build()
else -> builder.get().build()
}
http.newCall(request).execute().use { response ->
val text = cleanServerText(response.body?.string().orEmpty())
val json = runCatching { if (text.isBlank()) JSONObject() else JSONObject(text) }.getOrNull()
if (!response.isSuccessful) {
if (response.code == 401 || response.code == 403) {
throw UnauthorizedConnectionException("Authentication was rejected. Check the access key and retry.")
}
val message = json?.optString("reply")?.takeIf { it.isNotBlank() }
?: json?.optString("message")?.takeIf { it.isNotBlank() }
?: json?.optString("error")?.takeIf { it.isNotBlank() }
?: text.take(600).ifBlank { "Server returned HTTP ${response.code}." }
throw IllegalStateException(message)
}
return json ?: throw IllegalStateException("Server returned a non-JSON response: ${text.take(220).ifBlank { "empty body" }}")
}
}
private fun requestFirst(paths: List<String>): JSONObject {
var lastError: Throwable? = null
for (path in paths) {
val result = runCatching { request(path) }
result.onSuccess { return it }
val error = result.exceptionOrNull()
if (error is UnauthorizedConnectionException) throw error
lastError = error
}
throw lastError ?: IllegalStateException("Server is offline or unreachable.")
}
suspend fun health(): HealthState = withContext(Dispatchers.IO) {
val json = requestFirst(listOf("/health", "/api/health"))
val checks = json.optJSONObject("checks")?.optJSONObject("hermes")
val config = json.optJSONObject("config")
HealthState(
reachable = true,
status = json.optString("status", "Gateway is reachable.").ifBlank { "Gateway is reachable." },
hermesCli = if (checks?.optBoolean("cliAvailable") == true) "Found at ${checks.optString("cliPath")}" else "Hermes CLI was not found on PATH.",
workspace = config?.optString("workspaceRoot") ?: "Unknown",
uptime = "${json.optDouble("uptimeSeconds", 0.0).toInt()}s",
mode = json.optString("mode", "-")
) )
} }
suspend fun validate(): String = withContext(Dispatchers.IO) { suspend fun validate(): String = checkConnectionState().message
requestFirst(listOf("/v1/models", "/api/auth/validate")).optString("message", "Connection works.")
}
suspend fun checkConnection(): ConnectionCheckResult = withContext(Dispatchers.IO) { suspend fun checkConnection(
runCatching { trigger: ConnectionReevaluationTrigger = ConnectionReevaluationTrigger.ExplicitReconnect
val health = health() ): ConnectionUiState = checkConnectionState(trigger)
val authMessage = if (connectionStorage.authMode == ConnectionAuthMode.BearerToken) {
validate()
} else {
""
}
ConnectionCheckResult.Healthy(authMessage.ifBlank { health.status })
}.getOrElse { error ->
if (error is UnauthorizedConnectionException) {
ConnectionCheckResult.Unauthorized(error.message ?: "Authentication was rejected.")
} else {
ConnectionCheckResult.Offline(error.message ?: "Server is offline or unreachable.")
}
}
}
suspend fun chat(prompt: String): ChatResult = withContext(Dispatchers.IO) { suspend fun chat(prompt: String): ChatResult =
val json = request("/api/chat", "POST", JSONObject().put("prompt", prompt)) throw UnsupportedOperationException("Direct gateway chat integration is scheduled after the session contract is verified.")
ChatResult(
reply = json.optString("reply", ""),
hermesAvailable = json.optBoolean("hermesAvailable", false),
exitCode = if (json.isNull("exitCode")) null else json.optInt("exitCode"),
stderr = json.optString("stderr", "")
)
}
suspend fun testHermes(): String { suspend fun testHermes(): String = validate()
val result = chat("ping")
return if (result.hermesAvailable) {
"Hermes responded${result.exitCode?.let { " with exit code $it" } ?: ""}."
} else {
result.reply.ifBlank { "Hermes is not available through this server." }
}
}
suspend fun listFiles(path: String): Pair<String, List<FileEntry>> = withContext(Dispatchers.IO) { suspend fun listFiles(path: String): Pair<String, List<FileEntry>> = legacyRouteUnavailable()
val encoded = URLEncoder.encode(path, "UTF-8")
val json = request("/api/files?path=$encoded")
val entries = json.optJSONArray("entries") ?: JSONArray()
json.optString("path", path) to List(entries.length()) { i ->
val item = entries.getJSONObject(i)
FileEntry(
name = item.optString("name"),
path = item.optString("path"),
type = item.optString("type"),
size = item.optLong("size", 0)
)
}
}
suspend fun readFile(path: String): Pair<String, String> = withContext(Dispatchers.IO) { suspend fun readFile(path: String): Pair<String, String> = legacyRouteUnavailable()
val encoded = URLEncoder.encode(path, "UTF-8")
val json = request("/api/files/read?path=$encoded")
json.optString("path", path) to json.optString("content", "")
}
suspend fun writeFile(path: String, content: String): String = withContext(Dispatchers.IO) { suspend fun writeFile(path: String, content: String): String = legacyRouteUnavailable()
val json = request("/api/files/write", "POST", JSONObject().put("path", path).put("content", content))
"Saved ${json.optLong("bytesWritten")} bytes"
}
suspend fun runCommand(command: String, cwd: String): String = withContext(Dispatchers.IO) { suspend fun runCommand(command: String, cwd: String): String = legacyRouteUnavailable()
val json = request("/api/terminal/run", "POST", JSONObject().put("command", command).put("cwd", cwd))
listOf( private fun <T> legacyRouteUnavailable(): T =
"$ ${json.optString("command", command)}", throw UnsupportedOperationException("This legacy companion route is not part of the direct upstream gateway contract.")
json.optString("stdout"),
json.optString("stderr").takeIf { it.isNotBlank() }?.let { "stderr:\n$it" }.orEmpty(),
"exitCode=${json.opt("exitCode")} timedOut=${json.optBoolean("timedOut")}"
).filter { it.isNotBlank() }.joinToString("\n")
}
private fun migrateLegacyConnectionPrefs() { private fun migrateLegacyConnectionPrefs() {
val legacyUrl = prefs.getString("baseUrl", null).orEmpty() val legacyUrl = prefs.getString("baseUrl", null).orEmpty()
val legacyAccessKey = prefs.getString("accessKey", null).orEmpty() val legacyAccessKey = prefs.getString("accessKey", null).orEmpty()
if (connectionStorage.serverUrl.isBlank() && legacyUrl.isNotBlank()) { if (connectionStorage.remoteUrl.isBlank() && legacyUrl.isNotBlank()) {
connectionStorage.serverUrl = legacyUrl connectionStorage.remoteUrl = legacyUrl
} }
if (connectionStorage.bearerToken.isBlank() && legacyAccessKey.isNotBlank()) { if (connectionStorage.bearerToken.isBlank() && legacyAccessKey.isNotBlank()) {
connectionStorage.bearerToken = legacyAccessKey connectionStorage.bearerToken = legacyAccessKey
@@ -380,8 +276,6 @@ class CompanionApi(context: Context) {
} }
} }
class UnauthorizedConnectionException(message: String) : IllegalStateException(message)
fun cleanServerText(text: String): String = fun cleanServerText(text: String): String =
text.replace(Regex("\\u001B\\[[;?0-9]*[ -/]*[@-~]"), "") text.replace(Regex("\\u001B\\[[;?0-9]*[ -/]*[@-~]"), "")
.replace("\u0000", "") .replace("\u0000", "")
@@ -402,23 +296,24 @@ fun HermesNativeApp() {
val snackbar = remember { SnackbarHostState() } val snackbar = remember { SnackbarHostState() }
val scope = remember { CoroutineScope(Dispatchers.Main) } val scope = remember { CoroutineScope(Dispatchers.Main) }
var screen by remember { mutableStateOf(Screen.Inbox) } var screen by remember { mutableStateOf(Screen.Inbox) }
var online by remember { mutableStateOf(false) } var connectionState by remember { mutableStateOf(ConnectionStateReducer.initial(api.connectionConfig())) }
var resetInboxToken by remember { mutableStateOf(0) } var resetInboxToken by remember { mutableStateOf(0) }
fun notify(message: String) { fun notify(message: String) {
scope.launch { snackbar.showSnackbar(message) } scope.launch { snackbar.showSnackbar(message) }
} }
LaunchedEffect(screen) { LaunchedEffect(Unit) {
api.checkConnection().let { online = it is ConnectionCheckResult.Healthy } connectionState = api.checkConnection(ConnectionReevaluationTrigger.Foreground)
} }
GatewayReevaluationHooks(api) { connectionState = it }
HermesMobileTheme { HermesMobileTheme {
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
containerColor = HermesTheme.colors.background, containerColor = HermesTheme.colors.background,
topBar = { topBar = {
NativeTopBar(screen, online, onAction = { NativeTopBar(screen, connectionState.connected, onAction = {
if (screen == Screen.Inbox) { if (screen == Screen.Inbox) {
resetInboxToken++ resetInboxToken++
notify("New chat started.") notify("New chat started.")
@@ -451,13 +346,44 @@ fun HermesNativeApp() {
screen = Screen.Inbox screen = Screen.Inbox
notify("New chat started.") notify("New chat started.")
}) })
Screen.Settings -> SettingsScreen(api, ::notify) Screen.Settings -> SettingsScreen(api, connectionState, { connectionState = it }, ::notify)
} }
} }
} }
} }
} }
@Composable
private fun GatewayReevaluationHooks(api: CompanionApi, onState: (ConnectionUiState) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val scope = remember { CoroutineScope(Dispatchers.Main) }
DisposableEffect(api, lifecycleOwner) {
fun reevaluate(trigger: ConnectionReevaluationTrigger) {
scope.launch { onState(api.checkConnection(trigger)) }
}
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) reevaluate(ConnectionReevaluationTrigger.Foreground)
}
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
reevaluate(ConnectionReevaluationTrigger.ConnectivityChange)
}
override fun onLost(network: Network) {
reevaluate(ConnectionReevaluationTrigger.ConnectivityChange)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
connectivityManager.registerNetworkCallback(NetworkRequest.Builder().build(), callback)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
runCatching { connectivityManager.unregisterNetworkCallback(callback) }
}
}
}
fun slideFor(from: Int, to: Int): ContentTransform { fun slideFor(from: Int, to: Int): ContentTransform {
val direction = if (to > from) 1 else -1 val direction = if (to > from) 1 else -1
return slideInHorizontally(animationSpec = tween(260)) { width -> direction * width } togetherWith return slideInHorizontally(animationSpec = tween(260)) { width -> direction * width } togetherWith
@@ -867,12 +793,17 @@ fun FilesScreen(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit)
} }
@Composable @Composable
fun SettingsScreen(api: CompanionApi, notify: (String) -> Unit) { fun SettingsScreen(
api: CompanionApi,
connectionState: ConnectionUiState,
onConnectionState: (ConnectionUiState) -> Unit,
notify: (String) -> Unit
) {
var panel by remember { mutableStateOf(SettingsPanel.Home) } var panel by remember { mutableStateOf(SettingsPanel.Home) }
BackHandler(panel != SettingsPanel.Home) { panel = SettingsPanel.Home } BackHandler(panel != SettingsPanel.Home) { panel = SettingsPanel.Home }
when (panel) { when (panel) {
SettingsPanel.Home -> SettingsHome(api, notify, onPanel = { panel = it }) SettingsPanel.Home -> SettingsHome(api, connectionState, onConnectionState, notify, onPanel = { panel = it })
SettingsPanel.Appearance -> AppearanceSettings(api, notify, onBack = { panel = SettingsPanel.Home }) SettingsPanel.Appearance -> AppearanceSettings(api, notify, onBack = { panel = SettingsPanel.Home })
SettingsPanel.Defaults -> AgentDefaultSettings(api, notify, onBack = { panel = SettingsPanel.Home }) SettingsPanel.Defaults -> AgentDefaultSettings(api, notify, onBack = { panel = SettingsPanel.Home })
SettingsPanel.Health -> HealthSettings(api, notify, onBack = { panel = SettingsPanel.Home }) SettingsPanel.Health -> HealthSettings(api, notify, onBack = { panel = SettingsPanel.Home })
@@ -881,29 +812,34 @@ fun SettingsScreen(api: CompanionApi, notify: (String) -> Unit) {
} }
@Composable @Composable
fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (SettingsPanel) -> Unit) { fun SettingsHome(
api: CompanionApi,
connectionState: ConnectionUiState,
onConnectionState: (ConnectionUiState) -> Unit,
notify: (String) -> Unit,
onPanel: (SettingsPanel) -> Unit
) {
val scope = remember { CoroutineScope(Dispatchers.Main) } val scope = remember { CoroutineScope(Dispatchers.Main) }
val initialConfig = remember { api.connectionConfig() } val initialConfig = remember { api.connectionConfig() }
var baseUrl by remember { mutableStateOf(initialConfig.serverUrl) } var localUrl by remember { mutableStateOf(initialConfig.localUrl) }
var remoteUrl by remember { mutableStateOf(initialConfig.remoteUrl) }
var authMode by remember { mutableStateOf(initialConfig.authMode) } var authMode by remember { mutableStateOf(initialConfig.authMode) }
var accessKey by remember { mutableStateOf("") } var accessKey by remember { mutableStateOf("") }
var sessionLabel by remember { mutableStateOf(initialConfig.sessionLabel) } var sessionLabel by remember { mutableStateOf(initialConfig.sessionLabel) }
var connectionState by remember {
mutableStateOf(ConnectionStateReducer.initial(initialConfig))
}
fun saveAndCheck() { fun saveAndCheck() {
val form = ConnectionForm(baseUrl, authMode, accessKey, sessionLabel) val form = ConnectionForm(localUrl, remoteUrl, authMode, accessKey, sessionLabel)
val saved = api.saveConnection(form).getOrElse { val saved = api.saveConnection(form).getOrElse {
connectionState = ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.") val invalid = ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
notify(connectionState.message) onConnectionState(invalid)
notify(invalid.message)
return return
} }
connectionState = saved onConnectionState(saved)
accessKey = "" accessKey = ""
scope.launch { scope.launch {
val checked = api.checkConnectionState() val checked = api.checkConnectionState()
connectionState = checked onConnectionState(checked)
notify(checked.message) notify(checked.message)
} }
} }
@@ -912,8 +848,10 @@ fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (Settings
SectionHeader("Gateway", "Connect this device to an upstream Hermes gateway.") SectionHeader("Gateway", "Connect this device to an upstream Hermes gateway.")
FlatCard { FlatCard {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
FieldLabel("Hermes gateway URL") FieldLabel("Remote HTTPS URL")
AppTextField(baseUrl, { baseUrl = it }, "", keyboardType = KeyboardType.Uri) AppTextField(remoteUrl, { remoteUrl = it }, "https://hermes.example.com", keyboardType = KeyboardType.Uri)
FieldLabel("Local HTTPS URL (optional)")
AppTextField(localUrl, { localUrl = it }, "https://hermes.home.arpa", keyboardType = KeyboardType.Uri)
FieldLabel("Authentication") FieldLabel("Authentication")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
ModeChip("Bearer", authMode == ConnectionAuthMode.BearerToken, Modifier.weight(1f)) { ModeChip("Bearer", authMode == ConnectionAuthMode.BearerToken, Modifier.weight(1f)) {
@@ -936,15 +874,16 @@ fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (Settings
AppTextField(sessionLabel, { sessionLabel = it }, "Default session") AppTextField(sessionLabel, { sessionLabel = it }, "Default session")
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) { SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) {
val form = ConnectionForm(baseUrl, authMode, accessKey, sessionLabel) val form = ConnectionForm(localUrl, remoteUrl, authMode, accessKey, sessionLabel)
connectionState = api.saveConnection(form).getOrElse { var saved = api.saveConnection(form).getOrElse {
ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.") ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
} }
if (connectionState.status == ConnectionStatus.Checking) { if (saved.status == ConnectionStatus.Checking) {
connectionState = ConnectionStateReducer.savedForManualCheck(connectionState.config) saved = ConnectionStateReducer.savedForManualCheck(saved.config)
accessKey = "" accessKey = ""
} }
notify(connectionState.message) onConnectionState(saved)
notify(saved.message)
} }
PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) { PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) {
saveAndCheck() saveAndCheck()
@@ -953,6 +892,7 @@ fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (Settings
} }
} }
ConnectionNotice(connectionState) ConnectionNotice(connectionState)
MetricRow("Active route", connectionState.activeRouteLabel)
SectionHeader("Gateway", "Inspect server health and authentication.") SectionHeader("Gateway", "Inspect server health and authentication.")
ListRow(icon = Icons.Filled.Terminal, title = "Hermes", subtitle = "Health and compatibility status") { onPanel(SettingsPanel.Health) } ListRow(icon = Icons.Filled.Terminal, title = "Hermes", subtitle = "Health and compatibility status") { onPanel(SettingsPanel.Health) }
ListRow(icon = Icons.Outlined.Security, title = "Accounts", subtitle = "Provider credentials stay upstream, outside the app") { onPanel(SettingsPanel.Accounts) } ListRow(icon = Icons.Outlined.Security, title = "Accounts", subtitle = "Provider credentials stay upstream, outside the app") { onPanel(SettingsPanel.Accounts) }
@@ -968,7 +908,8 @@ fun ConnectionNotice(state: ConnectionUiState) {
state.noticeText, state.noticeText,
danger = state.status == ConnectionStatus.Offline || danger = state.status == ConnectionStatus.Offline ||
state.status == ConnectionStatus.Unauthorized || state.status == ConnectionStatus.Unauthorized ||
state.urlError != null state.localUrlError != null ||
state.remoteUrlError != null
) )
} }
@@ -1032,7 +973,7 @@ fun HealthSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Un
DetailList("Hermes", onBack) { DetailList("Hermes", onBack) {
NoticeCard(health.status, danger = !health.reachable) NoticeCard(health.status, danger = !health.reachable)
MetricRow("Gateway URL", api.baseUrl.ifBlank { "Not set" }) MetricRow("Active route", health.mode)
MetricRow("Hermes CLI", health.hermesCli) MetricRow("Hermes CLI", health.hermesCli)
MetricRow("Workspace", health.workspace) MetricRow("Workspace", health.workspace)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
@@ -2,20 +2,28 @@ package cloud.molberg.hermesmobile.connection
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
enum class ConnectionAuthMode { None, BearerToken } enum class ConnectionAuthMode { None, BearerToken }
enum class ConnectionRoute { Local, Remote }
enum class ConnectionStatus { Unconfigured, Offline, Checking, Connected, Unauthorized } enum class ConnectionStatus { Unconfigured, Offline, Checking, Connected, Unauthorized }
enum class ConnectionReevaluationTrigger { Foreground, ExplicitReconnect, ConnectivityChange, RequestFailure }
data class ConnectionConfig( data class ConnectionConfig(
val serverUrl: String = "", val localUrl: String = "",
val remoteUrl: String = "",
val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken, val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken,
val hasBearerToken: Boolean = false, val hasBearerToken: Boolean = false,
val sessionLabel: String = "Default session" val sessionLabel: String = "Default session"
) )
data class ConnectionForm( data class ConnectionForm(
val serverUrl: String = "", val localUrl: String = "",
val remoteUrl: String = "",
val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken, val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken,
val bearerToken: String = "", val bearerToken: String = "",
val sessionLabel: String = "Default session" val sessionLabel: String = "Default session"
@@ -24,17 +32,20 @@ data class ConnectionForm(
data class ConnectionUiState( data class ConnectionUiState(
val config: ConnectionConfig = ConnectionConfig(), val config: ConnectionConfig = ConnectionConfig(),
val status: ConnectionStatus = ConnectionStatus.Unconfigured, val status: ConnectionStatus = ConnectionStatus.Unconfigured,
val message: String = "Enter a server URL to connect.", val activeRoute: ConnectionRoute? = null,
val urlError: String? = null, val message: String = "Enter a remote gateway URL to connect.",
val localUrlError: String? = null,
val remoteUrlError: String? = null,
val canRetry: Boolean = false val canRetry: Boolean = false
) { ) {
val connected: Boolean get() = status == ConnectionStatus.Connected val connected: Boolean get() = status == ConnectionStatus.Connected
val activeRouteLabel: String get() = activeRoute?.name ?: "None"
val noticeText: String val noticeText: String
get() { get() {
val label = when (status) { val label = when (status) {
ConnectionStatus.Unconfigured -> "Not configured" ConnectionStatus.Unconfigured -> "Not configured"
ConnectionStatus.Checking -> "Checking" ConnectionStatus.Checking -> "Checking"
ConnectionStatus.Connected -> "Connected" ConnectionStatus.Connected -> "Connected · $activeRouteLabel"
ConnectionStatus.Offline -> "Offline" ConnectionStatus.Offline -> "Offline"
ConnectionStatus.Unauthorized -> "Unauthorized" ConnectionStatus.Unauthorized -> "Unauthorized"
} }
@@ -43,137 +54,234 @@ data class ConnectionUiState(
} }
} }
sealed interface ConnectionCheckResult { sealed interface GatewayProbeResult {
data class Healthy(val message: String) : ConnectionCheckResult data class Healthy(val message: String = "") : GatewayProbeResult
data class Offline(val message: String) : ConnectionCheckResult data class Offline(val message: String) : GatewayProbeResult
data class Unauthorized(val message: String) : ConnectionCheckResult data class Unauthorized(val message: String) : GatewayProbeResult
data class Incompatible(val message: String) : GatewayProbeResult
} }
fun interface GatewayProbe {
suspend fun probe(baseUrl: String, authMode: ConnectionAuthMode, bearerToken: String): GatewayProbeResult
}
sealed interface RouteSelectionResult {
data class Connected(
val route: ConnectionRoute,
val baseUrl: String,
val message: String
) : RouteSelectionResult
data class Offline(val message: String) : RouteSelectionResult
data class Unauthorized(val message: String) : RouteSelectionResult
}
data class GatewayRouteLease(
val route: ConnectionRoute,
val baseUrl: String,
val generation: Long
)
object ConnectionValidator { object ConnectionValidator {
fun normalizeServerUrl(input: String): Result<String> { fun normalizeGatewayUrl(input: String, required: Boolean): Result<String> {
val trimmed = input.trim().trimEnd('/') val trimmed = input.trim().trimEnd('/')
if (trimmed.isBlank()) return Result.failure(IllegalArgumentException("Server URL is required.")) if (trimmed.isBlank()) {
return if (required) {
Result.failure(IllegalArgumentException("Remote gateway URL is required."))
} else {
Result.success("")
}
}
val uri = try { val uri = try {
URI(trimmed) URI(trimmed)
} catch (_: URISyntaxException) { } catch (_: URISyntaxException) {
return Result.failure(IllegalArgumentException("Enter a valid http or https URL.")) return Result.failure(IllegalArgumentException("Enter a valid HTTPS URL."))
} }
if (uri.scheme !in listOf("http", "https") || uri.host.isNullOrBlank()) { if (uri.scheme != "https" || uri.host.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("Enter a valid http or https URL.")) return Result.failure(IllegalArgumentException("Gateway URLs must use HTTPS."))
} }
if (uri.rawUserInfo != null) { if (uri.rawUserInfo != null) {
return Result.failure(IllegalArgumentException("Do not include credentials in the server URL.")) return Result.failure(IllegalArgumentException("Do not include credentials in a gateway URL."))
}
if (uri.rawQuery != null || uri.rawFragment != null || uri.path.orEmpty() !in listOf("", "/")) {
return Result.failure(IllegalArgumentException("Gateway URLs cannot include a path, query, or fragment."))
} }
return Result.success(trimmed) return Result.success(trimmed)
} }
fun validateForm(form: ConnectionForm, hasStoredBearerToken: Boolean = false): Result<ConnectionConfig> = fun validateForm(form: ConnectionForm, hasStoredBearerToken: Boolean = false): Result<ConnectionConfig> {
normalizeServerUrl(form.serverUrl).map { normalized -> val local = normalizeGatewayUrl(form.localUrl, required = false).getOrElse { return Result.failure(it) }
val remote = normalizeGatewayUrl(form.remoteUrl, required = true).getOrElse { return Result.failure(it) }
if (local.isNotBlank() && local == remote) {
return Result.failure(IllegalArgumentException("Local and remote gateway URLs must be different."))
}
val hasBearerToken = form.bearerToken.isNotBlank() || hasStoredBearerToken val hasBearerToken = form.bearerToken.isNotBlank() || hasStoredBearerToken
if (form.authMode == ConnectionAuthMode.BearerToken && !hasBearerToken) {
return Result.failure(IllegalArgumentException("Bearer token is required for token authentication."))
}
return Result.success(
ConnectionConfig( ConnectionConfig(
serverUrl = normalized, localUrl = local,
remoteUrl = remote,
authMode = form.authMode, authMode = form.authMode,
hasBearerToken = form.authMode == ConnectionAuthMode.BearerToken && hasBearerToken, hasBearerToken = form.authMode == ConnectionAuthMode.BearerToken && hasBearerToken,
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" } sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
) )
}.fold(
onSuccess = { config ->
if (form.authMode == ConnectionAuthMode.BearerToken && !config.hasBearerToken) {
Result.failure(IllegalArgumentException("Bearer token is required for token authentication."))
} else {
Result.success(config)
}
},
onFailure = { Result.failure(it) }
) )
} }
}
class GatewayRouteSelector(private val probe: GatewayProbe) {
suspend fun select(config: ConnectionConfig, bearerToken: String): RouteSelectionResult {
val failures = mutableListOf<GatewayProbeResult>()
if (config.localUrl.isNotBlank()) {
when (val local = probe.probe(config.localUrl, config.authMode, bearerToken)) {
is GatewayProbeResult.Healthy -> return RouteSelectionResult.Connected(
ConnectionRoute.Local,
config.localUrl,
local.message.ifBlank { "Local gateway is reachable and compatible." }
)
else -> failures += local
}
}
when (val remote = probe.probe(config.remoteUrl, config.authMode, bearerToken)) {
is GatewayProbeResult.Healthy -> return RouteSelectionResult.Connected(
ConnectionRoute.Remote,
config.remoteUrl,
remote.message.ifBlank { "Remote gateway is reachable and compatible." }
)
else -> failures += remote
}
val unauthorized = failures.filterIsInstance<GatewayProbeResult.Unauthorized>().lastOrNull()
return if (unauthorized != null) {
RouteSelectionResult.Unauthorized(unauthorized.message.ifBlank { "Authentication was rejected." })
} else {
RouteSelectionResult.Offline("Neither configured gateway route is reachable and compatible.")
}
}
}
class GatewayRouteCoordinator(private val selector: GatewayRouteSelector) {
private val mutex = Mutex()
@Volatile
private var activeLease: GatewayRouteLease? = null
private var generation = 0L
suspend fun reevaluate(config: ConnectionConfig, bearerToken: String): RouteSelectionResult = mutex.withLock {
val result = selector.select(config, bearerToken)
activeLease = if (result is RouteSelectionResult.Connected) {
GatewayRouteLease(result.route, result.baseUrl, ++generation)
} else {
null
}
result
}
suspend fun routeForRequest(config: ConnectionConfig, bearerToken: String): GatewayRouteLease {
activeLease?.takeIf { lease ->
lease.baseUrl == config.localUrl || lease.baseUrl == config.remoteUrl
}?.let { return it }
return when (val selected = reevaluate(config, bearerToken)) {
is RouteSelectionResult.Connected -> activeLease!!
is RouteSelectionResult.Unauthorized -> throw UnauthorizedConnectionException(selected.message)
is RouteSelectionResult.Offline -> throw IllegalStateException(selected.message)
}
}
fun activeRoute(): ConnectionRoute? = activeLease?.route
fun reportFailure(lease: GatewayRouteLease) {
if (activeLease?.generation == lease.generation) activeLease = null
}
fun clear() {
activeLease = null
}
}
object ConnectionStateReducer { object ConnectionStateReducer {
fun initial(config: ConnectionConfig): ConnectionUiState = fun initial(config: ConnectionConfig): ConnectionUiState = ConnectionUiState(
ConnectionUiState(
config = config, config = config,
status = if (config.serverUrl.isBlank()) ConnectionStatus.Unconfigured else ConnectionStatus.Offline, status = if (config.remoteUrl.isBlank()) ConnectionStatus.Unconfigured else ConnectionStatus.Offline,
message = if (config.serverUrl.isBlank()) { message = if (config.remoteUrl.isBlank()) {
"Server URL is blank until you enter your Hermes gateway address." "Remote gateway URL is blank until you configure this Hermes profile."
} else { } else {
"Saved connection is ready to check." "Saved connection is ready to check."
}, },
canRetry = config.serverUrl.isNotBlank() canRetry = config.remoteUrl.isNotBlank()
) )
fun saved(config: ConnectionConfig): ConnectionUiState = fun saved(config: ConnectionConfig): ConnectionUiState = ConnectionUiState(
ConnectionUiState(
config = config, config = config,
status = ConnectionStatus.Checking, status = ConnectionStatus.Checking,
message = "Checking server health...", message = "Checking local then remote gateway routes..."
canRetry = false
) )
fun savedForManualCheck(config: ConnectionConfig): ConnectionUiState = fun savedForManualCheck(config: ConnectionConfig): ConnectionUiState = ConnectionUiState(
ConnectionUiState(
config = config, config = config,
status = ConnectionStatus.Offline, status = ConnectionStatus.Offline,
message = "Settings saved. Run Test to check the server.", message = "Settings saved. Run Test to select the active route.",
canRetry = true canRetry = true
) )
fun result(config: ConnectionConfig, result: ConnectionCheckResult): ConnectionUiState = fun result(config: ConnectionConfig, result: RouteSelectionResult): ConnectionUiState = when (result) {
when (result) { is RouteSelectionResult.Connected -> ConnectionUiState(
is ConnectionCheckResult.Healthy -> ConnectionUiState(
config = config, config = config,
status = ConnectionStatus.Connected, status = ConnectionStatus.Connected,
message = result.message.ifBlank { "Server is reachable." }, activeRoute = result.route,
message = result.message,
canRetry = false canRetry = false
) )
is RouteSelectionResult.Offline -> ConnectionUiState(
is ConnectionCheckResult.Offline -> ConnectionUiState(
config = config, config = config,
status = ConnectionStatus.Offline, status = ConnectionStatus.Offline,
message = result.message.ifBlank { "Server is offline or unreachable." }, message = result.message,
canRetry = true canRetry = true
) )
is RouteSelectionResult.Unauthorized -> ConnectionUiState(
is ConnectionCheckResult.Unauthorized -> ConnectionUiState(
config = config, config = config,
status = ConnectionStatus.Unauthorized, status = ConnectionStatus.Unauthorized,
message = result.message.ifBlank { "Authentication was rejected." }, message = result.message,
canRetry = true canRetry = true
) )
} }
fun invalid(form: ConnectionForm, message: String): ConnectionUiState = fun invalid(form: ConnectionForm, message: String): ConnectionUiState = ConnectionUiState(
ConnectionUiState(
config = ConnectionConfig( config = ConnectionConfig(
serverUrl = form.serverUrl.trim(), localUrl = form.localUrl.trim(),
remoteUrl = form.remoteUrl.trim(),
authMode = form.authMode, authMode = form.authMode,
hasBearerToken = form.bearerToken.isNotBlank(), hasBearerToken = form.bearerToken.isNotBlank(),
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" } sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
), ),
status = ConnectionStatus.Unconfigured, status = ConnectionStatus.Unconfigured,
message = message, message = message,
urlError = message, localUrlError = message.takeIf { form.localUrl.isNotBlank() },
remoteUrlError = message,
canRetry = false canRetry = false
) )
} }
interface ConnectionStore { interface ConnectionStore {
var serverUrl: String var localUrl: String
var remoteUrl: String
var sessionLabel: String var sessionLabel: String
var authMode: ConnectionAuthMode var authMode: ConnectionAuthMode
var bearerToken: String var bearerToken: String
fun config(): ConnectionConfig = fun config(): ConnectionConfig = ConnectionConfig(
ConnectionConfig( localUrl = ConnectionValidator.normalizeGatewayUrl(localUrl, required = false).getOrDefault(""),
serverUrl = serverUrl, remoteUrl = ConnectionValidator.normalizeGatewayUrl(remoteUrl, required = true).getOrDefault(""),
authMode = authMode, authMode = authMode,
hasBearerToken = bearerToken.isNotBlank(), hasBearerToken = bearerToken.isNotBlank(),
sessionLabel = sessionLabel sessionLabel = sessionLabel
) )
fun save(form: ConnectionForm, normalizedUrl: String) { fun save(form: ConnectionForm, config: ConnectionConfig) {
serverUrl = normalizedUrl localUrl = config.localUrl
remoteUrl = config.remoteUrl
authMode = form.authMode authMode = form.authMode
sessionLabel = form.sessionLabel sessionLabel = config.sessionLabel
bearerToken = if (form.authMode == ConnectionAuthMode.BearerToken) form.bearerToken else "" bearerToken = if (form.authMode == ConnectionAuthMode.BearerToken) form.bearerToken else ""
} }
} }
@@ -181,12 +289,14 @@ interface ConnectionStore {
interface ConnectionRepository { interface ConnectionRepository {
fun config(): ConnectionConfig fun config(): ConnectionConfig
fun save(form: ConnectionForm): Result<ConnectionUiState> fun save(form: ConnectionForm): Result<ConnectionUiState>
suspend fun check(): ConnectionUiState suspend fun check(trigger: ConnectionReevaluationTrigger): ConnectionUiState
suspend fun routeForRequest(): GatewayRouteLease
fun reportRequestFailure(lease: GatewayRouteLease)
} }
class StoredConnectionRepository( class StoredConnectionRepository(
private val store: ConnectionStore, private val store: ConnectionStore,
private val healthCheck: suspend () -> ConnectionCheckResult private val coordinator: GatewayRouteCoordinator
) : ConnectionRepository { ) : ConnectionRepository {
override fun config(): ConnectionConfig = store.config() override fun config(): ConnectionConfig = store.config()
@@ -200,14 +310,30 @@ class StoredConnectionRepository(
} else { } else {
form form
} }
return ConnectionValidator.validateForm(effectiveForm).mapCatching { config -> return ConnectionValidator.validateForm(effectiveForm, store.bearerToken.isNotBlank()).mapCatching { config ->
store.save(effectiveForm, config.serverUrl) store.save(effectiveForm, config)
coordinator.clear()
ConnectionStateReducer.saved(store.config()) ConnectionStateReducer.saved(store.config())
}.recoverCatching { }.recoverCatching {
throw IllegalArgumentException(it.message ?: "Connection settings are invalid.") throw IllegalArgumentException(it.message ?: "Connection settings are invalid.")
} }
} }
override suspend fun check(): ConnectionUiState = override suspend fun check(trigger: ConnectionReevaluationTrigger): ConnectionUiState =
ConnectionStateReducer.result(store.config(), healthCheck()) store.config().let { config ->
if (config.remoteUrl.isBlank()) {
ConnectionStateReducer.initial(config)
} else {
ConnectionStateReducer.result(config, coordinator.reevaluate(config, store.bearerToken))
} }
}
override suspend fun routeForRequest(): GatewayRouteLease =
coordinator.routeForRequest(store.config(), store.bearerToken)
override fun reportRequestFailure(lease: GatewayRouteLease) {
coordinator.reportFailure(lease)
}
}
class UnauthorizedConnectionException(message: String) : IllegalStateException(message)
@@ -0,0 +1,72 @@
package cloud.molberg.hermesmobile.connection
import java.io.IOException
import java.util.concurrent.TimeUnit
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
class GatewayHttpProbe(
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.callTimeout(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.build()
) : GatewayProbe {
override suspend fun probe(
baseUrl: String,
authMode: ConnectionAuthMode,
bearerToken: String
): GatewayProbeResult {
val health = getJson("$baseUrl/health")
if (health != HttpProbeResult.Success) return health.toGatewayResult("Health probe")
if (authMode == ConnectionAuthMode.None) {
return GatewayProbeResult.Healthy("Gateway health check passed.")
}
val compatibility = getJson("$baseUrl/v1/models", bearerToken)
return when (compatibility) {
HttpProbeResult.Success -> GatewayProbeResult.Healthy("Gateway health and bearer authentication checks passed.")
else -> compatibility.toGatewayResult("Authenticated compatibility check")
}
}
private fun getJson(url: String, bearerToken: String = ""): HttpProbeResult {
val builder = Request.Builder().url(url).get().header("Accept", "application/json")
if (bearerToken.isNotBlank()) builder.header("Authorization", "Bearer $bearerToken")
return try {
client.newCall(builder.build()).execute().use { response ->
val body = response.body?.string().orEmpty()
when {
response.code == 401 || response.code == 403 -> HttpProbeResult.Unauthorized
!response.isSuccessful -> HttpProbeResult.Unavailable
body.isBlank() || !isJson(body) -> HttpProbeResult.Incompatible
else -> HttpProbeResult.Success
}
}
} catch (_: IOException) {
HttpProbeResult.Unavailable
} catch (_: IllegalArgumentException) {
HttpProbeResult.Incompatible
}
}
private fun isJson(body: String): Boolean =
runCatching { JSONObject(body) }.isSuccess || runCatching { JSONArray(body) }.isSuccess
private fun HttpProbeResult.toGatewayResult(check: String): GatewayProbeResult = when (this) {
HttpProbeResult.Success -> GatewayProbeResult.Healthy()
HttpProbeResult.Unauthorized -> GatewayProbeResult.Unauthorized("$check was unauthorized.")
HttpProbeResult.Incompatible -> GatewayProbeResult.Incompatible("$check returned an incompatible response.")
HttpProbeResult.Unavailable -> GatewayProbeResult.Offline("$check could not reach the gateway.")
}
private enum class HttpProbeResult { Success, Unauthorized, Incompatible, Unavailable }
private companion object {
const val PROBE_TIMEOUT_SECONDS = 3L
}
}
@@ -15,10 +15,16 @@ class SecureConnectionStorage(context: Context) : ConnectionStore {
private val prefs: SharedPreferences = private val prefs: SharedPreferences =
context.getSharedPreferences("connection", Context.MODE_PRIVATE) context.getSharedPreferences("connection", Context.MODE_PRIVATE)
override var serverUrl: String override var localUrl: String
get() = prefs.getString(KEY_URL, "") ?: "" get() = prefs.getString(KEY_LOCAL_URL, "") ?: ""
set(value) { set(value) {
prefs.edit().putString(KEY_URL, value.trim().trimEnd('/')).apply() prefs.edit().putString(KEY_LOCAL_URL, value.trim().trimEnd('/')).apply()
}
override var remoteUrl: String
get() = prefs.getString(KEY_REMOTE_URL, prefs.getString(KEY_LEGACY_URL, "")) ?: ""
set(value) {
prefs.edit().putString(KEY_REMOTE_URL, value.trim().trimEnd('/')).remove(KEY_LEGACY_URL).apply()
} }
override var sessionLabel: String override var sessionLabel: String
@@ -84,7 +90,9 @@ class SecureConnectionStorage(context: Context) : ConnectionStore {
const val KEY_ALIAS = "hermes_mobile_connection" const val KEY_ALIAS = "hermes_mobile_connection"
const val TRANSFORMATION = "AES/GCM/NoPadding" const val TRANSFORMATION = "AES/GCM/NoPadding"
const val GCM_TAG_BITS = 128 const val GCM_TAG_BITS = 128
const val KEY_URL = "serverUrl" const val KEY_LOCAL_URL = "localUrl"
const val KEY_REMOTE_URL = "remoteUrl"
const val KEY_LEGACY_URL = "serverUrl"
const val KEY_SESSION = "sessionLabel" const val KEY_SESSION = "sessionLabel"
const val KEY_AUTH_MODE = "authMode" const val KEY_AUTH_MODE = "authMode"
const val KEY_BEARER_TOKEN = "bearerToken" const val KEY_BEARER_TOKEN = "bearerToken"
@@ -1,163 +1,209 @@
package cloud.molberg.hermesmobile.connection package cloud.molberg.hermesmobile.connection
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class ConnectionStateTest { class ConnectionStateTest {
@Test @Test
fun validatorNormalizesHttpUrlsAndRejectsUnsafeCredentials() { fun validatorRequiresHttpsRemoteAndRejectsEmbeddedCredentialsOrPaths() {
val normalized = ConnectionValidator.normalizeServerUrl(" https://hermes.example.test/ ").getOrThrow() val valid = ConnectionValidator.validateForm(
val embeddedCredentials = ConnectionValidator.normalizeServerUrl("https://user:secret@hermes.example.test")
assertEquals("https://hermes.example.test", normalized)
assertTrue(embeddedCredentials.isFailure)
}
@Test
fun validatorRequiresTokenForBearerAuthentication() {
val missingToken = ConnectionValidator.validateForm(
ConnectionForm(serverUrl = "http://10.0.2.2:8787", authMode = ConnectionAuthMode.BearerToken)
)
val blankToken = ConnectionValidator.validateForm(
ConnectionForm( ConnectionForm(
serverUrl = "http://10.0.2.2:8787", localUrl = " https://hermes.home.arpa/ ",
authMode = ConnectionAuthMode.BearerToken, remoteUrl = " https://hermes.example.test/ ",
bearerToken = " " authMode = ConnectionAuthMode.None
) )
)
val anonymous = ConnectionValidator.validateForm(
ConnectionForm(serverUrl = "http://10.0.2.2:8787", authMode = ConnectionAuthMode.None)
)
assertTrue(missingToken.isFailure)
assertTrue(blankToken.isFailure)
assertTrue(anonymous.isSuccess)
assertFalse(anonymous.getOrThrow().hasBearerToken)
}
@Test
fun validatorAcceptsStoredBearerTokenWithoutExposingItInConfig() {
val config = ConnectionValidator.validateForm(
ConnectionForm(
serverUrl = "http://10.0.2.2:8787",
authMode = ConnectionAuthMode.BearerToken,
bearerToken = ""
),
hasStoredBearerToken = true
).getOrThrow() ).getOrThrow()
assertTrue(config.hasBearerToken) assertEquals("https://hermes.home.arpa", valid.localUrl)
assertEquals("http://10.0.2.2:8787", config.serverUrl) assertEquals("https://hermes.example.test", valid.remoteUrl)
assertTrue(ConnectionValidator.validateForm(ConnectionForm(remoteUrl = "http://example.test", authMode = ConnectionAuthMode.None)).isFailure)
assertTrue(ConnectionValidator.validateForm(ConnectionForm(remoteUrl = "https://user:secret@example.test", authMode = ConnectionAuthMode.None)).isFailure)
assertTrue(ConnectionValidator.validateForm(ConnectionForm(remoteUrl = "https://example.test/base", authMode = ConnectionAuthMode.None)).isFailure)
} }
@Test
fun validatorRequiresRemoteUrlTokenAndDistinctRoutes() {
assertTrue(ConnectionValidator.validateForm(ConnectionForm(authMode = ConnectionAuthMode.None)).isFailure)
assertTrue(ConnectionValidator.validateForm(ConnectionForm(remoteUrl = REMOTE)).isFailure)
assertTrue(
ConnectionValidator.validateForm(
ConnectionForm(localUrl = REMOTE, remoteUrl = REMOTE, authMode = ConnectionAuthMode.None)
).isFailure
)
assertTrue(
ConnectionValidator.validateForm(ConnectionForm(remoteUrl = REMOTE), hasStoredBearerToken = true).isSuccess
)
}
@Test @Test
fun reducerSurfacesOfflineUnauthorizedRetryAndConnectedStates() { fun selectorPrefersCompatibleLocalWithoutProbingRemote() = runBlocking {
val config = ConnectionConfig( val probe = RecordingProbe(mapOf(LOCAL to GatewayProbeResult.Healthy("local ok")))
serverUrl = "http://10.0.2.2:8787", val result = GatewayRouteSelector(probe).select(config(), TOKEN)
authMode = ConnectionAuthMode.BearerToken,
hasBearerToken = true assertEquals(ConnectionRoute.Local, (result as RouteSelectionResult.Connected).route)
assertEquals(listOf(LOCAL), probe.urls)
}
@Test
fun selectorFallsBackToRemoteAfterLocalFailure() = runBlocking {
val probe = RecordingProbe(
mapOf(
LOCAL to GatewayProbeResult.Offline("local offline"),
REMOTE to GatewayProbeResult.Healthy("remote ok")
) )
)
val result = GatewayRouteSelector(probe).select(config(), TOKEN)
val offline = ConnectionStateReducer.result(config, ConnectionCheckResult.Offline("No route to host.")) assertEquals(ConnectionRoute.Remote, (result as RouteSelectionResult.Connected).route)
val unauthorized = ConnectionStateReducer.result(config, ConnectionCheckResult.Unauthorized("Rejected.")) assertEquals(listOf(LOCAL, REMOTE), probe.urls)
val connected = ConnectionStateReducer.result(config, ConnectionCheckResult.Healthy("Server is reachable.")) }
@Test
fun selectorSurfacesUnauthorizedAfterBothRoutesFail() = runBlocking {
val probe = RecordingProbe(
mapOf(
LOCAL to GatewayProbeResult.Unauthorized("local rejected"),
REMOTE to GatewayProbeResult.Offline("remote offline")
)
)
val result = GatewayRouteSelector(probe).select(config(), TOKEN)
assertTrue(result is RouteSelectionResult.Unauthorized)
assertEquals(listOf(LOCAL, REMOTE), probe.urls)
}
@Test
fun coordinatorKeepsInflightLeaseStableAndReevaluatesNextRequest() = runBlocking {
val probe = SequencedProbe(
mutableListOf(
mapOf(LOCAL to GatewayProbeResult.Healthy("local ok")),
mapOf(
LOCAL to GatewayProbeResult.Offline("local offline"),
REMOTE to GatewayProbeResult.Healthy("remote ok")
)
)
)
val coordinator = GatewayRouteCoordinator(GatewayRouteSelector(probe))
val first = coordinator.routeForRequest(config(), TOKEN)
coordinator.reportFailure(first)
val second = coordinator.routeForRequest(config(), TOKEN)
assertEquals(ConnectionRoute.Local, first.route)
assertEquals(LOCAL, first.baseUrl)
assertEquals(ConnectionRoute.Remote, second.route)
assertEquals(REMOTE, second.baseUrl)
assertNotEquals(first.generation, second.generation)
}
@Test
fun reducerReportsRouteOfflineUnauthorizedAndRetryState() {
val connected = ConnectionStateReducer.result(
config(),
RouteSelectionResult.Connected(ConnectionRoute.Local, LOCAL, "ready")
)
val offline = ConnectionStateReducer.result(config(), RouteSelectionResult.Offline("offline"))
val unauthorized = ConnectionStateReducer.result(config(), RouteSelectionResult.Unauthorized("rejected"))
assertEquals(ConnectionStatus.Connected, connected.status)
assertEquals("Local", connected.activeRouteLabel)
assertFalse(connected.canRetry)
assertEquals(ConnectionStatus.Offline, offline.status) assertEquals(ConnectionStatus.Offline, offline.status)
assertTrue(offline.canRetry) assertTrue(offline.canRetry)
assertEquals(ConnectionStatus.Unauthorized, unauthorized.status) assertEquals(ConnectionStatus.Unauthorized, unauthorized.status)
assertTrue(unauthorized.canRetry) assertTrue(unauthorized.canRetry)
assertEquals(ConnectionStatus.Connected, connected.status)
assertFalse(connected.canRetry)
} }
@Test @Test
fun invalidFormKeepsDraftValuesWithoutClaimingConnection() { fun repositoryStoresOneProfileAndPreservesOrClearsToken() {
val state = ConnectionStateReducer.invalid( val store = InMemoryConnectionStore().apply { bearerToken = TOKEN }
ConnectionForm(serverUrl = "ftp://example.test", bearerToken = "redacted-test-token"), val coordinator = GatewayRouteCoordinator(GatewayRouteSelector(RecordingProbe(emptyMap())))
"Enter a valid http or https URL." val repository = StoredConnectionRepository(store, coordinator)
)
assertEquals(ConnectionStatus.Unconfigured, state.status) repository.save(
assertEquals("ftp://example.test", state.config.serverUrl)
assertTrue(state.config.hasBearerToken)
assertEquals("Enter a valid http or https URL.", state.urlError)
}
@Test
fun repositoryPreservesExistingBearerTokenWhenSavingMetadataOnly() {
val store = InMemoryConnectionStore().apply {
serverUrl = "https://old.example.test"
authMode = ConnectionAuthMode.BearerToken
bearerToken = "stored-token"
}
val repository = StoredConnectionRepository(store) {
ConnectionCheckResult.Healthy("ok")
}
val saved = repository.save(
ConnectionForm( ConnectionForm(
serverUrl = " https://hermes.example.test/ ", localUrl = LOCAL,
remoteUrl = REMOTE,
authMode = ConnectionAuthMode.BearerToken, authMode = ConnectionAuthMode.BearerToken,
bearerToken = "",
sessionLabel = " Mobile " sessionLabel = " Mobile "
) )
).getOrThrow() ).getOrThrow()
assertEquals(ConnectionStatus.Checking, saved.status) assertEquals(LOCAL, store.localUrl)
assertEquals("https://hermes.example.test", store.serverUrl) assertEquals(REMOTE, store.remoteUrl)
assertEquals("stored-token", store.bearerToken) assertEquals(TOKEN, store.bearerToken)
assertEquals("Mobile", store.sessionLabel) assertEquals("Mobile", store.sessionLabel)
assertTrue(saved.config.hasBearerToken)
}
@Test repository.save(ConnectionForm(remoteUrl = REMOTE, authMode = ConnectionAuthMode.None)).getOrThrow()
fun repositoryClearsBearerTokenForAnonymousConnection() {
val store = InMemoryConnectionStore().apply {
bearerToken = "stored-token"
authMode = ConnectionAuthMode.BearerToken
}
val repository = StoredConnectionRepository(store) {
ConnectionCheckResult.Healthy("ok")
}
repository.save(
ConnectionForm(
serverUrl = "http://10.0.2.2:8787",
authMode = ConnectionAuthMode.None,
bearerToken = "ignored-token"
)
).getOrThrow()
assertEquals(ConnectionAuthMode.None, store.authMode)
assertEquals("", store.bearerToken) assertEquals("", store.bearerToken)
assertFalse(repository.config().hasBearerToken) assertFalse(repository.config().hasBearerToken)
} }
@Test @Test
fun repositoryCheckMapsHealthResultToUiState() { fun storedLegacyCleartextUrlIsNotReturnedAsAUsableRoute() {
val store = InMemoryConnectionStore().apply { val store = InMemoryConnectionStore().apply {
serverUrl = "http://10.0.2.2:8787" remoteUrl = "http://legacy.example.test"
authMode = ConnectionAuthMode.BearerToken authMode = ConnectionAuthMode.None
bearerToken = "stored-token"
}
val repository = StoredConnectionRepository(store) {
ConnectionCheckResult.Unauthorized("Authentication was rejected.")
} }
val state = kotlinx.coroutines.runBlocking { repository.check() } assertEquals("", store.config().remoteUrl)
assertEquals(ConnectionStatus.Unconfigured, ConnectionStateReducer.initial(store.config()).status)
}
assertEquals(ConnectionStatus.Unauthorized, state.status) private fun config() = ConnectionConfig(
assertTrue(state.canRetry) localUrl = LOCAL,
assertTrue(state.config.hasBearerToken) remoteUrl = REMOTE,
authMode = ConnectionAuthMode.BearerToken,
hasBearerToken = true
)
private class RecordingProbe(private val results: Map<String, GatewayProbeResult>) : GatewayProbe {
val urls = mutableListOf<String>()
override suspend fun probe(
baseUrl: String,
authMode: ConnectionAuthMode,
bearerToken: String
): GatewayProbeResult {
urls += baseUrl
assertEquals(ConnectionAuthMode.BearerToken, authMode)
assertEquals(TOKEN, bearerToken)
return results[baseUrl] ?: GatewayProbeResult.Offline("offline")
}
}
private class SequencedProbe(
private val attempts: MutableList<Map<String, GatewayProbeResult>>
) : GatewayProbe {
private var attemptIndex = 0
override suspend fun probe(
baseUrl: String,
authMode: ConnectionAuthMode,
bearerToken: String
): GatewayProbeResult {
val attempt = attempts[attemptIndex]
val result = attempt[baseUrl] ?: GatewayProbeResult.Offline("offline")
if (baseUrl == REMOTE || result is GatewayProbeResult.Healthy) attemptIndex++
return result
}
} }
private class InMemoryConnectionStore : ConnectionStore { private class InMemoryConnectionStore : ConnectionStore {
override var serverUrl: String = "" override var localUrl: String = ""
override var remoteUrl: String = ""
override var sessionLabel: String = "Default session" override var sessionLabel: String = "Default session"
override var authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken override var authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken
override var bearerToken: String = "" override var bearerToken: String = ""
} }
private companion object {
const val LOCAL = "https://hermes.home.arpa"
const val REMOTE = "https://hermes.example.test"
const val TOKEN = "test-token"
}
} }
@@ -0,0 +1,77 @@
package cloud.molberg.hermesmobile.connection
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class GatewayHttpProbeTest {
@Test
fun bearerProbeChecksHealthWithoutCredentialsBeforeModelsWithBearer() = runBlocking {
val requests = mutableListOf<Pair<String, String?>>()
val probe = GatewayHttpProbe(client { chain ->
requests += chain.request().url.encodedPath to chain.request().header("Authorization")
jsonResponse(chain, 200)
})
val result = probe.probe(BASE_URL, ConnectionAuthMode.BearerToken, TOKEN)
assertTrue(result is GatewayProbeResult.Healthy)
assertEquals(listOf("/health" to null, "/v1/models" to "Bearer $TOKEN"), requests)
assertFalse((result as GatewayProbeResult.Healthy).message.contains(TOKEN))
}
@Test
fun anonymousProbeStopsAfterHealth() = runBlocking {
val paths = mutableListOf<String>()
val probe = GatewayHttpProbe(client { chain ->
paths += chain.request().url.encodedPath
jsonResponse(chain, 200)
})
val result = probe.probe(BASE_URL, ConnectionAuthMode.None, "")
assertTrue(result is GatewayProbeResult.Healthy)
assertEquals(listOf("/health"), paths)
}
@Test
fun authenticatedCompatibilityRejectionIsUnauthorized() = runBlocking {
val probe = GatewayHttpProbe(client { chain ->
if (chain.request().url.encodedPath == "/health") jsonResponse(chain, 200) else jsonResponse(chain, 401)
})
val result = probe.probe(BASE_URL, ConnectionAuthMode.BearerToken, TOKEN)
assertTrue(result is GatewayProbeResult.Unauthorized)
assertFalse((result as GatewayProbeResult.Unauthorized).message.contains(BASE_URL))
assertFalse(result.message.contains(TOKEN))
}
private fun client(interceptor: (Interceptor.Chain) -> Response): OkHttpClient =
OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
.addInterceptor(Interceptor { chain -> interceptor(chain) })
.build()
private fun jsonResponse(chain: Interceptor.Chain, code: Int): Response = Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(code)
.message("test")
.body("{}".toResponseBody("application/json".toMediaType()))
.build()
private companion object {
const val BASE_URL = "https://hermes.example.test"
const val TOKEN = "secret-test-token"
}
}
+9
View File
@@ -0,0 +1,9 @@
# Hermes Mobile Blockers
## Stable gateway identity binding
The documented direct gateway routes currently available to Hermes Mobile (`GET /health` and authenticated `GET /v1/models`) do not expose a verified stable gateway instance identifier.
B2 therefore protects local-first routing by requiring explicit HTTPS URLs, disabling redirects and cleartext traffic, probing `/health` without credentials, and sending a bearer token only for the documented `/v1/models` compatibility check. This confirms TLS reachability and authenticated API compatibility, but it cannot cryptographically prove that separately configured Local and Remote URLs terminate at the same Hermes gateway identity.
Upstream compatibility needed for stronger binding: a documented stable gateway instance ID signed or authenticated consistently across both routes, or an upstream-supported certificate/public-key pin that the Android client can bind to the single logical profile. Until then, Local and Remote pairing remains an explicit user trust decision and diagnostics identify only the active route, not internal URLs.
+25 -2
View File
@@ -57,9 +57,32 @@ Android contracts may model these needs, but implementation must keep them capab
- The transport/domain model exposes direct gateway capabilities first. `legacyCompanionAdapter` exists only to identify old adapters during migration. - The transport/domain model exposes direct gateway capabilities first. `legacyCompanionAdapter` exists only to identify old adapters during migration.
- Backend behavior belongs upstream in Hermes Agent. Hermes Mobile may add client adapters and mappers, not server-side replacements. - Backend behavior belongs upstream in Hermes Agent. Hermes Mobile may add client adapters and mappers, not server-side replacements.
## Local-first gateway routing
A server profile can contain two URLs for the **same Hermes gateway identity**:
- **Local URL** — preferred when the phone can safely reach the gateway on the home/LAN (for example, `https://hermes.home.arpa`).
- **Remote URL** — fallback when local reachability is unavailable (for example, a configured public HTTPS hostname).
The client must choose automatically per connection attempt:
1. If a local URL is configured, probe its documented `GET /health` endpoint using a short, bounded timeout.
2. Use the local URL when the probe succeeds and the gateway identity/auth contract is compatible.
3. Otherwise immediately use the configured remote URL; never leave chat blocked waiting for LAN discovery.
4. Re-evaluate on app foreground, explicit reconnect, connectivity changes, and after a failed active request. Do not switch a request mid-stream.
5. Show the active route (`Local` or `Remote`) in connection diagnostics, but do not expose bearer tokens or internal host details in message content/logs.
Security and reliability rules:
- Local and remote URLs are explicit user configuration, not guessed IP ranges or silently discovered devices in the MVP.
- Both routes must use HTTPS by default and must be treated as the same intended gateway only after an authenticated compatibility check. The app must not silently send a bearer token to an arbitrary captive portal or unrelated LAN host.
- Remote is a fallback, not a duplicate profile. User preferences, selected session state, and credentials stay associated with one logical gateway profile.
- A later milestone may add opt-in mDNS/QR setup, certificate pinning/identity binding, and richer connectivity observation only after direct-gateway basics are verified.
- The current upstream identity-binding limitation and the stronger contract needed are tracked in `docs/BLOCKERS.md`.
## Android Contract Milestones ## Android Contract Milestones
1. Auth and connection: validate URL, store bearer tokens securely, probe `GET /health`, and use `GET /v1/models` as the first authenticated check. 1. Auth and connection: validate local + remote URLs, store bearer tokens securely, probe `GET /health`, use `GET /v1/models` as the first authenticated check, and make local-first fallback observable/retryable.
2. Sessions: keep repository interfaces for list/create/select/continue, but mark route mapping provisional until upstream session docs are verified. 2. Sessions: keep repository interfaces for list/create/select/continue, but mark route mapping provisional until upstream session docs are verified.
3. Streaming: support final response and token delta reduction now; gate structured tool/artifact/session events behind server capabilities. 3. Streaming: support final response and token delta reduction now; gate structured tool/artifact/session events behind server capabilities. Keep each stream bound to its selected local or remote route.
4. Companion removal: after direct gateway chat/session/streaming works end to end, remove legacy companion UI routes and then deprecate install/docs around `apps/companion`. 4. Companion removal: after direct gateway chat/session/streaming works end to end, remove legacy companion UI routes and then deprecate install/docs around `apps/companion`.
+5 -3
View File
@@ -6,7 +6,9 @@ Status: in progress. Android foundation work is complete, and the beta path now
Done: Done:
- Native Kotlin/Compose Android shell replaces the web-first mobile path for the beta surface. - Native Kotlin/Compose Android shell replaces the web-first mobile path for the beta surface.
- Android connection flow stores a gateway URL and bearer token locally, validates URL/auth state, and surfaces offline/unauthorized/retry states in-app. - Android connection flow stores one logical gateway profile with optional Local HTTPS, required Remote HTTPS, Android-keystore-protected bearer/no-auth configuration, and active Local/Remote diagnostics.
- Local-first route selection uses bounded `GET /health` probes, authenticated `GET /v1/models` compatibility checks, deterministic Remote fallback, and foreground/connectivity/request-failure re-evaluation without switching in-flight leases.
- Android no longer calls undocumented legacy companion `/api/*` routes from the direct-gateway client path.
- Chat UI supports conversation list/thread navigation, native composer, busy state, selectable replies, error bubbles, and new-chat reset. - Chat UI supports conversation list/thread navigation, native composer, busy state, selectable replies, error bubbles, and new-chat reset.
- Direct gateway compatibility contract is documented in `docs/DIRECT_GATEWAY_ARCHITECTURE.md`. - Direct gateway compatibility contract is documented in `docs/DIRECT_GATEWAY_ARCHITECTURE.md`.
- Legacy companion chat, terminal, and file utilities remain in the repo during migration but are not the target architecture. - Legacy companion chat, terminal, and file utilities remain in the repo during migration but are not the target architecture.
@@ -14,9 +16,9 @@ Done:
- Companion and workspace TypeScript typecheck, build, and lint pass as of 2026-07-24. - Companion and workspace TypeScript typecheck, build, and lint pass as of 2026-07-24.
Remaining for beta: Remaining for beta:
- Android debug build validation passed on 2026-07-24: `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android`. - Exercise the connection/settings unhappy paths on a real device or emulator; this sandbox cannot start ADB because local control sockets are prohibited.
- Exercise a real device or emulator against a running upstream Hermes gateway/API server. - Exercise a real device or emulator against a running upstream Hermes gateway/API server.
- Verify direct gateway behavior end to end: bearer auth, `GET /health`, `GET /v1/models`, chat request/response, session continuity expectations, streamed/final output handling, and failure recovery. - Verify direct gateway behavior end to end beyond B2: chat request/response, session continuity expectations, streamed/final output handling, and failure recovery.
- Verify or revise provisional session, streaming, tool-event, artifact, and cancellation route assumptions against official upstream docs. - Verify or revise provisional session, streaming, tool-event, artifact, and cancellation route assumptions against official upstream docs.
- Add or run focused Android UI validation for chat/settings flows on small and large screens. - Add or run focused Android UI validation for chat/settings flows on small and large screens.
- Confirm adaptive accessibility: font scaling, TalkBack labels, contrast, keyboard/IME behavior, and reduced-motion tolerance. - Confirm adaptive accessibility: font scaling, TalkBack labels, contrast, keyboard/IME behavior, and reduced-motion tolerance.