Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2b92b2755 | ||
|
|
8651656899 | ||
|
|
b458d83fcc | ||
|
|
aa26f7c9b6 | ||
|
|
325b8e5670 | ||
|
|
e9da59cf40 | ||
|
|
c9a03c9ed7 | ||
|
|
68f8b58a0b |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 会在系统浏览器中安全打开,兼容的提供商仍可使用应用内全屏登录。重放的聊天更新不再产生重复会话行。
|
语音专注模式现在会在实时消息与已保存的聊天记录同步时保持稳定的列表标识,避免重复键导致应用关闭。
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+26
@@ -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
|
||||||
|
|||||||
+1
-19
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user