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
@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:allowBackup="true"
android:usesCleartextTraffic="true"
android:usesCleartextTraffic="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -2,6 +2,9 @@ package cloud.molberg.hermesmobile
import android.app.Application
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
@@ -85,6 +88,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.sp
import cloud.molberg.hermesmobile.connection.ConnectionAuthMode
import cloud.molberg.hermesmobile.connection.ConnectionCheckResult
import cloud.molberg.hermesmobile.connection.ConnectionForm
import cloud.molberg.hermesmobile.connection.ConnectionReevaluationTrigger
import cloud.molberg.hermesmobile.connection.ConnectionStateReducer
import cloud.molberg.hermesmobile.connection.ConnectionStatus
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.StoredConnectionRepository
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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) {
Inbox("Chat", Icons.Filled.ChatBubble, "Chat"),
@@ -173,21 +173,18 @@ data class HealthState(
class CompanionApi(context: 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 http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
init {
migrateLegacyConnectionPrefs()
}
var baseUrl: String
get() = connectionStorage.serverUrl
get() = connectionStorage.remoteUrl
set(value) {
connectionStorage.serverUrl = value
connectionStorage.remoteUrl = value
}
var accessKey: String
@@ -227,149 +224,48 @@ class CompanionApi(context: Context) {
fun saveConnection(form: ConnectionForm): Result<ConnectionUiState> =
connectionRepository.save(form)
suspend fun checkConnectionState(): ConnectionUiState =
connectionRepository.check()
suspend fun checkConnectionState(
trigger: ConnectionReevaluationTrigger = ConnectionReevaluationTrigger.ExplicitReconnect
): ConnectionUiState = connectionRepository.check(trigger)
private fun request(path: String, method: String = "GET", body: JSONObject? = null): JSONObject {
val normalizedUrl = ConnectionValidator.normalizeServerUrl(baseUrl)
.getOrElse { throw IllegalStateException(it.message ?: "Enter a valid server URL in Settings.") }
val mediaType = "application/json; charset=utf-8".toMediaType()
val builder = Request.Builder()
.url(normalizedUrl + path)
.header("Accept", "application/json")
if (connectionStorage.authMode == ConnectionAuthMode.BearerToken && accessKey.isNotBlank()) {
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 health(trigger: ConnectionReevaluationTrigger = ConnectionReevaluationTrigger.ExplicitReconnect): HealthState {
val state = checkConnectionState(trigger)
return HealthState(
reachable = state.connected,
status = state.noticeText,
hermesCli = "Managed by upstream Hermes",
workspace = "Not exposed by documented gateway routes",
mode = state.activeRouteLabel
)
}
suspend fun validate(): String = withContext(Dispatchers.IO) {
requestFirst(listOf("/v1/models", "/api/auth/validate")).optString("message", "Connection works.")
}
suspend fun validate(): String = checkConnectionState().message
suspend fun checkConnection(): ConnectionCheckResult = withContext(Dispatchers.IO) {
runCatching {
val health = health()
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 checkConnection(
trigger: ConnectionReevaluationTrigger = ConnectionReevaluationTrigger.ExplicitReconnect
): ConnectionUiState = checkConnectionState(trigger)
suspend fun chat(prompt: String): ChatResult = withContext(Dispatchers.IO) {
val json = request("/api/chat", "POST", JSONObject().put("prompt", prompt))
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 chat(prompt: String): ChatResult =
throw UnsupportedOperationException("Direct gateway chat integration is scheduled after the session contract is verified.")
suspend fun testHermes(): String {
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 testHermes(): String = validate()
suspend fun listFiles(path: String): Pair<String, List<FileEntry>> = withContext(Dispatchers.IO) {
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 listFiles(path: String): Pair<String, List<FileEntry>> = legacyRouteUnavailable()
suspend fun readFile(path: String): Pair<String, String> = withContext(Dispatchers.IO) {
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 readFile(path: String): Pair<String, String> = legacyRouteUnavailable()
suspend fun writeFile(path: String, content: String): String = withContext(Dispatchers.IO) {
val json = request("/api/files/write", "POST", JSONObject().put("path", path).put("content", content))
"Saved ${json.optLong("bytesWritten")} bytes"
}
suspend fun writeFile(path: String, content: String): String = legacyRouteUnavailable()
suspend fun runCommand(command: String, cwd: String): String = withContext(Dispatchers.IO) {
val json = request("/api/terminal/run", "POST", JSONObject().put("command", command).put("cwd", cwd))
listOf(
"$ ${json.optString("command", command)}",
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")
}
suspend fun runCommand(command: String, cwd: String): String = legacyRouteUnavailable()
private fun <T> legacyRouteUnavailable(): T =
throw UnsupportedOperationException("This legacy companion route is not part of the direct upstream gateway contract.")
private fun migrateLegacyConnectionPrefs() {
val legacyUrl = prefs.getString("baseUrl", null).orEmpty()
val legacyAccessKey = prefs.getString("accessKey", null).orEmpty()
if (connectionStorage.serverUrl.isBlank() && legacyUrl.isNotBlank()) {
connectionStorage.serverUrl = legacyUrl
if (connectionStorage.remoteUrl.isBlank() && legacyUrl.isNotBlank()) {
connectionStorage.remoteUrl = legacyUrl
}
if (connectionStorage.bearerToken.isBlank() && legacyAccessKey.isNotBlank()) {
connectionStorage.bearerToken = legacyAccessKey
@@ -380,8 +276,6 @@ class CompanionApi(context: Context) {
}
}
class UnauthorizedConnectionException(message: String) : IllegalStateException(message)
fun cleanServerText(text: String): String =
text.replace(Regex("\\u001B\\[[;?0-9]*[ -/]*[@-~]"), "")
.replace("\u0000", "")
@@ -402,23 +296,24 @@ fun HermesNativeApp() {
val snackbar = remember { SnackbarHostState() }
val scope = remember { CoroutineScope(Dispatchers.Main) }
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) }
fun notify(message: String) {
scope.launch { snackbar.showSnackbar(message) }
}
LaunchedEffect(screen) {
api.checkConnection().let { online = it is ConnectionCheckResult.Healthy }
LaunchedEffect(Unit) {
connectionState = api.checkConnection(ConnectionReevaluationTrigger.Foreground)
}
GatewayReevaluationHooks(api) { connectionState = it }
HermesMobileTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = HermesTheme.colors.background,
topBar = {
NativeTopBar(screen, online, onAction = {
NativeTopBar(screen, connectionState.connected, onAction = {
if (screen == Screen.Inbox) {
resetInboxToken++
notify("New chat started.")
@@ -451,13 +346,44 @@ fun HermesNativeApp() {
screen = Screen.Inbox
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 {
val direction = if (to > from) 1 else -1
return slideInHorizontally(animationSpec = tween(260)) { width -> direction * width } togetherWith
@@ -867,12 +793,17 @@ fun FilesScreen(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit)
}
@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) }
BackHandler(panel != SettingsPanel.Home) { panel = SettingsPanel.Home }
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.Defaults -> AgentDefaultSettings(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
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 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 accessKey by remember { mutableStateOf("") }
var sessionLabel by remember { mutableStateOf(initialConfig.sessionLabel) }
var connectionState by remember {
mutableStateOf(ConnectionStateReducer.initial(initialConfig))
}
fun saveAndCheck() {
val form = ConnectionForm(baseUrl, authMode, accessKey, sessionLabel)
val form = ConnectionForm(localUrl, remoteUrl, authMode, accessKey, sessionLabel)
val saved = api.saveConnection(form).getOrElse {
connectionState = ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
notify(connectionState.message)
val invalid = ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
onConnectionState(invalid)
notify(invalid.message)
return
}
connectionState = saved
onConnectionState(saved)
accessKey = ""
scope.launch {
val checked = api.checkConnectionState()
connectionState = checked
onConnectionState(checked)
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.")
FlatCard {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
FieldLabel("Hermes gateway URL")
AppTextField(baseUrl, { baseUrl = it }, "", keyboardType = KeyboardType.Uri)
FieldLabel("Remote HTTPS URL")
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")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
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")
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) {
val form = ConnectionForm(baseUrl, authMode, accessKey, sessionLabel)
connectionState = api.saveConnection(form).getOrElse {
val form = ConnectionForm(localUrl, remoteUrl, authMode, accessKey, sessionLabel)
var saved = api.saveConnection(form).getOrElse {
ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
}
if (connectionState.status == ConnectionStatus.Checking) {
connectionState = ConnectionStateReducer.savedForManualCheck(connectionState.config)
if (saved.status == ConnectionStatus.Checking) {
saved = ConnectionStateReducer.savedForManualCheck(saved.config)
accessKey = ""
}
notify(connectionState.message)
onConnectionState(saved)
notify(saved.message)
}
PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) {
saveAndCheck()
@@ -953,6 +892,7 @@ fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (Settings
}
}
ConnectionNotice(connectionState)
MetricRow("Active route", connectionState.activeRouteLabel)
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.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,
danger = state.status == ConnectionStatus.Offline ||
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) {
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("Workspace", health.workspace)
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.URISyntaxException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
enum class ConnectionAuthMode { None, BearerToken }
enum class ConnectionRoute { Local, Remote }
enum class ConnectionStatus { Unconfigured, Offline, Checking, Connected, Unauthorized }
enum class ConnectionReevaluationTrigger { Foreground, ExplicitReconnect, ConnectivityChange, RequestFailure }
data class ConnectionConfig(
val serverUrl: String = "",
val localUrl: String = "",
val remoteUrl: String = "",
val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken,
val hasBearerToken: Boolean = false,
val sessionLabel: String = "Default session"
)
data class ConnectionForm(
val serverUrl: String = "",
val localUrl: String = "",
val remoteUrl: String = "",
val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken,
val bearerToken: String = "",
val sessionLabel: String = "Default session"
@@ -24,17 +32,20 @@ data class ConnectionForm(
data class ConnectionUiState(
val config: ConnectionConfig = ConnectionConfig(),
val status: ConnectionStatus = ConnectionStatus.Unconfigured,
val message: String = "Enter a server URL to connect.",
val urlError: String? = null,
val activeRoute: ConnectionRoute? = null,
val message: String = "Enter a remote gateway URL to connect.",
val localUrlError: String? = null,
val remoteUrlError: String? = null,
val canRetry: Boolean = false
) {
val connected: Boolean get() = status == ConnectionStatus.Connected
val activeRouteLabel: String get() = activeRoute?.name ?: "None"
val noticeText: String
get() {
val label = when (status) {
ConnectionStatus.Unconfigured -> "Not configured"
ConnectionStatus.Checking -> "Checking"
ConnectionStatus.Connected -> "Connected"
ConnectionStatus.Connected -> "Connected · $activeRouteLabel"
ConnectionStatus.Offline -> "Offline"
ConnectionStatus.Unauthorized -> "Unauthorized"
}
@@ -43,137 +54,234 @@ data class ConnectionUiState(
}
}
sealed interface ConnectionCheckResult {
data class Healthy(val message: String) : ConnectionCheckResult
data class Offline(val message: String) : ConnectionCheckResult
data class Unauthorized(val message: String) : ConnectionCheckResult
sealed interface GatewayProbeResult {
data class Healthy(val message: String = "") : GatewayProbeResult
data class Offline(val message: String) : GatewayProbeResult
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 {
fun normalizeServerUrl(input: String): Result<String> {
fun normalizeGatewayUrl(input: String, required: Boolean): Result<String> {
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 {
URI(trimmed)
} 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()) {
return Result.failure(IllegalArgumentException("Enter a valid http or https URL."))
if (uri.scheme != "https" || uri.host.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("Gateway URLs must use HTTPS."))
}
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)
}
fun validateForm(form: ConnectionForm, hasStoredBearerToken: Boolean = false): Result<ConnectionConfig> =
normalizeServerUrl(form.serverUrl).map { normalized ->
val hasBearerToken = form.bearerToken.isNotBlank() || hasStoredBearerToken
fun validateForm(form: ConnectionForm, hasStoredBearerToken: Boolean = false): Result<ConnectionConfig> {
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
if (form.authMode == ConnectionAuthMode.BearerToken && !hasBearerToken) {
return Result.failure(IllegalArgumentException("Bearer token is required for token authentication."))
}
return Result.success(
ConnectionConfig(
serverUrl = normalized,
localUrl = local,
remoteUrl = remote,
authMode = form.authMode,
hasBearerToken = form.authMode == ConnectionAuthMode.BearerToken && hasBearerToken,
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 {
fun initial(config: ConnectionConfig): ConnectionUiState =
ConnectionUiState(
config = config,
status = if (config.serverUrl.isBlank()) ConnectionStatus.Unconfigured else ConnectionStatus.Offline,
message = if (config.serverUrl.isBlank()) {
"Server URL is blank until you enter your Hermes gateway address."
} else {
"Saved connection is ready to check."
},
canRetry = config.serverUrl.isNotBlank()
)
fun initial(config: ConnectionConfig): ConnectionUiState = ConnectionUiState(
config = config,
status = if (config.remoteUrl.isBlank()) ConnectionStatus.Unconfigured else ConnectionStatus.Offline,
message = if (config.remoteUrl.isBlank()) {
"Remote gateway URL is blank until you configure this Hermes profile."
} else {
"Saved connection is ready to check."
},
canRetry = config.remoteUrl.isNotBlank()
)
fun saved(config: ConnectionConfig): ConnectionUiState =
ConnectionUiState(
fun saved(config: ConnectionConfig): ConnectionUiState = ConnectionUiState(
config = config,
status = ConnectionStatus.Checking,
message = "Checking local then remote gateway routes..."
)
fun savedForManualCheck(config: ConnectionConfig): ConnectionUiState = ConnectionUiState(
config = config,
status = ConnectionStatus.Offline,
message = "Settings saved. Run Test to select the active route.",
canRetry = true
)
fun result(config: ConnectionConfig, result: RouteSelectionResult): ConnectionUiState = when (result) {
is RouteSelectionResult.Connected -> ConnectionUiState(
config = config,
status = ConnectionStatus.Checking,
message = "Checking server health...",
status = ConnectionStatus.Connected,
activeRoute = result.route,
message = result.message,
canRetry = false
)
fun savedForManualCheck(config: ConnectionConfig): ConnectionUiState =
ConnectionUiState(
is RouteSelectionResult.Offline -> ConnectionUiState(
config = config,
status = ConnectionStatus.Offline,
message = "Settings saved. Run Test to check the server.",
message = result.message,
canRetry = true
)
fun result(config: ConnectionConfig, result: ConnectionCheckResult): ConnectionUiState =
when (result) {
is ConnectionCheckResult.Healthy -> ConnectionUiState(
config = config,
status = ConnectionStatus.Connected,
message = result.message.ifBlank { "Server is reachable." },
canRetry = false
)
is ConnectionCheckResult.Offline -> ConnectionUiState(
config = config,
status = ConnectionStatus.Offline,
message = result.message.ifBlank { "Server is offline or unreachable." },
canRetry = true
)
is ConnectionCheckResult.Unauthorized -> ConnectionUiState(
config = config,
status = ConnectionStatus.Unauthorized,
message = result.message.ifBlank { "Authentication was rejected." },
canRetry = true
)
}
fun invalid(form: ConnectionForm, message: String): ConnectionUiState =
ConnectionUiState(
config = ConnectionConfig(
serverUrl = form.serverUrl.trim(),
authMode = form.authMode,
hasBearerToken = form.bearerToken.isNotBlank(),
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
),
status = ConnectionStatus.Unconfigured,
message = message,
urlError = message,
canRetry = false
is RouteSelectionResult.Unauthorized -> ConnectionUiState(
config = config,
status = ConnectionStatus.Unauthorized,
message = result.message,
canRetry = true
)
}
fun invalid(form: ConnectionForm, message: String): ConnectionUiState = ConnectionUiState(
config = ConnectionConfig(
localUrl = form.localUrl.trim(),
remoteUrl = form.remoteUrl.trim(),
authMode = form.authMode,
hasBearerToken = form.bearerToken.isNotBlank(),
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
),
status = ConnectionStatus.Unconfigured,
message = message,
localUrlError = message.takeIf { form.localUrl.isNotBlank() },
remoteUrlError = message,
canRetry = false
)
}
interface ConnectionStore {
var serverUrl: String
var localUrl: String
var remoteUrl: String
var sessionLabel: String
var authMode: ConnectionAuthMode
var bearerToken: String
fun config(): ConnectionConfig =
ConnectionConfig(
serverUrl = serverUrl,
authMode = authMode,
hasBearerToken = bearerToken.isNotBlank(),
sessionLabel = sessionLabel
)
fun config(): ConnectionConfig = ConnectionConfig(
localUrl = ConnectionValidator.normalizeGatewayUrl(localUrl, required = false).getOrDefault(""),
remoteUrl = ConnectionValidator.normalizeGatewayUrl(remoteUrl, required = true).getOrDefault(""),
authMode = authMode,
hasBearerToken = bearerToken.isNotBlank(),
sessionLabel = sessionLabel
)
fun save(form: ConnectionForm, normalizedUrl: String) {
serverUrl = normalizedUrl
fun save(form: ConnectionForm, config: ConnectionConfig) {
localUrl = config.localUrl
remoteUrl = config.remoteUrl
authMode = form.authMode
sessionLabel = form.sessionLabel
sessionLabel = config.sessionLabel
bearerToken = if (form.authMode == ConnectionAuthMode.BearerToken) form.bearerToken else ""
}
}
@@ -181,12 +289,14 @@ interface ConnectionStore {
interface ConnectionRepository {
fun config(): ConnectionConfig
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(
private val store: ConnectionStore,
private val healthCheck: suspend () -> ConnectionCheckResult
private val coordinator: GatewayRouteCoordinator
) : ConnectionRepository {
override fun config(): ConnectionConfig = store.config()
@@ -200,14 +310,30 @@ class StoredConnectionRepository(
} else {
form
}
return ConnectionValidator.validateForm(effectiveForm).mapCatching { config ->
store.save(effectiveForm, config.serverUrl)
return ConnectionValidator.validateForm(effectiveForm, store.bearerToken.isNotBlank()).mapCatching { config ->
store.save(effectiveForm, config)
coordinator.clear()
ConnectionStateReducer.saved(store.config())
}.recoverCatching {
throw IllegalArgumentException(it.message ?: "Connection settings are invalid.")
}
}
override suspend fun check(): ConnectionUiState =
ConnectionStateReducer.result(store.config(), healthCheck())
override suspend fun check(trigger: ConnectionReevaluationTrigger): ConnectionUiState =
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 =
context.getSharedPreferences("connection", Context.MODE_PRIVATE)
override var serverUrl: String
get() = prefs.getString(KEY_URL, "") ?: ""
override var localUrl: String
get() = prefs.getString(KEY_LOCAL_URL, "") ?: ""
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
@@ -84,7 +90,9 @@ class SecureConnectionStorage(context: Context) : ConnectionStore {
const val KEY_ALIAS = "hermes_mobile_connection"
const val TRANSFORMATION = "AES/GCM/NoPadding"
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_AUTH_MODE = "authMode"
const val KEY_BEARER_TOKEN = "bearerToken"
@@ -1,163 +1,209 @@
package cloud.molberg.hermesmobile.connection
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionStateTest {
@Test
fun validatorNormalizesHttpUrlsAndRejectsUnsafeCredentials() {
val normalized = ConnectionValidator.normalizeServerUrl(" https://hermes.example.test/ ").getOrThrow()
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(
fun validatorRequiresHttpsRemoteAndRejectsEmbeddedCredentialsOrPaths() {
val valid = ConnectionValidator.validateForm(
ConnectionForm(
serverUrl = "http://10.0.2.2:8787",
authMode = ConnectionAuthMode.BearerToken,
bearerToken = " "
localUrl = " https://hermes.home.arpa/ ",
remoteUrl = " https://hermes.example.test/ ",
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()
assertTrue(config.hasBearerToken)
assertEquals("http://10.0.2.2:8787", config.serverUrl)
assertEquals("https://hermes.home.arpa", valid.localUrl)
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
fun reducerSurfacesOfflineUnauthorizedRetryAndConnectedStates() {
val config = ConnectionConfig(
serverUrl = "http://10.0.2.2:8787",
authMode = ConnectionAuthMode.BearerToken,
hasBearerToken = true
fun selectorPrefersCompatibleLocalWithoutProbingRemote() = runBlocking {
val probe = RecordingProbe(mapOf(LOCAL to GatewayProbeResult.Healthy("local ok")))
val result = GatewayRouteSelector(probe).select(config(), TOKEN)
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."))
val unauthorized = ConnectionStateReducer.result(config, ConnectionCheckResult.Unauthorized("Rejected."))
val connected = ConnectionStateReducer.result(config, ConnectionCheckResult.Healthy("Server is reachable."))
assertEquals(ConnectionRoute.Remote, (result as RouteSelectionResult.Connected).route)
assertEquals(listOf(LOCAL, REMOTE), probe.urls)
}
@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)
assertTrue(offline.canRetry)
assertEquals(ConnectionStatus.Unauthorized, unauthorized.status)
assertTrue(unauthorized.canRetry)
assertEquals(ConnectionStatus.Connected, connected.status)
assertFalse(connected.canRetry)
}
@Test
fun invalidFormKeepsDraftValuesWithoutClaimingConnection() {
val state = ConnectionStateReducer.invalid(
ConnectionForm(serverUrl = "ftp://example.test", bearerToken = "redacted-test-token"),
"Enter a valid http or https URL."
)
assertEquals(ConnectionStatus.Unconfigured, state.status)
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(
serverUrl = " https://hermes.example.test/ ",
authMode = ConnectionAuthMode.BearerToken,
bearerToken = "",
sessionLabel = "Mobile"
)
).getOrThrow()
assertEquals(ConnectionStatus.Checking, saved.status)
assertEquals("https://hermes.example.test", store.serverUrl)
assertEquals("stored-token", store.bearerToken)
assertEquals("Mobile", store.sessionLabel)
assertTrue(saved.config.hasBearerToken)
}
@Test
fun repositoryClearsBearerTokenForAnonymousConnection() {
val store = InMemoryConnectionStore().apply {
bearerToken = "stored-token"
authMode = ConnectionAuthMode.BearerToken
}
val repository = StoredConnectionRepository(store) {
ConnectionCheckResult.Healthy("ok")
}
fun repositoryStoresOneProfileAndPreservesOrClearsToken() {
val store = InMemoryConnectionStore().apply { bearerToken = TOKEN }
val coordinator = GatewayRouteCoordinator(GatewayRouteSelector(RecordingProbe(emptyMap())))
val repository = StoredConnectionRepository(store, coordinator)
repository.save(
ConnectionForm(
serverUrl = "http://10.0.2.2:8787",
authMode = ConnectionAuthMode.None,
bearerToken = "ignored-token"
localUrl = LOCAL,
remoteUrl = REMOTE,
authMode = ConnectionAuthMode.BearerToken,
sessionLabel = " Mobile "
)
).getOrThrow()
assertEquals(ConnectionAuthMode.None, store.authMode)
assertEquals(LOCAL, store.localUrl)
assertEquals(REMOTE, store.remoteUrl)
assertEquals(TOKEN, store.bearerToken)
assertEquals("Mobile", store.sessionLabel)
repository.save(ConnectionForm(remoteUrl = REMOTE, authMode = ConnectionAuthMode.None)).getOrThrow()
assertEquals("", store.bearerToken)
assertFalse(repository.config().hasBearerToken)
}
@Test
fun repositoryCheckMapsHealthResultToUiState() {
fun storedLegacyCleartextUrlIsNotReturnedAsAUsableRoute() {
val store = InMemoryConnectionStore().apply {
serverUrl = "http://10.0.2.2:8787"
authMode = ConnectionAuthMode.BearerToken
bearerToken = "stored-token"
}
val repository = StoredConnectionRepository(store) {
ConnectionCheckResult.Unauthorized("Authentication was rejected.")
remoteUrl = "http://legacy.example.test"
authMode = ConnectionAuthMode.None
}
val state = kotlinx.coroutines.runBlocking { repository.check() }
assertEquals("", store.config().remoteUrl)
assertEquals(ConnectionStatus.Unconfigured, ConnectionStateReducer.initial(store.config()).status)
}
assertEquals(ConnectionStatus.Unauthorized, state.status)
assertTrue(state.canRetry)
assertTrue(state.config.hasBearerToken)
private fun config() = ConnectionConfig(
localUrl = LOCAL,
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 {
override var serverUrl: String = ""
override var localUrl: String = ""
override var remoteUrl: String = ""
override var sessionLabel: String = "Default session"
override var authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken
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"
}
}