Compare commits

..
Author SHA1 Message Date
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 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
20 changed files with 120 additions and 302 deletions
+6
View File
@@ -10,6 +10,12 @@ 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.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 ## [Android 1.5.2] - 2026-07-28
### Fixed ### Fixed
+8 -15
View File
@@ -1,22 +1,15 @@
# Hermes-Relay — Dev Log # Hermes-Relay — Dev Log
## 2026-07-28 — Android 1.5.2 production release ## 2026-07-30 — Voice transcript identity alignment
Android 1.5.2 shipped from the approved `dev` to `main` release tree as Android voice Focus mode now keys transcript rows with the same stable UI
versionCode 35. The release adds provider-aware Dashboard sign-in: Nous uses identity as the main Chat list. A live row may adopt its persisted server
the advertised native PKCE system-browser flow, while compatible self-hosted message ID during history reconciliation while retaining its original Compose
providers retain cookie-backed full-page Dashboard authentication. Callback identity; using the mutable domain ID in the voice overlay could otherwise
origin discovery remains server-driven, private-network HTTP compatibility is collide during that transition and close the app.
preserved, and arbitrary public HTTP redirects remain rejected.
The private Play preflight validated the exact application tree before release Focused JVM coverage recreates two visible rows with a shared reconciled server
PR #265 merged. The immutable `android-v1.5.2` tag resolves to the resulting ID and verifies distinct stable transcript keys.
`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 ## 2026-07-27 — Android replayed-message identity reconciliation
+6 -10
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.5.2 # Hermes-Relay-Android v1.5.3
**Release Date:** July 28, 2026 **Release Date:** July 31, 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.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. The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,17 +12,13 @@ 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 prevents Voice Focus from closing when live chat rows receive their persisted server identities during history reconciliation.
## Fixed ## Fixed
- Self-hosted OIDC returns through the dashboard cookie flow instead of a desktop-only loopback callback. - Voice Focus now uses the same stable row identity as the main conversation, preventing duplicate-key crashes while live messages reconcile with persisted history.
- Nous Portal authentication opens in the system browser so provider security challenges can complete.
- Native PKCE uses standards-compatible unpadded Base64URL and preserves the dashboard's canonical HTTPS callback origin while keeping tokens scoped to the active route.
- Full-screen in-app sign-in remains available for compatible dashboard providers.
- 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.3** (versionCode **36**).
- 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. 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 @@
Hermes 仪表板登录现在可为自托管 OIDC 和 Nous Portal 可靠完成认证,并支持私有局域网与 Tailscale 路由。Nous 会在系统浏览器中安全打开,兼容的提供商仍可使用应用内全屏登录。重放的聊天更新不再产生重复会话行。 语音专注模式现在会在实时消息与已保存的聊天记录同步时保持稳定的列表标识,避免重复键导致应用关闭。
+13
View File
@@ -1,5 +1,18 @@
{ {
"versions": [ "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", "version": "1.5.2",
"title": "Sign in without detours", "title": "Sign in without detours",
+3 -5
View File
@@ -1,6 +1,4 @@
v1.5.2 - Sign in without detours v1.5.3 - Voice stays open
* Complete self-hosted OIDC sign-in through the dashboard callback. * Prevent Voice Focus from closing while live messages reconcile with chat history.
* Open Nous Portal securely in the system browser. * Keep transcript rows stable when they receive persisted server identities.
* Sign in over private-LAN and Tailscale dashboard routes.
* Keep replayed chat updates from duplicating conversation rows.
@@ -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
}
}
@@ -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
@@ -402,7 +402,12 @@ fun VoiceModeOverlay(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp), 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( CompactTranscriptRow(
message = msg, message = msg,
showThinking = showThinking, showThinking = showThinking,
@@ -729,6 +734,8 @@ internal fun pendingVoiceTranscriptText(
return if (alreadyRendered) null else transcribed return if (alreadyRendered) null else transcribed
} }
internal fun voiceTranscriptItemKey(message: ChatMessage): String = message.uiKey
@Composable @Composable
private fun InteractionMode.label(): String = when (this) { private fun InteractionMode.label(): String = when (this) {
InteractionMode.TapToTalk -> stringResource(R.string.voice_overlay_tap_to_talk) InteractionMode.TapToTalk -> stringResource(R.string.voice_overlay_tap_to_talk)
@@ -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()
}
} }
} }
@@ -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(
@@ -20,6 +20,32 @@ import org.junit.Test
class VoiceModeOverlayStateTest { 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 @Test
fun providerTranscript_isTranscribingAfterMicrophoneCaptureStops() { fun providerTranscript_isTranscribingAfterMicrophoneCaptureStops() {
assertEquals(VoiceState.Transcribing, realtimeTranscriptState(micCaptureActive = false)) assertEquals(VoiceState.Transcribing, realtimeTranscriptState(micCaptureActive = false))
@@ -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",
+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): Paste into Play Console → **What's new** (≤500 characters):
``` ```
v1.5.2 - Sign in without detours v1.5.3 - Voice stays open
* Reliable self-hosted OIDC and Nous Portal sign-in. * Prevent Voice Focus from closing during chat-history reconciliation.
* Secure system-browser flow for Nous provider challenges. * Keep live transcript rows stable when persisted identities arrive.
* Private-LAN and Tailscale dashboard route support.
* Replayed chat updates no longer duplicate rows.
``` ```
## Category ## Category
+2 -2
View File
@@ -1,6 +1,6 @@
[versions] [versions]
appVersionName = "1.5.2" appVersionName = "1.5.3"
appVersionCode = "35" appVersionCode = "36"
agp = "9.3.1" agp = "9.3.1"
kotlin = "2.4.10" kotlin = "2.4.10"
compose-bom = "2026.06.01" compose-bom = "2026.06.01"