Merge pull request #291 from Codename-11/fix/android-relay-session-recovery
fix: repair Relay recovery and Android session UX
This commit is contained in:
@@ -9,7 +9,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
### Fixed
|
||||
|
||||
- **Voice capture waits for the microphone to be released.** Manual recording no longer races barge-in teardown, and AudioRecord startup failures now explain how to free or permit the microphone before retrying.
|
||||
- **Android text selection stays stable as streamed replies finish.** Chat resets an active selection when live text becomes rich Markdown, preventing selection-handle drags from retaining removed text nodes.
|
||||
- **Android session history follows the upstream page-size contract.** The drawer keeps its 200-session window through bounded 100-row requests, avoiding HTTP 422 errors from current dashboard servers while preserving active-profile isolation.
|
||||
- **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 no longer mistakes optional-surface auth failures for expired Relay pairing.** Background session refreshes stay out of the global snackbar, Dashboard and API authorization errors name their owning credential, and Relay-only surfaces use consistent Optional, Ready, Reconnecting, Unavailable, and Needs re-pair states. Foreground recovery retries ordinary Relay backoff immediately while preserving server rate limits, and recovery prioritizes Dashboard or host session management while retained credentials are labeled as stored details instead of active pairing.
|
||||
- **Re-pairing repairs one device instead of accumulating duplicate sessions.** An explicit host-approved pair replaces older sessions and refresh credentials for the same device, while the Dashboard and `/relay revoke <token-prefix>` remain available for operator cleanup.
|
||||
|
||||
## [Android 1.6.0] - 2026-08-02
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.auth.CertPinStore
|
||||
@@ -20,6 +21,7 @@ import com.hermesandroid.relay.network.shared.EndpointSurface
|
||||
import com.hermesandroid.relay.network.shutdownOffMainThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -45,6 +47,20 @@ enum class ConnectionState {
|
||||
Reconnecting
|
||||
}
|
||||
|
||||
internal fun isRelayRateLimitBackoffActive(untilMs: Long, nowMs: Long): Boolean =
|
||||
untilMs > nowMs
|
||||
|
||||
internal fun canOverrideScheduledRelayReconnect(
|
||||
state: ConnectionState,
|
||||
backoffWaiting: Boolean,
|
||||
rateLimitBackoffActive: Boolean,
|
||||
): Boolean = !rateLimitBackoffActive && when (state) {
|
||||
ConnectionState.Disconnected -> true
|
||||
ConnectionState.Reconnecting -> backoffWaiting
|
||||
ConnectionState.Connecting,
|
||||
ConnectionState.Connected -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an OkHttp request for a relay socket URL, or `null` if the URL is
|
||||
* malformed. OkHttp's [Request.Builder.url] throws [IllegalArgumentException]
|
||||
@@ -163,6 +179,10 @@ class ConnectionManager(
|
||||
@Volatile
|
||||
private var serverUrl: String? = null
|
||||
private val reconnectState = RelayReconnectState()
|
||||
@Volatile
|
||||
private var reconnectJob: Job? = null
|
||||
@Volatile
|
||||
private var reconnectBackoffWaiting = false
|
||||
private var shouldReconnect = true
|
||||
// Last HTTP status seen during WSS upgrade, captured in onFailure.
|
||||
// Used by scheduleReconnect() to pick an appropriate backoff — notably
|
||||
@@ -170,6 +190,8 @@ class ConnectionManager(
|
||||
// we don't re-fill the ban bucket and brick our own auth window.
|
||||
@Volatile
|
||||
private var lastUpgradeResponseCode: Int? = null
|
||||
@Volatile
|
||||
private var rateLimitBackoffUntilMs: Long = 0L
|
||||
|
||||
// The relay requires the FIRST frame on a socket to be `system/auth` and
|
||||
// rejects the whole connection otherwise ("expected system/auth, got
|
||||
@@ -364,6 +386,41 @@ class ConnectionManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an ordinary scheduled reconnect with an immediate attempt.
|
||||
*
|
||||
* Foregrounding the app or opening Relay status is an explicit signal that
|
||||
* the route may be usable again, so exponential/slow-poll backoff should not
|
||||
* make the user wait. A server-issued 429 is different: retrying early would
|
||||
* extend the server block, so that protected backoff is never overridden.
|
||||
*/
|
||||
fun reconnectNowIfAllowed(url: String): Boolean {
|
||||
val rateLimitActive = isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
if (!canOverrideScheduledRelayReconnect(
|
||||
state = _connectionState.value,
|
||||
backoffWaiting = reconnectBackoffWaiting,
|
||||
rateLimitBackoffActive = rateLimitActive,
|
||||
)
|
||||
) {
|
||||
if (rateLimitActive) {
|
||||
Log.i(TAG, "reconnectNowIfAllowed: preserving rate-limit backoff")
|
||||
}
|
||||
return false
|
||||
}
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
// connectToUrlOnMainPath suppresses duplicate opens while the manager is
|
||||
// Reconnecting. Move to the honest idle state before starting the fresh
|
||||
// resolver/open path; the ViewModel's grace window prevents UI flicker.
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
connect(url)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [connect] but bypasses the resolver — used by the network-
|
||||
* change callback when we've already picked a winner and just want to
|
||||
@@ -405,6 +462,14 @@ class ConnectionManager(
|
||||
// hits the HTTP root and comes back as 404 Not Found during the
|
||||
// upgrade handshake. We still accept an explicit path if present.
|
||||
val normalized = normalizeRelayUrl(url)
|
||||
if (isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
) {
|
||||
Log.i(TAG, "connect: preserving active rate-limit backoff")
|
||||
return
|
||||
}
|
||||
val existingState = _connectionState.value
|
||||
if (serverUrl == normalized &&
|
||||
(existingState == ConnectionState.Connecting ||
|
||||
@@ -682,10 +747,30 @@ class ConnectionManager(
|
||||
if (relayResolved != null) activeRelayEndpoint = relayResolved
|
||||
val relayUrl = relayResolved?.relay?.url?.takeIf { it.isNotBlank() }
|
||||
?: return@launch
|
||||
if (isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
) {
|
||||
// Keep publishing the newly resolved standard/Relay routes, but
|
||||
// leave the protected retry job intact. It will resolve the
|
||||
// latest Relay winner again when the server cooldown expires.
|
||||
Log.i(TAG, "network change: preserving rate-limit retry job")
|
||||
return@launch
|
||||
}
|
||||
val normalizedNew = normalizeRelayUrl(relayUrl)
|
||||
if (normalizedNew != current) {
|
||||
Log.i(TAG, "network change: swapping $current → $normalizedNew")
|
||||
connectToUrlOnMainPath(relayUrl, closeReason)
|
||||
if (reconnectBackoffWaiting) {
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
}
|
||||
connectToUrlOnMainPath(
|
||||
relayUrl,
|
||||
closeReason,
|
||||
preserveReconnectBackoff = true,
|
||||
)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
reconnectGate()
|
||||
) {
|
||||
@@ -783,6 +868,11 @@ class ConnectionManager(
|
||||
|
||||
fun disconnect() {
|
||||
shouldReconnect = false
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
rateLimitBackoffUntilMs = 0L
|
||||
lastUpgradeResponseCode = null
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
@@ -838,13 +928,22 @@ class ConnectionManager(
|
||||
url: String,
|
||||
previousSocketToClose: WebSocket? = null,
|
||||
replaceReason: String = "Relay socket replaced",
|
||||
scheduledReconnect: Boolean = false,
|
||||
) {
|
||||
if (isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
) {
|
||||
Log.i(TAG, "doConnect: preserving active rate-limit backoff")
|
||||
return
|
||||
}
|
||||
val existingState = _connectionState.value
|
||||
if (previousSocketToClose == null &&
|
||||
serverUrl == url &&
|
||||
(existingState == ConnectionState.Connecting ||
|
||||
existingState == ConnectionState.Connected ||
|
||||
existingState == ConnectionState.Reconnecting)
|
||||
(existingState == ConnectionState.Reconnecting && !scheduledReconnect))
|
||||
) {
|
||||
Log.i(TAG, "doConnect: already ${existingState.name.lowercase()} to $url — skipping duplicate open")
|
||||
return
|
||||
@@ -908,6 +1007,10 @@ class ConnectionManager(
|
||||
return
|
||||
}
|
||||
reconnectState.connected(url)
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
rateLimitBackoffUntilMs = 0L
|
||||
lastUpgradeResponseCode = null
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
Log.i(TAG, "onOpen: WSS handshake complete ($url)")
|
||||
@@ -1067,6 +1170,7 @@ class ConnectionManager(
|
||||
// block window instead of re-filling the ban bucket at our normal
|
||||
// cadence.
|
||||
lastUpgradeResponseCode == 429 -> {
|
||||
rateLimitBackoffUntilMs = SystemClock.elapsedRealtime() + RATE_LIMIT_BACKOFF_MS
|
||||
Log.i(TAG, "scheduleReconnect: rate-limited (429) — backing off ${RATE_LIMIT_BACKOFF_MS}ms")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
@@ -1104,8 +1208,11 @@ class ConnectionManager(
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
reconnectJob?.cancel()
|
||||
reconnectBackoffWaiting = true
|
||||
val scheduledJob = scope.launch {
|
||||
delay(backoffMs)
|
||||
reconnectBackoffWaiting = false
|
||||
// Re-check the gate after the backoff — by the time the delay
|
||||
// expires, auth state may have changed (e.g., user hit Revoke
|
||||
// during the retry window).
|
||||
@@ -1128,12 +1235,19 @@ class ConnectionManager(
|
||||
preserveReconnectBackoff = true,
|
||||
)
|
||||
} else {
|
||||
doConnect(url)
|
||||
doConnect(url, scheduledReconnect = true)
|
||||
}
|
||||
} else if (!reconnectGate()) {
|
||||
Log.i(TAG, "scheduleReconnect: gate turned false during backoff — aborting retry")
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
reconnectJob = scheduledJob
|
||||
scheduledJob.invokeOnCompletion {
|
||||
if (reconnectJob === scheduledJob) {
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-15
@@ -720,25 +720,36 @@ class DashboardApiClient(
|
||||
*/
|
||||
suspend fun listSessions(
|
||||
profile: String? = null,
|
||||
limit: Int = 200,
|
||||
limit: Int = SESSION_LIST_WINDOW_LIMIT,
|
||||
archived: String? = null,
|
||||
): Result<List<SessionItem>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val query = buildList {
|
||||
add("limit=${limit.coerceIn(1, 200)}")
|
||||
add("order=recent")
|
||||
add("min_messages=1")
|
||||
val name = profile?.trim().orEmpty()
|
||||
if (name.isNotBlank()) add("profile=${pathSegment(name)}")
|
||||
// Upstream `archived` filter: exclude (default) | only | include.
|
||||
// Omitted unless requested so older hosts see an unchanged request.
|
||||
val archivedMode = archived?.trim().orEmpty()
|
||||
if (archivedMode.isNotBlank()) add("archived=${pathSegment(archivedMode)}")
|
||||
}.joinToString(prefix = "?", separator = "&")
|
||||
getJson("/api/sessions$query").mapCatching { root ->
|
||||
val parsed = json.decodeFromJsonElement(SessionListResponse.serializer(), root)
|
||||
parsed.sessions ?: parsed.items ?: parsed.data ?: emptyList()
|
||||
val sessions = linkedMapOf<String, SessionItem>()
|
||||
for (page in sessionListPages(limit)) {
|
||||
val query = buildList {
|
||||
// Upstream dashboard GET /api/sessions rejects pages over 100.
|
||||
// Keep Android's 200-row drawer window via two bounded pages.
|
||||
add("limit=${page.limit}")
|
||||
add("offset=${page.offset}")
|
||||
add("order=recent")
|
||||
add("min_messages=1")
|
||||
val name = profile?.trim().orEmpty()
|
||||
if (name.isNotBlank()) add("profile=${pathSegment(name)}")
|
||||
// Upstream `archived` filter: exclude (default) | only | include.
|
||||
// Omitted unless requested so older hosts see an unchanged request.
|
||||
val archivedMode = archived?.trim().orEmpty()
|
||||
if (archivedMode.isNotBlank()) add("archived=${pathSegment(archivedMode)}")
|
||||
}.joinToString(prefix = "?", separator = "&")
|
||||
val pageResult = getJson("/api/sessions$query").mapCatching { root ->
|
||||
val parsed = json.decodeFromJsonElement(SessionListResponse.serializer(), root)
|
||||
parsed.sessions ?: parsed.items ?: parsed.data ?: emptyList()
|
||||
}
|
||||
if (pageResult.isFailure) return@withContext pageResult
|
||||
val pageSessions = pageResult.getOrThrow()
|
||||
pageSessions.forEach { sessions.putIfAbsent(it.id, it) }
|
||||
if (pageSessions.size < page.limit) break
|
||||
}
|
||||
Result.success(sessions.values.take(limit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -596,27 +596,35 @@ class HermesApiClient(
|
||||
|
||||
// --- Session CRUD ---
|
||||
|
||||
suspend fun listSessionsResult(limit: Int = 200): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
|
||||
suspend fun listSessionsResult(limit: Int = SESSION_LIST_WINDOW_LIMIT): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=$limit").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "List sessions"))
|
||||
val sessions = linkedMapOf<String, SessionItem>()
|
||||
for (page in sessionListPages(limit)) {
|
||||
val request = authRequest(
|
||||
"$baseUrl/api/sessions?limit=${page.limit}&offset=${page.offset}",
|
||||
).get().build()
|
||||
val pageSessions = client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "List sessions"))
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("List sessions returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
parsed.data ?: parsed.items ?: parsed.sessions ?: emptyList()
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("List sessions returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
Result.success(parsed.data ?: parsed.items ?: parsed.sessions ?: emptyList())
|
||||
pageSessions.forEach { sessions.putIfAbsent(it.id, it) }
|
||||
if (pageSessions.size < page.limit) break
|
||||
}
|
||||
Result.success(sessions.values.take(limit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listSessions(limit: Int = 200): List<SessionItem> =
|
||||
suspend fun listSessions(limit: Int = SESSION_LIST_WINDOW_LIMIT): List<SessionItem> =
|
||||
listSessionsResult(limit).getOrElse { emptyList() }
|
||||
|
||||
suspend fun createSessionResult(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
internal const val SESSION_LIST_PAGE_LIMIT = 100
|
||||
internal const val SESSION_LIST_WINDOW_LIMIT = 200
|
||||
|
||||
internal data class SessionListPage(
|
||||
val limit: Int,
|
||||
val offset: Int,
|
||||
)
|
||||
|
||||
internal fun sessionListPages(requestedLimit: Int): List<SessionListPage> {
|
||||
val window = requestedLimit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)
|
||||
return buildList {
|
||||
var offset = 0
|
||||
while (offset < window) {
|
||||
add(
|
||||
SessionListPage(
|
||||
limit = minOf(SESSION_LIST_PAGE_LIMIT, window - offset),
|
||||
offset = offset,
|
||||
),
|
||||
)
|
||||
offset += SESSION_LIST_PAGE_LIMIT
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,6 +586,7 @@ fun RelayApp() {
|
||||
}
|
||||
|
||||
val coldStartAuthState by connectionViewModel.authState.collectAsState()
|
||||
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val effectiveSessionProfileName by connectionViewModel.effectiveSessionProfileName.collectAsState()
|
||||
val currentChatSessionId by chatViewModel.currentSessionId.collectAsState()
|
||||
@@ -2228,10 +2229,18 @@ fun RelayApp() {
|
||||
}
|
||||
// === END PHASE3-safety-rails ===
|
||||
composable(Screen.PairedDevices.route) {
|
||||
if (coldStartAuthState is AuthState.Paired) {
|
||||
if (
|
||||
coldStartAuthState is AuthState.Paired ||
|
||||
currentPairedSession != null
|
||||
) {
|
||||
PairedDevicesScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onManageSessions = {
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onRequestRepair = {
|
||||
navController.navigate(Screen.Pair.route())
|
||||
}
|
||||
@@ -2315,7 +2324,7 @@ fun RelayApp() {
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onReconnect = {
|
||||
connectionViewModel.connectRelay()
|
||||
connectionViewModel.reconnectIfStale()
|
||||
UiMessageBus.status(reconnectingRelayLabel)
|
||||
},
|
||||
onRename = { id, newLabel ->
|
||||
|
||||
+74
-44
@@ -96,7 +96,6 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
|
||||
import com.hermesandroid.relay.viewmodel.asBadgeState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -216,17 +215,45 @@ fun ActiveCardRelayStatusSection(
|
||||
|
||||
// Pre-resolve strings for Toast (non-composable context)
|
||||
val reconnectingRelayToast = stringResource(R.string.active_section_reconnecting_relay)
|
||||
val connectedLabel = stringResource(R.string.conn_info_connected)
|
||||
val relayStatusText = when (relayRowState.phase) {
|
||||
RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
|
||||
RelayUiState.Connected -> stringResource(R.string.relay_state_ready)
|
||||
RelayUiState.Connecting -> stringResource(R.string.relay_state_reconnecting)
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected -> stringResource(R.string.relay_state_unavailable)
|
||||
RelayUiState.Expired -> stringResource(R.string.relay_state_needs_repair)
|
||||
}
|
||||
val relayRoleLabel = relayRowState.activeEndpointRole
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { role ->
|
||||
when (role.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"tailscale" -> "Tailscale"
|
||||
"public" -> "Public"
|
||||
else -> role
|
||||
}
|
||||
}
|
||||
val relayStatusWithRole = when (relayRowState.phase) {
|
||||
RelayUiState.Connected,
|
||||
RelayUiState.Connecting,
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected -> relayRoleLabel
|
||||
?.let { "$relayStatusText \u00B7 $it" }
|
||||
?: relayStatusText
|
||||
RelayUiState.NotConfigured,
|
||||
RelayUiState.Expired -> relayStatusText
|
||||
}
|
||||
|
||||
// ADR 24: relayRowState carries both the phase and the active endpoint
|
||||
// role. statusText appends " · <Role>" when the resolver has picked one.
|
||||
// ADR 24: keep the selected route visible without changing the Relay
|
||||
// phase vocabulary shared with the rest of Settings.
|
||||
ConnectionStatusRow(
|
||||
label = stringResource(R.string.active_section_relay),
|
||||
state = relayRowState.asBadgeState(),
|
||||
statusText = relayRowState.statusText(connectedLabel = connectedLabel),
|
||||
statusText = relayStatusWithRole,
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.connectRelay()
|
||||
connectionViewModel.reconnectIfStale()
|
||||
Toast.makeText(
|
||||
context,
|
||||
reconnectingRelayToast,
|
||||
@@ -240,20 +267,15 @@ fun ActiveCardRelayStatusSection(
|
||||
)
|
||||
|
||||
// Pre-resolve strings for Session status
|
||||
val pairedLabel = stringResource(R.string.conn_info_paired)
|
||||
val pairingLabel = stringResource(R.string.conn_info_pairing)
|
||||
val unpairedLabel = stringResource(R.string.conn_info_unpaired)
|
||||
val failedReasonLabel = stringResource(R.string.active_section_failed_reason)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = stringResource(R.string.active_section_session),
|
||||
isConnected = authState is AuthState.Paired,
|
||||
isConnecting = authState is AuthState.Pairing,
|
||||
statusText = when (authState) {
|
||||
is AuthState.Paired -> pairedLabel
|
||||
is AuthState.Pairing -> pairingLabel
|
||||
is AuthState.Unpaired -> unpairedLabel
|
||||
is AuthState.Failed -> failedReasonLabel.format((authState as AuthState.Failed).reason)
|
||||
is AuthState.Paired -> stringResource(R.string.relay_state_ready)
|
||||
is AuthState.Pairing -> stringResource(R.string.conn_info_pairing)
|
||||
is AuthState.Unpaired -> stringResource(R.string.relay_state_optional)
|
||||
is AuthState.Failed -> stringResource(R.string.relay_state_needs_repair)
|
||||
},
|
||||
onClick = onOpenSessionInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -281,7 +303,6 @@ fun ActiveCardFeaturesSection(
|
||||
connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
|
||||
val relayReady by connectionViewModel.relayReady.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
|
||||
@@ -347,29 +368,27 @@ fun ActiveCardFeaturesSection(
|
||||
StandardVoiceAvailability.Unknown -> CapabilityTone.Neutral
|
||||
}
|
||||
|
||||
val relayValue = when {
|
||||
!relayConfigured -> stringResource(R.string.active_section_optional)
|
||||
relayReady -> stringResource(R.string.active_section_ready)
|
||||
relayUiState == RelayUiState.Stale -> stringResource(R.string.active_section_reconnect)
|
||||
else -> stringResource(R.string.active_section_configured)
|
||||
val relayValue = when (relayUiState) {
|
||||
RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
|
||||
RelayUiState.Connected -> stringResource(R.string.relay_state_ready)
|
||||
RelayUiState.Connecting -> stringResource(R.string.relay_state_reconnecting)
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected -> stringResource(R.string.relay_state_unavailable)
|
||||
RelayUiState.Expired -> stringResource(R.string.relay_state_needs_repair)
|
||||
}
|
||||
val relayTone = when {
|
||||
!relayConfigured -> CapabilityTone.Neutral
|
||||
relayReady -> CapabilityTone.Good
|
||||
relayUiState == RelayUiState.Stale -> CapabilityTone.Warning
|
||||
else -> CapabilityTone.Info
|
||||
val relayTone = when (relayUiState) {
|
||||
RelayUiState.NotConfigured -> CapabilityTone.Neutral
|
||||
RelayUiState.Connected -> CapabilityTone.Good
|
||||
RelayUiState.Connecting -> CapabilityTone.Info
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected,
|
||||
RelayUiState.Expired -> CapabilityTone.Warning
|
||||
}
|
||||
|
||||
val terminalValue = when {
|
||||
authState is AuthState.Paired -> stringResource(R.string.active_section_ready)
|
||||
relayConfigured -> stringResource(R.string.active_section_pair_relay)
|
||||
else -> stringResource(R.string.active_section_optional)
|
||||
}
|
||||
val terminalTone = when {
|
||||
authState is AuthState.Paired -> CapabilityTone.Good
|
||||
relayConfigured && authState !is AuthState.Paired -> CapabilityTone.Info
|
||||
else -> CapabilityTone.Neutral
|
||||
}
|
||||
// Terminal rides the same Relay session, so it must never contradict the
|
||||
// Relay tools row with a second, independently-derived directive.
|
||||
val terminalValue = relayValue
|
||||
val terminalTone = relayTone
|
||||
|
||||
val proxyValue = if (secureProxyAdvertised) stringResource(R.string.active_section_available) else stringResource(R.string.active_section_not_advertised)
|
||||
val proxyTone = if (secureProxyAdvertised) CapabilityTone.Good else CapabilityTone.Neutral
|
||||
@@ -1439,6 +1458,7 @@ fun ActiveCardSecurityPosture(
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
|
||||
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
|
||||
val pairedDevices by connectionViewModel.pairedDevices.collectAsState()
|
||||
var showSecurityDetails by remember { mutableStateOf(false) }
|
||||
@@ -1450,11 +1470,13 @@ fun ActiveCardSecurityPosture(
|
||||
dashboardUrl.startsWith("http://", ignoreCase = true) -> "HTTP"
|
||||
else -> stringResource(R.string.active_section_not_configured)
|
||||
}
|
||||
val pairedLabel = if (currentPairedSession != null) {
|
||||
stringResource(R.string.active_section_paired)
|
||||
} else {
|
||||
stringResource(R.string.active_section_not_paired)
|
||||
val pairedLabel = when (authState) {
|
||||
is AuthState.Paired -> stringResource(R.string.relay_state_ready)
|
||||
AuthState.Pairing -> stringResource(R.string.relay_state_reconnecting)
|
||||
is AuthState.Failed -> stringResource(R.string.relay_state_needs_repair)
|
||||
AuthState.Unpaired -> stringResource(R.string.relay_state_optional)
|
||||
}
|
||||
val relaySessionUsable = authState is AuthState.Paired
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Surface(
|
||||
@@ -1540,7 +1562,7 @@ fun ActiveCardSecurityPosture(
|
||||
icon = Icons.Filled.Link,
|
||||
label = stringResource(R.string.active_section_relay_session),
|
||||
value = pairedLabel,
|
||||
positive = currentPairedSession != null,
|
||||
positive = relaySessionUsable,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1555,14 +1577,22 @@ fun ActiveCardSecurityPosture(
|
||||
SecurityAccessRow(
|
||||
icon = Icons.Filled.Devices,
|
||||
label = stringResource(R.string.active_section_paired_devices),
|
||||
value = stringResource(R.string.active_section_device_count, pairedDevices.size),
|
||||
value = if (relaySessionUsable) {
|
||||
stringResource(R.string.active_section_device_count, pairedDevices.size)
|
||||
} else {
|
||||
pairedLabel
|
||||
},
|
||||
onClick = onNavigateToPairedDevices,
|
||||
)
|
||||
HorizontalDivider()
|
||||
SecurityAccessRow(
|
||||
icon = Icons.Filled.Schedule,
|
||||
label = stringResource(R.string.active_section_session_activity),
|
||||
value = stringResource(R.string.active_section_last_checked_just_now),
|
||||
value = if (relaySessionUsable) {
|
||||
stringResource(R.string.active_section_last_checked_just_now)
|
||||
} else {
|
||||
pairedLabel
|
||||
},
|
||||
onClick = onNavigateToPairedDevices,
|
||||
)
|
||||
}
|
||||
@@ -1579,7 +1609,7 @@ fun ActiveCardSecurityPosture(
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onRevokeRelay,
|
||||
enabled = currentPairedSession != null,
|
||||
enabled = relaySessionUsable && currentPairedSession != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.LinkOff, contentDescription = null)
|
||||
|
||||
@@ -212,7 +212,7 @@ private fun connectionChip(state: ConnectionState) {
|
||||
private fun authStateChip(state: AuthState) {
|
||||
val (label, bg, fg) = when (state) {
|
||||
is AuthState.Unpaired -> Triple(
|
||||
stringResource(R.string.conn_info_unpaired),
|
||||
stringResource(R.string.relay_state_optional),
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -222,12 +222,12 @@ private fun authStateChip(state: AuthState) {
|
||||
MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
is AuthState.Paired -> Triple(
|
||||
stringResource(R.string.conn_info_paired),
|
||||
stringResource(R.string.relay_state_ready),
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
is AuthState.Failed -> Triple(
|
||||
stringResource(R.string.conn_info_failed_reason, state.reason),
|
||||
stringResource(R.string.relay_state_needs_repair),
|
||||
MaterialTheme.colorScheme.errorContainer,
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
@@ -358,6 +358,21 @@ fun SessionInfoSheet(
|
||||
// Security overhaul (2026-04-11) — show expiry + grants + storage.
|
||||
pairedSession?.let { paired ->
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = if (authState is AuthState.Paired) {
|
||||
stringResource(R.string.conn_info_session_details)
|
||||
} else {
|
||||
stringResource(R.string.conn_info_stored_session_details)
|
||||
},
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
if (authState !is AuthState.Paired) {
|
||||
Text(
|
||||
text = stringResource(R.string.conn_info_stored_session_details_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
val expiryLabel = when {
|
||||
paired.expiresAt == null -> stringResource(R.string.conn_info_never)
|
||||
else -> java.text.DateFormat
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -552,14 +553,24 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Conversation voice owns long-press with its action menu.
|
||||
// Disable partial-text selection in that state so Android's
|
||||
// floating selection toolbar does not stack over Copy/Quote/
|
||||
// Speak response. Normal chat retains selectable message text.
|
||||
// Compose's SelectionManager assumes that selectable IDs
|
||||
// captured by a drag remain registered. Reset its owner when
|
||||
// a live Text node becomes a Markdown tree, or settled
|
||||
// Markdown content changes its node topology, so a handle
|
||||
// cannot keep pointing at a removed selectable.
|
||||
if (showSpeakAction) {
|
||||
DisableSelection { messageTextContent() }
|
||||
} else {
|
||||
SelectionContainer { messageTextContent() }
|
||||
key(
|
||||
messageSelectionTopologyKey(
|
||||
isPlainText = isUser || isSystem,
|
||||
isStreaming = message.isStreaming,
|
||||
retainStreamingLayout = retainStreamingLayout,
|
||||
markdownBody = markdownBody,
|
||||
),
|
||||
) {
|
||||
SelectionContainer { messageTextContent() }
|
||||
}
|
||||
}
|
||||
|
||||
// Inline generated images (assistant only) — rendered OUTSIDE
|
||||
@@ -791,6 +802,23 @@ fun MessageBubble(
|
||||
} // end CompositionLocalProvider(LocalMediaBlurMode)
|
||||
}
|
||||
|
||||
internal data class MessageSelectionTopologyKey(
|
||||
val renderer: String,
|
||||
val markdownBody: String?,
|
||||
)
|
||||
|
||||
internal fun messageSelectionTopologyKey(
|
||||
isPlainText: Boolean,
|
||||
isStreaming: Boolean,
|
||||
retainStreamingLayout: Boolean,
|
||||
markdownBody: String,
|
||||
): MessageSelectionTopologyKey = when {
|
||||
isPlainText -> MessageSelectionTopologyKey(renderer = "plain", markdownBody = null)
|
||||
isStreaming || retainStreamingLayout ->
|
||||
MessageSelectionTopologyKey(renderer = "live", markdownBody = null)
|
||||
else -> MessageSelectionTopologyKey(renderer = "markdown", markdownBody = markdownBody)
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a settled, plain assistant response can become a transient pet visit
|
||||
* target. Rich cards, attachments, and tool/action rows remain interaction
|
||||
|
||||
@@ -45,7 +45,7 @@ enum class PowerFeatureGateStatus(
|
||||
explanationRes = R.string.power_feature_requires_pairing_explain,
|
||||
),
|
||||
PairingExpired(
|
||||
labelRes = R.string.power_feature_pairing_expired_label,
|
||||
labelRes = R.string.relay_state_needs_repair,
|
||||
actionLabelRes = R.string.power_feature_pairing_expired_action,
|
||||
explanationRes = R.string.power_feature_pairing_expired_explain,
|
||||
),
|
||||
|
||||
@@ -3472,9 +3472,13 @@ fun ChatScreen(
|
||||
|
||||
// Clean-mode discoverability hint — a quiet, persistent pill teaching
|
||||
// the long-press entry. Shown ONLY on the empty / new-chat view; it
|
||||
// disappears the moment a conversation exists or clean mode is entered.
|
||||
// yields the bottom area whenever clean or voice mode owns it.
|
||||
AnimatedVisibility(
|
||||
visible = messages.isEmpty() && !ambientMode,
|
||||
visible = shouldShowCleanViewHint(
|
||||
hasMessages = messages.isNotEmpty(),
|
||||
ambientMode = ambientMode,
|
||||
voiceMode = voiceUiState.voiceMode,
|
||||
),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
@@ -4177,6 +4181,12 @@ private fun createCameraCaptureUri(context: android.content.Context): Uri {
|
||||
|
||||
private val CHAT_INPUT_REASONING_EFFORTS = listOf("none", "minimal", "low", "medium", "high", "xhigh")
|
||||
|
||||
internal fun shouldShowCleanViewHint(
|
||||
hasMessages: Boolean,
|
||||
ambientMode: Boolean,
|
||||
voiceMode: Boolean,
|
||||
): Boolean = !hasMessages && !ambientMode && !voiceMode
|
||||
|
||||
private fun compactModelChipLabel(model: String?, defaultLabel: String): String {
|
||||
val raw = model?.trim().orEmpty()
|
||||
if (raw.isBlank()) return defaultLabel
|
||||
|
||||
@@ -78,7 +78,6 @@ import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -621,7 +620,12 @@ private fun ConnectionSurfaceSummary(
|
||||
|
||||
val relayText = when {
|
||||
!relayConfigured -> stringResource(R.string.conn_relay_optional)
|
||||
liveState != null -> liveState.statusText(connectedLabel = stringResource(R.string.conn_relay_ready))
|
||||
liveState == RelayUiState.Connected -> stringResource(R.string.relay_state_ready)
|
||||
liveState == RelayUiState.Connecting -> stringResource(R.string.relay_state_reconnecting)
|
||||
liveState == RelayUiState.Stale || liveState == RelayUiState.Disconnected ->
|
||||
stringResource(R.string.relay_state_unavailable)
|
||||
liveState == RelayUiState.Expired -> stringResource(R.string.relay_state_needs_repair)
|
||||
liveState == RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
|
||||
connection.pairedAt != null -> stringResource(R.string.conn_relay_paired)
|
||||
connection.relayUrl.isNotBlank() -> stringResource(R.string.conn_relay_configured)
|
||||
else -> stringResource(R.string.conn_relay_configure)
|
||||
@@ -629,7 +633,9 @@ private fun ConnectionSurfaceSummary(
|
||||
val relayTone = when {
|
||||
!relayConfigured -> SummaryTone.Neutral
|
||||
liveState == RelayUiState.Connected -> SummaryTone.Good
|
||||
liveState == RelayUiState.Stale || liveState == RelayUiState.Disconnected -> SummaryTone.Warning
|
||||
liveState == RelayUiState.Stale ||
|
||||
liveState == RelayUiState.Disconnected ||
|
||||
liveState == RelayUiState.Expired -> SummaryTone.Warning
|
||||
else -> SummaryTone.Info
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ import java.util.Date
|
||||
fun PairedDevicesScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onManageSessions: () -> Unit,
|
||||
onRequestRepair: () -> Unit,
|
||||
) {
|
||||
val devices by connectionViewModel.pairedDevices.collectAsState()
|
||||
@@ -157,7 +158,9 @@ fun PairedDevicesScreen(
|
||||
loading && devices.isEmpty() -> LoadingState()
|
||||
loadError != null && devices.isEmpty() -> ErrorState(
|
||||
message = loadError!!,
|
||||
onRetry = { connectionViewModel.loadPairedDevices() }
|
||||
onRetry = { connectionViewModel.loadPairedDevices() },
|
||||
onManageSessions = onManageSessions,
|
||||
onRequestRepair = onRequestRepair,
|
||||
)
|
||||
devices.isEmpty() -> EmptyState(onRequestRepair = onRequestRepair)
|
||||
else -> DeviceList(
|
||||
@@ -376,7 +379,12 @@ private fun LoadingState() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorState(message: String, onRetry: () -> Unit) {
|
||||
private fun ErrorState(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
onManageSessions: () -> Unit,
|
||||
onRequestRepair: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -402,8 +410,22 @@ private fun ErrorState(message: String, onRetry: () -> Unit) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.paired_devices_invalid_session_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = onRetry) {
|
||||
Button(onClick = onManageSessions) {
|
||||
Text(stringResource(R.string.paired_devices_manage_sessions))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(onClick = onRequestRepair) {
|
||||
Text(stringResource(R.string.paired_devices_repair_this_phone))
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TextButton(onClick = onRetry) {
|
||||
Text(stringResource(R.string.paired_devices_try_again))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
@@ -209,14 +208,12 @@ fun SettingsScreen(
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val apiServerReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val apiServerHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val devOptionsUnlocked by FeatureFlags.devOptionsUnlocked(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
val relayPaired = authState is AuthState.Paired
|
||||
val chatRuntimeStatus = resolveChatRuntimeStatus(
|
||||
gateway = when (gatewayAvailability) {
|
||||
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
@@ -276,17 +273,28 @@ fun SettingsScreen(
|
||||
// The Power tools below all ride the relay plugin. Rather than stamp an
|
||||
// identical badge on every card (noise, not signal), the dependency is
|
||||
// surfaced ONCE on the section header as a single plugin-state badge.
|
||||
val pluginBadge = when {
|
||||
!relayPaired ->
|
||||
SettingsStatusPillModel(label = stringResource(R.string.settings_plugin_required), tone = SettingsStatusTone.Info)
|
||||
relayUiState == RelayUiState.Disconnected ->
|
||||
SettingsStatusPillModel(label = stringResource(R.string.settings_plugin_offline), tone = SettingsStatusTone.Warning)
|
||||
relayUiState == RelayUiState.Stale ->
|
||||
SettingsStatusPillModel(label = stringResource(R.string.settings_plugin_stale), tone = SettingsStatusTone.Warning)
|
||||
relayUiState == RelayUiState.Connecting ->
|
||||
SettingsStatusPillModel(label = stringResource(R.string.settings_plugin_connecting), tone = SettingsStatusTone.Info)
|
||||
else ->
|
||||
SettingsStatusPillModel(label = stringResource(R.string.settings_plugin_active), tone = SettingsStatusTone.Good)
|
||||
val pluginBadge = when (relayUiState) {
|
||||
RelayUiState.NotConfigured -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.relay_state_optional),
|
||||
tone = SettingsStatusTone.Info,
|
||||
)
|
||||
RelayUiState.Connected -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.relay_state_ready),
|
||||
tone = SettingsStatusTone.Good,
|
||||
)
|
||||
RelayUiState.Connecting -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.relay_state_reconnecting),
|
||||
tone = SettingsStatusTone.Info,
|
||||
)
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.relay_state_unavailable),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
RelayUiState.Expired -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.relay_state_needs_repair),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
}
|
||||
|
||||
// Kick a WSS reconnect when Settings first composes so the Connections
|
||||
|
||||
@@ -105,10 +105,19 @@ private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): Hum
|
||||
retryable = false,
|
||||
actionLabel = ctx?.getString(R.string.error_classify_voice_settings) ?: "Voice settings",
|
||||
)
|
||||
context == "load_profile_sessions" &&
|
||||
("401" in msg || "403" in msg || "unauthorized" in msg || "forbidden" in msg) -> HumanError(
|
||||
title = ctx?.getString(R.string.power_feature_dashboard_signin_label) ?: "Dashboard sign-in required",
|
||||
body = ctx?.getString(R.string.power_feature_dashboard_signin_explain)
|
||||
?: "Sign in to the Hermes Dashboard to load profile sessions.",
|
||||
retryable = false,
|
||||
)
|
||||
(
|
||||
"api key" in msg ||
|
||||
"sessions auth failed" in msg ||
|
||||
"api auth" in msg ||
|
||||
(context in setOf("load_sessions", "create_session") &&
|
||||
("401" in msg || "403" in msg || "unauthorized" in msg || "forbidden" in msg)) ||
|
||||
(context == "send_message" && ("401" in msg || "unauthorized" in msg))
|
||||
) -> HumanError(
|
||||
title = ctx?.getString(R.string.error_classify_api_key) ?: "API key rejected",
|
||||
|
||||
@@ -152,6 +152,14 @@ internal fun shouldReloadHistoryAfterSuccessfulTurn(
|
||||
actualTransport == "sessions" ||
|
||||
(actualTransport == "gateway" && gatewayReconcileRequired)
|
||||
|
||||
internal fun shouldSuppressPassiveSessionError(context: String?, error: Throwable?): Boolean {
|
||||
if (context != "load_sessions" && context != "load_profile_sessions") return false
|
||||
if (isConnectivityError(error)) return true
|
||||
val message = error?.message?.lowercase().orEmpty()
|
||||
return "401" in message || "403" in message ||
|
||||
"unauthorized" in message || "forbidden" in message
|
||||
}
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
|
||||
private var apiClient: HermesApiClient? = null
|
||||
@@ -415,12 +423,12 @@ class ChatViewModel : ViewModel() {
|
||||
// the server" failure there is non-actionable noise — the themed
|
||||
// connection banner + startup sphere already surface the unreachable
|
||||
// state. Keep the diagnostics record (classifyError above) but suppress
|
||||
// the redundant, scary "server isn't accepting connections" snackbar
|
||||
// that used to flash from the bottom on first load. Actionable failures
|
||||
// (auth rejected, server error) and all interactive contexts
|
||||
// (send_message, …) still surface normally.
|
||||
if ((context == "load_sessions" || context == "create_session") &&
|
||||
isConnectivityError(t)
|
||||
// the redundant, scary "server isn't accepting connections" snackbar.
|
||||
// Passive session-list auth belongs to Dashboard/API setup and must not
|
||||
// become a global Relay re-pair nag. Interactive create/send/media
|
||||
// failures still surface normally.
|
||||
if (shouldSuppressPassiveSessionError(context, t) ||
|
||||
(context == "create_session" && isConnectivityError(t))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -136,13 +136,28 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private data class RelayUiInputs(
|
||||
internal data class RelayUiInputs(
|
||||
val auth: AuthState,
|
||||
val conn: ConnectionState,
|
||||
val url: String,
|
||||
val configured: Boolean,
|
||||
)
|
||||
|
||||
internal fun RelayUiInputs.requiresReconnectGrace(): Boolean =
|
||||
configured &&
|
||||
url.isNotBlank() &&
|
||||
auth is AuthState.Paired &&
|
||||
(conn == ConnectionState.Disconnected || conn == ConnectionState.Reconnecting)
|
||||
|
||||
internal fun RelayUiInputs.resolveRelayUiState(graceElapsed: Boolean = false): RelayUiState = when {
|
||||
!configured || url.isBlank() -> RelayUiState.NotConfigured
|
||||
auth is AuthState.Failed -> RelayUiState.Expired
|
||||
auth is AuthState.Paired && conn == ConnectionState.Connected -> RelayUiState.Connected
|
||||
conn == ConnectionState.Connecting || auth is AuthState.Pairing -> RelayUiState.Connecting
|
||||
requiresReconnectGrace() -> if (graceElapsed) RelayUiState.Stale else RelayUiState.Connecting
|
||||
else -> RelayUiState.Disconnected
|
||||
}
|
||||
|
||||
private data class ConnectionHealthInputs(
|
||||
val connection: Connection?,
|
||||
val relayRow: RelayRowState,
|
||||
@@ -3429,14 +3444,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
.collect { inputs ->
|
||||
pendingStaleJob?.cancel()
|
||||
pendingStaleJob = null
|
||||
_relayUiState.value = when {
|
||||
!inputs.configured || inputs.url.isBlank() -> RelayUiState.NotConfigured
|
||||
inputs.conn == ConnectionState.Connected -> RelayUiState.Connected
|
||||
inputs.conn == ConnectionState.Connecting ||
|
||||
inputs.conn == ConnectionState.Reconnecting ->
|
||||
RelayUiState.Connecting
|
||||
inputs.auth is AuthState.Paired &&
|
||||
inputs.conn == ConnectionState.Disconnected -> {
|
||||
_relayUiState.value = if (inputs.requiresReconnectGrace()) {
|
||||
// Start the grace-window timer. If the WSS
|
||||
// doesn't come up within RELAY_RECONNECT_GRACE_MS,
|
||||
// we promote to Stale so the UI stops lying
|
||||
@@ -3444,7 +3452,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// tap-to-retry affordance.
|
||||
pendingStaleJob = launch {
|
||||
delay(RELAY_RECONNECT_GRACE_MS)
|
||||
_relayUiState.value = RelayUiState.Stale
|
||||
_relayUiState.value = inputs.resolveRelayUiState(graceElapsed = true)
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
@@ -3454,14 +3462,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
)
|
||||
}
|
||||
RelayUiState.Connecting
|
||||
} else {
|
||||
inputs.resolveRelayUiState()
|
||||
}
|
||||
// The relay rejected our token (revoked, or wiped by a
|
||||
// relay restart). Reconnecting won't help — surface a
|
||||
// distinct "pair again" state instead of a generic
|
||||
// Disconnected the user can't act on.
|
||||
inputs.auth is AuthState.Failed -> RelayUiState.Expired
|
||||
else -> RelayUiState.Disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4273,6 +4276,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
* that can't measure it (or want to force a probe) pass [Long.MAX_VALUE].
|
||||
*/
|
||||
fun revalidateOnResume(awayMs: Long) {
|
||||
// Relay recovery is independent of standard API health. Even a brief
|
||||
// resume should replace ordinary WSS backoff with an immediate attempt.
|
||||
reconnectIfStale()
|
||||
val healthy = _apiServerHealth.value == HealthStatus.Reachable
|
||||
if (awayMs in 0 until BRIEF_RESUME_REVALIDATE_MS && healthy) {
|
||||
return
|
||||
@@ -6177,17 +6183,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
* and from the "Reconnect" button / tap-to-reconnect action on the
|
||||
* Relay status row.
|
||||
*
|
||||
* No-op when not paired, already connected, connecting, or reconnecting —
|
||||
* avoids duplicate connect calls that would interrupt an in-flight auth.
|
||||
* No-op when not paired, already connected, or actively handshaking. A
|
||||
* scheduled ordinary reconnect is replaced with an immediate attempt; the
|
||||
* connection manager preserves server-issued rate-limit backoff.
|
||||
*/
|
||||
fun reconnectIfStale() {
|
||||
if (isDemoMode.value) return // Demo mode is offline — never open a socket.
|
||||
val paired = authState.value is AuthState.Paired
|
||||
val disconnected = relayConnectionState.value == ConnectionState.Disconnected
|
||||
val retryableState = relayConnectionState.value == ConnectionState.Disconnected ||
|
||||
relayConnectionState.value == ConnectionState.Reconnecting
|
||||
val relayUrl = effectiveRelayUrlSnapshot()
|
||||
val hasUrl = relayUrl.isNotBlank()
|
||||
if (paired && disconnected && hasUrl) {
|
||||
connectionManager.connect(relayUrl)
|
||||
if (paired && retryableState && hasUrl) {
|
||||
connectionManager.reconnectNowIfAllowed(relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ import com.hermesandroid.relay.ui.components.BadgeState
|
||||
* underlying moment.
|
||||
*
|
||||
* Grace-window behavior: when the session is paired but the WSS is
|
||||
* currently [com.hermesandroid.relay.network.relay.ConnectionState.Disconnected],
|
||||
* currently [com.hermesandroid.relay.network.relay.ConnectionState.Disconnected]
|
||||
* or waiting in scheduled [com.hermesandroid.relay.network.relay.ConnectionState.Reconnecting] backoff,
|
||||
* the VM emits [Connecting] for a short grace window (see
|
||||
* `RELAY_RECONNECT_GRACE_MS` in [ConnectionViewModel]) and only promotes
|
||||
* to [Stale] if the WSS doesn't come up in time. This avoids the "flash
|
||||
@@ -45,9 +46,9 @@ sealed interface RelayUiState {
|
||||
data object Connected : RelayUiState
|
||||
|
||||
/**
|
||||
* Either an in-flight [Connecting]/[com.hermesandroid.relay.network.relay.ConnectionState.Reconnecting]
|
||||
* WSS attempt OR the grace window right after a Paired-but-Disconnected
|
||||
* transition. UI renders this in amber — "we're trying, hold on."
|
||||
* An in-flight [Connecting] WSS attempt, or the grace window right after a
|
||||
* paired socket enters Disconnected/scheduled-Reconnecting state. UI renders
|
||||
* this in amber only while a prompt recovery can still reasonably settle.
|
||||
*/
|
||||
data object Connecting : RelayUiState
|
||||
|
||||
@@ -60,19 +61,18 @@ sealed interface RelayUiState {
|
||||
data object Stale : RelayUiState
|
||||
|
||||
/**
|
||||
* The relay rejected our session token (revoked, or wiped by a relay
|
||||
* restart) — i.e. [com.hermesandroid.relay.auth.AuthState.Failed]. Unlike
|
||||
* [Stale], reconnecting won't help: the fix is to pair again. Rendered red
|
||||
* with a "tap to pair again" hint, and the row's tap opens the relay info /
|
||||
* re-pair surface rather than firing another doomed reconnect. This is the
|
||||
* highest-frequency real failure because the relay's session store is
|
||||
* in-memory and wiped on every restart.
|
||||
* The relay rejected our session token (for example, because it expired
|
||||
* or was revoked) — i.e. [com.hermesandroid.relay.auth.AuthState.Failed]. Unlike
|
||||
* [Stale], reconnecting won't help: the fix is to pair again. Rendered as
|
||||
* "Needs re-pair" only on Relay status and Relay-only feature gates. Retained
|
||||
* pairing metadata remains available so recovery can identify the prior
|
||||
* session without presenting it as active.
|
||||
*/
|
||||
data object Expired : RelayUiState
|
||||
|
||||
/**
|
||||
* No paired session (or auth failed). User action required — usually
|
||||
* re-pair via the Connections sub-screen.
|
||||
* Relay is configured but no usable connection is available. This is a
|
||||
* neutral "Unavailable" state; only [Expired] directs the user to re-pair.
|
||||
*/
|
||||
data object Disconnected : RelayUiState
|
||||
}
|
||||
@@ -181,12 +181,12 @@ fun RelayRowState.asBadgeState(): BadgeState = phase.asBadgeState()
|
||||
* "Connected" word (Connection sub-screen).
|
||||
*/
|
||||
fun RelayUiState.statusText(connectedLabel: String): String = when (this) {
|
||||
RelayUiState.NotConfigured -> "Not configured"
|
||||
RelayUiState.NotConfigured -> "Optional"
|
||||
RelayUiState.Connected -> connectedLabel
|
||||
RelayUiState.Connecting -> "Reconnecting…"
|
||||
RelayUiState.Stale -> "Relay unreachable - tap to reconnect"
|
||||
RelayUiState.Expired -> "Pairing expired — tap to pair again"
|
||||
RelayUiState.Disconnected -> "Disconnected"
|
||||
RelayUiState.Connecting -> "Reconnecting"
|
||||
RelayUiState.Stale -> "Unavailable"
|
||||
RelayUiState.Expired -> "Needs re-pair"
|
||||
RelayUiState.Disconnected -> "Unavailable"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,9 +211,9 @@ fun RelayRowState.statusText(connectedLabel: String): String {
|
||||
return when (phase) {
|
||||
RelayUiState.Connected -> "$base \u00B7 $display"
|
||||
RelayUiState.Connecting -> "$base \u00B7 $display"
|
||||
RelayUiState.Stale -> "Unreachable \u00B7 $display - tap to reconnect"
|
||||
RelayUiState.Stale -> "$base \u00B7 $display"
|
||||
RelayUiState.Expired -> base
|
||||
RelayUiState.Disconnected -> "$base (last via $display)"
|
||||
RelayUiState.Disconnected -> "$base \u00B7 $display"
|
||||
RelayUiState.NotConfigured -> base
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,6 +585,11 @@
|
||||
<string name="settings_plugin_stale">Plugin desatualizado</string>
|
||||
<string name="settings_plugin_connecting">Conectando o plugin</string>
|
||||
<string name="settings_plugin_active">Plugin ativo</string>
|
||||
<string name="relay_state_optional">Opcional</string>
|
||||
<string name="relay_state_ready">Pronto</string>
|
||||
<string name="relay_state_reconnecting">Reconectando</string>
|
||||
<string name="relay_state_unavailable">Indisponível</string>
|
||||
<string name="relay_state_needs_repair">Precisa parear novamente</string>
|
||||
<string name="settings_no_connection">Sem conexão</string>
|
||||
<string name="settings_server_default">Padrão do servidor</string>
|
||||
<string name="settings_connections">Conexões</string>
|
||||
@@ -1313,7 +1318,10 @@
|
||||
<string name="paired_devices_channel_revoked">Acesso a %1$s revogado</string>
|
||||
<string name="paired_devices_channel_revoke_failed">Falha ao revogar o acesso a %1$s</string>
|
||||
<string name="paired_devices_load_error">Não foi possível carregar as sessões do relay</string>
|
||||
<string name="paired_devices_invalid_session_help">Se a sessão Relay deste telefone não for mais válida, remova os dispositivos antigos em Dashboard → Relay ou execute /relay revoke <token-prefix> no host Hermes. Faça o pareamento novamente somente se você usa extensões do Relay.</string>
|
||||
<string name="paired_devices_try_again">Tentar novamente</string>
|
||||
<string name="paired_devices_manage_sessions">Abrir Dashboard</string>
|
||||
<string name="paired_devices_repair_this_phone">Parear este telefone novamente</string>
|
||||
<string name="paired_devices_empty_title">Nenhuma sessão do relay (ainda)</string>
|
||||
<string name="paired_devices_empty_body">Pareie este celular com seu relay para vê-lo aqui.</string>
|
||||
<string name="paired_devices_pair_now">Parear agora</string>
|
||||
@@ -2253,6 +2261,9 @@
|
||||
<string name="conn_info_server_default_model">Padrão do servidor: %1$s</string>
|
||||
<string name="conn_info_session_desc">Detalhes e segurança do pareamento com o Relay.</string>
|
||||
<string name="conn_info_session_title">Sessão</string>
|
||||
<string name="conn_info_session_details">Detalhes da sessão</string>
|
||||
<string name="conn_info_stored_session_details">Detalhes da sessão armazenada</string>
|
||||
<string name="conn_info_stored_session_details_desc">Estes dados foram mantidos da última sessão Relay válida e podem não estar atualizados.</string>
|
||||
<string name="conn_info_session_token_present">Token de sessão</string>
|
||||
<string name="conn_info_show_less">Mostrar menos</string>
|
||||
<string name="conn_info_skills_count">%1$d habilidades</string>
|
||||
|
||||
@@ -626,6 +626,11 @@
|
||||
<string name="settings_plugin_stale">插件过期</string>
|
||||
<string name="settings_plugin_connecting">插件连接中</string>
|
||||
<string name="settings_plugin_active">插件已激活</string>
|
||||
<string name="relay_state_optional">可选</string>
|
||||
<string name="relay_state_ready">就绪</string>
|
||||
<string name="relay_state_reconnecting">正在重新连接</string>
|
||||
<string name="relay_state_unavailable">不可用</string>
|
||||
<string name="relay_state_needs_repair">需要重新配对</string>
|
||||
<string name="settings_no_connection">无连接</string>
|
||||
<string name="settings_server_default">服务器默认</string>
|
||||
<string name="settings_connections">连接</string>
|
||||
@@ -1372,7 +1377,10 @@
|
||||
<string name="paired_devices_channel_revoked">%1$s 访问权限已撤销</string>
|
||||
<string name="paired_devices_channel_revoke_failed">撤销 %1$s 访问权限失败</string>
|
||||
<string name="paired_devices_load_error">无法加载 Relay 会话</string>
|
||||
<string name="paired_devices_invalid_session_help">如果此手机的 Relay 会话已失效,请在 Dashboard → Relay 中移除旧设备,或在 Hermes 主机上运行 /relay revoke <token-prefix>。仅当你使用 Relay 扩展功能时才重新配对。</string>
|
||||
<string name="paired_devices_try_again">重试</string>
|
||||
<string name="paired_devices_manage_sessions">打开 Dashboard</string>
|
||||
<string name="paired_devices_repair_this_phone">重新配对此手机</string>
|
||||
<string name="paired_devices_empty_title">(暂无 Relay 会话)</string>
|
||||
<string name="paired_devices_empty_body">将此手机与您的 Relay 配对即可在此处查看。</string>
|
||||
<string name="paired_devices_pair_now">立即配对</string>
|
||||
@@ -2347,6 +2355,9 @@
|
||||
<string name="conn_info_server_default_model">服务器默认:%1$s</string>
|
||||
<string name="conn_info_session_desc">Relay 配对详情和安全信息。</string>
|
||||
<string name="conn_info_session_title">会话</string>
|
||||
<string name="conn_info_session_details">会话详情</string>
|
||||
<string name="conn_info_stored_session_details">已存储的会话详情</string>
|
||||
<string name="conn_info_stored_session_details_desc">这些信息保留自上次有效的 Relay 会话,可能已不是最新状态。</string>
|
||||
<string name="conn_info_session_token_present">会话令牌</string>
|
||||
<string name="conn_info_show_less">收起</string>
|
||||
<string name="conn_info_skills_count">%1$d 项技能</string>
|
||||
|
||||
@@ -626,6 +626,11 @@
|
||||
<string name="settings_plugin_stale">Plugin veraltet</string>
|
||||
<string name="settings_plugin_connecting">Plugin verbindet sich</string>
|
||||
<string name="settings_plugin_active">Plugin aktiv</string>
|
||||
<string name="relay_state_optional">Optional</string>
|
||||
<string name="relay_state_ready">Bereit</string>
|
||||
<string name="relay_state_reconnecting">Verbindung wird wiederhergestellt</string>
|
||||
<string name="relay_state_unavailable">Nicht verfügbar</string>
|
||||
<string name="relay_state_needs_repair">Erneut koppeln</string>
|
||||
<string name="settings_no_connection">Keine Verbindung</string>
|
||||
<string name="settings_server_default">Serverstandard</string>
|
||||
<string name="settings_connections">Verbindungen</string>
|
||||
@@ -1375,7 +1380,10 @@
|
||||
<string name="paired_devices_channel_revoked">%1$s-Zugriff widerrufen</string>
|
||||
<string name="paired_devices_channel_revoke_failed">%1$s-Zugriff konnte nicht widerrufen werden</string>
|
||||
<string name="paired_devices_load_error">Relay-Sitzungen konnten nicht geladen werden</string>
|
||||
<string name="paired_devices_invalid_session_help">Wenn die Relay-Sitzung dieses Telefons nicht mehr gültig ist, entferne veraltete Geräte unter Dashboard → Relay oder führe /relay revoke <token-prefix> auf dem Hermes-Host aus. Kopple nur erneut, wenn du Relay-Erweiterungen verwendest.</string>
|
||||
<string name="paired_devices_try_again">Erneut versuchen</string>
|
||||
<string name="paired_devices_manage_sessions">Dashboard öffnen</string>
|
||||
<string name="paired_devices_repair_this_phone">Dieses Telefon erneut koppeln</string>
|
||||
<string name="paired_devices_empty_title">Noch keine Relay-Sitzungen</string>
|
||||
<string name="paired_devices_empty_body">Kopple dieses Smartphone mit deinem Relay, damit es hier erscheint.</string>
|
||||
<string name="paired_devices_pair_now">Jetzt koppeln</string>
|
||||
@@ -2352,6 +2360,9 @@
|
||||
<string name="conn_info_server_default_model">Serverstandard: %1$s</string>
|
||||
<string name="conn_info_session_desc">Relay-Kopplungsdetails und Sicherheit.</string>
|
||||
<string name="conn_info_session_title">Sitzung</string>
|
||||
<string name="conn_info_session_details">Sitzungsdetails</string>
|
||||
<string name="conn_info_stored_session_details">Gespeicherte Sitzungsdetails</string>
|
||||
<string name="conn_info_stored_session_details_desc">Diese Angaben stammen aus der letzten gültigen Relay-Sitzung und sind möglicherweise nicht mehr aktuell.</string>
|
||||
<string name="conn_info_session_token_present">Sitzungstoken</string>
|
||||
<string name="conn_info_show_less">Weniger anzeigen</string>
|
||||
<string name="conn_info_skills_count">%1$d Skills</string>
|
||||
|
||||
@@ -553,6 +553,11 @@
|
||||
<string name="settings_plugin_stale">Complemento obsoleto</string>
|
||||
<string name="settings_plugin_connecting">Conexión de complementos</string>
|
||||
<string name="settings_plugin_active">Complemento activo</string>
|
||||
<string name="relay_state_optional">Opcional</string>
|
||||
<string name="relay_state_ready">Listo</string>
|
||||
<string name="relay_state_reconnecting">Reconectando</string>
|
||||
<string name="relay_state_unavailable">No disponible</string>
|
||||
<string name="relay_state_needs_repair">Requiere volver a emparejar</string>
|
||||
<string name="settings_no_connection">Sin conexión</string>
|
||||
<string name="settings_server_default">Valor predeterminado del servidor</string>
|
||||
<string name="settings_connections">Conexiones</string>
|
||||
@@ -1254,7 +1259,10 @@
|
||||
<string name="paired_devices_channel_revoked">Acceso %1$s revocado</string>
|
||||
<string name="paired_devices_channel_revoke_failed">No se pudo revocar el acceso a %1$s</string>
|
||||
<string name="paired_devices_load_error">No se han podido cargar las sesiones relay</string>
|
||||
<string name="paired_devices_invalid_session_help">Si la sesión Relay de este teléfono ya no es válida, elimina los dispositivos obsoletos en Dashboard → Relay o ejecuta /relay revoke <token-prefix> en el host de Hermes. Vuelve a emparejar solo si usas extensiones de Relay.</string>
|
||||
<string name="paired_devices_try_again">Intentar otra vez</string>
|
||||
<string name="paired_devices_manage_sessions">Abrir Dashboard</string>
|
||||
<string name="paired_devices_repair_this_phone">Volver a emparejar este teléfono</string>
|
||||
<string name="paired_devices_empty_title">No hay sesiones de relay (todavía)</string>
|
||||
<string name="paired_devices_empty_body">Empareje este teléfono con su relay para verlo aquí.</string>
|
||||
<string name="paired_devices_pair_now">Emparejar ahora</string>
|
||||
@@ -2151,6 +2159,9 @@
|
||||
<string name="conn_info_server_default_model">Valor predeterminado del servidor: %1$s</string>
|
||||
<string name="conn_info_session_desc">Detalles de emparejamiento y seguridad Relay.</string>
|
||||
<string name="conn_info_session_title">Sesión</string>
|
||||
<string name="conn_info_session_details">Detalles de la sesión</string>
|
||||
<string name="conn_info_stored_session_details">Detalles de sesión almacenados</string>
|
||||
<string name="conn_info_stored_session_details_desc">Estos datos se conservan de la última sesión Relay válida y podrían no estar actualizados.</string>
|
||||
<string name="conn_info_session_token_present">token de sesión</string>
|
||||
<string name="conn_info_show_less">Mostrar menos</string>
|
||||
<string name="conn_info_skills_count">Habilidades %1$d</string>
|
||||
|
||||
@@ -626,6 +626,11 @@
|
||||
<string name="settings_plugin_stale">プラグインが古い</string>
|
||||
<string name="settings_plugin_connecting">プラグイン接続中</string>
|
||||
<string name="settings_plugin_active">プラグインがアクティブです</string>
|
||||
<string name="relay_state_optional">オプション</string>
|
||||
<string name="relay_state_ready">準備完了</string>
|
||||
<string name="relay_state_reconnecting">再接続中</string>
|
||||
<string name="relay_state_unavailable">利用不可</string>
|
||||
<string name="relay_state_needs_repair">再ペアリングが必要</string>
|
||||
<string name="settings_no_connection">接続がありません</string>
|
||||
<string name="settings_server_default">サーバーのデフォルト</string>
|
||||
<string name="settings_connections">接続</string>
|
||||
@@ -1388,7 +1393,10 @@
|
||||
<string name="paired_devices_channel_revoked">%1$s アクセスが取り消されました</string>
|
||||
<string name="paired_devices_channel_revoke_failed">%1$s アクセスを取り消すことができませんでした</string>
|
||||
<string name="paired_devices_load_error">Relayセッションをロードできませんでした</string>
|
||||
<string name="paired_devices_invalid_session_help">このスマートフォンの Relay セッションが無効になった場合は、Dashboard → Relay から古いデバイスを削除するか、Hermes ホストで /relay revoke <token-prefix> を実行してください。Relay 拡張機能を使用する場合のみ、再度ペアリングしてください。</string>
|
||||
<string name="paired_devices_try_again">もう一度やり直してください</string>
|
||||
<string name="paired_devices_manage_sessions">Dashboard を開く</string>
|
||||
<string name="paired_devices_repair_this_phone">このスマートフォンを再ペアリング</string>
|
||||
<string name="paired_devices_empty_title">Relayセッションは(まだ)ありません</string>
|
||||
<string name="paired_devices_empty_body">この電話をRelayとペアリングすると、ここで表示されます。</string>
|
||||
<string name="paired_devices_pair_now">今すぐペアリング</string>
|
||||
@@ -2363,6 +2371,9 @@
|
||||
<string name="conn_info_server_default_model">サーバーのデフォルト: %1$s</string>
|
||||
<string name="conn_info_session_desc">Relay ペアリングの詳細とセキュリティ。</string>
|
||||
<string name="conn_info_session_title">セッション</string>
|
||||
<string name="conn_info_session_details">セッションの詳細</string>
|
||||
<string name="conn_info_stored_session_details">保存されたセッションの詳細</string>
|
||||
<string name="conn_info_stored_session_details_desc">これらは最後に有効だった Relay セッションの情報で、現在の状態とは異なる場合があります。</string>
|
||||
<string name="conn_info_session_token_present">セッショントークン</string>
|
||||
<string name="conn_info_show_less">表示を少なくする</string>
|
||||
<string name="conn_info_skills_count">%1$d スキル</string>
|
||||
|
||||
@@ -596,6 +596,11 @@
|
||||
<string name="settings_plugin_stale">Плагин устарел</string>
|
||||
<string name="settings_plugin_connecting">Подключение плагина</string>
|
||||
<string name="settings_plugin_active">Плагин активен</string>
|
||||
<string name="relay_state_optional">Необязательно</string>
|
||||
<string name="relay_state_ready">Готово</string>
|
||||
<string name="relay_state_reconnecting">Переподключение</string>
|
||||
<string name="relay_state_unavailable">Недоступно</string>
|
||||
<string name="relay_state_needs_repair">Требуется повторное сопряжение</string>
|
||||
<string name="settings_no_connection">Нет соединения</string>
|
||||
<string name="settings_server_default">Сервер по умолчанию</string>
|
||||
<string name="settings_connections">Подключения</string>
|
||||
@@ -1381,7 +1386,10 @@
|
||||
<string name="paired_devices_channel_revoked">Доступ %1$s отозван</string>
|
||||
<string name="paired_devices_channel_revoke_failed">Не удалось отозвать доступ %1$s</string>
|
||||
<string name="paired_devices_load_error">Не удалось загрузить сессии Relay</string>
|
||||
<string name="paired_devices_invalid_session_help">Если сессия Relay этого телефона больше недействительна, удалите устаревшие устройства в Dashboard → Relay или выполните /relay revoke <token-prefix> на хосте Hermes. Выполняйте повторное сопряжение, только если используете расширения Relay.</string>
|
||||
<string name="paired_devices_try_again">Попробовать снова</string>
|
||||
<string name="paired_devices_manage_sessions">Открыть Dashboard</string>
|
||||
<string name="paired_devices_repair_this_phone">Повторно сопрячь этот телефон</string>
|
||||
<string name="paired_devices_empty_title">Нет сессий Relay (пока)</string>
|
||||
<string name="paired_devices_empty_body">Сопоставьте этот телефон с вашим Relay, чтобы увидеть его здесь.</string>
|
||||
<string name="paired_devices_pair_now">Сопоставить сейчас</string>
|
||||
@@ -2341,6 +2349,9 @@
|
||||
<string name="conn_info_server_default_model">По умолчанию сервера: %1$s</string>
|
||||
<string name="conn_info_session_desc">Детали сопряжения Relay и безопасность.</string>
|
||||
<string name="conn_info_session_title">Сессия</string>
|
||||
<string name="conn_info_session_details">Сведения о сеансе</string>
|
||||
<string name="conn_info_stored_session_details">Сохранённые сведения о сеансе</string>
|
||||
<string name="conn_info_stored_session_details_desc">Эти сведения сохранены из последнего действительного сеанса Relay и могут быть устаревшими.</string>
|
||||
<string name="conn_info_session_token_present">Токен сессии</string>
|
||||
<string name="conn_info_show_less">Показать меньше</string>
|
||||
<string name="conn_info_skills_count">%1$d навыков</string>
|
||||
|
||||
@@ -645,6 +645,11 @@
|
||||
<string name="settings_plugin_stale">Plugin stale</string>
|
||||
<string name="settings_plugin_connecting">Plugin connecting</string>
|
||||
<string name="settings_plugin_active">Plugin active</string>
|
||||
<string name="relay_state_optional">Optional</string>
|
||||
<string name="relay_state_ready">Ready</string>
|
||||
<string name="relay_state_reconnecting">Reconnecting</string>
|
||||
<string name="relay_state_unavailable">Unavailable</string>
|
||||
<string name="relay_state_needs_repair">Needs re-pair</string>
|
||||
<string name="settings_no_connection">No connection</string>
|
||||
<string name="settings_server_default">Server default</string>
|
||||
<string name="settings_connections">Connections</string>
|
||||
@@ -1480,7 +1485,10 @@
|
||||
<string name="paired_devices_channel_revoked">%1$s access revoked</string>
|
||||
<string name="paired_devices_channel_revoke_failed">Failed to revoke %1$s access</string>
|
||||
<string name="paired_devices_load_error">Couldn\'t load relay sessions</string>
|
||||
<string name="paired_devices_invalid_session_help">If this phone\'s Relay session is no longer valid, remove stale devices from Dashboard → Relay or run /relay revoke <token-prefix> on the Hermes host. Pair again only if you use Relay extensions.</string>
|
||||
<string name="paired_devices_try_again">Try again</string>
|
||||
<string name="paired_devices_manage_sessions">Open Dashboard</string>
|
||||
<string name="paired_devices_repair_this_phone">Re-pair this phone</string>
|
||||
<string name="paired_devices_empty_title">No relay sessions (yet)</string>
|
||||
<string name="paired_devices_empty_body">Pair this phone with your relay to see it here.</string>
|
||||
<string name="paired_devices_pair_now">Pair now</string>
|
||||
@@ -2472,6 +2480,9 @@
|
||||
<string name="conn_info_server_default_model">Server default: %1$s</string>
|
||||
<string name="conn_info_session_desc">Relay pairing details and security.</string>
|
||||
<string name="conn_info_session_title">Session</string>
|
||||
<string name="conn_info_session_details">Session details</string>
|
||||
<string name="conn_info_stored_session_details">Stored session details</string>
|
||||
<string name="conn_info_stored_session_details_desc">These details are retained from the last valid Relay session and may no longer be current.</string>
|
||||
<string name="conn_info_session_token_present">Session token</string>
|
||||
<string name="conn_info_show_less">Show less</string>
|
||||
<string name="conn_info_skills_count">%1$d skills</string>
|
||||
|
||||
@@ -5,6 +5,40 @@ import org.junit.Test
|
||||
|
||||
class RelayReconnectStateTest {
|
||||
|
||||
@Test
|
||||
fun scheduledReconnectPolicyOnlyOverridesOrdinaryWaitingBackoff() {
|
||||
assertEquals(
|
||||
true,
|
||||
canOverrideScheduledRelayReconnect(
|
||||
state = ConnectionState.Reconnecting,
|
||||
backoffWaiting = true,
|
||||
rateLimitBackoffActive = false,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
false,
|
||||
canOverrideScheduledRelayReconnect(
|
||||
state = ConnectionState.Reconnecting,
|
||||
backoffWaiting = false,
|
||||
rateLimitBackoffActive = false,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
false,
|
||||
canOverrideScheduledRelayReconnect(
|
||||
state = ConnectionState.Reconnecting,
|
||||
backoffWaiting = true,
|
||||
rateLimitBackoffActive = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rateLimitBackoffRemainsActiveUntilItsDeadline() {
|
||||
assertEquals(true, isRelayRateLimitBackoffActive(untilMs = 10_000, nowMs = 9_999))
|
||||
assertEquals(false, isRelayRateLimitBackoffActive(untilMs = 10_000, nowMs = 10_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun consecutiveFailuresAreScopedToTheSocketRoute() {
|
||||
val state = RelayReconnectState()
|
||||
|
||||
+35
@@ -820,6 +820,8 @@ class DashboardApiClientTest {
|
||||
// carry profile=mizu (the desktop's `_open_session_db_for_profile` path).
|
||||
val url = request.requestUrl!!
|
||||
assertEquals("/api/sessions", url.encodedPath)
|
||||
assertEquals("100", url.queryParameter("limit"))
|
||||
assertEquals("0", url.queryParameter("offset"))
|
||||
assertEquals("mizu", url.queryParameter("profile"))
|
||||
assertEquals("1", url.queryParameter("min_messages"))
|
||||
assertEquals(2, sessions.size)
|
||||
@@ -832,6 +834,39 @@ class DashboardApiClientTest {
|
||||
assertEquals("Review title fallbacks", sessions[1].preview)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listSessions_pagesAtUpstreamMaximumWhilePreservingTwoHundredRowWindow() = runTest {
|
||||
val firstPage = (0 until 100).joinToString(",") { "{\"id\":\"sess-$it\"}" }
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("{\"sessions\":[$firstPage],\"total\":102,\"limit\":100,\"offset\":0}"),
|
||||
)
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""{"sessions":[{"id":"sess-100"},{"id":"sess-101"}],"total":102,"limit":100,"offset":100}""",
|
||||
),
|
||||
)
|
||||
|
||||
val sessions = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
.listSessions(profile = "mizu")
|
||||
.getOrThrow()
|
||||
|
||||
val firstRequest = server.takeRequest().requestUrl!!
|
||||
val secondRequest = server.takeRequest().requestUrl!!
|
||||
assertEquals("100", firstRequest.queryParameter("limit"))
|
||||
assertEquals("0", firstRequest.queryParameter("offset"))
|
||||
assertEquals("mizu", firstRequest.queryParameter("profile"))
|
||||
assertEquals("100", secondRequest.queryParameter("limit"))
|
||||
assertEquals("100", secondRequest.queryParameter("offset"))
|
||||
assertEquals("mizu", secondRequest.queryParameter("profile"))
|
||||
assertEquals(102, sessions.size)
|
||||
assertEquals("sess-0", sessions.first().id)
|
||||
assertEquals("sess-101", sessions.last().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listSessions_omitsProfileParamForTheDefaultSelection() = runTest {
|
||||
server.enqueue(
|
||||
|
||||
+16
-2
@@ -406,8 +406,22 @@ class HermesApiClientTest {
|
||||
@Test
|
||||
fun urlConstruction_sessionsEndpoint() {
|
||||
val baseUrl = "http://localhost:8642"
|
||||
val url = "$baseUrl/api/sessions?limit=200"
|
||||
assertEquals("http://localhost:8642/api/sessions?limit=200", url)
|
||||
val page = sessionListPages(200).first()
|
||||
val url = "$baseUrl/api/sessions?limit=${page.limit}&offset=${page.offset}"
|
||||
assertEquals("http://localhost:8642/api/sessions?limit=100&offset=0", url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionListPages_preservesWindowWithoutExceedingUpstreamMaximum() {
|
||||
assertEquals(
|
||||
listOf(SessionListPage(limit = 100, offset = 0), SessionListPage(limit = 100, offset = 100)),
|
||||
sessionListPages(200),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(SessionListPage(limit = 100, offset = 0), SessionListPage(limit = 100, offset = 100)),
|
||||
sessionListPages(999),
|
||||
)
|
||||
assertEquals(listOf(SessionListPage(limit = 25, offset = 0)), sessionListPages(25))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -37,4 +39,43 @@ class MessageBubbleActionTest {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialSelection_resetsOnlyWhenSelectableTopologyChanges() {
|
||||
val initialLive = messageSelectionTopologyKey(
|
||||
isPlainText = false,
|
||||
isStreaming = true,
|
||||
retainStreamingLayout = false,
|
||||
markdownBody = "First",
|
||||
)
|
||||
val updatedLive = messageSelectionTopologyKey(
|
||||
isPlainText = false,
|
||||
isStreaming = true,
|
||||
retainStreamingLayout = false,
|
||||
markdownBody = "First paragraph\n\nSecond",
|
||||
)
|
||||
val retainedLive = messageSelectionTopologyKey(
|
||||
isPlainText = false,
|
||||
isStreaming = false,
|
||||
retainStreamingLayout = true,
|
||||
markdownBody = "First paragraph\n\nSecond",
|
||||
)
|
||||
val settledMarkdown = messageSelectionTopologyKey(
|
||||
isPlainText = false,
|
||||
isStreaming = false,
|
||||
retainStreamingLayout = false,
|
||||
markdownBody = "First paragraph\n\nSecond",
|
||||
)
|
||||
val revisedMarkdown = messageSelectionTopologyKey(
|
||||
isPlainText = false,
|
||||
isStreaming = false,
|
||||
retainStreamingLayout = false,
|
||||
markdownBody = "First paragraph\n\nSecond\n\nThird",
|
||||
)
|
||||
|
||||
assertEquals(initialLive, updatedLive)
|
||||
assertEquals(initialLive, retainedLive)
|
||||
assertNotEquals(initialLive, settledMarkdown)
|
||||
assertNotEquals(settledMarkdown, revisedMarkdown)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatCleanViewHintTest {
|
||||
|
||||
@Test
|
||||
fun emptyTextChatShowsCleanViewHint() {
|
||||
assertTrue(
|
||||
shouldShowCleanViewHint(
|
||||
hasMessages = false,
|
||||
ambientMode = false,
|
||||
voiceMode = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceModeOwnsBottomAreaOnEmptyChat() {
|
||||
assertFalse(
|
||||
shouldShowCleanViewHint(
|
||||
hasMessages = false,
|
||||
ambientMode = false,
|
||||
voiceMode = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun existingConversationOrCleanModeSuppressesHint() {
|
||||
assertFalse(
|
||||
shouldShowCleanViewHint(
|
||||
hasMessages = true,
|
||||
ambientMode = false,
|
||||
voiceMode = false,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
shouldShowCleanViewHint(
|
||||
hasMessages = false,
|
||||
ambientMode = true,
|
||||
voiceMode = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,29 @@ class RelayErrorClassifierTest {
|
||||
assertFalse(err.body.contains("re-pair", ignoreCase = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun apiSessionLoadUnauthorizedPointsAtApiKeyInsteadOfRepairingRelay() {
|
||||
val err = classifyError(
|
||||
IOException("List sessions unauthorized - check your API key"),
|
||||
context = "load_sessions",
|
||||
)
|
||||
|
||||
assertEquals("API key rejected", err.title)
|
||||
assertFalse(err.body.contains("re-pair", ignoreCase = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardProfileSessionUnauthorizedDoesNotBlameRelayPairing() {
|
||||
val err = classifyError(
|
||||
IOException("Profile sessions unauthorized - HTTP 401"),
|
||||
context = "load_profile_sessions",
|
||||
)
|
||||
|
||||
assertEquals("Dashboard sign-in required", err.title)
|
||||
assertFalse(err.body.contains("re-pair", ignoreCase = true))
|
||||
assertEquals(null, err.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayUnauthorizedStillPointsAtPairing() {
|
||||
val err = classifyError(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatPassiveErrorPolicyTest {
|
||||
@Test
|
||||
fun backgroundSessionAuthAndConnectivityStayOutOfGlobalSnackbar() {
|
||||
assertTrue(shouldSuppressPassiveSessionError("load_sessions", ConnectException("refused")))
|
||||
assertTrue(shouldSuppressPassiveSessionError("load_sessions", IOException("401 Unauthorized")))
|
||||
assertTrue(shouldSuppressPassiveSessionError("load_profile_sessions", IOException("HTTP 403")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serverAndInteractiveErrorsStillSurface() {
|
||||
assertFalse(shouldSuppressPassiveSessionError("load_sessions", IOException("HTTP 500")))
|
||||
assertFalse(shouldSuppressPassiveSessionError("create_session", IOException("401 Unauthorized")))
|
||||
assertFalse(shouldSuppressPassiveSessionError("send_message", IOException("401 Unauthorized")))
|
||||
assertFalse(shouldSuppressPassiveSessionError("media_fetch", IOException("401 Unauthorized")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.network.relay.ConnectionState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RelayUiStateTest {
|
||||
@Test
|
||||
fun `relay phases use the standard user-facing vocabulary`() {
|
||||
assertEquals("Optional", RelayUiState.NotConfigured.statusText("Ready"))
|
||||
assertEquals("Ready", RelayUiState.Connected.statusText("Ready"))
|
||||
assertEquals("Reconnecting", RelayUiState.Connecting.statusText("Ready"))
|
||||
assertEquals("Unavailable", RelayUiState.Stale.statusText("Ready"))
|
||||
assertEquals("Needs re-pair", RelayUiState.Expired.statusText("Ready"))
|
||||
assertEquals("Unavailable", RelayUiState.Disconnected.statusText("Ready"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scheduled reconnect becomes unavailable after grace`() {
|
||||
val inputs = RelayUiInputs(
|
||||
auth = AuthState.Paired("token"),
|
||||
conn = ConnectionState.Reconnecting,
|
||||
url = "wss://relay.example/ws",
|
||||
configured = true,
|
||||
)
|
||||
|
||||
assertEquals(RelayUiState.Connecting, inputs.resolveRelayUiState())
|
||||
assertEquals(RelayUiState.Stale, inputs.resolveRelayUiState(graceElapsed = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed auth takes precedence over reconnecting transport`() {
|
||||
val inputs = RelayUiInputs(
|
||||
auth = AuthState.Failed("expired"),
|
||||
conn = ConnectionState.Reconnecting,
|
||||
url = "wss://relay.example/ws",
|
||||
configured = true,
|
||||
)
|
||||
|
||||
assertEquals(RelayUiState.Expired, inputs.resolveRelayUiState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `socket is not ready until pairing auth succeeds`() {
|
||||
val inputs = RelayUiInputs(
|
||||
auth = AuthState.Pairing,
|
||||
conn = ConnectionState.Connected,
|
||||
url = "wss://relay.example/ws",
|
||||
configured = true,
|
||||
)
|
||||
|
||||
assertEquals(RelayUiState.Connecting, inputs.resolveRelayUiState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `route detail does not replace the standard relay phase`() {
|
||||
assertEquals(
|
||||
"Unavailable \u00B7 Tailscale",
|
||||
RelayRowState(
|
||||
phase = RelayUiState.Stale,
|
||||
activeEndpointRole = "tailscale",
|
||||
).statusText("Ready"),
|
||||
)
|
||||
assertEquals(
|
||||
"Needs re-pair",
|
||||
RelayRowState(
|
||||
phase = RelayUiState.Expired,
|
||||
activeEndpointRole = "lan",
|
||||
).statusText("Ready"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,7 @@ Phone (WSS) → Relay Server (:8767) [bridge, terminal]
|
||||
- Pairing codes are user-friendly and don't require pre-shared secrets.
|
||||
- Session tokens avoid re-pairing on every app restart.
|
||||
- Tokens stored in EncryptedSharedPreferences (Android Keystore-backed AES-256-GCM).
|
||||
- An explicit re-pair replaces older sessions and trusted credentials for the same non-empty device ID; it does not accumulate duplicate entries for one app installation. Other devices and legacy entries without an identity remain independent.
|
||||
|
||||
#### 6a. QR Carries Both API and Relay Credentials (updated 2026-05-03)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "903cf3f6d9cbc77e044848ee56777862ef052be84668dcc46ac402242ef7b3cc",
|
||||
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "903cf3f6d9cbc77e044848ee56777862ef052be84668dcc46ac402242ef7b3cc",
|
||||
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "903cf3f6d9cbc77e044848ee56777862ef052be84668dcc46ac402242ef7b3cc",
|
||||
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "903cf3f6d9cbc77e044848ee56777862ef052be84668dcc46ac402242ef7b3cc",
|
||||
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "903cf3f6d9cbc77e044848ee56777862ef052be84668dcc46ac402242ef7b3cc",
|
||||
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "903cf3f6d9cbc77e044848ee56777862ef052be84668dcc46ac402242ef7b3cc",
|
||||
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
@@ -295,7 +295,7 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
|
||||
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard and desktop pair/repair flows. Reads `API_SERVER_KEY` from the host-local config chain when the dashboard does not pass `api_key` explicitly. Optional request field `dashboard_url` is mirrored into the QR payload and response. |
|
||||
| `/pairing/approve` | POST | **Loopback only, Phase 3 stub.** Same wire shape and loopback gate as `/pairing/register` — present so the Android client can target the route today. The semantic difference (operator reviewing a phone-initiated pending code before approval) still needs the pending-codes store + approval UX, marked `# TODO(Phase 3)` in the handler. |
|
||||
| `/sessions` | GET | Bearer-auth'd. Returns `{"sessions": [ {token_prefix, device_name, device_id, created_at, last_seen, expires_at, grants, transport_hint, is_current}, ... ]}` for all currently-active paired devices. `token_prefix` is the first 8 characters of the session token — full tokens are NEVER included, so a caller holding one session token can't extract another. `expires_at` and grant values that are `math.inf` serialize as `null` (never expire). `is_current` is true for the session matching the caller's bearer. 401 on missing/invalid bearer. Used by the Android Paired Devices screen. **Loopback branch (2026-04-18):** callers on `127.0.0.1` / `::1` may skip the bearer and receive the same `{sessions: [...]}` payload without the `is_current` flag (no caller context). Added so the dashboard plugin proxy can list paired devices without needing to mint its own bearer. Non-loopback callers still require the bearer and retain `is_current`. |
|
||||
| `/sessions/{token_prefix}` | DELETE | Bearer-auth'd. Revoke a paired device by first-N-char token prefix (N ≥ 4). Returns 200 `{"ok": true, "revoked_self": bool}` on exact match; 404 on zero matches; 409 on ambiguous (2+) matches with the count in the body. Self-revoke is allowed and flagged via `revoked_self: true` so the caller knows to wipe local state. Any paired device can revoke any other — see ADR 15 for the trade-off rationale. |
|
||||
| `/sessions/{token_prefix}` | DELETE | Bearer-auth'd for network callers; loopback callers may omit the bearer for host-operator management. Revoke a paired device by first-N-char token prefix (N ≥ 4). Returns 200 `{"ok": true, "revoked_self": bool}` on exact match; 404 on zero matches; 409 on ambiguous (2+) matches with the count in the body. Self-revoke is allowed and flagged via `revoked_self: true` so the caller knows to wipe local state. Any paired phone can revoke any other — see ADR 15 for the trade-off rationale. The Dashboard Relay tab and `/relay revoke <token-prefix>` use the loopback operator path. |
|
||||
| `/sessions/{token_prefix}` | PATCH | Bearer-auth'd, self-targeted, and reduction-only. Body `{"ttl_seconds": 3600}`, `{"grants": {"terminal": 600}}`, or both may shorten the caller's current session policy. A bearer cannot target another session, extend its lifetime, add or lengthen grants, or change a finite expiry to never-expire; authority-increasing changes require a fresh operator-approved pairing flow. Omitted grants retain their existing absolute ceilings and are clamped if the parent session is shortened. Returns 200 with the reduced `{expires_at, grants}`; 400 on missing/invalid or unknown grants; 403 on cross-session targets or policy expansion; 404 on prefix miss; 409 on ambiguous prefix. |
|
||||
| `/chat/image-activity` | GET | Optional read-only Standard Gateway compatibility route. Requires a valid Relay bearer with an active `chat` grant and query parameters `profile`, `session_id`, and `since` (Unix seconds). Reads the selected profile's Hermes `state.db` without mutation and returns persisted `image_generate` calls as `running` or `completed`. Android polls only during an active turn, deduplicates against native Gateway tool events, and silently disables the bridge when the route is absent. |
|
||||
| `/clipboard/inbox` | POST | Bearer-auth'd clipboard rendezvous used by remote clients before native platform clipboard fallback. |
|
||||
|
||||
+30
-1
@@ -893,7 +893,9 @@ class SessionManager:
|
||||
issue_refresh_token:
|
||||
When True, also create a persisted trusted-device credential and
|
||||
attach the raw one-time refresh token to the returned
|
||||
:class:`Session` for inclusion in ``auth.ok``.
|
||||
:class:`Session` for inclusion in ``auth.ok``. This is an explicit
|
||||
pairing operation, so any older sessions and trusted-device
|
||||
credentials for the same non-empty ``device_id`` are replaced.
|
||||
"""
|
||||
if ttl_seconds is None:
|
||||
ttl_seconds = DEFAULT_TTL_SECONDS
|
||||
@@ -907,6 +909,33 @@ class SessionManager:
|
||||
resolved_grants = _materialize_grants(grants, float(ttl_seconds), now)
|
||||
refresh_token: str | None = None
|
||||
if issue_refresh_token:
|
||||
# A fresh operator-approved pair repairs this device; it does not
|
||||
# authorize another indefinite row for the same app installation.
|
||||
# Keep different devices independent and leave legacy clients with
|
||||
# no device_id alone because an empty id cannot identify ownership.
|
||||
normalized_device_id = device_id.strip()
|
||||
if normalized_device_id:
|
||||
replaced_sessions = [
|
||||
token
|
||||
for token, existing in self._sessions.items()
|
||||
if existing.device_id == normalized_device_id
|
||||
]
|
||||
for token in replaced_sessions:
|
||||
del self._sessions[token]
|
||||
replaced_devices = [
|
||||
refresh_hash
|
||||
for refresh_hash, existing in self._trusted_devices.items()
|
||||
if existing.device_id == normalized_device_id
|
||||
]
|
||||
for refresh_hash in replaced_devices:
|
||||
del self._trusted_devices[refresh_hash]
|
||||
if replaced_sessions or replaced_devices:
|
||||
logger.info(
|
||||
"Re-pair replaced %d session(s) and %d trusted credential(s) for device %s",
|
||||
len(replaced_sessions),
|
||||
len(replaced_devices),
|
||||
normalized_device_id,
|
||||
)
|
||||
refresh_token = _generate_refresh_token()
|
||||
refresh_hash = _refresh_token_hash(refresh_token)
|
||||
self._trusted_devices[refresh_hash] = TrustedDevice(
|
||||
|
||||
@@ -9,6 +9,7 @@ Subcommands (parsed out of ``raw_args`` by :func:`relay_slash_handler`):
|
||||
|
||||
/relay status Relay reachability + connected-phone summary
|
||||
/relay devices Paired-device list (loopback ``GET /sessions``)
|
||||
/relay revoke ID Revoke a paired device by token prefix
|
||||
/relay pair Mint a fresh 6-char pairing code on the running relay
|
||||
/relay help This help text
|
||||
|
||||
@@ -25,6 +26,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -142,6 +144,36 @@ def _cmd_devices() -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _cmd_revoke(token_prefix: str) -> str:
|
||||
"""Revoke one paired device through the relay's loopback operator path."""
|
||||
prefix = token_prefix.strip()
|
||||
if len(prefix) < 4:
|
||||
return "Usage: `/relay revoke <token-prefix>` (at least 4 characters)."
|
||||
|
||||
port = _relay_port()
|
||||
encoded_prefix = urllib.parse.quote(prefix, safe="")
|
||||
url = f"http://127.0.0.1:{port}/sessions/{encoded_prefix}"
|
||||
req = urllib.request.Request(url, method="DELETE")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=2.0) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return f"No paired device matches `{prefix}`. Run `/relay devices` to refresh."
|
||||
if exc.code == 409:
|
||||
return f"More than one device matches `{prefix}`. Use a longer token prefix."
|
||||
return f"Could not revoke `{prefix}` — relay returned HTTP {exc.code}."
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
return (
|
||||
f"Could not revoke `{prefix}` — relay unreachable on "
|
||||
f"127.0.0.1:{port} ({exc})."
|
||||
)
|
||||
|
||||
if isinstance(payload, dict) and payload.get("ok") is True:
|
||||
return f"Revoked paired device `{prefix}`."
|
||||
return f"Relay did not confirm revocation for `{prefix}`."
|
||||
|
||||
|
||||
def _seconds_since(epoch_ts: Any) -> Optional[float]:
|
||||
"""Convert an absolute epoch ``last_seen`` to seconds-ago (best effort)."""
|
||||
if not isinstance(epoch_ts, (int, float)):
|
||||
@@ -188,6 +220,7 @@ _HELP = (
|
||||
"/relay — Hermes-Relay control\n"
|
||||
" status Relay reachability + connected-phone summary\n"
|
||||
" devices List paired devices\n"
|
||||
" revoke Revoke a device by token prefix\n"
|
||||
" pair Mint a fresh 6-char pairing code\n"
|
||||
" help Show this help"
|
||||
)
|
||||
@@ -211,6 +244,8 @@ def relay_slash_handler(raw_args: str) -> str:
|
||||
return _cmd_status()
|
||||
if sub == "devices":
|
||||
return _cmd_devices()
|
||||
if sub == "revoke":
|
||||
return _cmd_revoke(argv[1] if len(argv) > 1 else "")
|
||||
if sub == "pair":
|
||||
return _cmd_pair()
|
||||
return f"Unknown subcommand '{sub}'.\n\n{_HELP}"
|
||||
|
||||
@@ -170,6 +170,52 @@ class SessionPersistenceRoundtripTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIsNone(recovered)
|
||||
|
||||
def test_explicit_repair_replaces_same_device_session_and_refresh(self) -> None:
|
||||
mgr = SessionManager(persistence_path=self.path)
|
||||
original = mgr.create_session(
|
||||
device_name="Phone-A",
|
||||
device_id="dev-a",
|
||||
ttl_seconds=0,
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
original_refresh = original.refresh_token
|
||||
assert original_refresh is not None
|
||||
|
||||
replacement = mgr.create_session(
|
||||
device_name="Phone-A",
|
||||
device_id="dev-a",
|
||||
ttl_seconds=0,
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
|
||||
self.assertEqual(mgr.active_count(), 1)
|
||||
self.assertIsNone(mgr.get_session(original.token))
|
||||
self.assertIsNotNone(mgr.get_session(replacement.token))
|
||||
self.assertIsNone(
|
||||
mgr.refresh_session(
|
||||
original_refresh,
|
||||
device_name="Phone-A",
|
||||
device_id="dev-a",
|
||||
)
|
||||
)
|
||||
|
||||
def test_explicit_pair_keeps_other_devices(self) -> None:
|
||||
mgr = SessionManager(persistence_path=self.path)
|
||||
phone_a = mgr.create_session(
|
||||
device_name="Phone-A",
|
||||
device_id="dev-a",
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
phone_b = mgr.create_session(
|
||||
device_name="Phone-B",
|
||||
device_id="dev-b",
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
|
||||
self.assertEqual(mgr.active_count(), 2)
|
||||
self.assertIsNotNone(mgr.get_session(phone_a.token))
|
||||
self.assertIsNotNone(mgr.get_session(phone_b.token))
|
||||
|
||||
def test_existing_session_can_be_upgraded_with_refresh_token(self) -> None:
|
||||
mgr = SessionManager(persistence_path=self.path)
|
||||
session = mgr.create_session(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Focused tests for host-side Relay session management commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from plugin import slash
|
||||
|
||||
|
||||
class RelaySlashRevokeTests(unittest.TestCase):
|
||||
def test_revoke_requires_token_prefix(self) -> None:
|
||||
self.assertIn("at least 4", slash.relay_slash_handler("revoke abc"))
|
||||
|
||||
@patch("plugin.slash.urllib.request.urlopen")
|
||||
@patch("plugin.slash._relay_port", return_value=8767)
|
||||
def test_revoke_uses_loopback_delete(self, _port: MagicMock, urlopen: MagicMock) -> None:
|
||||
response = MagicMock()
|
||||
response.read.return_value = json.dumps({"ok": True}).encode("utf-8")
|
||||
urlopen.return_value.__enter__.return_value = response
|
||||
|
||||
result = slash.relay_slash_handler("revoke abcdef12")
|
||||
|
||||
request = urlopen.call_args.args[0]
|
||||
self.assertEqual(request.get_method(), "DELETE")
|
||||
self.assertEqual(request.full_url, "http://127.0.0.1:8767/sessions/abcdef12")
|
||||
self.assertEqual(result, "Revoked paired device `abcdef12`.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user