Compare commits

...
Author SHA1 Message Date
Bailey Dixon f2b92b2755 Merge pull request #274 from Codename-11/fix/android-1.5.3-voice-transcript-keys
release(android): Android 1.5.3 duplicate-key hotfix
2026-07-31 19:03:40 -04:00
Bailey Dixon 8651656899 release(android): android-v1.5.3 2026-07-31 18:50:21 -04:00
Bailey Dixon b458d83fcc fix(android): stabilize voice transcript keys 2026-07-31 18:27:59 -04:00
Bailey Dixon aa26f7c9b6 Merge pull request #265 from Codename-11/dev
release(android): android-v1.5.2
2026-07-28 17:58:37 -04:00
Bailey Dixon bfb608bea6 release(android): android-v1.5.2 2026-07-28 17:30:59 -04:00
Bailey Dixon 95a95fe7d2 Merge pull request #264 from Codename-11/fix/android-nous-native-auth
fix(android): support Nous system-browser sign-in
2026-07-28 17:28:56 -04:00
Bailey Dixon 1bdf2ae71b fix(android): support Nous system-browser sign-in 2026-07-28 17:15:26 -04:00
Bailey Dixon f9e7a2f320 Merge pull request #263 from Codename-11/fix/android-duplicate-compose-keys
fix(android): coalesce replayed chat message ids
2026-07-27 11:38:06 -04:00
Bailey Dixon 988fac8522 Merge pull request #262 from Codename-11/fix/android-oidc-manage-callback
fix(android): keep dashboard OIDC on cookie flow
2026-07-27 11:37:43 -04:00
Bailey Dixon d4832a6a38 fix(android): coalesce replayed chat message ids 2026-07-27 09:30:39 -04:00
Bailey Dixon f9c8736e5b fix(android): keep dashboard OIDC on cookie flow 2026-07-27 08:57:17 -04:00
dependabot[bot] a815dd33fa build(deps): bump com.android.library from 9.3.0 to 9.3.1 (#261)
Bumps com.android.library from 9.3.0 to 9.3.1.

---
updated-dependencies:
- dependency-name: com.android.library
  dependency-version: 9.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:56:57 +00:00
dependabot[bot] cc9c75a636 build(deps): bump androidx.browser:browser from 1.9.0 to 1.10.0 (#260)
Bumps androidx.browser:browser from 1.9.0 to 1.10.0.

---
updated-dependencies:
- dependency-name: androidx.browser:browser
  dependency-version: 1.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:55:03 +00:00
dependabot[bot] 43179e03c0 build(deps): bump com.android.application from 9.3.0 to 9.3.1 (#259)
Bumps com.android.application from 9.3.0 to 9.3.1.

---
updated-dependencies:
- dependency-name: com.android.application
  dependency-version: 9.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:55:00 +00:00
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
23 changed files with 737 additions and 128 deletions
+13
View File
@@ -10,6 +10,19 @@ 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.
## [Android 1.5.3] - 2026-07-31
### Fixed
- **Voice transcripts retain stable rows after chat-history reconciliation.** Focus mode uses the same stable Compose identity as the main conversation, preventing duplicate-key crashes when live rows adopt persisted server IDs.
## [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
### Added
+23
View File
@@ -1,5 +1,28 @@
# Hermes-Relay — Dev Log
## 2026-07-30 — Voice transcript identity alignment
Android voice Focus mode now keys transcript rows with the same stable UI
identity as the main Chat list. A live row may adopt its persisted server
message ID during history reconciliation while retaining its original Compose
identity; using the mutable domain ID in the voice overlay could otherwise
collide during that transition and close the app.
Focused JVM coverage recreates two visible rows with a shared reconciled server
ID and verifies distinct stable transcript keys.
## 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
Android 1.5.1 reconciles the post-1.5.0 voice and chat fixes into versionCode
+6 -15
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.5.1
# Hermes-Relay-Android v1.5.3
**Release Date:** July 26, 2026
**Release Date:** July 31, 2026
## Download
> 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).
> Installing on your phone? Download `hermes-relay-1.5.3-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.
@@ -12,22 +12,13 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
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.
This patch prevents Voice Focus from closing when live chat rows receive their persisted server identities during history reconciliation.
## Fixed
- Standard Voice narrates valid completed assistant responses instead of losing them during the generation-to-speech handoff.
- Realtime tasks promoted to background release the foreground spinner and microphone while progress and results remain reachable.
- Completed streamed answers switch to full Markdown and preserve the measured trailing edge instead of jumping to the start of the response.
- Assistant text uses the theme's full-contrast foreground with a more readable chat type scale.
- Voice Focus now uses the same stable row identity as the main conversation, preventing duplicate-key crashes while live messages reconcile with persisted history.
## Install / Verify
- App version: **1.5.1** (versionCode **34**).
- App version: **1.5.3** (versionCode **36**).
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
@@ -1 +1 @@
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.
Voice Focus now keeps transcript rows stable while live messages reconcile with persisted chat history, preventing the duplicate-key crash that could close the app.
@@ -1 +1 @@
可选择精简的语音专注视图或完整的对话视图。标准语音现在会可靠朗读已完成的回复,实时后台任务不再阻塞语音控制,流式回复会渲染 Markdown 并停留在完成位置。
语音专注模式现在会在实时消息与已保存的聊天记录同步时保持稳定的列表标识,避免重复键导致应用关闭。
+33
View File
@@ -1,5 +1,38 @@
{
"versions": [
{
"version": "1.5.3",
"title": "Voice stays open",
"date": "2026-07-31",
"sections": [
{
"header": "Stable voice transcripts",
"bullets": [
"Voice Focus keeps stable transcript rows while live messages reconcile with persisted chat history, preventing duplicate-key crashes that could close the app."
]
}
]
},
{
"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",
"title": "Voice and chat stay in place",
+3 -5
View File
@@ -1,6 +1,4 @@
v1.5.1 - Voice and chat stay in place
v1.5.3 - Voice stays open
* Choose a compact Voice Focus view or the complete Conversation renderer.
* Hear Standard Voice replies reliably after generation completes.
* Keep Realtime background work active without blocking voice controls.
* Read formatted streamed answers without jumping back to their beginning.
* Prevent Voice Focus from closing while live messages reconcile with chat history.
* Keep transcript rows stable when they receive persisted server identities.
@@ -1223,6 +1223,14 @@ class ChatHandler {
// so we can attach results back to the originating assistant message's ToolCall
val toolResults = items.filter { it.role == "tool" }
.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
// the wholesale `_messages.value = ...` assignment so the ViewModel's
@@ -1240,8 +1248,8 @@ class ChatHandler {
// silently misses those rows, so a gateway turn's tokens/badges survived
// only if a content match happened to cover them. See
// [reconcileLiveIdsToServer].
val serverItemIds = items.mapNotNullTo(HashSet()) { it.id }
val idRemap = reconcileLiveIdsToServer(items, serverItemIds)
val serverItemIds = renderedItems.mapNotNullTo(HashSet()) { it.id }
val idRemap = reconcileLiveIdsToServer(renderedItems, serverItemIds)
// Carry CLIENT-ONLY enrichment forward across the reload, keyed by the
// RECONCILED message id. The server transcript (MessageItem) rebuilds
@@ -1278,7 +1286,7 @@ class ChatHandler {
// clientOnly bubbles (same exchange, pre-sync copy).
val syncedRealtimeTurnContents = mutableSetOf<String>()
val loaded = items.mapNotNull { item ->
val loaded = renderedItems.mapNotNull { item ->
val displayKind = item.displayKind?.trim()?.lowercase()
if (displayKind == "hidden") return@mapNotNull null
val role = when {
@@ -1540,6 +1548,34 @@ 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. */
private class ReconcileSlot(
val serverId: String,
@@ -108,13 +108,18 @@ class NativeDashboardAuthClient(
provider: String? = null,
): NativeDashboardAuthorization {
requireStrictLoopbackRedirect(redirectUri)
val verifier = randomBytes(32).base64Url()
// RFC 7636 uses unpadded Base64URL. Okio's base64Url() preserves
// 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")
.digest(verifier.toByteArray(Charsets.US_ASCII))
.toByteString()
.base64Url()
.trimEnd('=')
val state = randomBytes(24).base64Url()
val root = "$baseUrl/auth/native/authorize".toHttpUrlOrNull()
val authorizationBaseUrl = resolveAuthorizationBaseUrl(provider)
val root = "$authorizationBaseUrl/auth/native/authorize".toHttpUrlOrNull()
?: throw IOException("Dashboard URL is not a valid http(s) address")
val url = root.newBuilder()
.addQueryParameter("code_challenge", challenge)
@@ -130,6 +135,41 @@ class NativeDashboardAuthClient(
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(
authorization: NativeDashboardAuthorization,
callbackTarget: String,
@@ -280,7 +320,52 @@ internal class NativeDashboardCallbackException(
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
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,6 +33,24 @@ internal fun dashboardRedirectAuthMode(authFlows: List<String>): DashboardRedire
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.
*
@@ -402,7 +402,12 @@ fun VoiceModeOverlay(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(visibleTranscriptMessages, key = { it.id }) { msg ->
// Match ChatScreen's identity contract. A history
// reconcile can adopt the same authoritative server
// id into a live row while Compose still holds the
// pre-reconcile row for a frame. uiKey remains stable
// and unique across that transition.
items(visibleTranscriptMessages, key = ::voiceTranscriptItemKey) { msg ->
CompactTranscriptRow(
message = msg,
showThinking = showThinking,
@@ -729,6 +734,8 @@ internal fun pendingVoiceTranscriptText(
return if (alreadyRendered) null else transcribed
}
internal fun voiceTranscriptItemKey(message: ChatMessage): String = message.uiKey
@Composable
private fun InteractionMode.label(): String = when (this) {
InteractionMode.TapToTalk -> stringResource(R.string.voice_overlay_tap_to_talk)
@@ -1,18 +1,19 @@
package com.hermesandroid.relay.ui.screens
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
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.material3.Button
import androidx.compose.material3.Card
@@ -21,6 +22,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -43,7 +45,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep
@@ -51,10 +52,10 @@ import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.upstream.DashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator
import com.hermesandroid.relay.network.upstream.dashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.androidDashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -63,6 +64,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* Connection-level Dashboard authentication flow. It is deliberately outside
@@ -92,8 +94,8 @@ fun DashboardSignInScreen(
var actionMessage by remember { mutableStateOf<String?>(null) }
var actionIsError by remember { mutableStateOf(false) }
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
var redirectAuthMode by remember(dashboardUrl, connectionId) {
mutableStateOf(DashboardRedirectAuthMode.WebView)
var authFlows by remember(dashboardUrl, connectionId) {
mutableStateOf<List<String>>(emptyList())
}
var nativeSignInJob by remember(dashboardUrl, connectionId) { mutableStateOf<Job?>(null) }
var authenticationComplete by remember { mutableStateOf(false) }
@@ -150,7 +152,7 @@ fun DashboardSignInScreen(
providers = client.getAuthProviders().getOrNull()
?.takeIf { it.isNotEmpty() }
?: status.authProviderDetails
redirectAuthMode = dashboardRedirectAuthMode(status.authFlows)
authFlows = status.authFlows
val session = if (status.authRequired) client.currentSession().getOrNull() else null
connectionViewModel.recordDashboardStatus(
status = status,
@@ -196,7 +198,10 @@ fun DashboardSignInScreen(
fun startRedirectSignIn(provider: DashboardAuthProvider) {
if (actionInFlight || dashboardUrl.isBlank()) return
if (redirectAuthMode == DashboardRedirectAuthMode.WebView) {
if (
androidDashboardRedirectAuthMode(provider.name, authFlows) ==
DashboardRedirectAuthMode.WebView
) {
oauthProvider = provider
return
}
@@ -255,10 +260,8 @@ fun DashboardSignInScreen(
onDispose { nativeSignInJob?.cancel() }
}
oauthProvider
?.takeIf { redirectAuthMode == DashboardRedirectAuthMode.WebView }
?.let { provider ->
DashboardOAuthDialog(
oauthProvider?.let { provider ->
DashboardOAuthScreen(
dashboardUrl = dashboardUrl,
provider = provider,
cookieStoreFactory = cookieStoreFactory,
@@ -284,6 +287,7 @@ fun DashboardSignInScreen(
actionIsError = true
},
)
return
}
Scaffold(
@@ -291,10 +295,7 @@ fun DashboardSignInScreen(
TopAppBar(
title = { Text(stringResource(R.string.dashboard_sign_in)) },
navigationIcon = {
IconButton(onClick = {
nativeSignInJob?.cancel()
onBack()
}) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.dashboard_back),
@@ -323,9 +324,7 @@ fun DashboardSignInScreen(
actionInFlight = actionInFlight,
actionMessage = actionMessage,
actionIsError = actionIsError,
nativePkce = redirectAuthMode == DashboardRedirectAuthMode.NativePkce,
nativeSignInInFlight = nativeSignInJob != null,
nativeTransportEligible = isNativeDashboardTransportEligible(dashboardUrl),
onSignIn = ::submitPassword,
onOAuthSignIn = ::startRedirectSignIn,
onCancelNativeSignIn = {
@@ -398,9 +397,7 @@ private fun DashboardSignInForm(
actionInFlight: Boolean,
actionMessage: String?,
actionIsError: Boolean,
nativePkce: Boolean,
nativeSignInInFlight: Boolean,
nativeTransportEligible: Boolean,
onSignIn: (String, String, String) -> Unit,
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
onCancelNativeSignIn: () -> Unit,
@@ -429,18 +426,19 @@ private fun DashboardSignInForm(
redirectProviders.forEach { provider ->
Button(
onClick = { onOAuthSignIn(provider) },
enabled = !actionInFlight && (!nativePkce || nativeTransportEligible),
enabled = !actionInFlight,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
}
}
if (nativePkce && !nativeTransportEligible && redirectProviders.isNotEmpty()) {
Text(
text = stringResource(R.string.dashboard_native_signin_requires_https),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
if (nativeSignInInFlight) {
Button(
onClick = onCancelNativeSignIn,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.dashboard_cancel))
}
}
if (passwordProvider != null || providers.isEmpty()) {
if (redirectProviders.isNotEmpty()) HorizontalDivider()
@@ -478,18 +476,11 @@ private fun DashboardSignInForm(
},
)
}
if (nativeSignInInFlight) {
Button(
onClick = onCancelNativeSignIn,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.dashboard_cancel))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DashboardOAuthDialog(
private fun DashboardOAuthScreen(
dashboardUrl: String,
provider: DashboardAuthProvider,
cookieStoreFactory: () -> DashboardCookieStore,
@@ -506,6 +497,8 @@ private fun DashboardOAuthDialog(
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
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) {
DashboardApiClient.authLoginUrl(
baseUrl = dashboardUrl,
@@ -514,14 +507,17 @@ private fun DashboardOAuthDialog(
)
}
fun maybeVerify(url: String?) {
fun handleNavigation(url: String?) {
val loadedUrl = url?.takeIf { it.isNotBlank() } ?: return
val root = dashboardUrl.trim().trimEnd('/')
val relative = loadedUrl.trim().removePrefix(root)
val stillAuthenticating = relative.startsWith("/login", true) ||
relative.startsWith("/auth/login", true) ||
relative.startsWith("/auth/callback", true)
if (!loadedUrl.startsWith(root, true) || stillAuthenticating) return
when (dashboardWebViewAuthNavigation(dashboardUrl, loadedUrl)) {
DashboardWebViewAuthNavigation.Continue -> return
DashboardWebViewAuthNavigation.RejectLoopbackCallback -> {
statusText = notAcceptedStatus
onError(notAcceptedStatus)
return
}
DashboardWebViewAuthNavigation.ImportAndVerify -> Unit
}
val manager = CookieManager.getInstance()
manager.flush()
val imported = importDashboardCookieHeader(
@@ -551,39 +547,162 @@ private fun DashboardOAuthDialog(
}
}
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
BackHandler(onBack = onDismiss)
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
maybeVerify(url)
}
}
loadUrl(loginUrl)
}
},
DisposableEffect(Unit) {
onDispose {
webView?.stopLoading()
webView?.destroy()
webView = null
}
}
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
}
}
@@ -1517,6 +1517,49 @@ class ChatHandlerTest {
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
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
// Once the first reload adopts the server id, subsequent reloads match by
@@ -68,10 +68,36 @@ class NativeDashboardAuthTest {
assertEquals("http://127.0.0.1:43123/callback", query["redirect_uri"])
assertEquals("nous", query["provider"])
assertTrue(query.getValue("state").length >= 32)
assertTrue(query.getValue("code_challenge").length >= 43)
assertEquals(43, query.getValue("code_challenge").length)
assertFalse(query.getValue("code_challenge").contains('='))
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)
fun beginAuthorization_rejectsHostnameLoopback() {
NativeDashboardAuthClient(server.url("/").toString(), store)
@@ -103,6 +129,7 @@ class NativeDashboardAuthTest {
.digest(verifier.toByteArray(Charsets.US_ASCII))
.toByteString()
.base64Url()
.trimEnd('=')
val authorizeChallenge = java.net.URI(authorization.authorizationUrl).rawQuery
.split("&")
.first { it.startsWith("code_challenge=") }
@@ -115,7 +115,28 @@ class NativeDashboardSignInCoordinatorTest {
)
assertTrue(isNativeDashboardTransportEligible("https://hermes.example.test/prefix"))
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://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(
@@ -20,6 +20,32 @@ import org.junit.Test
class VoiceModeOverlayStateTest {
@Test
fun transcriptKeys_remainDistinctWhenRowsShareReconciledServerId() {
val serverId = "7c4af8b7-1bb2-4830-a4e5-0332d5ddcd1f"
val messages = listOf(
ChatMessage(
id = serverId,
uiKey = "persisted-assistant-row",
role = MessageRole.ASSISTANT,
content = "Earlier snapshot",
timestamp = 1L,
),
ChatMessage(
id = serverId,
uiKey = "live-assistant-row",
role = MessageRole.ASSISTANT,
content = "Reconciled live snapshot",
timestamp = 2L,
),
)
assertEquals(
listOf("persisted-assistant-row", "live-assistant-row"),
messages.map(::voiceTranscriptItemKey),
)
}
@Test
fun providerTranscript_isTranscribingAfterMicrophoneCaptureStops() {
assertEquals(VoiceState.Transcribing, realtimeTranscriptState(micCaptureActive = false))
@@ -0,0 +1,114 @@
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/",
),
)
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
plugins {
id("com.android.application") version "9.3.0" apply false
id("com.android.library") version "9.3.0" apply false
id("com.android.application") version "9.3.1" apply false
id("com.android.library") version "9.3.1" 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
}
+53 -1
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
**Status:** Accepted (2026-07-25).
**Status:** Superseded by ADR 40 (2026-07-27).
**Context.** Android originally completed redirect-provider dashboard sign-in
inside a WebView and imported cookies. Current upstream Gateway can advertise a
@@ -2202,3 +2202,55 @@ socket.
is offered.
- Older upstream versions remain usable through the explicitly identified
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.
+3 -5
View File
@@ -85,12 +85,10 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.5.1 - Voice and chat stay in place
v1.5.3 - Voice stays open
* Voice Focus and full Conversation layouts.
* Reliable Standard Voice narration after generation.
* Realtime background work without blocked voice controls.
* Formatted streamed answers stay at their completed end.
* Prevent Voice Focus from closing during chat-history reconciliation.
* Keep live transcript rows stable when persisted identities arrive.
```
## Category
+15 -10
View File
@@ -170,16 +170,21 @@ Phone control — mirrors upstream relay protocol.
### 3.3 Auth Flow
Dashboard/Gateway redirect providers use the upstream native PKCE contract when
`GET /api/status` advertises `native_pkce`. Android opens the selected provider
in a Custom Tab and owns a single ephemeral callback on
`http://127.0.0.1:<os-assigned-port>/callback`. PKCE verifier and CSRF state
exist only for that sign-in coroutine. Access and refresh tokens are encrypted
per connection and are attached only to the exact trusted dashboard base for
Manage, Gateway tickets, and standard voice. Native exchange is allowed only
for HTTPS dashboard addresses (plus literal loopback for development). A
gateway without the capability uses the legacy cookie/WebView flow; a failed
native attempt never silently downgrades.
Dashboard/Gateway redirect authentication is provider-compatible. Nous Portal,
which relies on a challenge that rejects embedded Android WebViews, uses the
upstream brokered `native_pkce` flow in a system Custom Tab when the dashboard
advertises it. The app owns an ephemeral loopback callback and stores the
resulting bearer session only for that connection and exact dashboard origin.
Self-hosted OIDC remains on the dashboard cookie flow: Android opens
`/auth/login` in a full-screen embedded browser destination, lets the provider
return through the public `/auth/callback`, imports only same-origin cookies,
and verifies them through `/api/auth/me`. HTTPS is required on public routes;
explicit private-LAN and Tailscale-IP dashboards may use their existing HTTP
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.
+4 -4
View File
@@ -1,7 +1,7 @@
[versions]
appVersionName = "1.5.1"
appVersionCode = "34"
agp = "9.3.0"
appVersionName = "1.5.3"
appVersionCode = "36"
agp = "9.3.1"
kotlin = "2.4.10"
compose-bom = "2026.06.01"
navigation-compose = "2.9.8"
@@ -15,7 +15,7 @@ security-crypto = "1.1.0"
tink-android = "1.23.0"
lifecycle = "2.11.0"
activity-compose = "1.13.0"
browser = "1.9.0"
browser = "1.10.0"
appcompat = "1.7.1"
core-ktx = "1.19.0"
datastore = "1.2.1"
+2 -2
View File
@@ -5,8 +5,8 @@ pluginManagement {
gradlePluginPortal()
}
plugins {
id("com.android.application") version "9.3.0"
id("com.android.library") version "9.3.0"
id("com.android.application") version "9.3.1"
id("com.android.library") version "9.3.1"
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
}