Compare commits

..
Author SHA1 Message Date
Bailey Dixon 325b8e5670 Merge pull request #257 from Codename-11/dev
release(android): promote android-v1.5.1
2026-07-26 16:11:49 -04:00
Bailey Dixon e9da59cf40 Merge pull request #254 from Codename-11/dev
fix(android): repair immutable release dispatch
2026-07-25 18:30:53 -04:00
Bailey Dixon c9a03c9ed7 Merge pull request #252 from Codename-11/dev
release(android): android-v1.5.0
2026-07-25 18:26:34 -04:00
Bailey Dixon 68f8b58a0b Merge pull request #232 from Codename-11/dev
release(android): android-v1.4.9
2026-07-19 21:27:28 -04:00
30 changed files with 170 additions and 961 deletions
-7
View File
@@ -10,13 +10,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced. - **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
## [Android 1.5.2] - 2026-07-28
### Fixed
- **Dashboard sign-in completes across supported providers and network routes.** Self-hosted OIDC stays on the dashboard cookie flow, while Nous Portal opens in the system browser and completes standards-compatible PKCE through HTTPS, private-LAN, or Tailscale dashboard routes.
- **Replayed chat updates no longer destabilize the conversation list.** Duplicate upstream message identifiers are coalesced before Compose renders them.
## [Android 1.5.1] - 2026-07-26 ## [Android 1.5.1] - 2026-07-26
### Added ### Added
-30
View File
@@ -1,35 +1,5 @@
# Hermes-Relay — Dev Log # Hermes-Relay — Dev Log
## 2026-07-28 — Android 1.5.2 production release
Android 1.5.2 shipped from the approved `dev` to `main` release tree as
versionCode 35. The release adds provider-aware Dashboard sign-in: Nous uses
the advertised native PKCE system-browser flow, while compatible self-hosted
providers retain cookie-backed full-page Dashboard authentication. Callback
origin discovery remains server-driven, private-network HTTP compatibility is
preserved, and arbitrary public HTTP redirects remain rejected.
The private Play preflight validated the exact application tree before release
PR #265 merged. The immutable `android-v1.5.2` tag resolves to the resulting
`main` tip, the production workflow promoted versionCode 35 to the completed
Google Play production track, and the public GitHub release contains the
signed AAB, sideload APK, and SHA-256 manifest. The published sideload APK
checksum was independently verified; replacing the debug-signed phone build
with the release-signed artifact requires an uninstall because Android
correctly rejects cross-signature in-place updates.
## 2026-07-27 — Android replayed-message identity reconciliation
Android history reconciliation now collapses reconnect/rejoin replays of the
same persisted message ID before publishing the transcript to Compose. The
latest repeated snapshot replaces the value at the message's first transcript
position, preserving stable ordering, distinct messages, and the LazyColumn
identity contract without index- or random-key fallbacks.
Focused coverage reproduces the duplicate UUID condition and verifies that the
authoritative final content wins while every rendered message keeps a unique
stable UI key.
## 2026-07-26 — Android 1.5.1 patch reconciliation ## 2026-07-26 — Android 1.5.1 patch reconciliation
Android 1.5.1 reconciles the post-1.5.0 voice and chat fixes into versionCode Android 1.5.1 reconciles the post-1.5.0 voice and chat fixes into versionCode
+15 -10
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.5.2 # Hermes-Relay-Android v1.5.1
**Release Date:** July 28, 2026 **Release Date:** July 26, 2026
## Download ## Download
> Installing on your phone? Download `hermes-relay-1.5.2-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). > Installing on your phone? Download `hermes-relay-1.5.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone. The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,17 +12,22 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary ## Summary
This patch restores reliable dashboard sign-in for self-hosted OIDC and Nous Portal connections, including private-LAN and Tailscale routes, and prevents replayed chat events from destabilizing the conversation list. This patch restores reliable narration and background behavior in Voice, adds focused and full-conversation voice layouts, and keeps richly formatted streamed answers anchored at their completed end.
## Added
- Voice Focus keeps narration, Markdown, tools, media, and actionable cards in a compact voice-first view.
- Voice Conversation exposes the complete Chat renderer while preserving the active voice session.
- A final-answer speech preference keeps intermediate progress visual while supported voice paths wait for the settled response.
## Fixed ## Fixed
- Self-hosted OIDC returns through the dashboard cookie flow instead of a desktop-only loopback callback. - Standard Voice narrates valid completed assistant responses instead of losing them during the generation-to-speech handoff.
- Nous Portal authentication opens in the system browser so provider security challenges can complete. - Realtime tasks promoted to background release the foreground spinner and microphone while progress and results remain reachable.
- Native PKCE uses standards-compatible unpadded Base64URL and preserves the dashboard's canonical HTTPS callback origin while keeping tokens scoped to the active route. - Completed streamed answers switch to full Markdown and preserve the measured trailing edge instead of jumping to the start of the response.
- Full-screen in-app sign-in remains available for compatible dashboard providers. - Assistant text uses the theme's full-contrast foreground with a more readable chat type scale.
- Replayed upstream chat events are coalesced before rendering, preventing duplicate message keys.
## Install / Verify ## Install / Verify
- App version: **1.5.2** (versionCode **35**). - App version: **1.5.1** (versionCode **34**).
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes. - Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
@@ -1 +1 @@
Dashboard sign-in now completes reliably for self-hosted OIDC and Nous Portal, including private-LAN and Tailscale routes. Nous opens securely in the system browser, while compatible providers retain full-screen in-app sign-in. Replayed chat updates no longer duplicate conversation rows. Choose compact Voice Focus or the complete Conversation layout. Standard Voice reliably speaks completed replies, Realtime background work no longer blocks voice controls, and streamed answers render Markdown while staying anchored at their completed end.
@@ -1 +1 @@
Hermes 仪表板登录现在可为自托管 OIDC 和 Nous Portal 可靠完成认证,并支持私有局域网与 Tailscale 路由。Nous 会在系统浏览器中安全打开,兼容的提供商仍可使用应用内全屏登录。重放的聊天更新不再产生重复会话行。 可选择精简的语音专注视图或完整的对话视图。标准语音现在会可靠朗读已完成的回复,实时后台任务不再阻塞语音控制,流式回复会渲染 Markdown 并停留在完成位置。
-20
View File
@@ -1,25 +1,5 @@
{ {
"versions": [ "versions": [
{
"version": "1.5.2",
"title": "Sign in without detours",
"date": "2026-07-28",
"sections": [
{
"header": "Provider-compatible sign-in",
"bullets": [
"Self-hosted OIDC returns through the dashboard callback, while Nous Portal opens securely in the system browser.",
"Private-LAN and Tailscale dashboard routes preserve the configured HTTPS callback and keep credentials scoped to the active connection."
]
},
{
"header": "Stable conversation updates",
"bullets": [
"Replayed upstream chat events are coalesced before rendering so duplicate message identifiers do not destabilize the conversation list."
]
}
]
},
{ {
"version": "1.5.1", "version": "1.5.1",
"title": "Voice and chat stay in place", "title": "Voice and chat stay in place",
+5 -5
View File
@@ -1,6 +1,6 @@
v1.5.2 - Sign in without detours v1.5.1 - Voice and chat stay in place
* Complete self-hosted OIDC sign-in through the dashboard callback. * Choose a compact Voice Focus view or the complete Conversation renderer.
* Open Nous Portal securely in the system browser. * Hear Standard Voice replies reliably after generation completes.
* Sign in over private-LAN and Tailscale dashboard routes. * Keep Realtime background work active without blocking voice controls.
* Keep replayed chat updates from duplicating conversation rows. * Read formatted streamed answers without jumping back to their beginning.
@@ -247,7 +247,6 @@ data class Connection(
apiServerUrl: String, apiServerUrl: String,
relayUrl: String, relayUrl: String,
extraApiUrls: List<Pair<String, String>> = emptyList(), extraApiUrls: List<Pair<String, String>> = emptyList(),
dashboardUrl: String? = null,
): List<EndpointCandidate> { ): List<EndpointCandidate> {
val routes = buildList { val routes = buildList {
endpointCandidateFromApiUrl( endpointCandidateFromApiUrl(
@@ -256,7 +255,6 @@ data class Connection(
apiServerUrl = apiServerUrl, apiServerUrl = apiServerUrl,
relayUrl = relayUrl.takeIf { it.isNotBlank() } relayUrl = relayUrl.takeIf { it.isNotBlank() }
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(), ?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
dashboardUrl = dashboardUrl,
)?.let(::add) )?.let(::add)
extraApiUrls extraApiUrls
@@ -268,7 +266,6 @@ data class Connection(
priority = index + 1, priority = index + 1,
apiServerUrl = url, apiServerUrl = url,
relayUrl = deriveDefaultRelayUrl(url).orEmpty(), relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
dashboardUrl = dashboardUrl,
)?.let(::add) )?.let(::add)
} }
} }
@@ -343,7 +340,6 @@ data class Connection(
priority: Int, priority: Int,
apiServerUrl: String, apiServerUrl: String,
relayUrl: String, relayUrl: String,
dashboardUrl: String? = null,
): EndpointCandidate? { ): EndpointCandidate? {
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull() val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
?: return null ?: return null
@@ -367,62 +363,12 @@ data class Connection(
role = role.ifBlank { inferRouteRole(apiServerUrl) }, role = role.ifBlank { inferRouteRole(apiServerUrl) },
priority = priority, priority = priority,
api = ApiEndpoint(host = host, port = port, tls = tls), api = ApiEndpoint(host = host, port = port, tls = tls),
dashboard = dashboardUrl dashboard = deriveDefaultDashboardUrl(apiServerUrl)
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() && urlsShareHost(it, apiServerUrl) }
?.let { DashboardEndpoint(url = it) }
?: deriveDefaultDashboardUrl(apiServerUrl)
?.let { DashboardEndpoint(url = it) }, ?.let { DashboardEndpoint(url = it) },
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint), relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
) )
} }
/**
* Reconcile stored API-derived routes with the Dashboard origin that
* was actually verified during setup. Older app versions synthesized
* `:9119` for every API route, even when the same host was reached
* through an HTTPS reverse proxy on 443. Replace only that conventional
* synthesized value (or a missing value); preserve explicit and
* different-host LAN/Tailscale routes.
*/
fun reconcileDashboardRoutes(
dashboardUrl: String?,
candidates: List<EndpointCandidate>,
): List<EndpointCandidate> {
val explicitDashboard = dashboardUrl
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?: return candidates
return candidates.map { candidate ->
val apiUrl = candidate.api?.url ?: return@map candidate
if (!urlsShareHost(explicitDashboard, apiUrl)) return@map candidate
val currentDashboard = candidate.dashboard?.url
val derivedDashboard = deriveDefaultDashboardUrl(apiUrl)
val canReplace = currentDashboard.isNullOrBlank() ||
(
derivedDashboard != null &&
currentDashboard.trim().trimEnd('/')
.equals(derivedDashboard, ignoreCase = true)
)
if (canReplace) {
candidate.copy(dashboard = DashboardEndpoint(url = explicitDashboard))
} else {
candidate
}
}
}
fun urlsShareHost(leftUrl: String, rightUrl: String): Boolean {
val leftHost = runCatching { URI(leftUrl.trim()) }.getOrNull()?.host
val rightHost = runCatching { URI(rightUrl.trim()) }.getOrNull()?.host
return !leftHost.isNullOrBlank() &&
!rightHost.isNullOrBlank() &&
leftHost.equals(rightHost, ignoreCase = true)
}
/** /**
* De-duplication identity for rebuilding stored routes. Prefer the * De-duplication identity for rebuilding stored routes. Prefer the
* legacy API authority when present so an older API-only candidate and * legacy API authority when present so an older API-only candidate and
@@ -543,6 +543,29 @@ class ConnectionStore private constructor(
} }
} }
private fun Connection.withDashboardDefaults(): Connection {
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
val normalizedRoutes = routeCandidates.ifEmpty {
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
}
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
}
return if (
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
normalizedRoutes != routeCandidates ||
normalizedPreferredRouteRole != preferredRouteRole
) {
copy(
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
routeCandidates = normalizedRoutes,
preferredRouteRole = normalizedPreferredRouteRole,
)
} else {
this
}
}
companion object { companion object {
private const val TAG = "ConnectionStore" private const val TAG = "ConnectionStore"
@@ -562,40 +585,3 @@ class ConnectionStore private constructor(
private const val DEFAULT_RELAY_URL = "ws://localhost:8767" private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
} }
} }
/**
* Restore route defaults after loading a serialized connection. This remains
* internal so focused persistence tests can exercise the same normalization
* path used by [ConnectionStore].
*/
internal fun Connection.withDashboardDefaults(): Connection {
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
val effectiveDashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl
val storedOrDefaultRoutes = routeCandidates.ifEmpty {
Connection.buildRouteCandidates(
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
dashboardUrl = effectiveDashboardUrl,
)
}
val normalizedRoutes = Connection.reconcileDashboardRoutes(
dashboardUrl = effectiveDashboardUrl,
candidates = storedOrDefaultRoutes,
)
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
}
return if (
dashboardUrl != effectiveDashboardUrl ||
normalizedRoutes != routeCandidates ||
normalizedPreferredRouteRole != preferredRouteRole
) {
copy(
dashboardUrl = effectiveDashboardUrl,
routeCandidates = normalizedRoutes,
preferredRouteRole = normalizedPreferredRouteRole,
)
} else {
this
}
}
@@ -1223,14 +1223,6 @@ class ChatHandler {
// so we can attach results back to the originating assistant message's ToolCall // so we can attach results back to the originating assistant message's ToolCall
val toolResults = items.filter { it.role == "tool" } val toolResults = items.filter { it.role == "tool" }
.associateBy { it.toolCallId } .associateBy { it.toolCallId }
// A reconnect/rejoin history response can repeat a persisted message row.
// Chat's LazyColumn renders domain ids as stable keys (via ChatMessage.uiKey),
// so allowing both copies through would crash Compose before either copy
// could be reconciled. A domain id identifies one persisted message: retain
// its first transcript position while adopting the latest repeated snapshot.
// Rows without ids remain independent, and tool/hidden rows keep their
// separate handling above/below.
val renderedItems = coalesceRenderedHistoryItems(items)
// Accumulator for media markers we find in loaded content — fired AFTER // Accumulator for media markers we find in loaded content — fired AFTER
// the wholesale `_messages.value = ...` assignment so the ViewModel's // the wholesale `_messages.value = ...` assignment so the ViewModel's
@@ -1248,8 +1240,8 @@ class ChatHandler {
// silently misses those rows, so a gateway turn's tokens/badges survived // silently misses those rows, so a gateway turn's tokens/badges survived
// only if a content match happened to cover them. See // only if a content match happened to cover them. See
// [reconcileLiveIdsToServer]. // [reconcileLiveIdsToServer].
val serverItemIds = renderedItems.mapNotNullTo(HashSet()) { it.id } val serverItemIds = items.mapNotNullTo(HashSet()) { it.id }
val idRemap = reconcileLiveIdsToServer(renderedItems, serverItemIds) val idRemap = reconcileLiveIdsToServer(items, serverItemIds)
// Carry CLIENT-ONLY enrichment forward across the reload, keyed by the // Carry CLIENT-ONLY enrichment forward across the reload, keyed by the
// RECONCILED message id. The server transcript (MessageItem) rebuilds // RECONCILED message id. The server transcript (MessageItem) rebuilds
@@ -1286,7 +1278,7 @@ class ChatHandler {
// clientOnly bubbles (same exchange, pre-sync copy). // clientOnly bubbles (same exchange, pre-sync copy).
val syncedRealtimeTurnContents = mutableSetOf<String>() val syncedRealtimeTurnContents = mutableSetOf<String>()
val loaded = renderedItems.mapNotNull { item -> val loaded = items.mapNotNull { item ->
val displayKind = item.displayKind?.trim()?.lowercase() val displayKind = item.displayKind?.trim()?.lowercase()
if (displayKind == "hidden") return@mapNotNull null if (displayKind == "hidden") return@mapNotNull null
val role = when { val role = when {
@@ -1548,34 +1540,6 @@ class ChatHandler {
} }
} }
/**
* Collapse replayed visible history rows by their authoritative message id.
*
* Replacing the value at its first-seen slot preserves transcript ordering;
* the last repeated value wins so a later, more complete snapshot is not lost.
* Null ids cannot be proven identical and therefore remain separate rows.
*/
private fun coalesceRenderedHistoryItems(items: List<MessageItem>): List<MessageItem> {
val firstSlotById = HashMap<String, Int>()
val coalesced = ArrayList<MessageItem>(items.size)
for (item in items) {
if (renderedRoleOf(item) == null) continue
val id = item.id
if (id == null) {
coalesced += item
continue
}
val existingSlot = firstSlotById[id]
if (existingSlot == null) {
firstSlotById[id] = coalesced.size
coalesced += item
} else {
coalesced[existingSlot] = item
}
}
return coalesced
}
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */ /** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
private class ReconcileSlot( private class ReconcileSlot(
val serverId: String, val serverId: String,
@@ -108,18 +108,13 @@ class NativeDashboardAuthClient(
provider: String? = null, provider: String? = null,
): NativeDashboardAuthorization { ): NativeDashboardAuthorization {
requireStrictLoopbackRedirect(redirectUri) requireStrictLoopbackRedirect(redirectUri)
// RFC 7636 uses unpadded Base64URL. Okio's base64Url() preserves val verifier = randomBytes(32).base64Url()
// trailing "=", which makes Hermes' standards-compliant S256
// comparison fail even though both sides hashed the same bytes.
val verifier = randomBytes(32).base64Url().trimEnd('=')
val challenge = MessageDigest.getInstance("SHA-256") val challenge = MessageDigest.getInstance("SHA-256")
.digest(verifier.toByteArray(Charsets.US_ASCII)) .digest(verifier.toByteArray(Charsets.US_ASCII))
.toByteString() .toByteString()
.base64Url() .base64Url()
.trimEnd('=')
val state = randomBytes(24).base64Url() val state = randomBytes(24).base64Url()
val authorizationBaseUrl = resolveAuthorizationBaseUrl(provider) val root = "$baseUrl/auth/native/authorize".toHttpUrlOrNull()
val root = "$authorizationBaseUrl/auth/native/authorize".toHttpUrlOrNull()
?: throw IOException("Dashboard URL is not a valid http(s) address") ?: throw IOException("Dashboard URL is not a valid http(s) address")
val url = root.newBuilder() val url = root.newBuilder()
.addQueryParameter("code_challenge", challenge) .addQueryParameter("code_challenge", challenge)
@@ -135,41 +130,6 @@ class NativeDashboardAuthClient(
return NativeDashboardAuthorization(url, verifier, state, generation) return NativeDashboardAuthorization(url, verifier, state, generation)
} }
/**
* A private-route dashboard may be configured with a canonical HTTPS
* callback origin for its provider. Starting the browser on the private
* origin would scope Hermes' temporary PKCE cookie to the wrong host, so
* discover the provider's declared callback and start native auth there.
* Token exchange still uses [baseUrl], keeping the resulting bearer bound
* to the active connection route.
*/
private fun resolveAuthorizationBaseUrl(provider: String?): String {
val configured = baseUrl.toHttpUrlOrNull() ?: return baseUrl
if (
!provider.equals("nous", ignoreCase = true) ||
configured.scheme != "http" ||
!isPrivateNetworkLiteral(configured.host)
) {
return baseUrl
}
val loginUrl = configured.newBuilder()
.addPathSegments("auth/login")
.addQueryParameter("provider", provider)
.addQueryParameter("next", "/")
.build()
val discoveryClient = client.newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.build()
val location = discoveryClient.newCall(
Request.Builder().url(loginUrl).get().build(),
).execute().use { response ->
if (response.code !in 300..399) null else response.header("Location")
}
return canonicalDashboardBaseFromNousRedirect(location)
?: throw IOException("Dashboard did not advertise a secure Nous callback origin")
}
fun exchangeCallback( fun exchangeCallback(
authorization: NativeDashboardAuthorization, authorization: NativeDashboardAuthorization,
callbackTarget: String, callbackTarget: String,
@@ -320,52 +280,7 @@ internal class NativeDashboardCallbackException(
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean { internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
return url.scheme == "https" || return url.scheme == "https" ||
( (url.scheme == "http" && url.host == "127.0.0.1")
url.scheme == "http" &&
(url.host == "127.0.0.1" || isPrivateNetworkLiteral(url.host))
)
}
/**
* Hermes already permits explicitly configured HTTP dashboard sessions on
* local routes. The brokered flow is no less protected than that cookie flow,
* but remains unavailable to arbitrary cleartext Internet hosts.
*/
private fun isPrivateNetworkLiteral(host: String): Boolean {
val octets = host.split('.').mapNotNull(String::toIntOrNull)
if (octets.size != 4 || octets.any { it !in 0..255 }) return false
val first = octets[0]
val second = octets[1]
return first == 10 ||
(first == 172 && second in 16..31) ||
(first == 192 && second == 168) ||
(first == 100 && second in 64..127)
}
internal fun canonicalDashboardBaseFromNousRedirect(location: String?): String? {
val providerUrl = location?.toHttpUrlOrNull() ?: return null
if (
providerUrl.scheme != "https" ||
!providerUrl.host.equals("portal.nousresearch.com", ignoreCase = true)
) {
return null
}
val callback = providerUrl.queryParameter("redirect_uri")
?.toHttpUrlOrNull()
?: return null
if (callback.scheme != "https") return null
val callbackSuffix = "/auth/callback"
if (!callback.encodedPath.endsWith(callbackSuffix)) return null
val basePath = callback.encodedPath
.removeSuffix(callbackSuffix)
.ifBlank { "/" }
return callback.newBuilder()
.encodedPath(basePath)
.query(null)
.fragment(null)
.build()
.toString()
.trimEnd('/')
} }
/** /**
@@ -33,24 +33,6 @@ internal fun dashboardRedirectAuthMode(authFlows: List<String>): DashboardRedire
DashboardRedirectAuthMode.WebView DashboardRedirectAuthMode.WebView
} }
/**
* Nous Portal uses Cloudflare Turnstile and does not support embedded Android
* WebViews. Keep self-hosted OIDC on the dashboard cookie flow, but use the
* gateway's brokered system-browser flow for Nous when it is advertised.
*/
internal fun androidDashboardRedirectAuthMode(
providerName: String,
authFlows: List<String>,
): DashboardRedirectAuthMode =
if (
providerName.equals("nous", ignoreCase = true) &&
dashboardRedirectAuthMode(authFlows) == DashboardRedirectAuthMode.NativePkce
) {
DashboardRedirectAuthMode.NativePkce
} else {
DashboardRedirectAuthMode.WebView
}
/** /**
* Owns one native dashboard sign-in attempt. * Owns one native dashboard sign-in attempt.
* *
@@ -35,7 +35,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@@ -184,8 +183,8 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
// Short-lived snackbar by default; retryable errors get Long so users have // Short-lived snackbar by default; retryable errors get Long so users have
// time to tap the action before it auto-dismisses. // time to tap the action before it auto-dismisses.
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult { suspend fun SnackbarHostState.showHumanError(err: HumanError) {
return showSnackbar( showSnackbar(
message = err.body, message = err.body,
actionLabel = err.actionLabel, actionLabel = err.actionLabel,
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short, duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
@@ -1946,16 +1945,6 @@ fun RelayApp() {
launchSingleTop = true launchSingleTop = true
} }
}, },
onRepairConnection = {
navController.navigate(
Screen.Pair.route(
connectionId = activeConnectionId,
autoStart = "relay",
),
) {
launchSingleTop = true
}
},
// Empty-chat "needs connection" card also offers the offline // Empty-chat "needs connection" card also offers the offline
// demo, so a skipped / never-connected first run can explore // demo, so a skipped / never-connected first run can explore
// without leaving Chat. Safe here — this state only shows when // without leaving Chat. Safe here — this state only shows when
@@ -139,7 +139,6 @@ import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import android.content.ClipData import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
@@ -207,7 +206,6 @@ import com.hermesandroid.relay.ui.components.showsImageGenerationPlaceholder
import com.hermesandroid.relay.ui.components.VoiceModeOverlay import com.hermesandroid.relay.ui.components.VoiceModeOverlay
import com.hermesandroid.relay.ui.LocalSnackbarHost import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.showHumanError import com.hermesandroid.relay.ui.showHumanError
import com.hermesandroid.relay.util.HumanErrorAction
import com.hermesandroid.relay.ui.theme.RelayRefresh import com.hermesandroid.relay.ui.theme.RelayRefresh
import kotlin.math.abs import kotlin.math.abs
import com.hermesandroid.relay.ui.theme.relayGridTexture import com.hermesandroid.relay.ui.theme.relayGridTexture
@@ -468,7 +466,6 @@ fun ChatScreen(
// don't wire navigation. // don't wire navigation.
onNavigateToConnections: () -> Unit = {}, onNavigateToConnections: () -> Unit = {},
onNavigateToConnect: () -> Unit = onNavigateToConnections, onNavigateToConnect: () -> Unit = onNavigateToConnections,
onRepairConnection: () -> Unit = onNavigateToConnect,
// Offline demo entry, surfaced on the empty-chat "needs connection" card so a // Offline demo entry, surfaced on the empty-chat "needs connection" card so a
// skipped / never-connected first run can explore without a server. null hides it. // skipped / never-connected first run can explore without a server. null hides it.
onTryDemo: (() -> Unit)? = null, onTryDemo: (() -> Unit)? = null,
@@ -496,13 +493,7 @@ fun ChatScreen(
val snackbarHost = LocalSnackbarHost.current val snackbarHost = LocalSnackbarHost.current
LaunchedEffect(chatViewModel) { LaunchedEffect(chatViewModel) {
chatViewModel.errorEvents.collect { err -> chatViewModel.errorEvents.collect { err ->
val result = snackbarHost.showHumanError(err) snackbarHost.showHumanError(err)
if (
result == SnackbarResult.ActionPerformed &&
err.action == HumanErrorAction.Repair
) {
onRepairConnection()
}
} }
} }
@@ -1,19 +1,18 @@
package com.hermesandroid.relay.ui.screens package com.hermesandroid.relay.ui.screens
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -22,7 +21,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -45,6 +43,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import com.hermesandroid.relay.R import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep
@@ -52,10 +51,10 @@ import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
import com.hermesandroid.relay.network.upstream.DashboardAuthSession import com.hermesandroid.relay.network.upstream.DashboardAuthSession
import com.hermesandroid.relay.network.upstream.DashboardCookieStore import com.hermesandroid.relay.network.upstream.DashboardCookieStore
import com.hermesandroid.relay.network.upstream.DashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.upstream.DashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator
import com.hermesandroid.relay.network.upstream.androidDashboardRedirectAuthMode import com.hermesandroid.relay.network.upstream.dashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
import com.hermesandroid.relay.viewmodel.ConnectionViewModel import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -64,7 +63,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/** /**
* Connection-level Dashboard authentication flow. It is deliberately outside * Connection-level Dashboard authentication flow. It is deliberately outside
@@ -94,8 +92,8 @@ fun DashboardSignInScreen(
var actionMessage by remember { mutableStateOf<String?>(null) } var actionMessage by remember { mutableStateOf<String?>(null) }
var actionIsError by remember { mutableStateOf(false) } var actionIsError by remember { mutableStateOf(false) }
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) } var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
var authFlows by remember(dashboardUrl, connectionId) { var redirectAuthMode by remember(dashboardUrl, connectionId) {
mutableStateOf<List<String>>(emptyList()) mutableStateOf(DashboardRedirectAuthMode.WebView)
} }
var nativeSignInJob by remember(dashboardUrl, connectionId) { mutableStateOf<Job?>(null) } var nativeSignInJob by remember(dashboardUrl, connectionId) { mutableStateOf<Job?>(null) }
var authenticationComplete by remember { mutableStateOf(false) } var authenticationComplete by remember { mutableStateOf(false) }
@@ -152,7 +150,7 @@ fun DashboardSignInScreen(
providers = client.getAuthProviders().getOrNull() providers = client.getAuthProviders().getOrNull()
?.takeIf { it.isNotEmpty() } ?.takeIf { it.isNotEmpty() }
?: status.authProviderDetails ?: status.authProviderDetails
authFlows = status.authFlows redirectAuthMode = dashboardRedirectAuthMode(status.authFlows)
val session = if (status.authRequired) client.currentSession().getOrNull() else null val session = if (status.authRequired) client.currentSession().getOrNull() else null
connectionViewModel.recordDashboardStatus( connectionViewModel.recordDashboardStatus(
status = status, status = status,
@@ -198,10 +196,7 @@ fun DashboardSignInScreen(
fun startRedirectSignIn(provider: DashboardAuthProvider) { fun startRedirectSignIn(provider: DashboardAuthProvider) {
if (actionInFlight || dashboardUrl.isBlank()) return if (actionInFlight || dashboardUrl.isBlank()) return
if ( if (redirectAuthMode == DashboardRedirectAuthMode.WebView) {
androidDashboardRedirectAuthMode(provider.name, authFlows) ==
DashboardRedirectAuthMode.WebView
) {
oauthProvider = provider oauthProvider = provider
return return
} }
@@ -260,8 +255,10 @@ fun DashboardSignInScreen(
onDispose { nativeSignInJob?.cancel() } onDispose { nativeSignInJob?.cancel() }
} }
oauthProvider?.let { provider -> oauthProvider
DashboardOAuthScreen( ?.takeIf { redirectAuthMode == DashboardRedirectAuthMode.WebView }
?.let { provider ->
DashboardOAuthDialog(
dashboardUrl = dashboardUrl, dashboardUrl = dashboardUrl,
provider = provider, provider = provider,
cookieStoreFactory = cookieStoreFactory, cookieStoreFactory = cookieStoreFactory,
@@ -287,7 +284,6 @@ fun DashboardSignInScreen(
actionIsError = true actionIsError = true
}, },
) )
return
} }
Scaffold( Scaffold(
@@ -295,7 +291,10 @@ fun DashboardSignInScreen(
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.dashboard_sign_in)) }, title = { Text(stringResource(R.string.dashboard_sign_in)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = {
nativeSignInJob?.cancel()
onBack()
}) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.dashboard_back), contentDescription = stringResource(R.string.dashboard_back),
@@ -324,7 +323,9 @@ fun DashboardSignInScreen(
actionInFlight = actionInFlight, actionInFlight = actionInFlight,
actionMessage = actionMessage, actionMessage = actionMessage,
actionIsError = actionIsError, actionIsError = actionIsError,
nativePkce = redirectAuthMode == DashboardRedirectAuthMode.NativePkce,
nativeSignInInFlight = nativeSignInJob != null, nativeSignInInFlight = nativeSignInJob != null,
nativeTransportEligible = isNativeDashboardTransportEligible(dashboardUrl),
onSignIn = ::submitPassword, onSignIn = ::submitPassword,
onOAuthSignIn = ::startRedirectSignIn, onOAuthSignIn = ::startRedirectSignIn,
onCancelNativeSignIn = { onCancelNativeSignIn = {
@@ -397,7 +398,9 @@ private fun DashboardSignInForm(
actionInFlight: Boolean, actionInFlight: Boolean,
actionMessage: String?, actionMessage: String?,
actionIsError: Boolean, actionIsError: Boolean,
nativePkce: Boolean,
nativeSignInInFlight: Boolean, nativeSignInInFlight: Boolean,
nativeTransportEligible: Boolean,
onSignIn: (String, String, String) -> Unit, onSignIn: (String, String, String) -> Unit,
onOAuthSignIn: (DashboardAuthProvider) -> Unit, onOAuthSignIn: (DashboardAuthProvider) -> Unit,
onCancelNativeSignIn: () -> Unit, onCancelNativeSignIn: () -> Unit,
@@ -426,19 +429,18 @@ private fun DashboardSignInForm(
redirectProviders.forEach { provider -> redirectProviders.forEach { provider ->
Button( Button(
onClick = { onOAuthSignIn(provider) }, onClick = { onOAuthSignIn(provider) },
enabled = !actionInFlight, enabled = !actionInFlight && (!nativePkce || nativeTransportEligible),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name)) Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
} }
} }
if (nativeSignInInFlight) { if (nativePkce && !nativeTransportEligible && redirectProviders.isNotEmpty()) {
Button( Text(
onClick = onCancelNativeSignIn, text = stringResource(R.string.dashboard_native_signin_requires_https),
modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodySmall,
) { color = MaterialTheme.colorScheme.error,
Text(stringResource(R.string.dashboard_cancel)) )
}
} }
if (passwordProvider != null || providers.isEmpty()) { if (passwordProvider != null || providers.isEmpty()) {
if (redirectProviders.isNotEmpty()) HorizontalDivider() if (redirectProviders.isNotEmpty()) HorizontalDivider()
@@ -476,11 +478,18 @@ private fun DashboardSignInForm(
}, },
) )
} }
if (nativeSignInInFlight) {
Button(
onClick = onCancelNativeSignIn,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.dashboard_cancel))
}
}
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun DashboardOAuthScreen( private fun DashboardOAuthDialog(
dashboardUrl: String, dashboardUrl: String,
provider: DashboardAuthProvider, provider: DashboardAuthProvider,
cookieStoreFactory: () -> DashboardCookieStore, cookieStoreFactory: () -> DashboardCookieStore,
@@ -497,8 +506,6 @@ private fun DashboardOAuthScreen(
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed) val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) } var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
var checking by remember { mutableStateOf(false) } var checking by remember { mutableStateOf(false) }
var pageProgress by remember { mutableStateOf(0) }
var webView by remember { mutableStateOf<WebView?>(null) }
val loginUrl = remember(dashboardUrl, provider.name) { val loginUrl = remember(dashboardUrl, provider.name) {
DashboardApiClient.authLoginUrl( DashboardApiClient.authLoginUrl(
baseUrl = dashboardUrl, baseUrl = dashboardUrl,
@@ -507,17 +514,14 @@ private fun DashboardOAuthScreen(
) )
} }
fun handleNavigation(url: String?) { fun maybeVerify(url: String?) {
val loadedUrl = url?.takeIf { it.isNotBlank() } ?: return val loadedUrl = url?.takeIf { it.isNotBlank() } ?: return
when (dashboardWebViewAuthNavigation(dashboardUrl, loadedUrl)) { val root = dashboardUrl.trim().trimEnd('/')
DashboardWebViewAuthNavigation.Continue -> return val relative = loadedUrl.trim().removePrefix(root)
DashboardWebViewAuthNavigation.RejectLoopbackCallback -> { val stillAuthenticating = relative.startsWith("/login", true) ||
statusText = notAcceptedStatus relative.startsWith("/auth/login", true) ||
onError(notAcceptedStatus) relative.startsWith("/auth/callback", true)
return if (!loadedUrl.startsWith(root, true) || stillAuthenticating) return
}
DashboardWebViewAuthNavigation.ImportAndVerify -> Unit
}
val manager = CookieManager.getInstance() val manager = CookieManager.getInstance()
manager.flush() manager.flush()
val imported = importDashboardCookieHeader( val imported = importDashboardCookieHeader(
@@ -547,162 +551,39 @@ private fun DashboardOAuthScreen(
} }
} }
BackHandler(onBack = onDismiss) Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.fillMaxWidth().heightIn(max = 640.dp)) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = onDismiss) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.dashboard_close_signin))
}
Text(statusText, style = MaterialTheme.typography.bodySmall)
AndroidView(
modifier = Modifier.fillMaxWidth().weight(1f),
factory = { viewContext ->
CookieManager.getInstance().setAcceptCookie(true)
WebView(viewContext).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean = false
DisposableEffect(Unit) { override fun onPageFinished(view: WebView, url: String?) {
onDispose { super.onPageFinished(view, url)
webView?.stopLoading() maybeVerify(url)
webView?.destroy() }
webView = null }
} loadUrl(loginUrl)
} }
},
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
text = stringResource(
R.string.dashboard_signin_with_provider,
provider.displayName ?: provider.name,
),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = statusText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
},
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.dashboard_back),
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
if (pageProgress in 0..99) {
LinearProgressIndicator(
progress = { pageProgress / 100f },
modifier = Modifier.fillMaxWidth(),
) )
} }
AndroidView(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
factory = { viewContext ->
CookieManager.getInstance().setAcceptCookie(true)
WebView(viewContext).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView, newProgress: Int) {
pageProgress = newProgress
}
}
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean {
val target = request.url.toString()
if (
dashboardWebViewAuthNavigation(dashboardUrl, target) ==
DashboardWebViewAuthNavigation.RejectLoopbackCallback
) {
handleNavigation(target)
return true
}
return false
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
super.onReceivedError(view, request, error)
if (request.isForMainFrame) {
val message = error.description?.toString()
?.takeIf { it.isNotBlank() }
?: verifyFailedStatus
statusText = message
onError(message)
}
}
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
handleNavigation(url)
}
}
webView = this
loadUrl(loginUrl)
}
},
)
} }
} }
} }
internal enum class DashboardWebViewAuthNavigation {
Continue,
ImportAndVerify,
RejectLoopbackCallback,
}
/**
* Android redirect providers use the dashboard's cookie/OIDC flow. A foreign
* loopback callback belongs to the desktop native-PKCE contract and must never
* be followed, imported, or treated as an authenticated Android return.
*/
internal fun dashboardWebViewAuthNavigation(
dashboardUrl: String,
loadedUrl: String,
): DashboardWebViewAuthNavigation {
val dashboard = dashboardUrl.trim().trimEnd('/').toHttpUrlOrNull()
?: return DashboardWebViewAuthNavigation.Continue
val loaded = loadedUrl.trim().toHttpUrlOrNull()
?: return DashboardWebViewAuthNavigation.Continue
val sameOrigin = dashboard.scheme == loaded.scheme &&
dashboard.host.equals(loaded.host, ignoreCase = true) &&
dashboard.port == loaded.port
if (!sameOrigin) {
val foreignLoopback = loaded.scheme == "http" &&
loaded.host in setOf("127.0.0.1", "localhost", "::1") &&
loaded.encodedPath == "/callback"
return if (foreignLoopback) {
DashboardWebViewAuthNavigation.RejectLoopbackCallback
} else {
DashboardWebViewAuthNavigation.Continue
}
}
val basePath = dashboard.encodedPath.trimEnd('/')
val relativePath = loaded.encodedPath
.removePrefix(basePath)
.ifBlank { "/" }
return if (
relativePath.equals("/login", ignoreCase = true) ||
relativePath.equals("/auth/login", ignoreCase = true)
) {
DashboardWebViewAuthNavigation.Continue
} else {
// Includes the public /auth/callback response: import its cookies at
// root scope, then verify the resulting session through /api/auth/me.
DashboardWebViewAuthNavigation.ImportAndVerify
}
}
@@ -22,16 +22,11 @@ import javax.net.ssl.SSLPeerUnverifiedException
* showHumanError in RelayApp.kt. * showHumanError in RelayApp.kt.
*/ */
enum class HumanErrorAction {
Repair,
}
data class HumanError( data class HumanError(
val title: String, val title: String,
val body: String, val body: String,
val retryable: Boolean = false, val retryable: Boolean = false,
val actionLabel: String? = null, val actionLabel: String? = null,
val action: HumanErrorAction? = null,
) )
private fun titlePrefix(context: String?, ctx: Context?): String = ctx?.let { c -> private fun titlePrefix(context: String?, ctx: Context?): String = ctx?.let { c ->
@@ -121,7 +116,6 @@ private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): Hum
body = "Your session is no longer valid — re-pair this device", body = "Your session is no longer valid — re-pair this device",
retryable = false, retryable = false,
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair", actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
action = HumanErrorAction.Repair,
) )
"403" in msg || "forbidden" in msg -> HumanError( "403" in msg || "forbidden" in msg -> HumanError(
title = ctx?.getString(R.string.error_classify_not_allowed) ?: "Not allowed", title = ctx?.getString(R.string.error_classify_not_allowed) ?: "Not allowed",
@@ -277,7 +271,6 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
body = "The server certificate changed since you paired — re-pair to trust it", body = "The server certificate changed since you paired — re-pair to trust it",
retryable = false, retryable = false,
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair", actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
action = HumanErrorAction.Repair,
) )
is SecurityException -> HumanError( is SecurityException -> HumanError(
title = ctx?.getString(R.string.error_classify_perm_needed) ?: "Permission needed", title = ctx?.getString(R.string.error_classify_perm_needed) ?: "Permission needed",
@@ -212,12 +212,9 @@ internal fun resolveEffectiveDashboardUrl(
endpoint?.dashboard?.url endpoint?.dashboard?.url
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?.let { return it } ?.let { return it }
endpoint?.api?.url?.let { apiUrl -> endpoint?.api?.url
connection.dashboardUrl ?.let(Connection::deriveDefaultDashboardUrl)
?.takeIf { it.isNotBlank() && Connection.urlsShareHost(it, apiUrl) } ?.let { return it }
?.let { return it }
Connection.deriveDefaultDashboardUrl(apiUrl)?.let { return it }
}
return connection.resolvedDashboardUrl return connection.resolvedDashboardUrl
} }
@@ -791,7 +788,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
apiServerUrl = apiServerUrl, apiServerUrl = apiServerUrl,
relayUrl = relayUrl, relayUrl = relayUrl,
extraApiUrls = extraApiUrls, extraApiUrls = extraApiUrls,
dashboardUrl = activeConnection.value?.resolvedDashboardUrl,
), ),
existing = activeConnection.value?.routeCandidates.orEmpty(), existing = activeConnection.value?.routeCandidates.orEmpty(),
) )
@@ -4690,21 +4686,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
} else { } else {
current.dashboardUrl current.dashboardUrl
} }
val newRouteCandidates = Connection.reconcileDashboardRoutes(
dashboardUrl = newDashboardUrl,
candidates = payload.endpoints.orEmpty(),
)
val needsUpdate = current.apiServerUrl != payload.serverUrl || val needsUpdate = current.apiServerUrl != payload.serverUrl ||
current.relayUrl != newRelayUrl || current.relayUrl != newRelayUrl ||
current.dashboardUrl != newDashboardUrl || current.dashboardUrl != newDashboardUrl ||
current.routeCandidates != newRouteCandidates current.routeCandidates != payload.endpoints.orEmpty()
if (needsUpdate) { if (needsUpdate) {
connectionStore.updateConnection( connectionStore.updateConnection(
current.copy( current.copy(
apiServerUrl = payload.serverUrl, apiServerUrl = payload.serverUrl,
relayUrl = newRelayUrl, relayUrl = newRelayUrl,
dashboardUrl = newDashboardUrl, dashboardUrl = newDashboardUrl,
routeCandidates = newRouteCandidates, routeCandidates = payload.endpoints.orEmpty(),
preferredRouteRole = current.preferredRouteRole preferredRouteRole = current.preferredRouteRole
?.takeIf { preferred -> ?.takeIf { preferred ->
payload.endpoints.orEmpty().any { payload.endpoints.orEmpty().any {
@@ -4935,22 +4927,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
current.copy( current.copy(
label = nextLabel, label = nextLabel,
dashboardUrl = normalized, dashboardUrl = normalized,
routeCandidates = Connection.reconcileDashboardRoutes(
dashboardUrl = normalized,
candidates = current.routeCandidates.ifEmpty {
listOfNotNull(
Connection.endpointCandidateFromDashboardUrl(
role = Connection.inferRouteRole(normalized),
priority = 0,
dashboardUrl = normalized,
apiServerUrl = current.apiServerUrl
.takeIf { it.isNotBlank() },
relayUrl = current.relayUrl
.takeIf { it.isNotBlank() },
),
)
},
),
), ),
) )
probeStandardVoice() probeStandardVoice()
@@ -6140,6 +6116,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
) { ) {
val activeId = connectionStore.activeConnectionId.value ?: return val activeId = connectionStore.activeConnectionId.value ?: return
val current = connectionStore.connections.value.firstOrNull { it.id == activeId } ?: return val current = connectionStore.connections.value.firstOrNull { it.id == activeId } ?: return
val nextRouteCandidates = routeCandidates ?: current.routeCandidates
val nextPreferredRouteRole = when {
preferredRouteRole != null -> preferredRouteRole.takeIf { it.isNotBlank() }
routeCandidates != null &&
current.preferredRouteRole != null &&
nextRouteCandidates.none {
it.role.equals(current.preferredRouteRole, ignoreCase = true)
} -> null
else -> current.preferredRouteRole
}
val nextDashboardUrl = when { val nextDashboardUrl = when {
dashboardUrlOverride != null -> { dashboardUrlOverride != null -> {
dashboardUrlOverride dashboardUrlOverride
@@ -6153,19 +6139,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
} }
else -> current.dashboardUrl else -> current.dashboardUrl
} }
val nextRouteCandidates = Connection.reconcileDashboardRoutes(
dashboardUrl = nextDashboardUrl,
candidates = routeCandidates ?: current.routeCandidates,
)
val nextPreferredRouteRole = when {
preferredRouteRole != null -> preferredRouteRole.takeIf { it.isNotBlank() }
routeCandidates != null &&
current.preferredRouteRole != null &&
nextRouteCandidates.none {
it.role.equals(current.preferredRouteRole, ignoreCase = true)
} -> null
else -> current.preferredRouteRole
}
if ( if (
current.apiServerUrl == apiServerUrl && current.apiServerUrl == apiServerUrl &&
current.relayUrl == relayUrl && current.relayUrl == relayUrl &&
@@ -111,75 +111,6 @@ class ConnectionDashboardFieldsTest {
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay?.url) assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay?.url)
} }
@Test
fun buildRouteCandidates_preservesExplicitSameHostHttpsDashboard() {
val routes = Connection.buildRouteCandidates(
apiServerUrl = "https://hermes.example.com:8643",
relayUrl = "wss://hermes.example.com:8767",
dashboardUrl = "https://hermes.example.com:443",
)
assertEquals(1, routes.size)
assertEquals("https://hermes.example.com:443", routes.single().dashboard?.url)
assertEquals("https://hermes.example.com:8643", routes.single().api?.url)
}
@Test
fun reconcileDashboardRoutes_repairsStoredSameHostDerivedPort() {
val stored = Connection.buildRouteCandidates(
apiServerUrl = "https://hermes.example.com:8643",
relayUrl = "wss://hermes.example.com:8767",
)
val repaired = Connection.reconcileDashboardRoutes(
dashboardUrl = "https://hermes.example.com:443",
candidates = stored,
)
assertEquals("https://hermes.example.com:443", repaired.single().dashboard?.url)
}
@Test
fun reconcileDashboardRoutes_keepsDifferentHostRoamingDashboard() {
val stored = Connection.buildRouteCandidates(
apiServerUrl = "http://100.71.8.56:8642",
relayUrl = "ws://100.71.8.56:8767",
)
val repaired = Connection.reconcileDashboardRoutes(
dashboardUrl = "https://hermes.example.com:443",
candidates = stored,
)
assertEquals("http://100.71.8.56:9119", repaired.single().dashboard?.url)
}
@Test
fun persistedSecureDashboard_repairsDerivedGatewayRouteOnReload() {
val stored = Connection(
id = "conn-https",
label = "Secure Hermes",
apiServerUrl = "https://hermes.example.com:8643",
relayUrl = "wss://hermes.example.com:8767",
tokenStoreKey = "hermes_auth_https",
dashboardUrl = "https://hermes.example.com:443",
routeCandidates = Connection.buildRouteCandidates(
apiServerUrl = "https://hermes.example.com:8643",
relayUrl = "wss://hermes.example.com:8767",
),
)
val reloaded = json.decodeFromString<Connection>(
json.encodeToString(Connection.serializer(), stored),
).withDashboardDefaults()
assertEquals("https://hermes.example.com:443", reloaded.dashboardUrl)
assertEquals(
"https://hermes.example.com:443",
reloaded.routeCandidates.single().dashboard?.url,
)
}
@Test @Test
fun dashboardRouteBuilder_acceptsBareTailscaleHostWithoutOptionalSurfaces() { fun dashboardRouteBuilder_acceptsBareTailscaleHostWithoutOptionalSurfaces() {
val route = Connection.endpointCandidateFromDashboardUrl( val route = Connection.endpointCandidateFromDashboardUrl(
@@ -1517,49 +1517,6 @@ class ChatHandlerTest {
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size) assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
} }
@Test
fun loadMessageHistory_coalescesReplayedDomainIdWithoutLosingOrderOrContent() {
val replayedId = "2c93af28-0b0b-436b-a112-7f164cac931d"
handler.loadMessageHistory(
listOf(
MessageItem(
id = "user-1",
role = "user",
content = JsonPrimitive("question"),
timestamp = 1.0,
),
MessageItem(
id = replayedId,
role = "assistant",
content = JsonPrimitive("partial answer"),
timestamp = 2.0,
),
MessageItem(
id = "system-1",
role = "system",
content = JsonPrimitive("distinct visible content"),
timestamp = 3.0,
),
// Rejoin replay of the same persisted message. The latest
// snapshot is authoritative, but its first transcript position
// and Compose identity must remain stable.
MessageItem(
id = replayedId,
role = "assistant",
content = JsonPrimitive("final answer"),
timestamp = 4.0,
),
)
)
val messages = handler.messages.value
assertEquals(listOf("user-1", replayedId, "system-1"), messages.map { it.id })
assertEquals("final answer", messages[1].content)
assertEquals("distinct visible content", messages[2].content)
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
}
@Test @Test
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() { fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
// Once the first reload adopts the server id, subsequent reloads match by // Once the first reload adopts the server id, subsequent reloads match by
@@ -68,36 +68,10 @@ class NativeDashboardAuthTest {
assertEquals("http://127.0.0.1:43123/callback", query["redirect_uri"]) assertEquals("http://127.0.0.1:43123/callback", query["redirect_uri"])
assertEquals("nous", query["provider"]) assertEquals("nous", query["provider"])
assertTrue(query.getValue("state").length >= 32) assertTrue(query.getValue("state").length >= 32)
assertEquals(43, query.getValue("code_challenge").length) assertTrue(query.getValue("code_challenge").length >= 43)
assertFalse(query.getValue("code_challenge").contains('='))
assertNotEquals(query["state"], query["code_challenge"]) assertNotEquals(query["state"], query["code_challenge"])
} }
@Test
fun canonicalNousCallbackBase_usesSecurePublicOriginAndPreservesPrefix() {
val location = "https://portal.nousresearch.com/oauth/authorize" +
"?redirect_uri=https%3A%2F%2Fhermes.example.test%2Fgateway%2Fauth%2Fcallback"
assertEquals(
"https://hermes.example.test/gateway",
canonicalDashboardBaseFromNousRedirect(location),
)
assertEquals(
null,
canonicalDashboardBaseFromNousRedirect(
"https://portal.nousresearch.com/oauth/authorize" +
"?redirect_uri=http%3A%2F%2Fhermes.example.test%2Fauth%2Fcallback",
),
)
assertEquals(
null,
canonicalDashboardBaseFromNousRedirect(
"https://attacker.example/oauth/authorize" +
"?redirect_uri=https%3A%2F%2Fhermes.example.test%2Fauth%2Fcallback",
),
)
}
@Test(expected = IllegalArgumentException::class) @Test(expected = IllegalArgumentException::class)
fun beginAuthorization_rejectsHostnameLoopback() { fun beginAuthorization_rejectsHostnameLoopback() {
NativeDashboardAuthClient(server.url("/").toString(), store) NativeDashboardAuthClient(server.url("/").toString(), store)
@@ -129,7 +103,6 @@ class NativeDashboardAuthTest {
.digest(verifier.toByteArray(Charsets.US_ASCII)) .digest(verifier.toByteArray(Charsets.US_ASCII))
.toByteString() .toByteString()
.base64Url() .base64Url()
.trimEnd('=')
val authorizeChallenge = java.net.URI(authorization.authorizationUrl).rawQuery val authorizeChallenge = java.net.URI(authorization.authorizationUrl).rawQuery
.split("&") .split("&")
.first { it.startsWith("code_challenge=") } .first { it.startsWith("code_challenge=") }
@@ -115,28 +115,7 @@ class NativeDashboardSignInCoordinatorTest {
) )
assertTrue(isNativeDashboardTransportEligible("https://hermes.example.test/prefix")) assertTrue(isNativeDashboardTransportEligible("https://hermes.example.test/prefix"))
assertTrue(isNativeDashboardTransportEligible("http://127.0.0.1:9119")) assertTrue(isNativeDashboardTransportEligible("http://127.0.0.1:9119"))
assertTrue(isNativeDashboardTransportEligible("http://172.16.24.250:9119"))
assertTrue(isNativeDashboardTransportEligible("http://100.71.8.56:9119"))
assertFalse(isNativeDashboardTransportEligible("http://hermes.local:9119")) assertFalse(isNativeDashboardTransportEligible("http://hermes.local:9119"))
assertFalse(isNativeDashboardTransportEligible("http://203.0.113.10:9119"))
}
@Test
fun androidRedirectMode_usesBrowserForNous_andCookieFlowForSelfHostedOidc() {
val flows = listOf("cookie", "native_pkce")
assertEquals(
DashboardRedirectAuthMode.NativePkce,
androidDashboardRedirectAuthMode("nous", flows),
)
assertEquals(
DashboardRedirectAuthMode.WebView,
androidDashboardRedirectAuthMode("oidc", flows),
)
assertEquals(
DashboardRedirectAuthMode.WebView,
androidDashboardRedirectAuthMode("nous", listOf("cookie")),
)
} }
private suspend fun completeSignIn( private suspend fun completeSignIn(
@@ -1,114 +0,0 @@
package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardCookieJar
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class DashboardWebViewAuthPolicyTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun selfHostedOidc_usesDashboardLoginWithoutNativeOrLoopbackParameters() {
val url = DashboardApiClient.authLoginUrl(
baseUrl = "https://hermes.example.test",
provider = "self-hosted",
next = "/",
)
assertEquals(
"https://hermes.example.test/auth/login?provider=self-hosted&next=%2F",
url,
)
assertFalse(url.contains("/auth/native/authorize"))
assertFalse(url.contains("redirect_uri"))
assertFalse(url.contains("127.0.0.1"))
}
@Test
fun publicDashboardCallback_importsCookieAndVerifiesAuthenticatedSession() = runTest {
assertEquals(
DashboardWebViewAuthNavigation.ImportAndVerify,
dashboardWebViewAuthNavigation(
"https://hermes.example.test",
"https://hermes.example.test/auth/callback?code=public-code&state=public-state",
),
)
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
"""{"authenticated":true,"username":"operator","provider":"self-hosted"}""",
),
)
val store = InMemoryDashboardCookieStore()
val callbackUrl = server.url("/auth/callback?code=public-code").toString()
assertEquals(
1,
importDashboardCookieHeader(
store = store,
url = callbackUrl,
cookieHeader = "hermes_session=authenticated",
),
)
val client = DashboardApiClient(
baseUrl = server.url("/").toString(),
okHttpClient = OkHttpClient.Builder()
.cookieJar(DashboardCookieJar(store))
.build(),
)
val session = client.currentSession().getOrThrow()
assertTrue(session.authenticated)
val request = server.takeRequest()
assertEquals("/api/auth/me", request.path)
assertEquals("hermes_session=authenticated", request.getHeader("Cookie"))
client.shutdown()
}
@Test
fun foreignLoopbackCallbacksAreRejectedWhileProviderPagesContinue() {
val dashboard = "https://hermes.example.test"
listOf(
"http://127.0.0.1:40179/callback?code=code",
"http://localhost:40179/callback?code=code",
"http://[::1]:40179/callback?code=code",
).forEach { callback ->
assertEquals(
callback,
DashboardWebViewAuthNavigation.RejectLoopbackCallback,
dashboardWebViewAuthNavigation(dashboard, callback),
)
}
assertEquals(
DashboardWebViewAuthNavigation.Continue,
dashboardWebViewAuthNavigation(
dashboard,
"https://auth.example.test/application/o/authorize/",
),
)
}
}
@@ -4,7 +4,6 @@ import java.io.IOException
import java.net.ConnectException import java.net.ConnectException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
import javax.net.ssl.SSLException
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -61,15 +60,6 @@ class RelayErrorClassifierTest {
assertEquals("Session expired", err.title) assertEquals("Session expired", err.title)
assertTrue(err.body.contains("re-pair", ignoreCase = true)) assertTrue(err.body.contains("re-pair", ignoreCase = true))
assertEquals(HumanErrorAction.Repair, err.action)
}
@Test
fun certificateMismatchExposesRepairAction() {
val err = classifyError(SSLException("certificate changed"))
assertEquals("Certificate mismatch", err.title)
assertEquals(HumanErrorAction.Repair, err.action)
} }
@Test @Test
@@ -54,25 +54,7 @@ class EffectiveDashboardRouteTest {
} }
@Test @Test
fun `selected API-only route keeps explicit same-host secure dashboard`() { fun `selected API-only route derives dashboard even when primary dashboard is explicit`() {
val connection = connection(
dashboardUrl = "https://hermes.example.com:443",
apiServerUrl = "https://hermes.example.com:8643",
)
val fallback = EndpointCandidate(
role = "public",
priority = 1,
api = ApiEndpoint("hermes.example.com", 8643, tls = true),
)
assertEquals(
"https://hermes.example.com:443",
resolveEffectiveDashboardUrl(connection, fallback),
)
}
@Test
fun `selected API-only route derives dashboard for a different route host`() {
val connection = connection( val connection = connection(
dashboardUrl = "http://192.168.1.20:9119", dashboardUrl = "http://192.168.1.20:9119",
apiServerUrl = "http://192.168.1.20:8642", apiServerUrl = "http://192.168.1.20:8642",
+2 -2
View File
@@ -1,6 +1,6 @@
plugins { plugins {
id("com.android.application") version "9.3.1" apply false id("com.android.application") version "9.3.0" apply false
id("com.android.library") version "9.3.1" apply false id("com.android.library") version "9.3.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" apply false
} }
+1 -53
View File
@@ -2169,7 +2169,7 @@ An API endpoint or Relay can be added later without recreating the connection.
## ADR 39 — Android dashboard redirect auth uses native PKCE ## ADR 39 — Android dashboard redirect auth uses native PKCE
**Status:** Superseded by ADR 40 (2026-07-27). **Status:** Accepted (2026-07-25).
**Context.** Android originally completed redirect-provider dashboard sign-in **Context.** Android originally completed redirect-provider dashboard sign-in
inside a WebView and imported cookies. Current upstream Gateway can advertise a inside a WebView and imported cookies. Current upstream Gateway can advertise a
@@ -2202,55 +2202,3 @@ socket.
is offered. is offered.
- Older upstream versions remain usable through the explicitly identified - Older upstream versions remain usable through the explicitly identified
WebView compatibility path. WebView compatibility path.
---
## ADR 40 — Android dashboard redirect auth is provider-compatible
**Status:** Amended (2026-07-28).
**Context.** Upstream advertises `native_pkce` in `/api/status.auth_flows` for
its desktop client. The corresponding `/auth/native/*` broker is explicitly a
desktop system-browser flow: it redirects to a loopback listener owned by the
desktop process and returns bearer tokens rather than dashboard cookies.
Android incorrectly treated that server-wide capability as a platform-neutral
mode selector, so redirect providers such as self-hosted OIDC were sent through
the desktop loopback contract.
**Decision.** Android redirect-provider sign-in uses the upstream dashboard
cookie flow by default:
- open `/auth/login?provider=...&next=...` in a full-screen embedded sign-in
destination with a normal app bar rather than a modal WebView;
- allow the provider to return through the dashboard's public
`/auth/callback`;
- import only cookies observed on the configured dashboard origin;
- verify the imported session through `/api/auth/me`;
- reject a foreign `http://127.0.0.1`, `localhost`, or `[::1]` `/callback`
navigation instead of following or importing it.
Android does not select `/auth/native/authorize` merely because it appears in
`auth_flows`. Self-hosted OIDC remains on the cookie contract above. Nous Portal
is the narrow exception: its Cloudflare Turnstile challenge rejects embedded
Android WebViews, so Android uses the gateway-brokered native PKCE route for
that provider when advertised and opens it in a system Custom Tab. The
ephemeral loopback listener, S256 verifier, state validation, encrypted bearer
store, and exact-origin attachment remain app-owned. Public cleartext
dashboards are rejected; explicitly configured RFC 1918 and Tailscale-IP
dashboard routes retain the same HTTP allowance as their existing cookie
sessions. If the provider redirect from a private route declares a canonical
HTTPS dashboard callback, Android begins browser authorization on that
canonical origin so the temporary PKCE cookie and callback remain same-origin;
the one-time code exchange and resulting exact-origin bearer stay bound to the
active private route.
**Consequences.**
- Self-hosted OIDC uses the same public callback registered for the dashboard.
- Android Manage, Chat, Voice, and onboarding continue to share one verified
dashboard cookie session.
- A server-wide desktop capability can no longer switch Android into a
loopback callback flow.
- Android retains a full-screen embedded WebView for compatible dashboard
cookie providers, while providers that prohibit embedding use the explicit
brokered native route.
+5 -5
View File
@@ -85,12 +85,12 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters): Paste into Play Console → **What's new** (≤500 characters):
``` ```
v1.5.2 - Sign in without detours v1.5.1 - Voice and chat stay in place
* Reliable self-hosted OIDC and Nous Portal sign-in. * Voice Focus and full Conversation layouts.
* Secure system-browser flow for Nous provider challenges. * Reliable Standard Voice narration after generation.
* Private-LAN and Tailscale dashboard route support. * Realtime background work without blocked voice controls.
* Replayed chat updates no longer duplicate rows. * Formatted streamed answers stay at their completed end.
``` ```
## Category ## Category
+10 -15
View File
@@ -170,21 +170,16 @@ Phone control — mirrors upstream relay protocol.
### 3.3 Auth Flow ### 3.3 Auth Flow
Dashboard/Gateway redirect authentication is provider-compatible. Nous Portal, Dashboard/Gateway redirect providers use the upstream native PKCE contract when
which relies on a challenge that rejects embedded Android WebViews, uses the `GET /api/status` advertises `native_pkce`. Android opens the selected provider
upstream brokered `native_pkce` flow in a system Custom Tab when the dashboard in a Custom Tab and owns a single ephemeral callback on
advertises it. The app owns an ephemeral loopback callback and stores the `http://127.0.0.1:<os-assigned-port>/callback`. PKCE verifier and CSRF state
resulting bearer session only for that connection and exact dashboard origin. exist only for that sign-in coroutine. Access and refresh tokens are encrypted
Self-hosted OIDC remains on the dashboard cookie flow: Android opens per connection and are attached only to the exact trusted dashboard base for
`/auth/login` in a full-screen embedded browser destination, lets the provider Manage, Gateway tickets, and standard voice. Native exchange is allowed only
return through the public `/auth/callback`, imports only same-origin cookies, for HTTPS dashboard addresses (plus literal loopback for development). A
and verifies them through `/api/auth/me`. HTTPS is required on public routes; gateway without the capability uses the legacy cookie/WebView flow; a failed
explicit private-LAN and Tailscale-IP dashboards may use their existing HTTP native attempt never silently downgrades.
transport. When such a private route advertises a canonical HTTPS Nous callback,
Android starts the browser on that canonical origin so Hermes' temporary PKCE
cookie and the provider callback remain same-origin, then exchanges the
one-time code through the active private route. The verified session is shared
by Manage, Gateway tickets, and standard voice.
Pairing is QR-driven. The operator runs the pair command on the host — `hermes pair`, `/hermes-relay-pair` from any Hermes chat surface, or the compatibility `hermes-pair` shell shim. All share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (plus the API server credentials and optional dashboard URL) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge. Pairing is QR-driven. The operator runs the pair command on the host — `hermes pair`, `/hermes-relay-pair` from any Hermes chat surface, or the compatibility `hermes-pair` shell shim. All share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (plus the API server credentials and optional dashboard URL) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge.
+4 -4
View File
@@ -1,7 +1,7 @@
[versions] [versions]
appVersionName = "1.5.2" appVersionName = "1.5.1"
appVersionCode = "35" appVersionCode = "34"
agp = "9.3.1" agp = "9.3.0"
kotlin = "2.4.10" kotlin = "2.4.10"
compose-bom = "2026.06.01" compose-bom = "2026.06.01"
navigation-compose = "2.9.8" navigation-compose = "2.9.8"
@@ -15,7 +15,7 @@ security-crypto = "1.1.0"
tink-android = "1.23.0" tink-android = "1.23.0"
lifecycle = "2.11.0" lifecycle = "2.11.0"
activity-compose = "1.13.0" activity-compose = "1.13.0"
browser = "1.10.0" browser = "1.9.0"
appcompat = "1.7.1" appcompat = "1.7.1"
core-ktx = "1.19.0" core-ktx = "1.19.0"
datastore = "1.2.1" datastore = "1.2.1"
+2 -2
View File
@@ -5,8 +5,8 @@ pluginManagement {
gradlePluginPortal() gradlePluginPortal()
} }
plugins { plugins {
id("com.android.application") version "9.3.1" id("com.android.application") version "9.3.0"
id("com.android.library") version "9.3.1" id("com.android.library") version "9.3.0"
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" id("org.jetbrains.kotlin.plugin.compose") version "2.4.10"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
} }