Compare commits

...
Author SHA1 Message Date
Bailey Dixon 97231eb291 Merge pull request #375 from Codename-11/dev
release(android): android-v1.10.0
2026-08-18 22:02:10 -04:00
Bailey Dixon f7c707be03 release(android): android-v1.10.0 2026-08-18 21:32:09 -04:00
Bailey Dixon 331fad0827 merge: fix Android completion bubble layout 2026-08-18 20:50:24 -04:00
Bailey Dixon f131fa08cc fix(android): keep completion bubble layout stable 2026-08-18 20:50:18 -04:00
Bailey Dixon cbc81513bd merge: fix Android completion scroll anchor 2026-08-18 19:56:48 -04:00
Bailey Dixon 3ad5ad8dc7 fix(android): preserve stream completion scroll anchor 2026-08-18 19:56:39 -04:00
Bailey Dixon d695553c0b merge: integrate native streaming Markdown renderer 2026-08-18 19:32:42 -04:00
Bailey Dixon 6b324fa822 fix(android): keep streaming markdown renderer stable 2026-08-18 19:32:28 -04:00
Bailey Dixon 52ee97f2b5 merge: fix Android streaming scroll settlement 2026-08-17 19:57:56 -04:00
Bailey Dixon 76bfe97c78 fix(android): stop streaming markdown scroll bounce 2026-08-17 19:57:47 -04:00
Bailey Dixon c8a3072704 merge: integrate Android streaming Markdown follow
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ChatScreen.kt
2026-08-17 18:43:50 -04:00
Bailey Dixon a7612d7f05 chore(android): reconcile localization catalog hash 2026-08-17 18:25:32 -04:00
Bailey Dixon e82ed47573 merge: integrate Android composer continuity 2026-08-17 18:18:34 -04:00
Bailey Dixon c6e4a2877d merge: fix Android reconnect and credential safety 2026-08-17 09:48:42 -04:00
Bailey Dixon 79335fea16 feat(android): persist composer drafts and attach large pastes 2026-08-17 09:40:07 -04:00
Bailey Dixon 9c8b6c30bf fix(android): recover chat sessions and reject malformed credentials 2026-08-17 09:11:21 -04:00
Bailey Dixon 4cf89df627 feat(android): stabilize streaming markdown follow 2026-08-17 09:10:53 -04:00
Bailey Dixon ad98ca9486 fix(android): expose newline on software keyboard 2026-08-17 08:38:50 -04:00
dependabot[bot] 9d50f401ad chore(deps): bump com.github.triplet.play from 4.0.0 to 4.1.1 (#373)
Bumps com.github.triplet.play from 4.0.0 to 4.1.1.

---
updated-dependencies:
- dependency-name: com.github.triplet.play
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 11:57:58 +00:00
dependabot[bot] 2bc62c84e0 chore(deps): bump androidx.appcompat:appcompat from 1.7.1 to 1.8.0 (#372)
Bumps androidx.appcompat:appcompat from 1.7.1 to 1.8.0.

---
updated-dependencies:
- dependency-name: androidx.appcompat:appcompat
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 11:57:08 +00:00
dependabot[bot] c3011e286c chore(deps): bump com.microsoft.onnxruntime:onnxruntime-android (#371)
Bumps [com.microsoft.onnxruntime:onnxruntime-android](https://github.com/microsoft/onnxruntime) from 1.28.0 to 1.29.0.
- [Release notes](https://github.com/microsoft/onnxruntime/releases)
- [Changelog](https://github.com/microsoft/onnxruntime/blob/main/docs/ReleaseNotesWorkflow.md)
- [Commits](https://github.com/microsoft/onnxruntime/compare/v1.28.0...v1.29.0)

---
updated-dependencies:
- dependency-name: com.microsoft.onnxruntime:onnxruntime-android
  dependency-version: 1.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 11:56:34 +00:00
dependabot[bot] 9458fea4f7 chore(deps): bump the testing group with 2 updates (#370)
Bumps the testing group with 2 updates: [io.github.takahirom.roborazzi:roborazzi](https://github.com/takahirom/roborazzi) and [io.github.takahirom.roborazzi:roborazzi-compose](https://github.com/takahirom/roborazzi).


Updates `io.github.takahirom.roborazzi:roborazzi` from 1.71.0 to 1.72.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.71.0...1.72.0)

Updates `io.github.takahirom.roborazzi:roborazzi-compose` from 1.71.0 to 1.72.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.71.0...1.72.0)

---
updated-dependencies:
- dependency-name: io.github.takahirom.roborazzi:roborazzi
  dependency-version: 1.72.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
- dependency-name: io.github.takahirom.roborazzi:roborazzi-compose
  dependency-version: 1.72.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 11:55:21 +00:00
dependabot[bot] 94b29c4a01 chore(deps): bump androidx.compose:compose-bom in the compose group (#369)
Bumps the compose group with 1 update: androidx.compose:compose-bom.


Updates `androidx.compose:compose-bom` from 2026.06.01 to 2026.08.00

---
updated-dependencies:
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2026.08.00
  dependency-type: direct:production
  dependency-group: compose
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 11:54:39 +00:00
56 changed files with 2258 additions and 601 deletions
+15
View File
@@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [1.10.0] - 2026-08-18
### Added
- **Android preserves composer drafts across app restarts.** Text, quote/edit context, and pending attachments remain scoped to their exact connection, profile, and session in bounded app-private no-backup storage, and successful sends remove the saved draft.
- **Android can turn large pastes into reviewable text attachments.** The default-on Chat setting converts inserts of at least 5,000 characters into a compact attachment while preserving surrounding text; Gateway uploads the file through upstream Hermes and fallback transports retain the pasted content as text.
- **Android renders Markdown incrementally while replies stream.** The native streaming parser retains stable message, selection, and AST identities from the first token through completion, including provisional paragraphs, lists, links, fenced code, and tables.
### Fixed
- **The Android software keyboard exposes Return in the multiline composer.** The dedicated composer button sends, while physical Enter, Shift+Enter, and caret-arrow behavior remain unchanged. (#367)
- **Open chats reattach after Android returns to the foreground.** Gateway reconnect restores the visible session subscription and reconciles missed work without requiring the user to leave and reopen the conversation. (#365)
- **Imported credentials fail closed before network or secure-state mutation.** Control characters and malformed values are rejected before header construction or encrypted-state replacement without logging credential material.
- **Streaming follow remains stable through completion.** Deliberate scrollback stays untouched, bottom-follow uses one bounded owner, and Markdown, voice actions, timestamps, and token metadata settle without rebuilding the bubble or resetting its scroll anchor. (#341)
## [1.9.1] - 2026-08-16
### Added
+18
View File
@@ -1,5 +1,23 @@
# Hermes-Relay — Dev Log
## 2026-08-17 — Android composer continuity and large-paste review
Android's multiline composer now leaves the software IME action as Return while
the dedicated trailing button sends. Physical keyboard Enter, Shift+Enter, and
directional caret behavior retain their existing contracts.
Composer drafts now persist in bounded app-private no-backup storage using
small owner metadata plus content-addressed attachment blobs. Draft ownership
follows the exact connection, opened session profile, session, and draft slot;
profile and connection switches save before restoring, lifecycle stop flushes
the latest state, and successful sends remove the saved draft.
A default-on Chat setting converts a single insertion of at least 5,000
characters into a reviewable text attachment. Preparation runs off the UI
thread behind a visible loading card. Current Gateways send it through upstream
`file.attach`; API-server SSE and proactive Thread paths materialize the same
UTF-8 content into the prompt so no route silently loses the paste.
## 2026-08-14 — Android 1.9.0 session identity and conversation controls
Hermes-Relay Android 1.9.0 is published from the immutable
+26 -26
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.9.1
# Hermes-Relay-Android v1.10.0
**Release Date:** August 16, 2026
**Release Date:** August 18, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.9.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.10.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,35 +12,35 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This patch aligns Agent Passport with current Hermes profile identity, makes
shared avatar changes reliable from the phone, and hardens several Gateway
operations around profile ownership, attachments, recovery, and model consent.
This release makes Android chat more continuous: drafts survive restarts, large
pastes become reviewable attachments, live replies render with incremental
Markdown, and foreground reconnect or completion no longer disrupts the open
conversation.
## Added
- Create Hermes profiles with explicit shared, copied, or isolated
authentication choices and inspect partial setup outcomes.
- Select upstream animated pets that follow the active Hermes profile across
supported clients, while keeping phone-only animated icons separate.
- Create finite recurring schedules, review bounded reset evidence, and see
host resource or model-consent warnings before taking action.
- Preserve text, quote/edit context, and pending attachments in the exact
connection, profile, and session draft across app restarts.
- Convert large pastes into reviewable text attachments before sending while
retaining compatible text delivery on fallback transports.
- Render paragraphs, lists, links, fenced code, and tables incrementally from
the first streamed token without replacing the message at completion.
## Fixed
- Shared avatar images selected from Android now persist. The app accepts any
decodable image and safely converts it to Hermes' supported static format and
size contract when needed.
- Named-profile sessions, draft writes, rewinds, and recovery fail closed when
Hermes cannot confirm the owning profile or durable history.
- Attachment sends remain bounded and no longer fall through to text-only
delivery after an unsupported or interrupted upload.
- Nous hosted sign-in follows the official native callback and provider
contract with clearer, non-sensitive failure guidance.
- Reattach the visible Gateway session after background/foreground reconnect
and reconcile missed work without leaving the conversation.
- Expose Return on the software keyboard while keeping the dedicated Send
action and physical-keyboard behavior distinct.
- Reject malformed imported credentials before network-header construction or
encrypted-state replacement.
- Keep intentional scrollback fixed and bottom-follow stable while Markdown,
voice actions, timestamps, and token metadata settle.
## Install / Verify
- App version: **1.9.1** (versionCode **44**).
- Standard Chat, sessions, Manage, profile identity, and Vanilla Hermes voice
continue to work against unmodified upstream Hermes.
- The optional Relay plugin is not required for standard Android chat, shared
profile avatars, upstream pets, or hosted Dashboard authentication.
- App version: **1.10.0** (versionCode **45**).
- Standard Chat, sessions, Manage, profile identity, streaming Markdown, and
Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- The optional Relay plugin is not required for standard Android chat,
foreground session reattachment, or streaming Markdown.
+2 -2
View File
@@ -372,8 +372,8 @@ dependencies {
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
// Renders real composables on the JVM at an exact canvas — no device, no
// status bar, no clipping. See StoreScreenshotTest.
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.71.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.71.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.72.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.72.0")
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.ui.test.manifest)
testImplementation("androidx.test.ext:junit:1.3.0")
@@ -1 +1 @@
Change shared avatars from your phone without oversized or native image formats disappearing. Select upstream animated pets that follow the Hermes profile across supported clients while keeping phone-only icons separate. Profile creation now exposes clear authentication choices, and attachment, recovery, rewind, model-consent, ownership, and hosted sign-in paths fail more safely.
See Markdown take shape while replies stream without a final message rebuild or scroll jump. Return from another app and resume the open Hermes session automatically. Composer drafts and pending attachments now survive restarts, large pastes become reviewable text attachments, and the software keyboard exposes Return while the dedicated button sends.
+28
View File
@@ -1,5 +1,33 @@
{
"versions": [
{
"version": "1.10.0",
"title": "Chat that stays put",
"date": "2026-08-18",
"sections": [
{
"header": "Watch replies take shape",
"bullets": [
"Render paragraphs, lists, links, fenced code, and tables incrementally without replacing the message at completion.",
"Keep bottom-follow smooth while intentional scrollback remains exactly where you left it."
]
},
{
"header": "Pick up where you left off",
"bullets": [
"Resume the visible Hermes session automatically after returning from another app.",
"Restore composer text, quote or edit context, and pending attachments in the correct conversation after an app restart."
]
},
{
"header": "Review before sending",
"bullets": [
"Turn large pastes into compact text attachments while preserving compatible fallback delivery.",
"Use Return on the software keyboard while the dedicated composer button remains the Send action."
]
}
]
},
{
"version": "1.9.1",
"title": "Profile identity that sticks",
+7 -6
View File
@@ -1,7 +1,8 @@
v1.9.1 - Profile identity that sticks
v1.10.0 - Chat that stays put
* Change shared avatars from your phone without oversized or native image formats disappearing.
* Select upstream animated pets that follow the Hermes profile across supported clients.
* Keep phone-only animated icons and local avatar overrides clearly separate.
* Create profiles with explicit authentication choices and safer ownership checks.
* Get stricter attachment, recovery, rewind, model-consent, and hosted sign-in behavior.
* See Markdown take shape while replies stream, without a final message rebuild.
* Keep the bottom smoothly followed—or scroll back without being pulled away.
* Return from another app and resume the open Hermes session automatically.
* Keep composer drafts and pending attachments across app restarts.
* Turn large pastes into reviewable text attachments before sending.
* Use Return on the software keyboard while the dedicated button sends.
@@ -14,6 +14,8 @@ import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.isSafeProfileUiMeta
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.network.shared.InvalidCredentialException
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -217,21 +219,47 @@ class AuthManager(
tokenStoreKey: String,
secrets: ConnectionAuthSecrets,
) {
val normalized = normalizeStoredSecrets(secrets)
withContext(Dispatchers.IO) {
val store = tokenStoreForBackup(context, tokenStoreKey)
writeOrRemove(store, KEY_SESSION_TOKEN, secrets.sessionToken)
writeOrRemove(store, KEY_REFRESH_TOKEN, secrets.refreshToken)
writeOrRemove(store, KEY_DEVICE_ID, secrets.deviceId)
writeOrRemove(store, KEY_API_KEY, secrets.apiKey)
writeOrRemove(store, KEY_SESSION_TOKEN, normalized.sessionToken)
writeOrRemove(store, KEY_REFRESH_TOKEN, normalized.refreshToken)
writeOrRemove(store, KEY_DEVICE_ID, normalized.deviceId)
writeOrRemove(store, KEY_API_KEY, normalized.apiKey)
writeOrRemove(
store,
KEY_PROFILE_API_KEYS,
secrets.profileApiKeys.takeIf { it.isNotEmpty() }?.let(::encodeProfileApiKeys),
normalized.profileApiKeys
.takeIf { it.isNotEmpty() }
?.let(::encodeProfileApiKeys),
)
writeOrRemove(store, KEY_PAIRED_META, secrets.pairedSessionMetaJson)
writeOrRemove(store, KEY_PAIRED_META, normalized.pairedSessionMetaJson)
}
}
/** Validate a backup fully before any existing encrypted state is replaced. */
fun validateStoredSecrets(secrets: ConnectionAuthSecrets) {
normalizeStoredSecrets(secrets)
}
private fun normalizeStoredSecrets(secrets: ConnectionAuthSecrets): ConnectionAuthSecrets =
secrets.copy(
sessionToken = secrets.sessionToken?.let {
normalizeCredentialForHeader(it, "Relay session credential")
}?.takeIf { it.isNotEmpty() },
refreshToken = secrets.refreshToken?.let {
normalizeCredentialForHeader(it, "Relay refresh credential")
}?.takeIf { it.isNotEmpty() },
apiKey = secrets.apiKey?.let {
normalizeCredentialForHeader(it, "API credential")
}?.takeIf { it.isNotEmpty() },
profileApiKeys = secrets.profileApiKeys
.mapValues { (_, value) ->
normalizeCredentialForHeader(value, "Profile API credential")
}
.filterValues { it.isNotEmpty() },
)
private fun tokenStoreForBackup(
context: Context,
tokenStoreKey: String,
@@ -607,6 +635,8 @@ class AuthManager(
*/
private val _apiKeyPresent = MutableStateFlow(false)
val apiKeyPresent: StateFlow<Boolean> = _apiKeyPresent.asStateFlow()
private val _apiKeyError = MutableStateFlow<String?>(null)
val apiKeyError: StateFlow<String?> = _apiKeyError.asStateFlow()
init {
// Register as system channel handler for auth messages
@@ -626,19 +656,40 @@ class AuthManager(
val s = store()
val existingToken = s.getString(KEY_SESSION_TOKEN)
if (existingToken != null) {
_authState.value = AuthState.Paired(existingToken)
_currentPairedSession.value = loadStoredMetadata(existingToken)
Log.i(
TAG,
"init: hydrated existing session_token=${existingToken.take(8)}… " +
"→ authState=Paired (stale-at-startup unless this is a real continuous session)"
)
runCatching {
normalizeCredentialForHeader(existingToken, "Relay session credential")
.also { require(it.isNotEmpty()) }
}.onSuccess { normalized ->
if (normalized != existingToken) s.putString(KEY_SESSION_TOKEN, normalized)
_authState.value = AuthState.Paired(normalized)
_currentPairedSession.value = loadStoredMetadata(normalized)
Log.i(TAG, "init: hydrated existing session credential")
}.onFailure {
_authState.value = AuthState.Failed(
"Saved Relay credential is malformed. Re-pair this connection.",
)
Log.w(TAG, "init: rejected malformed saved Relay credential")
}
} else {
Log.i(TAG, "init: no stored session_token → authState stays Unpaired")
}
// Converge the plain api-key-present hint with the decrypted
// truth (also repairs a hint that predates legacy migration).
recordApiKeyHint(!s.getString(KEY_API_KEY).isNullOrBlank())
val storedApiKey = s.getString(KEY_API_KEY)
if (storedApiKey != null) {
runCatching {
normalizeCredentialForHeader(storedApiKey, "API credential")
.also { require(it.isNotEmpty()) }
}.onSuccess { normalized ->
if (normalized != storedApiKey) s.putString(KEY_API_KEY, normalized)
_apiKeyError.value = null
}.onFailure {
_apiKeyError.value =
"Saved API credential is malformed. Replace or clear it."
Log.w(TAG, "init: rejected malformed saved API credential")
}
}
recordApiKeyHint(!storedApiKey.isNullOrBlank())
}
}
}
@@ -815,10 +866,20 @@ class AuthManager(
val deviceId = getDeviceId()
val payload = when (currentState) {
is AuthState.Paired -> {
val refreshToken = store().getString(KEY_REFRESH_TOKEN)
val refreshToken = store().getString(KEY_REFRESH_TOKEN)?.let { raw ->
runCatching {
normalizeCredentialForHeader(raw, "Relay refresh credential")
}.getOrElse {
_authState.value = AuthState.Failed(
"Saved Relay credential is malformed. Re-pair this connection.",
)
Log.w(TAG, "authenticate: rejected malformed refresh credential")
return@launch
}
}
Log.i(
TAG,
"authenticate: sending session_token (state=Paired, token=${currentState.token.take(8)}…, " +
"authenticate: sending saved session credential (state=Paired, " +
"refresh=${!refreshToken.isNullOrBlank()})"
)
buildJsonObject {
@@ -1006,10 +1067,26 @@ class AuthManager(
// --- API Key storage (for direct Hermes API Server auth) ---
suspend fun getApiKey(): String? = store().getString(KEY_API_KEY)
suspend fun getApiKey(): String? {
val raw = store().getString(KEY_API_KEY) ?: return null
return runCatching {
normalizeCredentialForHeader(raw, "API credential")
.takeIf { it.isNotEmpty() }
}.onSuccess {
_apiKeyError.value = null
}.onFailure {
_apiKeyError.value = "Saved API credential is malformed. Replace or clear it."
Log.w(TAG, "getApiKey: rejected malformed saved API credential")
}.getOrNull()
}
suspend fun setApiKey(key: String) {
val trimmed = key.trim()
val trimmed = runCatching {
normalizeCredentialForHeader(key, "API credential")
}.getOrElse {
_apiKeyError.value = "API credentials must be a single line."
throw it
}
val s = store()
if (trimmed.isBlank()) {
s.remove(KEY_API_KEY)
@@ -1018,11 +1095,13 @@ class AuthManager(
s.putString(KEY_API_KEY, trimmed)
recordApiKeyHint(true)
}
_apiKeyError.value = null
}
suspend fun clearApiKey() {
store().remove(KEY_API_KEY)
recordApiKeyHint(false)
_apiKeyError.value = null
}
suspend fun getProfileApiKey(profileName: String): String? =
@@ -1034,7 +1113,7 @@ class AuthManager(
profileApiKeysMutex.withLock {
val tokenStore = store()
val keys = decodeProfileApiKeys(tokenStore.getString(KEY_PROFILE_API_KEYS)).toMutableMap()
val normalizedKey = key.trim()
val normalizedKey = normalizeCredentialForHeader(key, "Profile API credential")
if (normalizedKey.isBlank()) keys.remove(normalizedProfile)
else keys[normalizedProfile] = normalizedKey
if (keys.isEmpty()) tokenStore.remove(KEY_PROFILE_API_KEYS)
@@ -1053,7 +1132,10 @@ class AuthManager(
scope.launch {
try {
val payload = envelope.payload
val token = payload["session_token"]?.jsonPrimitive?.contentOrNull
val token = payload["session_token"]?.jsonPrimitive?.contentOrNull?.let { raw ->
normalizeCredentialForHeader(raw, "Relay session credential")
.takeIf { it.isNotEmpty() }
}
if (token == null) {
Log.w(
@@ -1070,13 +1152,14 @@ class AuthManager(
val refreshToken = payload["refresh_token"]
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
?.let { normalizeCredentialForHeader(it, "Relay refresh credential") }
?.takeIf { it.isNotEmpty() }
if (refreshToken != null) {
s.putString(KEY_REFRESH_TOKEN, refreshToken)
Log.i(TAG, "handleAuthOk: stored rotated refresh token")
}
_authState.value = AuthState.Paired(token)
Log.i(TAG, "handleAuthOk: Paired(token=${token.take(8)}…)")
Log.i(TAG, "handleAuthOk: paired with server-issued session credential")
// Per-connection signal for socket-scoped consumers (e.g.
// re-sending proactive.subscribe). Fires on every auth.ok.
_authOkEvents.tryEmit(Unit)
@@ -1169,6 +1252,11 @@ class AuthManager(
// handler is exactly why the broken `_sessionLabels` parser
// (stringifying object entries) sat undetected for so long.
Log.w(TAG, "auth.ok parse failed: ${e.message}", e)
if (e is InvalidCredentialException) {
_authState.value = AuthState.Failed(
"Relay returned a malformed credential. Re-pair this connection.",
)
}
}
}
}
@@ -1192,7 +1280,14 @@ class AuthManager(
protocolVersion = current.protocolVersion,
hostId = current.hostId,
credentialKind = "route",
token = credential["token"]?.jsonPrimitive?.contentOrNull ?: return,
token = credential["token"]?.jsonPrimitive?.contentOrNull?.let {
runCatching {
normalizeCredentialForHeader(it, "Hermes Reach credential")
}.getOrElse {
Log.w(TAG, "Ignoring malformed Hermes Reach route credential")
return
}
} ?: return,
expiresAt = credential["expires_at"]?.jsonPrimitive?.longOrNull,
)
val validated = active.copy(broker = replacement).takeIf { it.hasHermesReach() } ?: return
@@ -1,9 +1,27 @@
package com.hermesandroid.relay.data
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.Base64
import java.util.WeakHashMap
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* Immutable owner of one composer draft.
@@ -86,15 +104,15 @@ data class ChatComposerDraft(
*/
interface ChatComposerDraftStore {
fun observe(key: ChatComposerDraftKey): Flow<ChatComposerDraft>
fun snapshot(key: ChatComposerDraftKey): ChatComposerDraft
fun save(key: ChatComposerDraftKey, draft: ChatComposerDraft)
fun update(
suspend fun snapshot(key: ChatComposerDraftKey): ChatComposerDraft
suspend fun save(key: ChatComposerDraftKey, draft: ChatComposerDraft)
suspend fun update(
key: ChatComposerDraftKey,
transform: (ChatComposerDraft) -> ChatComposerDraft,
)
fun remove(key: ChatComposerDraftKey)
fun removeSession(connectionId: String, profileId: String, sessionId: String)
fun clear()
suspend fun remove(key: ChatComposerDraftKey)
suspend fun removeSession(connectionId: String, profileId: String, sessionId: String)
suspend fun clear()
}
class InMemoryChatComposerDraftStore : ChatComposerDraftStore {
@@ -105,43 +123,370 @@ class InMemoryChatComposerDraftStore : ChatComposerDraftStore {
.map { it[key] ?: ChatComposerDraft() }
.distinctUntilChanged()
override fun snapshot(key: ChatComposerDraftKey): ChatComposerDraft =
override suspend fun snapshot(key: ChatComposerDraftKey): ChatComposerDraft =
drafts.value[key] ?: ChatComposerDraft()
@Synchronized
override fun save(key: ChatComposerDraftKey, draft: ChatComposerDraft) {
val normalized = draft.normalized()
drafts.value = if (normalized.isEmpty) {
drafts.value - key
} else {
drafts.value + (key to normalized)
override suspend fun save(key: ChatComposerDraftKey, draft: ChatComposerDraft) {
synchronized(drafts) {
val normalized = draft.normalized()
drafts.value = if (normalized.isEmpty) {
drafts.value - key
} else {
drafts.value + (key to normalized)
}
}
}
@Synchronized
override fun update(
override suspend fun update(
key: ChatComposerDraftKey,
transform: (ChatComposerDraft) -> ChatComposerDraft,
) {
save(key, transform(snapshot(key)))
}
@Synchronized
override fun remove(key: ChatComposerDraftKey) {
drafts.value = drafts.value - key
}
@Synchronized
override fun removeSession(connectionId: String, profileId: String, sessionId: String) {
drafts.value = drafts.value.filterKeys { key ->
key.connectionId != connectionId ||
key.profileId != profileId ||
key.sessionId != sessionId
override suspend fun remove(key: ChatComposerDraftKey) {
synchronized(drafts) {
drafts.value = drafts.value - key
}
}
@Synchronized
override fun clear() {
drafts.value = emptyMap()
override suspend fun removeSession(connectionId: String, profileId: String, sessionId: String) {
synchronized(drafts) {
drafts.value = drafts.value.filterKeys { key ->
key.connectionId != connectionId ||
key.profileId != profileId ||
key.sessionId != sessionId
}
}
}
override suspend fun clear() {
synchronized(drafts) {
drafts.value = emptyMap()
}
}
}
/**
* App-private durable composer storage.
*
* The caller supplies a directory under `noBackupFilesDir`: drafts survive
* process death and ordinary app exits but never enter Android cloud backup.
* Metadata stays small JSON while attachment bytes are content-addressed blobs,
* so typing does not repeatedly rewrite Base64 payloads.
*/
class PersistentChatComposerDraftStore(
private val root: File,
) : ChatComposerDraftStore {
private val mutex = Mutex()
private val updates = MutableSharedFlow<ChatComposerDraftKey>(extraBufferCapacity = 64)
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val draftsDir = File(root, "drafts")
private val blobsDir = File(root, "blobs")
private val contentBlobIds = WeakHashMap<String, String>()
override fun observe(key: ChatComposerDraftKey): Flow<ChatComposerDraft> = flow {
emit(snapshot(key))
emitAll(
updates
.filter { it == key }
.map { snapshot(key) }
.distinctUntilChanged(),
)
}.distinctUntilChanged()
override suspend fun snapshot(key: ChatComposerDraftKey): ChatComposerDraft = withContext(Dispatchers.IO) {
mutex.withLock { readDraft(key) }
}
override suspend fun save(key: ChatComposerDraftKey, draft: ChatComposerDraft) {
withContext(Dispatchers.IO) {
mutex.withLock {
val normalized = draft.normalized()
if (normalized.isEmpty) {
draftFile(key).delete()
} else {
ensureDirectories()
val persisted = normalized.toPersisted(key)
atomicWrite(
draftFile(key),
json.encodeToString(PersistedDraft.serializer(), persisted)
.toByteArray(Charsets.UTF_8),
)
}
pruneAndCollect(except = key)
}
}
updates.tryEmit(key)
}
override suspend fun update(
key: ChatComposerDraftKey,
transform: (ChatComposerDraft) -> ChatComposerDraft,
) {
withContext(Dispatchers.IO) {
mutex.withLock {
val normalized = transform(readDraft(key)).normalized()
if (normalized.isEmpty) {
draftFile(key).delete()
} else {
ensureDirectories()
atomicWrite(
draftFile(key),
json.encodeToString(
PersistedDraft.serializer(),
normalized.toPersisted(key),
).toByteArray(Charsets.UTF_8),
)
}
pruneAndCollect(except = key)
}
}
updates.tryEmit(key)
}
override suspend fun remove(key: ChatComposerDraftKey) {
withContext(Dispatchers.IO) {
mutex.withLock {
draftFile(key).delete()
collectOrphanBlobs()
}
}
updates.tryEmit(key)
}
override suspend fun removeSession(connectionId: String, profileId: String, sessionId: String) {
val removed = mutableListOf<ChatComposerDraftKey>()
withContext(Dispatchers.IO) {
mutex.withLock {
draftFiles().forEach { file ->
val persisted = readPersisted(file) ?: return@forEach
val key = persisted.key.toDomain()
if (
key.connectionId == connectionId &&
key.profileId == profileId &&
key.sessionId == sessionId
) {
file.delete()
removed += key
}
}
collectOrphanBlobs()
}
}
removed.forEach(updates::tryEmit)
}
override suspend fun clear() {
withContext(Dispatchers.IO) {
mutex.withLock {
root.listFiles().orEmpty().forEach(File::deleteRecursively)
contentBlobIds.clear()
}
}
}
private fun ChatComposerDraft.toPersisted(key: ChatComposerDraftKey): PersistedDraft =
PersistedDraft(
key = PersistedKey.from(key),
text = text,
selectionStart = selectionStart,
selectionEnd = selectionEnd,
quotedMessageId = context.quotedMessageId,
editingMessageId = context.editingMessageId,
attachments = attachments.mapNotNull(::persistAttachment),
savedAtEpochMs = System.currentTimeMillis(),
)
private fun persistAttachment(attachment: Attachment): PersistedAttachment? {
val rawBytes = attachment.composerRawText
?.takeIf { attachment.isLargePaste }
?.toByteArray(Charsets.UTF_8)
val cachedBlobId = if (rawBytes == null) contentBlobIds[attachment.content] else null
val cachedBlob = cachedBlobId?.let { File(blobsDir, "$it.blob") }
if (cachedBlobId != null && cachedBlob?.exists() == true) {
return attachment.toPersistedAttachment(cachedBlobId)
}
val bytes = rawBytes
?: runCatching { Base64.getDecoder().decode(attachment.content) }.getOrNull()
?: return null
if (bytes.isEmpty()) return null
val blobId = sha256(bytes)
val blob = File(blobsDir, "$blobId.blob")
if (!blob.exists()) atomicWrite(blob, bytes)
if (rawBytes == null) contentBlobIds[attachment.content] = blobId
return attachment.toPersistedAttachment(blobId)
}
private fun Attachment.toPersistedAttachment(blobId: String): PersistedAttachment =
PersistedAttachment(
contentType = contentType,
blobId = blobId,
fileName = fileName,
fileSize = fileSize,
sensitive = sensitive,
isLargePaste = isLargePaste,
composerId = composerId,
)
private fun readDraft(key: ChatComposerDraftKey): ChatComposerDraft {
val persisted = readPersisted(draftFile(key)) ?: return ChatComposerDraft()
if (persisted.key.toDomain() != key) return ChatComposerDraft()
return ChatComposerDraft(
text = persisted.text,
selectionStart = persisted.selectionStart,
selectionEnd = persisted.selectionEnd,
context = ChatComposerDraftContext(
quotedMessageId = persisted.quotedMessageId,
editingMessageId = persisted.editingMessageId,
),
attachments = persisted.attachments.mapNotNull { attachment ->
val blob = File(blobsDir, "${attachment.blobId}.blob")
val bytes = runCatching { blob.readBytes() }.getOrNull()
?.takeIf(ByteArray::isNotEmpty) ?: return@mapNotNull null
val content = Base64.getEncoder().encodeToString(bytes)
contentBlobIds[content] = attachment.blobId
Attachment(
contentType = attachment.contentType,
content = content,
fileName = attachment.fileName,
fileSize = attachment.fileSize ?: bytes.size.toLong(),
sensitive = attachment.sensitive,
isLargePaste = attachment.isLargePaste,
composerId = attachment.composerId,
)
},
).normalized()
}
private fun readPersisted(file: File): PersistedDraft? = runCatching {
json.decodeFromString(PersistedDraft.serializer(), file.readText(Charsets.UTF_8))
}.getOrNull()
private fun pruneAndCollect(except: ChatComposerDraftKey) {
val exceptFile = draftFile(except)
val candidates = draftFiles()
.filterNot { it == exceptFile }
.sortedBy(File::lastModified)
candidates
.take((draftFiles().size - MAX_DRAFTS).coerceAtLeast(0))
.forEach { it.delete() }
collectOrphanBlobs()
for (oldest in candidates) {
if (blobsDir.listFiles().orEmpty().sumOf(File::length) <= MAX_BLOB_BYTES) break
if (oldest.exists()) {
oldest.delete()
collectOrphanBlobs()
}
}
}
private fun collectOrphanBlobs() {
val referenced = draftFiles()
.mapNotNull(::readPersisted)
.flatMap { draft -> draft.attachments.map(PersistedAttachment::blobId) }
.toSet()
blobsDir.listFiles().orEmpty()
.filter { it.isFile && it.extension == "blob" && it.nameWithoutExtension !in referenced }
.forEach(File::delete)
}
private fun ensureDirectories() {
check(draftsDir.exists() || draftsDir.mkdirs()) { "Could not create composer draft directory" }
check(blobsDir.exists() || blobsDir.mkdirs()) { "Could not create composer blob directory" }
}
private fun draftFiles(): List<File> = draftsDir.listFiles().orEmpty()
.filter { it.isFile && it.extension == "json" }
private fun draftFile(key: ChatComposerDraftKey): File =
File(draftsDir, "${sha256(key.storageIdentity().toByteArray(Charsets.UTF_8))}.json")
private fun atomicWrite(target: File, bytes: ByteArray) {
target.parentFile?.let { parent ->
check(parent.exists() || parent.mkdirs()) { "Could not create composer storage directory" }
}
val temporary = File(target.parentFile, ".${target.name}.${System.nanoTime()}.tmp")
try {
FileOutputStream(temporary).use { output ->
output.write(bytes)
output.fd.sync()
}
try {
Files.move(
temporary.toPath(),
target.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
temporary.toPath(),
target.toPath(),
StandardCopyOption.REPLACE_EXISTING,
)
}
} finally {
temporary.delete()
}
}
private fun ChatComposerDraftKey.storageIdentity(): String =
listOf(connectionId, profileId, sessionId, draftId).joinToString("\u0000")
private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256")
.digest(bytes)
.joinToString("") { byte -> "%02x".format(byte) }
companion object {
private const val MAX_DRAFTS = 64
private const val MAX_BLOB_BYTES = 128L * 1024L * 1024L
}
}
@Serializable
private data class PersistedDraft(
val key: PersistedKey,
val text: String,
val selectionStart: Int,
val selectionEnd: Int,
val quotedMessageId: String? = null,
val editingMessageId: String? = null,
val attachments: List<PersistedAttachment> = emptyList(),
val savedAtEpochMs: Long,
)
@Serializable
private data class PersistedKey(
val connectionId: String,
val profileId: String,
val sessionId: String,
val draftId: String,
) {
fun toDomain(): ChatComposerDraftKey = ChatComposerDraftKey(
connectionId = connectionId,
profileId = profileId,
sessionId = sessionId,
draftId = draftId,
)
companion object {
fun from(key: ChatComposerDraftKey): PersistedKey = PersistedKey(
connectionId = key.connectionId,
profileId = key.profileId,
sessionId = key.sessionId,
draftId = key.draftId,
)
}
}
@Serializable
private data class PersistedAttachment(
val contentType: String,
val blobId: String,
val fileName: String? = null,
val fileSize: Long? = null,
val sensitive: Boolean = false,
val isLargePaste: Boolean = false,
val composerId: String? = null,
)
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
@@ -30,6 +31,8 @@ class ChatInputPreferencesRepository(
companion object {
internal val KEY_PHYSICAL_KEYBOARD_ENTER =
stringPreferencesKey("physical_keyboard_enter_behavior")
internal val KEY_CONVERT_LARGE_PASTES =
booleanPreferencesKey("convert_large_pastes_to_attachments")
}
val physicalKeyboardEnterBehavior: Flow<PhysicalKeyboardEnterBehavior> = dataStore.data
@@ -40,9 +43,19 @@ class ChatInputPreferencesRepository(
}
.distinctUntilChanged()
val convertLargePastesToAttachments: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[KEY_CONVERT_LARGE_PASTES] ?: true }
.distinctUntilChanged()
suspend fun setPhysicalKeyboardEnterBehavior(behavior: PhysicalKeyboardEnterBehavior) {
dataStore.edit { preferences ->
preferences[KEY_PHYSICAL_KEYBOARD_ENTER] = behavior.storedValue
}
}
suspend fun setConvertLargePastesToAttachments(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[KEY_CONVERT_LARGE_PASTES] = enabled
}
}
}
@@ -0,0 +1,44 @@
package com.hermesandroid.relay.data
import java.util.Base64
const val LARGE_PASTE_THRESHOLD_CHARS = 5_000
data class TextTransportAttachments(
val message: String,
val attachments: List<Attachment>,
)
fun largePasteAttachment(text: String, composerId: String? = null): Attachment {
val bytes = text.toByteArray(Charsets.UTF_8)
return Attachment(
contentType = "text/plain; charset=utf-8",
content = Base64.getEncoder().encodeToString(bytes),
fileName = "pasted-text.txt",
fileSize = bytes.size.toLong(),
isLargePaste = true,
composerId = composerId,
)
}
fun prepareTextTransportAttachments(
message: String,
attachments: List<Attachment>,
): TextTransportAttachments {
val largePastes = attachments.filter(Attachment::isLargePaste)
if (largePastes.isEmpty()) return TextTransportAttachments(message, attachments)
val materialized = largePastes.mapNotNull { attachment ->
runCatching {
val text = String(Base64.getDecoder().decode(attachment.content), Charsets.UTF_8)
val name = attachment.fileName?.takeIf(String::isNotBlank) ?: "pasted text"
"--- $name ---\n$text"
}.getOrNull()
}
return TextTransportAttachments(
message = (listOf(message) + materialized)
.filter(String::isNotBlank)
.joinToString("\n\n"),
attachments = attachments.filterNot(Attachment::isLargePaste),
)
}
@@ -328,7 +328,13 @@ data class Attachment(
* existing outbound/inbound call site stays valid and unflagged media
* renders exactly as before.
*/
val sensitive: Boolean = false
val sensitive: Boolean = false,
/** Local composer metadata; never serialized onto the Hermes wire. */
val isLargePaste: Boolean = false,
/** Stable id for an asynchronous composer preparation; never sent to Hermes. */
val composerId: String? = null,
/** Raw UTF-8 text retained only while a large-paste attachment is preparing. */
val composerRawText: String? = null,
) {
val isImage: Boolean get() = contentType.startsWith("image/")
@@ -172,6 +172,11 @@ class DataManager(
suspend fun restoreConnectionBackup(backup: AppBackup) {
val store = connectionStore ?: return
// Validate every credential before deleting or replacing any current
// encrypted state. A malformed/hostile backup fails atomically.
backup.connectionSecrets.forEach { secret ->
AuthManager.validateStoredSecrets(secret.auth)
}
deleteSensitivePreferenceFiles()
store.replaceConnections(
connections = backup.connections,
@@ -299,10 +299,10 @@ class RelayHttpClient(
Result.success(FetchedMedia(contentType, bytes, fileName, sensitive))
}
} catch (e: IOException) {
Log.w(TAG, "fetchMedia failed for $token: ${e.message}")
Log.w(TAG, "fetchMedia failed: ${e.message}")
Result.failure(e)
} catch (e: Exception) {
Log.w(TAG, "fetchMedia unexpected error for $token: ${e.message}")
Log.w(TAG, "fetchMedia unexpected error: ${e.message}")
Result.failure(e)
}
}
@@ -0,0 +1,41 @@
package com.hermesandroid.relay.network.shared
import okhttp3.Request
import java.io.IOException
/** Secret-free failure raised before OkHttp sees a malformed credential. */
class InvalidCredentialException internal constructor(message: String) : IOException(message)
/**
* Normalize only harmless surrounding horizontal whitespace. Credentials are
* otherwise single-line visible ASCII: embedded whitespace, CR/LF, controls,
* and non-ASCII input are rejected instead of repaired or logged.
*/
fun normalizeCredentialForHeader(raw: String, label: String): String {
val normalized = raw.trim(' ', '\t')
if (normalized.any { it < '!' || it > '~' }) {
throw InvalidCredentialException(
"Invalid $label — enter or import a single-line value.",
)
}
return normalized
}
fun Request.Builder.bearerAuthorization(
rawCredential: String,
label: String,
): Request.Builder {
val credential = normalizeCredentialForHeader(rawCredential, label)
if (credential.isEmpty()) return this
return header("Authorization", "Bearer $credential")
}
fun Request.Builder.credentialHeader(
name: String,
rawCredential: String,
label: String,
): Request.Builder {
val credential = normalizeCredentialForHeader(rawCredential, label)
if (credential.isEmpty()) return this
return header(name, credential)
}
@@ -102,7 +102,11 @@ fun buildPluginProxyClient(
?.takeIf { it.isNotBlank() }
val request = if (token != null) {
chain.request().newBuilder()
.header("X-Hermes-Relay-Session", token)
.credentialHeader(
"X-Hermes-Relay-Session",
token,
"Relay session credential",
)
.build()
} else {
chain.request()
@@ -1645,7 +1645,7 @@ class ChatHandler {
is MediaMarkerHit.RelayToken -> {
val dedupeKey = "$messageId:relay:${hit.token}"
if (dispatchedMediaMarkers.add(dedupeKey)) {
Log.d(TAG, "Media marker (relay, reload): token=${hit.token}")
Log.d(TAG, "Media marker accepted from reloaded Relay history")
onMediaAttachmentRequested(messageId, hit.token)
}
}
@@ -2511,7 +2511,7 @@ class ChatHandler {
is MediaMarkerHit.RelayToken -> {
val dedupeKey = "$messageId:relay:${hit.token}"
if (dispatchedMediaMarkers.add(dedupeKey)) {
Log.d(TAG, "Media marker (relay): token=${hit.token}")
Log.d(TAG, "Media marker accepted from Relay stream")
onMediaAttachmentRequested(messageId, hit.token)
}
}
@@ -6,6 +6,9 @@ import android.util.Log
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.AppAnalytics
import com.hermesandroid.relay.network.shutdownOffMainThread
import com.hermesandroid.relay.network.shared.InvalidCredentialException
import com.hermesandroid.relay.network.shared.bearerAuthorization
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
import com.hermesandroid.relay.network.upstream.models.CreateSessionRequest
import com.hermesandroid.relay.network.upstream.models.HermesSseEvent
import com.hermesandroid.relay.network.upstream.models.MessageItem
@@ -431,7 +434,7 @@ private class RetryingEventSource(
*/
class HermesApiClient(
baseUrl: String,
private val apiKey: String,
apiKey: String,
httpClient: OkHttpClient? = null,
private val json: Json = Json {
ignoreUnknownKeys = true
@@ -442,6 +445,9 @@ class HermesApiClient(
@Volatile
private var lastCapabilities: ServerCapabilities? = null
private val baseUrl: String = baseUrl.trimEnd('/')
private val apiCredential = runCatching {
normalizeCredentialForHeader(apiKey, "API credential")
}
companion object {
private const val TAG = "HermesApiClient"
@@ -540,6 +546,8 @@ class HermesApiClient(
HealthCheckResult.Unhealthy("Server not found — check the hostname")
} catch (e: java.net.SocketTimeoutException) {
HealthCheckResult.Unhealthy("Connection timed out — is the server running?")
} catch (e: InvalidCredentialException) {
HealthCheckResult.Unhealthy(e.message ?: "Invalid API credential")
} catch (e: IOException) {
val msg = e.message ?: ""
when {
@@ -2023,9 +2031,8 @@ class HermesApiClient(
private fun authRequest(url: String): Request.Builder {
val builder = Request.Builder().url(url)
if (apiKey.isNotBlank()) {
builder.header("Authorization", "Bearer $apiKey")
}
val credential = apiCredential.getOrElse { throw it }
builder.bearerAuthorization(credential, "API credential")
return builder
}
@@ -2042,10 +2049,12 @@ class HermesApiClient(
*/
private fun authRequestOrNull(url: String): Request.Builder? {
val builder = buildApiRequestOrNull(url) ?: return null
if (apiKey.isNotBlank()) {
builder.header("Authorization", "Bearer $apiKey")
}
return builder
return runCatching {
builder.bearerAuthorization(
apiCredential.getOrElse { throw it },
"API credential",
)
}.getOrNull()
}
/**
@@ -2063,7 +2072,8 @@ class HermesApiClient(
/** Human message for a base URL that fails to parse (#131). */
private fun invalidBaseUrlMessage(): String =
"Invalid server address ($baseUrl) — edit the connection's API URL or re-pair."
apiCredential.exceptionOrNull()?.message
?: "Invalid server address ($baseUrl) — edit the connection's API URL or re-pair."
/**
* Make attachment drops on the SSE fallback transports explicit
@@ -4,6 +4,8 @@ import android.content.Context
import com.hermesandroid.relay.auth.SessionTokenStore
import com.hermesandroid.relay.auth.SecureStoreCache
import com.hermesandroid.relay.auth.buildRawTokenStore
import com.hermesandroid.relay.network.shared.bearerAuthorization
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
import java.io.EOFException
import java.io.IOException
import java.io.InterruptedIOException
@@ -75,11 +77,13 @@ class EncryptedNativeDashboardTokenStore(
override fun load(): NativeDashboardTokens? =
store.getString(TOKEN_KEY)?.let { raw ->
runCatching { json.decodeFromString<NativeDashboardTokens>(raw) }.getOrNull()
runCatching {
json.decodeFromString<NativeDashboardTokens>(raw).normalizedForStorage()
}.getOrNull()
}
override fun save(tokens: NativeDashboardTokens) {
store.putString(TOKEN_KEY, json.encodeToString(tokens))
store.putString(TOKEN_KEY, json.encodeToString(tokens.normalizedForStorage()))
}
override fun clear() {
@@ -450,7 +454,7 @@ class DashboardBearerAuth(
val tokens = usableTokens(forceRefresh = false, failedAccessToken = null)
val request = tokens?.let {
chain.request().newBuilder()
.header("Authorization", "Bearer ${it.accessToken}")
.bearerAuthorization(it.accessToken, "Dashboard credential")
.build()
} ?: chain.request()
return chain.proceed(request)
@@ -464,9 +468,12 @@ class DashboardBearerAuth(
forceRefresh = true,
failedAccessToken = failedAccessToken,
) ?: return null
val next = "Bearer ${tokens.accessToken}"
val accessToken = normalizeCredentialForHeader(tokens.accessToken, "Dashboard credential")
val next = "Bearer $accessToken"
if (next == previous) return null
return response.request.newBuilder().header("Authorization", next).build()
return response.request.newBuilder()
.bearerAuthorization(accessToken, "Dashboard credential")
.build()
}
private fun usableTokens(
@@ -496,6 +503,14 @@ class DashboardBearerAuth(
}
}
private fun NativeDashboardTokens.normalizedForStorage(): NativeDashboardTokens = copy(
accessToken = normalizeCredentialForHeader(accessToken, "Dashboard credential")
.also { require(it.isNotEmpty()) { "Dashboard credential is empty" } },
refreshToken = if (refreshToken.isEmpty()) "" else {
normalizeCredentialForHeader(refreshToken, "Dashboard refresh credential")
},
)
internal class NativeDashboardAuthHttpException(
val statusCode: Int,
) : IOException("Dashboard native authentication failed (HTTP $statusCode)")
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModelStore
import com.hermesandroid.relay.HermesRelayApp
import com.hermesandroid.relay.data.VoicePreferencesRepository
import com.hermesandroid.relay.data.VoiceSettings
import com.hermesandroid.relay.data.PersistentChatComposerDraftStore
import java.io.File
import com.hermesandroid.relay.network.relay.RelayVoiceClient
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -70,7 +72,13 @@ class HermesProcessRuntime internal constructor(
}
val chatViewModel: ChatViewModel by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
viewModelProvider[ChatViewModel::class.java]
viewModelProvider[ChatViewModel::class.java].also { chat ->
chat.installComposerDraftStore(
PersistentChatComposerDraftStore(
File(application.noBackupFilesDir, "chat-composer-drafts"),
),
)
}
}
val voiceViewModel: VoiceViewModel by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
@@ -821,12 +821,21 @@ private fun ManualUrlSubsection(
val apiServerUrl by connectionViewModel.apiServerUrl.collectAsState()
val relayUrl by connectionViewModel.relayUrl.collectAsState()
val apiKeyPresent by connectionViewModel.authManager.apiKeyPresent.collectAsState()
val savedApiKeyError by connectionViewModel.authManager.apiKeyError.collectAsState()
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
// Keyed on the backing URL so a connection switch refreshes the input.
var apiUrlInput by remember(apiServerUrl) { mutableStateOf(apiServerUrl) }
var apiKeyInput by remember { mutableStateOf("") }
var apiKeyVisible by remember { mutableStateOf(false) }
val apiKeySingleLineError = stringResource(R.string.api_credential_single_line_error)
val inputApiKeyError = remember(apiKeyInput, apiKeySingleLineError) {
if (apiKeyInput.trim(' ', '\t').any { it < '!' || it > '~' }) {
apiKeySingleLineError
} else {
null
}
}
var relayUrlInput by remember(relayUrl) { mutableStateOf(relayUrl) }
var isTestingApi by remember { mutableStateOf(false) }
var apiVoiceSetupResult by remember {
@@ -900,13 +909,14 @@ private fun ManualUrlSubsection(
},
supportingText = {
Text(
if (apiKeyPresent && apiKeyInput.isBlank()) {
inputApiKeyError ?: savedApiKeyError ?: if (apiKeyPresent && apiKeyInput.isBlank()) {
apiKeyStoredHint
} else {
apiKeyNeededHint
},
)
},
isError = inputApiKeyError != null || (apiKeyInput.isBlank() && savedApiKeyError != null),
singleLine = true,
visualTransformation = if (apiKeyVisible) {
VisualTransformation.None
@@ -962,7 +972,7 @@ private fun ManualUrlSubsection(
).show()
}
},
enabled = apiUrlInput.isNotBlank() && !isTestingApi,
enabled = apiUrlInput.isNotBlank() && !isTestingApi && inputApiKeyError == null,
) {
Text(saveAndTestText)
}
@@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
@@ -187,9 +186,12 @@ fun ChatInputBar(
modifier: Modifier = Modifier,
surfaceModifier: Modifier = Modifier,
enabled: Boolean = true,
submitEnabled: Boolean = true,
physicalEnterSends: Boolean = true,
largePasteThreshold: Int? = null,
onLargePaste: (String) -> Unit = {},
) {
val canSubmit = enabled && trailing in setOf(
val canSubmit = enabled && submitEnabled && trailing in setOf(
ChatInputTrailing.SEND,
ChatInputTrailing.STEER,
ChatInputTrailing.QUEUE,
@@ -345,7 +347,16 @@ fun ChatInputBar(
) {
BasicTextField(
value = value,
onValueChange = { if (it.length <= charLimit) onValueChange(it) },
onValueChange = { updated ->
val converted = largePasteThreshold
?.let { threshold -> detectLargeTextInsertion(value, updated, threshold) }
if (converted != null) {
onValueChange(converted.remainingText)
onLargePaste(converted.insertedText)
} else if (updated.length <= charLimit) {
onValueChange(updated)
}
},
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 30.dp)
@@ -382,10 +393,10 @@ fun ChatInputBar(
enabled = enabled,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Send,
),
keyboardActions = KeyboardActions(
onSend = { if (canSubmit) onSend() },
// The field is multiline and already has a dedicated
// send button. Leave the software keyboard action as
// Return; hardware Enter remains handled above.
imeAction = ImeAction.Default,
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
@@ -521,12 +532,12 @@ fun ChatInputBar(
when (state) {
ChatInputTrailing.SEND -> IconButton(
onClick = onSend,
enabled = enabled,
enabled = canSubmit,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_input_send_message),
tint = if (enabled) MaterialTheme.colorScheme.primary
tint = if (canSubmit) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -580,7 +591,7 @@ fun ChatInputBar(
ChatInputTrailing.STEER -> IconButton(
onClick = onSend,
enabled = enabled,
enabled = canSubmit,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
@@ -591,7 +602,7 @@ fun ChatInputBar(
ChatInputTrailing.QUEUE -> IconButton(
onClick = onSend,
enabled = enabled,
enabled = canSubmit,
) {
Box {
Icon(
@@ -620,6 +631,39 @@ fun ChatInputBar(
}
}
internal data class LargeTextInsertion(
val insertedText: String,
val remainingText: String,
)
/** Finds one large contiguous edit without requiring clipboard access or retaining clipboard data. */
internal fun detectLargeTextInsertion(
previous: String,
updated: String,
threshold: Int,
): LargeTextInsertion? {
if (threshold <= 0 || updated == previous) return null
val prefixLength = previous.commonPrefixWith(updated).length
val maxSuffixLength = minOf(
previous.length - prefixLength,
updated.length - prefixLength,
)
var suffixLength = 0
while (
suffixLength < maxSuffixLength &&
previous[previous.lastIndex - suffixLength] == updated[updated.lastIndex - suffixLength]
) {
suffixLength++
}
val insertedEnd = updated.length - suffixLength
val inserted = updated.substring(prefixLength, insertedEnd)
if (inserted.length < threshold) return null
return LargeTextInsertion(
insertedText = inserted,
remainingText = updated.removeRange(prefixLength, insertedEnd),
)
}
@Composable
private fun ChatInputPickerChip(
control: ChatInputPickerControl,
@@ -2249,6 +2249,11 @@ private fun StandardEntryStep(
val apiError = apiUrlSchemeError(apiUrl, context)
val tailscaleError = optionalHttpUrlError(tailscaleApiUrl, context)
val dashboardError = optionalHttpUrlError(dashboardUrl, context)
val apiKeyError = if (apiKey.trim(' ', '\t').any { it < '!' || it > '~' }) {
stringResource(R.string.api_credential_single_line_error)
} else {
null
}
var advancedExpanded by remember { mutableStateOf(false) }
var scanBusy by remember { mutableStateOf(false) }
var scanResults by remember { mutableStateOf<List<HermesLanDiscoveryResult>>(emptyList()) }
@@ -2263,6 +2268,7 @@ private fun StandardEntryStep(
apiError == null &&
tailscaleError == null &&
dashboardError == null &&
apiKeyError == null &&
!isConnecting
val defaultDashboardUrl = Connection.deriveDefaultDashboardUrl(apiUrl)
val effectiveDashboardUrl = dashboardUrl
@@ -2407,8 +2413,9 @@ private fun StandardEntryStep(
label = { Text(stringResource(R.string.cw_api_key_label)) },
placeholder = { Text(stringResource(R.string.cw_api_key_placeholder)) },
singleLine = true,
isError = apiKeyError != null,
supportingText = {
Text(stringResource(R.string.cw_api_key_hint))
Text(apiKeyError ?: stringResource(R.string.cw_api_key_hint))
},
visualTransformation = if (apiKeyVisible) {
VisualTransformation.None
@@ -15,9 +15,13 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -51,6 +55,8 @@ import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.markdownDimens
import com.mikepenz.markdown.model.markdownExtendedSpans
import com.mikepenz.markdown.model.rememberStreamingMarkdownState
import com.mikepenz.markdown.model.StreamingMarkdownState
import com.hermesandroid.relay.ui.theme.LocalBrand
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes
@@ -66,6 +72,23 @@ fun MarkdownContent(
textColor: Color,
modifier: Modifier = Modifier
) {
ConfiguredMarkdownContent(
content = content,
textColor = textColor,
modifier = modifier,
)
}
@Composable
private fun ConfiguredMarkdownContent(
textColor: Color,
modifier: Modifier = Modifier,
content: String? = null,
streamingState: StreamingMarkdownState? = null,
) {
require((content == null) != (streamingState == null)) {
"Exactly one Markdown source must be provided"
}
val isDarkTheme = LocalBrand.current.isDark
val chatBodyStyle = MaterialTheme.typography.bodyMedium.copy(
fontSize = 15.sp,
@@ -75,109 +98,108 @@ fun MarkdownContent(
val highlightsBuilder = remember(isDarkTheme) {
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
}
Markdown(
content = content,
modifier = modifier,
// Code surfaces must contrast against the bubble (which is itself
// surfaceVariant for assistant turns) or code reads as invisible. The
// block uses the lowest container (a darker inset in dark themes, a
// clean white inset in light), inline code a subtle raised step.
colors = markdownColor(
text = textColor,
codeBackground = MaterialTheme.colorScheme.surfaceContainerLowest,
inlineCodeBackground = MaterialTheme.colorScheme.surfaceContainerHighest
),
// Chat-tuned type ramp. Left unset, the mikepenz M3 defaults map headings
// to DISPLAY roles (in this app's scale h1=displayLarge 57sp, h2=displayMedium
// ~45sp, h3=displaySmall 36sp) — a single `#` becomes a billboard inside the
// ~272dp bubble. Here every level derives from bodyLarge/bodyMedium (so the
// live font-picker still applies) and is capped so the largest heading is
// proportionate to the 15sp body, matching Discord / GitHub-mobile
// in-message headings.
typography = markdownTypography(
h1 = MaterialTheme.typography.bodyLarge.copy(
fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor,
),
h2 = MaterialTheme.typography.bodyLarge.copy(
fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.Bold, color = textColor,
),
h3 = MaterialTheme.typography.bodyLarge.copy(
fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold, color = textColor,
),
h4 = MaterialTheme.typography.bodyMedium.copy(
fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold, color = textColor,
),
h5 = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold, color = textColor,
),
h6 = MaterialTheme.typography.bodyMedium.copy(
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp,
color = textColor.copy(alpha = 0.85f),
),
// Prose, list items, and quotes share a 15sp/21sp reading rhythm.
// The library default 'text'/list role is bodyLarge (16sp), while
// bodyMedium was previously 14sp and unnecessarily small for long chat.
paragraph = chatBodyStyle,
text = chatBodyStyle,
bullet = chatBodyStyle,
ordered = chatBodyStyle,
list = chatBodyStyle,
quote = chatBodyStyle.copy(
fontStyle = FontStyle.Italic,
color = textColor.copy(alpha = 0.9f),
),
// Inline + fenced code at 13sp (one step under body, not two): monospace
// + the tinted chip already signal "code" without also shrinking it, and
// the loose 0.4sp default tracking is reset to 0 for tighter token runs.
code = MaterialTheme.typography.bodySmall.copy(
fontSize = 13.sp, letterSpacing = 0.sp,
fontFamily = FontFamily.Monospace,
color = textColor,
),
inlineCode = MaterialTheme.typography.bodyMedium.copy(
fontSize = 13.sp, letterSpacing = 0.sp,
fontFamily = FontFamily.Monospace, color = textColor,
),
// Links get an accent color + underline so they read as tappable on the
// muted assistant bubble (the default textLink is body-colored).
textLink = TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
textDecoration = TextDecoration.Underline,
),
),
),
// Tables get a phone-friendly minimum measure. The stock renderer uses
// one-line cells; our table component below keeps the same AST/inline
// annotator path but permits wrapping and exposes horizontal overflow.
dimens = markdownDimens(
tableCellWidth = 110.dp,
tableCellPadding = 12.dp,
),
components = markdownComponents(
codeBlock = {
SafeMarkdownHighlightedCodeBlock(
content = it.content,
node = it.node,
highlightsBuilder = highlightsBuilder,
showHeader = true
)
},
codeFence = {
SafeMarkdownHighlightedCodeFence(
content = it.content,
node = it.node,
highlightsBuilder = highlightsBuilder,
showHeader = true
)
},
table = { WideMarkdownTable(it) },
),
extendedSpans = markdownExtendedSpans {
remember { ExtendedSpans(RoundedCornerSpanPainter()) }
}
// Code surfaces must contrast against the assistant bubble. Keeping these
// exact values shared between static and streaming renderers prevents a
// typography/color change when a live turn completes.
val colors = markdownColor(
text = textColor,
codeBackground = MaterialTheme.colorScheme.surfaceContainerLowest,
inlineCodeBackground = MaterialTheme.colorScheme.surfaceContainerHighest,
)
val typography = markdownTypography(
h1 = MaterialTheme.typography.bodyLarge.copy(
fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor,
),
h2 = MaterialTheme.typography.bodyLarge.copy(
fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.Bold, color = textColor,
),
h3 = MaterialTheme.typography.bodyLarge.copy(
fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold, color = textColor,
),
h4 = MaterialTheme.typography.bodyMedium.copy(
fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold, color = textColor,
),
h5 = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold, color = textColor,
),
h6 = MaterialTheme.typography.bodyMedium.copy(
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp,
color = textColor.copy(alpha = 0.85f),
),
paragraph = chatBodyStyle,
text = chatBodyStyle,
bullet = chatBodyStyle,
ordered = chatBodyStyle,
list = chatBodyStyle,
quote = chatBodyStyle.copy(
fontStyle = FontStyle.Italic,
color = textColor.copy(alpha = 0.9f),
),
code = MaterialTheme.typography.bodySmall.copy(
fontSize = 13.sp,
letterSpacing = 0.sp,
fontFamily = FontFamily.Monospace,
color = textColor,
),
inlineCode = MaterialTheme.typography.bodyMedium.copy(
fontSize = 13.sp,
letterSpacing = 0.sp,
fontFamily = FontFamily.Monospace,
color = textColor,
),
textLink = TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
textDecoration = TextDecoration.Underline,
),
),
)
val dimens = markdownDimens(tableCellWidth = 110.dp, tableCellPadding = 12.dp)
val components = markdownComponents(
codeBlock = {
SafeMarkdownHighlightedCodeBlock(
content = it.content,
node = it.node,
highlightsBuilder = highlightsBuilder,
showHeader = true,
)
},
codeFence = {
SafeMarkdownHighlightedCodeFence(
content = it.content,
node = it.node,
highlightsBuilder = highlightsBuilder,
showHeader = true,
)
},
table = { WideMarkdownTable(it) },
)
val extendedSpans = markdownExtendedSpans {
remember { ExtendedSpans(RoundedCornerSpanPainter()) }
}
if (streamingState != null) {
Markdown(
streamingMarkdownState = streamingState,
modifier = modifier,
colors = colors,
typography = typography,
dimens = dimens,
components = components,
extendedSpans = extendedSpans,
)
} else {
Markdown(
content = checkNotNull(content),
modifier = modifier,
colors = colors,
typography = typography,
dimens = dimens,
components = components,
extendedSpans = extendedSpans,
)
}
}
/**
@@ -291,39 +313,63 @@ private fun WideMarkdownTable(model: MarkdownComponentModel) {
fun StreamingMarkdownContent(
content: String,
textColor: Color,
isStreaming: Boolean = true,
modifier: Modifier = Modifier,
) {
if (isStreaming) {
// Keep one stable layout node for the entire live turn. Promoting each
// blank-terminated paragraph into Markdown replaced the Text subtree
// repeatedly; LazyColumn then exposed its fallback anchor for a frame,
// which looked like the whole chat reloaded. Updating this Text value
// only remeasures the growing bubble. Full Markdown is parsed once the
// owning row releases its stable live-tail layout.
Text(
// CommonMark ignores blank lines before the first block. The live
// Text renderer must do the same or a response whose transport
// prefix contains newlines appears to start several lines down.
// Preserve indentation on the first non-blank line so indented
// code and deliberately spaced prose are not altered.
text = content.withoutLeadingBlankLines(),
modifier = modifier,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 15.sp,
lineHeight = 21.sp,
),
color = textColor,
)
} else {
MarkdownContent(
content = content,
var generation by remember { mutableIntStateOf(0) }
key(generation) {
NativeStreamingMarkdownGeneration(
content = content.withoutLeadingBlankLines(),
textColor = textColor,
modifier = modifier,
onResetRequired = { generation += 1 },
)
}
}
@Composable
private fun NativeStreamingMarkdownGeneration(
content: String,
textColor: Color,
modifier: Modifier,
onResetRequired: () -> Unit,
) {
val streamingState = rememberStreamingMarkdownState()
LaunchedEffect(content, streamingState) {
val plan = planStreamingMarkdownAppend(
renderedContent = streamingState.content.toString(),
nextContent = content,
)
if (plan.resetRequired) {
onResetRequired()
} else if (plan.delta.isNotEmpty()) {
streamingState.append(plan.delta)
}
}
ConfiguredMarkdownContent(
streamingState = streamingState,
textColor = textColor,
modifier = modifier,
)
}
internal data class StreamingMarkdownAppendPlan(
val resetRequired: Boolean,
val delta: String,
)
internal fun planStreamingMarkdownAppend(
renderedContent: String,
nextContent: String,
): StreamingMarkdownAppendPlan = if (nextContent.startsWith(renderedContent)) {
StreamingMarkdownAppendPlan(
resetRequired = false,
delta = nextContent.substring(renderedContent.length),
)
} else {
StreamingMarkdownAppendPlan(resetRequired = true, delta = "")
}
internal fun String.withoutLeadingBlankLines(): String {
var contentStart = 0
while (contentStart < length) {
@@ -1,7 +1,6 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -35,7 +34,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.ContentCopy
@@ -103,13 +101,6 @@ fun MessageBubble(
showThinking: Boolean = true,
isFirstInGroup: Boolean = true,
isLastInGroup: Boolean = true,
/**
* Keeps the current live tail on its stable Text layout while a final
* streaming frame commits. The owning list releases it immediately after
* completion so the same row transitions to full Markdown with its bottom
* anchor preserved.
*/
retainStreamingLayout: Boolean = false,
onCopyMessage: (String) -> Unit = {},
/**
* Select this message as a structured composer quote. Null hides Quote.
@@ -578,26 +569,6 @@ fun MessageBubble(
shape = bubbleShape,
color = backgroundColor,
modifier = Modifier
.then(
if (!isUser && !isSystem &&
(message.isStreaming || retainStreamingLayout)
) {
// The frame-paced text node is already measured at its
// new size. Animate and clip the owning surface so a
// newly wrapped line is revealed inside the expanding
// bubble instead of drawing below the previous bounds
// for one frame. TopStart keeps existing prose fixed.
Modifier.animateContentSize(
animationSpec = tween(
durationMillis = 72,
easing = LinearOutSlowInEasing,
),
alignment = Alignment.TopStart,
)
} else {
Modifier
}
)
.then(
if (!isUser && !isSystem && isDarkTheme) {
Modifier.leftEdgeGlow(
@@ -670,37 +641,28 @@ fun MessageBubble(
color = textColor
)
} else {
// Keep one plain Text node stable only while content is
// incomplete (plus the final committed live frame).
// Completion releases this flag and selects the full
// Markdown tree on the same stable message row.
// The renderer owns one incremental AST for the entire
// visible lifetime of this row. Stable and provisional
// blocks therefore use the same typography/components;
// completion is data state, not a renderer swap.
if (markdownBody.isNotEmpty()) {
StreamingMarkdownContent(
content = markdownBody,
textColor = textColor,
isStreaming = message.isStreaming || retainStreamingLayout,
)
}
}
}
// 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 || showStopSpeakingAction) {
DisableSelection { messageTextContent() }
} else {
key(
messageSelectionTopologyKey(
isPlainText = isUser || isSystem,
isStreaming = message.isStreaming,
retainStreamingLayout = retainStreamingLayout,
markdownBody = markdownBody,
),
) {
SelectionContainer { messageTextContent() }
}
// Voice actions live outside the message body and must never
// replace its selection owner. Speak becomes available exactly
// at completion; branching on it here recreated the complete
// incremental Markdown state for one frame.
key(
messageSelectionTopologyKey(
isPlainText = isUser || isSystem,
),
) {
SelectionContainer { messageTextContent() }
}
if (onSessionReference != null && sessionReferences.isNotEmpty()) {
sessionReferences.forEach { reference ->
@@ -826,26 +788,39 @@ fun MessageBubble(
}
}
val hasTokenUsage = !isUser &&
(message.inputTokens != null || message.outputTokens != null)
// Timestamp — only on the LAST bubble of a same-author run so a
// burst of fragments doesn't stack three near-touching time labels.
// Grouping breaks on a >5min gap (ChatScreen), so every pause still
// surfaces its own time. Alpha floored at 0.6 for 11sp contrast.
// Reserve the footer from the first streaming frame so
// completion is a color-only transition and cannot resize the
// row. Hide the reserved timestamp from accessibility until it
// becomes visible.
// This row is reserved from the first streaming frame. Completion
// can reveal both timestamp and token usage without adding a new
// footer line or changing the bubble's measured height.
if (isLastInGroup) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = timeFormat.format(Date(message.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
modifier = if (message.isStreaming) {
Modifier.clearAndSetSemantics { }
} else {
Modifier
},
)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = timeFormat.format(Date(message.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
modifier = if (message.isStreaming) {
Modifier.clearAndSetSemantics { }
} else {
Modifier
},
)
if (hasTokenUsage) {
TokenDisplay(
inputTokens = message.inputTokens,
outputTokens = message.outputTokens,
)
}
}
}
// Delivery status — only on agent-Thread reply bubbles (a user
@@ -866,12 +841,13 @@ fun MessageBubble(
)
}
// Token display (assistant messages only)
if (!isUser && (message.inputTokens != null || message.outputTokens != null)) {
// Non-tail historical fragments have no reserved timestamp row.
// Preserve their existing standalone token metadata layout.
if (!isLastInGroup && hasTokenUsage) {
Spacer(modifier = Modifier.height(2.dp))
TokenDisplay(
inputTokens = message.inputTokens,
outputTokens = message.outputTokens
outputTokens = message.outputTokens,
)
}
}
@@ -1052,14 +1028,9 @@ internal data class MessageSelectionTopologyKey(
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)
else -> MessageSelectionTopologyKey(renderer = "streaming-markdown", markdownBody = null)
}
/**
@@ -73,6 +73,7 @@ import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
import com.hermesandroid.relay.data.RelayEndpoint
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
@@ -300,6 +301,11 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
} else {
decoded
}
val normalizedKey = normalizeCredentialForHeader(
decodedWithAliases.key,
"API credential",
)
val decodedWithSafeCredential = decodedWithAliases.copy(key = normalizedKey)
// TODO(security): verify `decoded.sig` against the server's HMAC
// secret once the pairing protocol exposes a public verification
@@ -310,12 +316,12 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
// Synthesize a single priority-0 candidate from the top-level fields
// when the wire payload didn't carry an explicit `endpoints` array.
// v3+ payloads with an explicit array pass through untouched.
if (decodedWithAliases.endpoints.isNullOrEmpty()) {
decodedWithAliases.copy(
endpoints = listOf(synthesizeLegacyEndpoint(decodedWithAliases)),
if (decodedWithSafeCredential.endpoints.isNullOrEmpty()) {
decodedWithSafeCredential.copy(
endpoints = listOf(synthesizeLegacyEndpoint(decodedWithSafeCredential)),
)
} else {
decodedWithAliases
decodedWithSafeCredential
}
} catch (_: Exception) {
null
@@ -396,10 +402,13 @@ private fun payloadFromApiUrl(
else -> return null
}
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
val normalizedKey = runCatching {
normalizeCredentialForHeader(apiKey, "API credential")
}.getOrNull() ?: return null
val payload = HermesPairingPayload(
host = host,
port = if (uri.port > 0) uri.port else 8642,
key = apiKey.trim(),
key = normalizedKey,
tls = tls,
dashboardUrl = dashboardUrl?.trim()?.takeIf { it.isNotBlank() },
relay = null,
@@ -130,6 +130,7 @@ import com.hermesandroid.relay.network.relay.RealtimeVoiceConfig
import com.hermesandroid.relay.network.relay.VoiceOutputConfig
import com.hermesandroid.relay.ui.components.reasoningEffortLabel
import com.hermesandroid.relay.ui.components.resolveSessionModelUiState
import com.hermesandroid.relay.ui.components.rememberAccessibleMotionState
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
@@ -168,7 +169,9 @@ import com.hermesandroid.relay.util.AttachmentTooLargeException
import com.hermesandroid.relay.util.readBase64Bounded
import com.hermesandroid.relay.data.ChatComposerDraftKey
import com.hermesandroid.relay.data.ChatQuoteReference
import com.hermesandroid.relay.data.LARGE_PASTE_THRESHOLD_CHARS
import com.hermesandroid.relay.data.buildChatQuotedPrompt
import com.hermesandroid.relay.data.largePasteAttachment
import com.hermesandroid.relay.data.parseChatQuotedPrompt
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.HermesCardAction
@@ -226,6 +229,7 @@ import com.hermesandroid.relay.ui.components.pet.PetInteractionLayer
import com.hermesandroid.relay.ui.components.pet.petObstacleSurface
import com.hermesandroid.relay.ui.components.pet.petPerchSurface
import java.io.File
import java.util.UUID
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
import com.hermesandroid.relay.ui.components.SphereState
import com.hermesandroid.relay.ui.components.LocalThinkingIndicator
@@ -274,7 +278,10 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.delay
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -343,24 +350,6 @@ internal data class ChatScrollSnapshot(
val isStreaming: Boolean
)
internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?): Boolean =
previous?.isStreaming == true &&
!isStreaming &&
previous.messageCount == messageCount &&
previous.lastMessageUiKey == lastMessageUiKey
internal fun retainedLiveTailAfterTransition(
retainedUiKey: String?,
streamStarted: Boolean,
streamCompleted: Boolean,
lastMessageUiKey: String?,
): String? = when {
streamStarted -> lastMessageUiKey
streamCompleted && retainedUiKey == lastMessageUiKey -> null
retainedUiKey != null && retainedUiKey != lastMessageUiKey -> null
else -> retainedUiKey
}
private class ChatTailTransitionRef(
var snapshot: ChatScrollSnapshot? = null,
)
@@ -399,8 +388,13 @@ internal fun shouldCorrectConversationBottomAfterLayout(
) {
return false
}
return old.tailSizePx != current.tailSizePx ||
old.viewportHeightPx != current.viewportHeightPx ||
// Tail-row remeasurement (including completion chrome and Markdown
// settlement) is already owned by requiredBottomFollowScroll. Sending it
// through this fallback as well performs a full scrollToItem after the
// measured ramp, visibly resetting the anchor for one frame.
if (old.tailSizePx != current.tailSizePx) return false
return old.viewportHeightPx != current.viewportHeightPx ||
old.visibleBottomDistancePx != current.visibleBottomDistancePx
}
@@ -446,6 +440,72 @@ internal fun ownedBottomFollowScroll(
return requiredBottomFollowScroll(previous, current)
}
/**
* One frame of the live bottom-follow ramp. The maximum step is derived from
* the viewport, never the transcript distance, so a long session cannot make
* token updates accelerate into a full-list race. The symmetric braking limit
* keeps the last few frames from stopping abruptly.
*/
internal fun boundedBottomFollowStep(
remainingPx: Int,
previousStepPx: Int,
viewportHeightPx: Int,
motionEnabled: Boolean,
): Int {
if (remainingPx <= 0) return 0
if (!motionEnabled) return remainingPx
val maxStep = (viewportHeightPx / 14).coerceIn(12, 56)
val acceleration = (maxStep / 5).coerceAtLeast(3)
val accelerated = (previousStepPx + acceleration).coerceIn(acceleration, maxStep)
val brakingLimit = kotlin.math.sqrt(
2.0 * acceleration.toDouble() * remainingPx.toDouble(),
).toInt().coerceAtLeast(acceleration)
return minOf(remainingPx, accelerated, brakingLimit)
}
internal fun shouldInitiallyPositionConversation(
positionedSessionId: String?,
currentSessionId: String?,
isLoadingHistory: Boolean,
hasMessages: Boolean,
): Boolean = currentSessionId != null &&
positionedSessionId != currentSessionId &&
!isLoadingHistory &&
hasMessages
internal enum class ChatFollowEvent {
UserSend,
UserMovedAway,
ReturnedToBottom,
JumpToLatest,
StreamStarted,
StreamUpdated,
QueuedTurnStarted,
TurnCompleted,
HistoryRefreshed,
AppResumed,
}
/** User intent is the only state transition; transport/layout events retain it. */
internal fun reduceUserScrolledAway(
current: Boolean,
event: ChatFollowEvent,
): Boolean = when (event) {
ChatFollowEvent.UserSend,
ChatFollowEvent.ReturnedToBottom,
ChatFollowEvent.JumpToLatest -> false
ChatFollowEvent.UserMovedAway -> true
ChatFollowEvent.StreamStarted,
ChatFollowEvent.StreamUpdated,
ChatFollowEvent.QueuedTurnStarted,
ChatFollowEvent.TurnCompleted,
ChatFollowEvent.HistoryRefreshed,
ChatFollowEvent.AppResumed -> current
}
internal fun shouldFollowImeAfterInsetChange(
wasFollowing: Boolean,
previousImeBottomPx: Int,
@@ -882,6 +942,8 @@ fun ChatScreen(
connectionViewModel.keepComposerFocusedOnSend.collectAsState()
val physicalKeyboardEnterBehavior by
connectionViewModel.physicalKeyboardEnterBehavior.collectAsState()
val convertLargePastesToAttachments by
connectionViewModel.convertLargePastesToAttachments.collectAsState()
val availableSkills by chatViewModel.availableSkills.collectAsState()
val queuedMessages by chatViewModel.queuedMessages.collectAsState()
@@ -922,6 +984,7 @@ fun ChatScreen(
// sessions-SSE falls back to bounded persisted-history reconciliation.
val appForeground by com.hermesandroid.relay.util.AppForegroundTracker.isForeground.collectAsState()
LaunchedEffect(isGatewayTransport, appForeground, chatReady) {
chatViewModel.setChatVisible(appForeground && chatReady)
if (appForeground && chatReady) {
chatViewModel.prewarmGateway()
}
@@ -930,6 +993,9 @@ fun ChatScreen(
chatViewModel.refreshReasoningSettings()
}
}
DisposableEffect(chatViewModel) {
onDispose { chatViewModel.setChatVisible(false) }
}
// Cold-open recovery: the dashboard probe that flips gatewayAvailability to
// Ready (and with it isGatewayTransport, which gates the model + reasoning-
@@ -959,6 +1025,7 @@ fun ChatScreen(
// Animation settings
val animationEnabled by connectionViewModel.animationEnabled.collectAsState()
val accessibleMotion = rememberAccessibleMotionState()
val animationBehindChat by connectionViewModel.animationBehindChat.collectAsState()
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
val thinkingIndicatorStyle by connectionViewModel.thinkingIndicatorStyle.collectAsState()
@@ -1025,11 +1092,13 @@ fun ChatScreen(
val composerDraftKey = remember(
activeConnection?.id,
selectedProfile?.name,
openedSessionProfileName,
currentSessionId,
) {
ChatComposerDraftKey(
connectionId = activeConnection?.id?.takeIf(String::isNotBlank) ?: "offline",
profileId = selectedProfile?.name?.takeIf(String::isNotBlank)
profileId = (openedSessionProfileName ?: selectedProfile?.name)
?.takeIf(String::isNotBlank)
?: ChatComposerDraftKey.DEFAULT_PROFILE_ID,
sessionId = currentSessionId?.takeIf(String::isNotBlank) ?: "new-session",
)
@@ -1056,7 +1125,7 @@ fun ChatScreen(
}
restoringComposerDraft = true
val restored = chatViewModel.composerDraftStore.snapshot(composerDraftKey)
inputText = restored.text.take(charLimit)
inputText = restored.text
editingMessage = restored.context.editingMessageId?.let { messageId ->
messages.firstOrNull { it.id == messageId }
}
@@ -1076,6 +1145,7 @@ fun ChatScreen(
) {
val key = activeComposerDraftKey ?: return@LaunchedEffect
if (restoringComposerDraft) return@LaunchedEffect
delay(200)
chatViewModel.composerDraftStore.save(
key,
ChatComposerDraft(
@@ -1158,6 +1228,7 @@ fun ChatScreen(
}
}
val listState = rememberLazyListState()
val userScrolledAwayState = remember(currentSessionId) { mutableStateOf(false) }
val drawerState = rememberDrawerState(DrawerValue.Closed)
PetInteractionLayer(
owner = "chat-interaction-layer",
@@ -1196,12 +1267,46 @@ fun ChatScreen(
onDispose { petCompanionCoordinator.clearSurface("chat") }
}
val scope = rememberCoroutineScope()
val latestComposerDraft by rememberUpdatedState {
activeComposerDraftKey?.let { key ->
key to ChatComposerDraft(
text = inputText,
selectionStart = inputText.length,
selectionEnd = inputText.length,
context = ChatComposerDraftContext(
quotedMessageId = quotedMessage?.id,
editingMessageId = editingMessage?.id,
),
attachments = pendingAttachments,
)
}
}
DisposableEffect(lifecycleOwner, chatViewModel.composerDraftStore) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
latestComposerDraft()?.let { (key, draft) ->
chatViewModel.persistComposerDraft(key, draft)
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val copiedToClipboardMsg = stringResource(R.string.chat_copied_to_clipboard)
val copySessionIdLabel = stringResource(R.string.chat_copy_session_id)
val hermesMessageLabel = stringResource(R.string.chat_hermes_message)
val focusManager = LocalFocusManager.current
val finishSuccessfulSend: () -> Unit = {
activeComposerDraftKey?.let(chatViewModel.composerDraftStore::remove)
// Sending is an explicit "follow my turn" action, including when it
// queues behind an active run. Automatic queued-turn handoff itself
// never re-arms follow after the reader has deliberately moved away.
userScrolledAwayState.value = reduceUserScrolledAway(
current = userScrolledAwayState.value,
event = ChatFollowEvent.UserSend,
)
activeComposerDraftKey?.let { key ->
chatViewModel.removeComposerDraft(key)
}
quotedMessage = null
if (closeDrawerOnSend && drawerState.isOpen) {
scope.launch { drawerState.close() }
@@ -1509,10 +1614,9 @@ fun ChatScreen(
// streaming auto-scroll effect respects this — it will not yank the
// user back to the latest token while they are reading history.
// Reset to false the moment the user returns to the bottom.
var userScrolledAway by remember(currentSessionId) { mutableStateOf(false) }
var userScrolledAway by userScrolledAwayState
var isUserDragging by remember(currentSessionId) { mutableStateOf(false) }
var programmaticBottomScroll by remember { mutableStateOf(false) }
var retainedLiveTailUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
val density = LocalDensity.current
val imeBottomPx = WindowInsets.ime.getBottom(density)
val latestImeBottomPx by rememberUpdatedState(imeBottomPx)
@@ -1563,7 +1667,10 @@ fun ChatScreen(
// settlement must reach the real LazyColumn boundary.
slopPx = 0,
)
userScrolledAway = false
userScrolledAway = reduceUserScrolledAway(
current = userScrolledAway,
event = ChatFollowEvent.JumpToLatest,
)
} finally {
programmaticBottomScroll = false
}
@@ -1587,7 +1694,14 @@ fun ChatScreen(
}
is DragInteraction.Stop, is DragInteraction.Cancel -> {
isUserDragging = false
userScrolledAway = !listState.isAtConversationBottom(atBottomSlopPx)
userScrolledAway = reduceUserScrolledAway(
current = userScrolledAway,
event = if (listState.isAtConversationBottom(atBottomSlopPx)) {
ChatFollowEvent.ReturnedToBottom
} else {
ChatFollowEvent.UserMovedAway
},
)
}
}
}
@@ -1624,7 +1738,12 @@ fun ChatScreen(
// Reaching the bottom by any means (user, follow-pin, content shrank)
// always re-arms auto-follow.
LaunchedEffect(isAtBottom) {
if (isAtBottom) userScrolledAway = false
if (isAtBottom) {
userScrolledAway = reduceUserScrolledAway(
current = userScrolledAway,
event = ChatFollowEvent.ReturnedToBottom,
)
}
}
// IME insets arrive as an animation, not one layout. Wait until inset
@@ -1648,26 +1767,17 @@ fun ChatScreen(
}
}
// Scroll-to-bottom FAB visibility. The button means "you've scrolled up —
// tap to catch up", so it must NOT flash while we're auto-pinning to the
// bottom. Suppress it when:
// • a programmatic scroll is in flight (we're actively settling), or
// • we're streaming-and-following (smoothAutoScroll on, not scrolled
// away) — a content burst can momentarily make the list scrollable-
// forward for a frame before the re-pin, which would otherwise blink
// the FAB on every token.
// Once the user genuinely scrolls up (userScrolledAway), the streaming
// suppression lifts and the button appears.
// The latest affordance reflects user intent, not a transient layout gap.
// Same-row Markdown promotion can briefly put the footer below the viewport
// before the sole follow owner consumes its measured delta; never flash the
// button during that settle. Dragging up or explicitly navigating to an
// earlier message sets userScrolledAway and makes the affordance available.
val showScrollToBottom by remember {
derivedStateOf {
val retainingVisibleTail = retainedLiveTailUiKey != null &&
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
messages.isNotEmpty() &&
!isAtBottom &&
!programmaticBottomScroll &&
!((isStreaming || retainingVisibleTail) &&
smoothAutoScroll &&
!userScrolledAway)
userScrolledAway
}
}
@@ -1841,9 +1951,18 @@ fun ChatScreen(
}
}
LaunchedEffect(currentSessionId, isLoadingHistory) {
if (!isLoadingHistory && currentSessionId != null && messages.isNotEmpty()) {
var positionedSessionId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(currentSessionId, isLoadingHistory, messages.isNotEmpty()) {
if (
shouldInitiallyPositionConversation(
positionedSessionId = positionedSessionId,
currentSessionId = currentSessionId,
isLoadingHistory = isLoadingHistory,
hasMessages = messages.isNotEmpty(),
)
) {
scrollConversationToBottom(animated = false)
positionedSessionId = currentSessionId
}
}
@@ -1859,36 +1978,21 @@ fun ChatScreen(
)
val tailTransitionRef = remember(currentSessionId) { ChatTailTransitionRef() }
// A live tail owns one stable Text renderer while content is incomplete.
// Completion first commits that final live frame, then this SideEffect
// releases the retained renderer and anchors the same uiKey for the next
// remeasure, where the row deterministically becomes rich Markdown.
// Structural/new-row transitions request the stable footer during the same
// remeasure. Completion deliberately does not: Markdown tail promotion is
// a same-row size change owned only by the measured-delta follower below.
SideEffect {
val previous = tailTransitionRef.snapshot
val streamStarted = tailTransition.isStreaming && previous?.isStreaming != true
val streamCompleted = tailTransition.isCompletionAfter(previous)
val tailStructureChanged = tailTransition.lastMessageUiKey != null &&
(previous == null ||
previous.messageCount != tailTransition.messageCount ||
previous.lastMessageUiKey != tailTransition.lastMessageUiKey)
if (streamStarted) {
// Sending a turn means "follow my new answer" even if the idle
// transcript had previously been left above the bottom. Do not
// clear isUserDragging: a real finger keeps priority until release.
userScrolledAway = false
}
retainedLiveTailUiKey = retainedLiveTailAfterTransition(
retainedUiKey = retainedLiveTailUiKey,
streamStarted = streamStarted,
streamCompleted = streamCompleted,
lastMessageUiKey = tailTransition.lastMessageUiKey,
)
val shouldAnchor = smoothAutoScroll &&
!isUserDragging &&
(!userScrolledAway || streamStarted) &&
(streamStarted || streamCompleted || tailStructureChanged)
!userScrolledAway &&
(streamStarted || tailStructureChanged)
if (shouldAnchor) {
listState.requestScrollToItem(tailTransition.messageCount + 1)
}
@@ -1896,8 +2000,9 @@ fun ChatScreen(
tailTransitionRef.snapshot = tailTransition
}
// One owner follows every bottom-preserving viewport transition. Streaming
// growth advances by its measured delta, while any ordinary viewport loss
// One owner follows every bottom-preserving viewport transition. Tail
// growth, including Markdown promotion/finalization, advances by its single
// measured delta, while any ordinary viewport loss
// (IME, late composer controls, status text, or top chrome hydration)
// advances by the lost height. This matters on restore: model and effort
// controls can finish resolving after history has already reached the
@@ -1927,9 +2032,7 @@ fun ChatScreen(
followTailGrowth = !voiceDockAnchorTransitionActive &&
!isUserDragging &&
smoothAutoScroll &&
!userScrolledAway &&
(tail?.isStreaming == true ||
(retainedLiveTailUiKey != null && tail?.uiKey == retainedLiveTailUiKey)),
!userScrolledAway,
followViewportResize = shouldFollowConversationViewportResize(
userScrolledAway = userScrolledAway,
userDragging = isUserDragging,
@@ -1955,8 +2058,46 @@ fun ChatScreen(
)
previousLayout = current
if (scrollPx > 0) {
listState.scroll(MutatePriority.Default) {
scrollBy(scrollPx.toFloat())
val viewportHeight = current.viewportHeightPx.coerceAtLeast(1)
try {
if (scrollPx > viewportHeight) {
// A large Markdown/table remeasure or restored turn can
// exceed a viewport. Snap once to the stable footer;
// never animate across the transcript at a velocity
// proportional to its total distance.
val lastIndex = listState.layoutInfo.totalItemsCount - 1
if (lastIndex >= 0) listState.scrollToItem(lastIndex)
} else {
listState.scroll(MutatePriority.Default) {
var remaining = scrollPx
var previousStep = 0
while (
remaining > 0 &&
!isUserDragging &&
!userScrolledAway
) {
val step = boundedBottomFollowStep(
remainingPx = remaining,
previousStepPx = previousStep,
viewportHeightPx = viewportHeight,
motionEnabled = animationEnabled &&
accessibleMotion.osAnimations &&
!accessibleMotion.touchExploration,
)
val consumed = scrollBy(step.toFloat()).toInt()
if (consumed <= 0) break
remaining = (remaining - consumed).coerceAtLeast(0)
previousStep = step
if (remaining > 0) withFrameNanos { }
}
}
}
} catch (cancelled: CancellationException) {
// User-input mutations have higher priority and cancel
// either follow path immediately. Preserve parent/effect
// cancellation, but let a drag merely stop this request
// rather than kill the long-lived follow owner.
if (!currentCoroutineContext().isActive) throw cancelled
}
} else if (correctLateLayout) {
val lastIndex = listState.layoutInfo.totalItemsCount - 1
@@ -1965,6 +2106,8 @@ fun ChatScreen(
try {
listState.scrollToItem(lastIndex)
userScrolledAway = false
} catch (cancelled: CancellationException) {
if (!currentCoroutineContext().isActive) throw cancelled
} finally {
programmaticBottomScroll = false
}
@@ -1973,8 +2116,8 @@ fun ChatScreen(
}
}
// Completion settles the owned transcript after the final live frame. The
// SideEffect above separately anchors the same row's Markdown remeasure.
// Completion feedback is presentation-only. The measured-delta collector
// above is the sole scroll owner for the Markdown tail's final remeasure.
var observedActiveStream by remember(currentSessionId) { mutableStateOf(false) }
LaunchedEffect(isStreaming) {
if (isStreaming) {
@@ -1982,16 +2125,6 @@ fun ChatScreen(
} else if (observedActiveStream) {
observedActiveStream = false
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
if (
shouldExactlySettleConversation(
autoFollowEnabled = smoothAutoScroll,
userScrolledAway = userScrolledAway,
userDragging = isUserDragging,
hasMessages = messages.isNotEmpty(),
)
) {
scrollConversationToBottom(animated = false)
}
}
}
@@ -2162,7 +2295,17 @@ fun ChatScreen(
scope.launch { drawerState.close() }
},
onDeleteSession = { sessionId ->
chatViewModel.deleteSession(sessionId)
val connectionId = activeConnection?.id
val profileId = openedSessionProfileName ?: selectedProfile?.name
chatViewModel.deleteSession(sessionId) {
if (!connectionId.isNullOrBlank() && !profileId.isNullOrBlank()) {
chatViewModel.removeComposerDraftSession(
connectionId,
profileId,
sessionId,
)
}
}
},
onRenameSession = { sessionId, title ->
chatViewModel.renameSession(sessionId, title)
@@ -2290,6 +2433,13 @@ fun ChatScreen(
allProfileSessions = allProfileSessions.filterNot {
it.profile == profileName && it.session.sessionId == sessionId
}
activeConnection?.id?.let { connectionId ->
chatViewModel.removeComposerDraftSession(
connectionId,
profileName,
sessionId,
)
}
}
}
},
@@ -3135,9 +3285,6 @@ fun ChatScreen(
// LazyColumn retains the visible row and its scroll anchor.
items(messages.size, key = { messages[it].uiKey }) { index ->
val message = messages[index]
val retainLiveLayout =
index == messages.lastIndex &&
message.uiKey == retainedLiveTailUiKey
val processNotification = message.hermesProcessNotificationOrNull()
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
@@ -3235,7 +3382,6 @@ fun ChatScreen(
showThinking = showThinking,
isFirstInGroup = isFirstInGroup,
isLastInGroup = isLastInGroup,
retainStreamingLayout = retainLiveLayout,
recoveringAnswer = recoveringAnswer,
imageGenerationStylePreference = imageGenerationStyle,
imageGenerationRotationIndex =
@@ -3304,6 +3450,10 @@ fun ChatScreen(
onNavigateToMessage = { messageId ->
val targetIndex = messages.indexOfFirst { it.id == messageId }
if (targetIndex >= 0) {
userScrolledAway = reduceUserScrolledAway(
current = userScrolledAway,
event = ChatFollowEvent.UserMovedAway,
)
scope.launch { listState.animateScrollToItem(targetIndex + 1) }
}
},
@@ -3438,6 +3588,10 @@ fun ChatScreen(
onJumpToMessage = { uiKey ->
val messageIndex = messages.indexOfFirst { it.uiKey == uiKey }
if (messageIndex >= 0) {
userScrolledAway = reduceUserScrolledAway(
current = userScrolledAway,
event = ChatFollowEvent.UserMovedAway,
)
scope.launch { listState.animateScrollToItem(messageIndex + 1) }
}
},
@@ -3706,6 +3860,10 @@ fun ChatScreen(
onOpenOriginal = {
val index = messages.indexOfFirst { it.id == reference.messageId }
if (index >= 0) {
userScrolledAway = reduceUserScrolledAway(
current = userScrolledAway,
event = ChatFollowEvent.UserMovedAway,
)
scope.launch { listState.animateScrollToItem(index + 1) }
}
},
@@ -3802,6 +3960,11 @@ fun ChatScreen(
val editBusyMessage = stringResource(R.string.chat_edit_busy_snackbar)
val stoppedMessage = stringResource(R.string.chat_stopped_snackbar)
val attachmentPlaceholder = stringResource(R.string.chat_attachment_placeholder)
val largePasteAttachedMessage = stringResource(R.string.chat_large_paste_attached)
val largePasteTooLargeMessage = stringResource(
R.string.chat_large_paste_too_large,
maxAttachmentMb,
)
val sseModelOptions = remember(apiModelOptions, agentProfiles, selectedModelOverride) {
(apiModelOptions +
agentProfiles.mapNotNull { profile ->
@@ -4068,6 +4231,52 @@ fun ChatScreen(
isDarkTheme = isDarkTheme,
physicalEnterSends = physicalKeyboardEnterBehavior ==
PhysicalKeyboardEnterBehavior.SendMessage,
submitEnabled = pendingAttachments.none {
it.state == com.hermesandroid.relay.data.AttachmentState.LOADING
},
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS
.takeIf { convertLargePastesToAttachments },
onLargePaste = { pastedText ->
val owner = activeComposerDraftKey ?: composerDraftKey
val sizeBytes = pastedText.toByteArray(Charsets.UTF_8).size.toLong()
val maxBytes = maxAttachmentMb.toLong() * 1024L * 1024L
if (sizeBytes > maxBytes) {
scope.launch {
snackbarHostState.showSnackbar(largePasteTooLargeMessage)
}
} else {
val composerId = UUID.randomUUID().toString()
val placeholder = Attachment(
contentType = "text/plain; charset=utf-8",
content = "",
fileName = "pasted-text.txt",
fileSize = sizeBytes,
state = com.hermesandroid.relay.data.AttachmentState.LOADING,
isLargePaste = true,
composerId = composerId,
composerRawText = pastedText,
)
if (activeComposerDraftKey == owner) {
chatViewModel.addAttachment(placeholder)
}
scope.launch {
val attachment = withContext(Dispatchers.Default) {
largePasteAttachment(pastedText, composerId)
}
if (activeComposerDraftKey == owner) {
chatViewModel.replaceAttachment(composerId, attachment)
} else {
chatViewModel.composerDraftStore.update(owner) { draft ->
draft.copy(
attachments = draft.attachments
.filterNot { it.composerId == composerId } + attachment,
)
}
}
snackbarHostState.showSnackbar(largePasteAttachedMessage)
}
}
},
modelControl = modelControl,
onModelOptionSelected = { option ->
if (option.provider == null && apiModelOptions.any { it.id == option.value }) {
@@ -4260,6 +4469,10 @@ fun ChatScreen(
enabled = chatReady,
onSend = { text ->
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
userScrolledAwayState.value = reduceUserScrolledAway(
current = userScrolledAwayState.value,
event = ChatFollowEvent.UserSend,
)
chatViewModel.sendMessage(text)
},
onExit = { ambientMode = false },
@@ -444,6 +444,34 @@ fun ChatSettingsScreen(
HorizontalDivider()
val convertLargePastesToAttachments by
connectionViewModel.convertLargePastesToAttachments.collectAsState()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.chat_settings_large_pastes),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.chat_settings_large_pastes_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = convertLargePastesToAttachments,
onCheckedChange = {
connectionViewModel.setConvertLargePastesToAttachments(it)
},
)
}
HorizontalDivider()
val physicalKeyboardEnterBehavior by
connectionViewModel.physicalKeyboardEnterBehavior.collectAsState()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -13,6 +13,8 @@ import com.hermesandroid.relay.data.AttachmentState
import com.hermesandroid.relay.data.BackgroundTaskPhase
import com.hermesandroid.relay.data.BackgroundTaskState
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.ChatComposerDraft
import com.hermesandroid.relay.data.ChatComposerDraftKey
import com.hermesandroid.relay.data.ChatComposerDraftStore
import com.hermesandroid.relay.data.ChatQueuedMessageCheckpoint
import com.hermesandroid.relay.data.ChatSession
@@ -33,6 +35,7 @@ import com.hermesandroid.relay.data.MessageDeliveryStatus
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.applyMessageReaction
import com.hermesandroid.relay.data.parseChatQuotedPrompt
import com.hermesandroid.relay.data.prepareTextTransportAttachments
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.ProactiveInboxEntry
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
@@ -573,8 +576,27 @@ class ChatViewModel : ViewModel() {
private val _pendingAttachments = MutableStateFlow<List<Attachment>>(emptyList())
val pendingAttachments: StateFlow<List<Attachment>> = _pendingAttachments.asStateFlow()
/** Session-keyed composer state; intentionally memory-only for attachment privacy. */
val composerDraftStore: ChatComposerDraftStore = InMemoryChatComposerDraftStore()
/** Session-keyed composer state; the process runtime installs durable app-private storage. */
var composerDraftStore: ChatComposerDraftStore = InMemoryChatComposerDraftStore()
private set
fun installComposerDraftStore(store: ChatComposerDraftStore) {
composerDraftStore = store
}
fun persistComposerDraft(key: ChatComposerDraftKey, draft: ChatComposerDraft) {
viewModelScope.launch { composerDraftStore.save(key, draft) }
}
fun removeComposerDraft(key: ChatComposerDraftKey) {
viewModelScope.launch { composerDraftStore.remove(key) }
}
fun removeComposerDraftSession(connectionId: String, profileId: String, sessionId: String) {
viewModelScope.launch {
composerDraftStore.removeSession(connectionId, profileId, sessionId)
}
}
fun addAttachment(attachment: Attachment) {
_pendingAttachments.update { it + attachment }
@@ -590,6 +612,21 @@ class ChatViewModel : ViewModel() {
_pendingAttachments.value = attachments.toList()
}
fun replaceAttachment(composerId: String, attachment: Attachment) {
_pendingAttachments.update { attachments ->
var replaced = false
val updated = attachments.map { current ->
if (current.composerId == composerId) {
replaced = true
attachment
} else {
current
}
}
if (replaced) updated else updated + attachment
}
}
fun moveAttachment(fromIndex: Int, toIndex: Int) {
_pendingAttachments.update { attachments ->
if (fromIndex !in attachments.indices || toIndex !in attachments.indices) {
@@ -1756,6 +1793,9 @@ class ChatViewModel : ViewModel() {
*/
private var gatewayClient: GatewayChatClient? = null
private var gatewayHistoryReconcileJob: Job? = null
private var gatewayVisibleReattachJob: Job? = null
@Volatile
private var chatVisible = false
private var gatewayProcessSource: GatewayProcessSource? = null
private val gatewayProcessController = GatewayProcessController(viewModelScope)
@@ -1790,6 +1830,8 @@ class ChatViewModel : ViewModel() {
val previousClient = gatewayClient
val changed = previousClient !== client
if (changed) {
gatewayVisibleReattachJob?.cancel()
gatewayVisibleReattachJob = null
previousClient?.setUnsolicitedTurnProvider(null)
previousClient?.setColdPrewarmSessionReadyListener(null)
previousClient?.setUnmatchedTurnCompleteListener(null)
@@ -1889,6 +1931,24 @@ class ChatViewModel : ViewModel() {
gatewayStateSyncJob?.cancel()
gatewayStateSyncJob = null
client?.let { startGatewayStateSync(it) }
if (client != null) {
gatewayVisibleReattachJob = viewModelScope.launch {
client.connectionState.collect { state ->
if (
state == GatewayConnectionState.Idle &&
chatVisible &&
gatewayClient === client
) {
// Foreground can race OkHttp's delayed close callback:
// the first prewarm sees the old socket as Ready, then
// the callback moves it to Idle. Re-run from this exact
// client transition so the visible durable session is
// resumed and its authoritative history reconciled.
prewarmGateway()
}
}
}
}
}
when {
client == null -> {
@@ -2392,6 +2452,17 @@ class ChatViewModel : ViewModel() {
}
}
/**
* Marks whether Chat is currently visible in the foreground. A visible
* Gateway chat owns automatic idle-socket reattachment; other tabs and a
* backgrounded app retain the normal no-reconnect behavior.
*/
fun setChatVisible(visible: Boolean) {
val changed = chatVisible != visible
chatVisible = visible
if (visible && changed) prewarmGateway()
}
// === Gateway desktop-parity state ===
/**
@@ -3401,7 +3472,6 @@ class ChatViewModel : ViewModel() {
queuedMessageItems.clear()
completedQueueOwnerRuns.clear()
publishQueuedMessages()
_pendingAttachments.value = emptyList()
_steerableTurn.value = false
_steerNotice.value = null
dismissPendingAskNotification()
@@ -3520,7 +3590,6 @@ class ChatViewModel : ViewModel() {
activeProfileContextKey = contextKey
refreshRelayReasoningCapabilities()
publishBackgroundSessionActivity()
_pendingAttachments.value = emptyList()
_steerableTurn.value = false
_steerNotice.value = null
dismissPendingAskNotification()
@@ -4086,7 +4155,7 @@ class ChatViewModel : ViewModel() {
switchSession(sessionId)
}
fun deleteSession(sessionId: String) {
fun deleteSession(sessionId: String, onDeleted: () -> Unit = {}) {
val handler = chatHandler ?: return
val client = apiClient
if (streamingEndpoint != "gateway" && client == null) return
@@ -4128,6 +4197,7 @@ class ChatViewModel : ViewModel() {
client?.deleteSession(sessionId) == true
}
if (success) {
onDeleted()
// Re-fetch so a server that still has the row can't leave it
// resurrected in the drawer; mirrors session create's refresh.
refreshSessions()
@@ -6305,6 +6375,7 @@ class ChatViewModel : ViewModel() {
// Snapshot and clear pending attachments
val attachments = (queuedMessage?.attachments ?: _pendingAttachments.value).ifEmpty { null }
if (queuedMessage == null) _pendingAttachments.value = emptyList()
val textTransport = prepareTextTransportAttachments(outboundText, attachments.orEmpty())
val messageId = queuedMessage?.id ?: UUID.randomUUID().toString()
@@ -6337,7 +6408,7 @@ class ChatViewModel : ViewModel() {
.map { it.sessionId }
.toSet()
creatingThread = CreatingThread(pending.chatId, pending.name, knownIds)
send(outboundText, pending.chatId, null, messageId)
send(textTransport.message, pending.chatId, null, messageId)
switchToCreatedThread()
} else {
handler.updateDeliveryStatus(messageId, MessageDeliveryStatus.FAILED)
@@ -6357,7 +6428,7 @@ class ChatViewModel : ViewModel() {
val send = onProactiveReply
if (send != null) {
handler.updateDeliveryStatus(messageId, MessageDeliveryStatus.SENDING)
send(outboundText, threadChatIds[activeThread.sessionId], null, messageId)
send(textTransport.message, threadChatIds[activeThread.sessionId], null, messageId)
} else {
handler.updateDeliveryStatus(messageId, MessageDeliveryStatus.FAILED)
}
@@ -7841,7 +7912,8 @@ class ChatViewModel : ViewModel() {
// image_url), but no SSE path carries non-image files, and sessions/runs
// carry no attachments at all. Text always sends regardless.
fun warnIfAttachmentsDropped(endpoint: String) {
val dropped = attachments.orEmpty().filter { att ->
val dropped = prepareTextTransportAttachments(message, attachments.orEmpty())
.attachments.filter { att ->
if (endpoint == "completions") !att.isImage else true
}
if (dropped.isEmpty()) return
@@ -7864,15 +7936,16 @@ class ChatViewModel : ViewModel() {
onErrorCb("Gateway unavailable and no API fallback is configured.")
return null
}
val prepared = prepareTextTransportAttachments(message, attachments.orEmpty())
return when (endpoint) {
"runs" -> {
dispatchedSseEndpoint = endpoint
updateTurnCheckpointTransport(endpoint)
warnIfAttachmentsDropped(endpoint)
sseClient.sendRunStream(
message = message,
message = prepared.message,
systemMessage = systemMsg,
attachments = attachments,
attachments = prepared.attachments,
voiceIntentMessages = voiceIntentMessages,
onSessionId = { sid ->
handler.setSessionId(sid)
@@ -7898,9 +7971,9 @@ class ChatViewModel : ViewModel() {
updateTurnCheckpointTransport(endpoint)
warnIfAttachmentsDropped(endpoint)
sseClient.sendChatCompletionsStream(
message = message,
message = prepared.message,
systemMessage = systemMsg,
attachments = attachments,
attachments = prepared.attachments,
voiceIntentMessages = voiceIntentMessages,
onSessionId = { /* stateless OpenAI-compatible endpoint */ },
onMessageStarted = onMessageStartedCb,
@@ -7943,9 +8016,9 @@ class ChatViewModel : ViewModel() {
warnIfAttachmentsDropped(endpoint)
delegated = sseClient.sendChatStream(
sessionId = sessionId,
message = message,
message = prepared.message,
systemMessage = systemMsg,
attachments = attachments,
attachments = prepared.attachments,
voiceIntentMessages = voiceIntentMessages,
onSessionId = { /* already set */ },
onMessageStarted = onMessageStartedCb,
@@ -8864,6 +8937,8 @@ class ChatViewModel : ViewModel() {
publishBackgroundSessionActivity()
gatewayHistoryReconcileJob?.cancel()
gatewayHistoryReconcileJob = null
gatewayVisibleReattachJob?.cancel()
gatewayVisibleReattachJob = null
backgroundProcessSessionJob?.cancel()
backgroundProcessSessionJob = null
gatewayProcessController.close()
@@ -100,6 +100,7 @@ import com.hermesandroid.relay.network.upstream.GatewayKeepAliveService
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
import com.hermesandroid.relay.network.shared.ProfileApiUrlResolver
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
import com.hermesandroid.relay.network.upstream.ServerCapabilities
import com.hermesandroid.relay.network.relay.RelayHttpClient
import com.hermesandroid.relay.network.relay.RelayUrlDeriver
@@ -2287,6 +2288,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
val convertLargePastesToAttachments: StateFlow<Boolean> =
chatInputPreferencesRepository.convertLargePastesToAttachments
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setConvertLargePastesToAttachments(enabled: Boolean) {
viewModelScope.launch {
chatInputPreferencesRepository.setConvertLargePastesToAttachments(enabled)
}
}
// Turn-complete notification (default ON). RelayApp mirrors this into
// ChatViewModel.notifyOnTurnComplete; ChatSettingsScreen owns the toggle
// + the POST_NOTIFICATIONS runtime request on first enable.
@@ -5403,6 +5414,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
routeCandidatesOverride: List<EndpointCandidate>? = null,
onResult: (StandardApiSetupResult) -> Unit,
) {
val normalizedApiKey = runCatching {
normalizeCredentialForHeader(apiKey, "API credential")
}.getOrElse {
onResult(
StandardApiSetupResult(
ok = false,
message = it.message ?: "Invalid API credential",
apiReachable = false,
relayPaired = authState.value is AuthState.Paired,
),
)
return
}
// Bare host/IP input gets http:// and the surface's default port —
// typing `192.168.1.10` or a Tailscale `100.x.y.z` without a scheme
// is the common case and used to either block the wizard or silently
@@ -5464,8 +5488,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
relayUrl = routeRelayUrl,
routeCandidates = routeCandidates,
)
if (apiKey.isNotBlank()) {
authManager.setApiKey(apiKey.trim())
if (normalizedApiKey.isNotBlank()) {
authManager.setApiKey(normalizedApiKey)
}
getApplication<Application>().relayDataStore.edit { preferences ->
@@ -5668,6 +5692,21 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
manualRelayUrlOverride: String?,
onResult: (ApiVoiceSetupResult) -> Unit,
) {
val normalizedApiKey = runCatching {
normalizeCredentialForHeader(apiKey, "API credential")
}.getOrElse {
onResult(
ApiVoiceSetupResult(
apiReachable = false,
relayUrl = null,
relayAutoDerived = false,
voiceConfigReachable = false,
voiceConfigError = it.message,
voiceRoute = "none",
),
)
return
}
val trimmedApiUrl = apiUrl.trim()
val trimmedOverride = manualRelayUrlOverride?.trim().orEmpty()
val derivedRelayUrl = RelayUrlDeriver.deriveFromApiUrl(trimmedApiUrl)
@@ -5686,8 +5725,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
viewModelScope.launch {
if (apiKey.isNotBlank()) {
authManager.setApiKey(apiKey.trim())
if (normalizedApiKey.isNotBlank()) {
authManager.setApiKey(normalizedApiKey)
}
getApplication<Application>().relayDataStore.edit { preferences ->
preferences[KEY_API_SERVER_URL] = trimmedApiUrl
@@ -5766,8 +5805,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
fun updateApiKey(key: String) {
viewModelScope.launch {
authManager.setApiKey(key)
rebuildApiClient()
runCatching {
authManager.setApiKey(key)
rebuildApiClient()
}.onFailure {
DiagnosticsLog.record(
category = DiagnosticCategory.Api,
severity = DiagnosticSeverity.Warning,
title = "API credential was not saved",
detail = it.message,
)
}
}
}
@@ -694,6 +694,10 @@
<string name="chat_settings_recent_prompt_chips_desc">Mostre suas mensagens recentes como chips tocáveis acima do editor para enviá-las novamente. Desativado por padrão.</string>
<string name="chat_settings_keep_keyboard_open">Manter o teclado aberto ao enviar</string>
<string name="chat_settings_keep_keyboard_open_desc">Permaneça no editor após enviar. Desative para fechar o teclado depois de cada mensagem enviada.</string>
<string name="chat_settings_large_pastes">Converter colagens grandes em anexos</string>
<string name="chat_settings_large_pastes_desc">Transforma colagens com 5.000 caracteres ou mais em anexos de texto revisáveis. Ativado por padrão.</string>
<string name="chat_large_paste_attached">Colagem grande adicionada como anexo de texto</string>
<string name="chat_large_paste_too_large">O texto colado excede o limite de anexos de %1$d MB</string>
<string name="chat_settings_physical_keyboard_enter">Tecla Enter do teclado físico</string>
<string name="chat_settings_physical_keyboard_enter_desc">Escolha se Enter envia ou insere uma nova linha. Ctrl+Enter sempre envia.</string>
<string name="chat_settings_insert_newline">Inserir nova linha</string>
@@ -2091,6 +2095,7 @@
<string name="active_section_api_key_not_configured">Chave da API não configurada</string>
<string name="active_section_api_key_optional">Chave da API direta</string>
<string name="active_section_api_key_stored_hint">A chave está armazenada com segurança.</string>
<string name="api_credential_single_line_error">As credenciais da API devem estar em uma única linha.</string>
<string name="active_section_api_reachable_voice_review">API acessível — revise a configuração de voz</string>
<string name="active_section_api_relay_voice_reachable">API acessível — a voz do Relay está configurada</string>
<string name="active_section_api_server">Servidor API</string>
@@ -736,6 +736,10 @@
<string name="chat_settings_recent_prompt_chips_desc">在输入栏上方显示最近消息为可点击的标签以便重发。默认关闭。</string>
<string name="chat_settings_keep_keyboard_open">发送时保持键盘打开</string>
<string name="chat_settings_keep_keyboard_open_desc">发送后留在输入栏。关闭则在每条消息发送后收起键盘。</string>
<string name="chat_settings_large_pastes">将大型粘贴内容转换为附件</string>
<string name="chat_settings_large_pastes_desc">将 5,000 个或更多字符的粘贴内容转换为可检查的文本附件。默认开启。</string>
<string name="chat_large_paste_attached">大型粘贴内容已添加为文本附件</string>
<string name="chat_large_paste_too_large">粘贴的文本超过 %1$d MB 附件限制</string>
<string name="chat_settings_physical_keyboard_enter">实体键盘 Enter 键</string>
<string name="chat_settings_physical_keyboard_enter_desc">选择按 Enter 是发送消息还是插入新行。Ctrl+Enter 始终发送。</string>
<string name="chat_settings_insert_newline">插入新行</string>
@@ -2184,6 +2188,7 @@
<string name="active_section_api_key_not_configured">API 密钥未配置</string>
<string name="active_section_api_key_optional">直接 API 的 API 密钥</string>
<string name="active_section_api_key_stored_hint">密钥已安全存储。</string>
<string name="api_credential_single_line_error">API 凭据必须为单行内容。</string>
<string name="active_section_api_reachable_voice_review">API 可达——请检查语音配置</string>
<string name="active_section_api_relay_voice_reachable">API 可达——Relay 语音已配置</string>
<string name="active_section_api_server">API 服务器</string>
+5
View File
@@ -736,6 +736,10 @@
<string name="chat_settings_recent_prompt_chips_desc">Letzte Nachrichten als antippbare Chips über dem Eingabefeld anzeigen, um sie erneut zu senden. Standardmäßig aus.</string>
<string name="chat_settings_keep_keyboard_open">Tastatur nach dem Senden geöffnet lassen</string>
<string name="chat_settings_keep_keyboard_open_desc">Nach dem Senden im Eingabefeld bleiben. Ausschalten, um die Tastatur nach jeder gesendeten Nachricht zu schließen.</string>
<string name="chat_settings_large_pastes">Große Einfügungen in Anhänge umwandeln</string>
<string name="chat_settings_large_pastes_desc">Wandelt Einfügungen mit mindestens 5.000 Zeichen in überprüfbare Textanhänge um. Standardmäßig aktiviert.</string>
<string name="chat_large_paste_attached">Große Einfügung als Textanhang hinzugefügt</string>
<string name="chat_large_paste_too_large">Der eingefügte Text überschreitet das Anhangslimit von %1$d MB</string>
<string name="chat_settings_physical_keyboard_enter">Eingabetaste der physischen Tastatur</string>
<string name="chat_settings_physical_keyboard_enter_desc">Wähle, ob die Eingabetaste sendet oder eine neue Zeile einfügt. Strg+Eingabetaste sendet immer.</string>
<string name="chat_settings_insert_newline">Neue Zeile einfügen</string>
@@ -2187,6 +2191,7 @@
<string name="active_section_api_key_not_configured">API-Schlüssel nicht konfiguriert</string>
<string name="active_section_api_key_optional">API-Schlüssel für direkte API</string>
<string name="active_section_api_key_stored_hint">Schlüssel ist sicher gespeichert.</string>
<string name="api_credential_single_line_error">API-Anmeldedaten müssen in einer einzigen Zeile stehen.</string>
<string name="active_section_api_reachable_voice_review">API erreichbar — Sprachkonfiguration prüfen</string>
<string name="active_section_api_relay_voice_reachable">API erreichbar — Relay-Sprache ist konfiguriert</string>
<string name="active_section_api_server">API-Server</string>
+5
View File
@@ -661,6 +661,10 @@
<string name="chat_settings_recent_prompt_chips_desc">Muestre sus mensajes recientes como chips que se pueden tocar encima del redactor para enviarlos nuevamente. Desactivado de forma predeterminada.</string>
<string name="chat_settings_keep_keyboard_open">Mantener el teclado abierto al enviar</string>
<string name="chat_settings_keep_keyboard_open_desc">Permanece en el compositor después del envío. Desactívelo para descartar el teclado después de cada mensaje enviado.</string>
<string name="chat_settings_large_pastes">Convertir pegados grandes en archivos adjuntos</string>
<string name="chat_settings_large_pastes_desc">Convierte pegados de 5.000 caracteres o más en archivos de texto revisables. Activado de forma predeterminada.</string>
<string name="chat_large_paste_attached">El pegado grande se añadió como archivo de texto</string>
<string name="chat_large_paste_too_large">El texto pegado supera el límite de archivos adjuntos de %1$d MB</string>
<string name="chat_settings_physical_keyboard_enter">Tecla Intro del teclado físico</string>
<string name="chat_settings_physical_keyboard_enter_desc">Elige si Intro envía o inserta una línea nueva. Ctrl+Intro siempre envía.</string>
<string name="chat_settings_insert_newline">Insertar línea nueva</string>
@@ -1996,6 +2000,7 @@
<string name="active_section_api_key_not_configured">Clave API no configurada</string>
<string name="active_section_api_key_optional">Clave API para API directa</string>
<string name="active_section_api_key_stored_hint">La clave se almacena de forma segura.</string>
<string name="api_credential_single_line_error">Las credenciales de API deben estar en una sola línea.</string>
<string name="active_section_api_reachable_voice_review">API accesible: revisar la configuración de voz</string>
<string name="active_section_api_relay_voice_reachable">API accesible: la voz Relay está configurada</string>
<string name="active_section_api_server">Servidor API</string>
+5
View File
@@ -736,6 +736,10 @@
<string name="chat_settings_recent_prompt_chips_desc">最近のメッセージをコンポーザーの上にタップ可能なチップとして表示し、再送信します。デフォルトではオフです。</string>
<string name="chat_settings_keep_keyboard_open">送信時にキーボードを開いたままにする</string>
<string name="chat_settings_keep_keyboard_open_desc">送信後はコンポーザー内に留まります。メッセージを送信するたびにキーボードを閉じるには、オフにします。</string>
<string name="chat_settings_large_pastes">大きな貼り付けを添付ファイルに変換</string>
<string name="chat_settings_large_pastes_desc">5,000 文字以上の貼り付けを確認可能なテキスト添付ファイルに変換します。デフォルトでオンです。</string>
<string name="chat_large_paste_attached">大きな貼り付けをテキスト添付ファイルとして追加しました</string>
<string name="chat_large_paste_too_large">貼り付けたテキストが %1$d MB の添付ファイル上限を超えています</string>
<string name="chat_settings_physical_keyboard_enter">物理キーボードの Enter キー</string>
<string name="chat_settings_physical_keyboard_enter_desc">Enter で送信するか改行するかを選択します。Ctrl+Enter は常に送信します。</string>
<string name="chat_settings_insert_newline">改行を挿入</string>
@@ -2200,6 +2204,7 @@
<string name="active_section_api_key_not_configured">API キーが構成されていません</string>
<string name="active_section_api_key_optional">直接 API の API キー</string>
<string name="active_section_api_key_stored_hint">鍵は安全に保管されます。</string>
<string name="api_credential_single_line_error">API 認証情報は 1 行で入力してください。</string>
<string name="active_section_api_reachable_voice_review">API に到達可能 — 音声設定を確認してください</string>
<string name="active_section_api_relay_voice_reachable">API 到達可能 — Relay 音声が設定されています</string>
<string name="active_section_api_server">API サーバー</string>
+5
View File
@@ -704,6 +704,10 @@
<string name="chat_settings_recent_prompt_chips_desc">Показывать ваши недавние сообщения как нажимаемые чипы над композитором, чтобы отправить их снова. По умолчанию выключено.</string>
<string name="chat_settings_keep_keyboard_open">Оставить клавиатуру открытой при отправке</string>
<string name="chat_settings_keep_keyboard_open_desc">Оставаться в композиторе после отправки. Выключите, чтобы скрыть клавиатуру после каждого отправленного сообщения.</string>
<string name="chat_settings_large_pastes">Преобразовывать большие вставки во вложения</string>
<string name="chat_settings_large_pastes_desc">Преобразует вставки длиной от 5 000 символов в текстовые вложения для проверки. Включено по умолчанию.</string>
<string name="chat_large_paste_attached">Большая вставка добавлена как текстовое вложение</string>
<string name="chat_large_paste_too_large">Вставленный текст превышает лимит вложения %1$d МБ</string>
<string name="chat_settings_physical_keyboard_enter">Клавиша Enter на физической клавиатуре</string>
<string name="chat_settings_physical_keyboard_enter_desc">Выберите, отправляет ли Enter сообщение или добавляет новую строку. Ctrl+Enter всегда отправляет.</string>
<string name="chat_settings_insert_newline">Вставить новую строку</string>
@@ -2187,6 +2191,7 @@
<string name="active_section_api_key_not_configured">Ключ API не настроен</string>
<string name="active_section_api_key_optional">Ключ API для прямого API</string>
<string name="active_section_api_key_stored_hint">Ключ хранится безопасно.</string>
<string name="api_credential_single_line_error">Учетные данные API должны быть указаны в одной строке.</string>
<string name="active_section_api_reachable_voice_review">API доступен — проверьте настройку голоса</string>
<string name="active_section_api_relay_voice_reachable">API доступен — голос Relay настроен</string>
<string name="active_section_api_server">Сервер API</string>
+5
View File
@@ -774,6 +774,10 @@
<string name="chat_settings_recent_prompt_chips_desc">Show your recent messages as tappable chips above the composer to send them again. Off by default.</string>
<string name="chat_settings_keep_keyboard_open">Keep keyboard open on send</string>
<string name="chat_settings_keep_keyboard_open_desc">Stay in the composer after sending. Turn off to dismiss the keyboard after each sent message.</string>
<string name="chat_settings_large_pastes">Convert large pastes to attachments</string>
<string name="chat_settings_large_pastes_desc">Turn pastes of 5,000 or more characters into reviewable text attachments. On by default.</string>
<string name="chat_large_paste_attached">Large paste added as a text attachment</string>
<string name="chat_large_paste_too_large">Pasted text exceeds the %1$d MB attachment limit</string>
<string name="chat_settings_physical_keyboard_enter">Physical keyboard Enter key</string>
<string name="chat_settings_physical_keyboard_enter_desc">Choose whether Enter sends or inserts a new line. Ctrl+Enter always sends.</string>
<string name="chat_settings_insert_newline">Insert new line</string>
@@ -2434,6 +2438,7 @@
<string name="active_section_api_key_not_configured">API key not configured</string>
<string name="active_section_api_key_optional">API key for direct API</string>
<string name="active_section_api_key_stored_hint">Key is stored securely.</string>
<string name="api_credential_single_line_error">API credentials must be a single line.</string>
<string name="active_section_api_reachable_voice_review">API reachable — review voice config</string>
<string name="active_section_api_relay_voice_reachable">API reachable — Relay voice is configured</string>
<string name="active_section_api_server">API Server</string>
@@ -0,0 +1,21 @@
package com.hermesandroid.relay.auth
import com.hermesandroid.relay.network.shared.InvalidCredentialException
import org.junit.Assert.assertThrows
import org.junit.Test
class AuthManagerBackupCredentialTest {
@Test
fun backupValidationRejectsMultilineCredentialsBeforeRestore() {
for (secrets in listOf(
ConnectionAuthSecrets(sessionToken = "first\nsecond"),
ConnectionAuthSecrets(refreshToken = "first\r\nsecond"),
ConnectionAuthSecrets(apiKey = "first second"),
ConnectionAuthSecrets(profileApiKeys = mapOf("work" to "first\tsecond")),
)) {
assertThrows(InvalidCredentialException::class.java) {
AuthManager.validateStoredSecrets(secrets)
}
}
}
}
@@ -10,7 +10,7 @@ class ChatComposerDraftStoreTest {
private val store = InMemoryChatComposerDraftStore()
@Test
fun draftsAreIsolatedByCompleteOwnerIdentity() {
fun draftsAreIsolatedByCompleteOwnerIdentity() = runBlocking {
val original = key(connection = "connection-a", profile = "coder", session = "session-a")
val otherConnection = original.copy(connectionId = "connection-b")
val otherProfile = original.copy(profileId = "default")
@@ -26,7 +26,7 @@ class ChatComposerDraftStoreTest {
}
@Test
fun preservesSelectionContextAndPendingAttachments() {
fun preservesSelectionContextAndPendingAttachments() = runBlocking {
val attachment = Attachment(
contentType = "image/jpeg",
content = "base64-payload",
@@ -57,7 +57,7 @@ class ChatComposerDraftStoreTest {
}
@Test
fun updateUsesCurrentSessionDraftWithoutTouchingAnotherSession() {
fun updateUsesCurrentSessionDraftWithoutTouchingAnotherSession() = runBlocking {
val first = key(session = "first")
val second = key(session = "second")
store.save(first, ChatComposerDraft(text = "one"))
@@ -71,7 +71,7 @@ class ChatComposerDraftStoreTest {
}
@Test
fun invalidSelectionIsClampedAndOrderedWhenSaved() {
fun invalidSelectionIsClampedAndOrderedWhenSaved() = runBlocking {
store.save(
key(),
ChatComposerDraft(text = "hello", selectionStart = 99, selectionEnd = -4),
@@ -101,7 +101,7 @@ class ChatComposerDraftStoreTest {
}
@Test
fun removeSessionClearsEveryDraftSlotOnlyForExactNamespace() {
fun removeSessionClearsEveryDraftSlotOnlyForExactNamespace() = runBlocking {
val primary = key()
val alternate = primary.copy(draftId = "alternate")
val otherProfile = primary.copy(profileId = "other")
@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.mutablePreferencesOf
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -14,6 +15,20 @@ import org.junit.Test
class ChatInputPreferencesTest {
@Test
fun `large paste conversion defaults on and round trips`() = runTest {
val store = InMemoryChatInputDataStore()
val repository = ChatInputPreferencesRepository(store)
assertEquals(true, repository.convertLargePastesToAttachments.first())
repository.setConvertLargePastesToAttachments(false)
assertEquals(false, repository.convertLargePastesToAttachments.first())
repository.setConvertLargePastesToAttachments(true)
assertEquals(true, repository.convertLargePastesToAttachments.first())
}
@Test
fun `enter defaults to send and unknown values fall back safely`() = runTest {
assertEquals(
@@ -35,13 +50,20 @@ class ChatInputPreferencesTest {
@Test
fun `all enter behaviors round trip without replacing unrelated settings`() = runTest {
val unrelatedKey = stringPreferencesKey("unrelated_chat_input_test")
val store = InMemoryChatInputDataStore(mutablePreferencesOf(unrelatedKey to "keep-me"))
val unrelatedBoolean = booleanPreferencesKey("unrelated_chat_input_boolean_test")
val store = InMemoryChatInputDataStore(
mutablePreferencesOf(
unrelatedKey to "keep-me",
unrelatedBoolean to true,
),
)
val repository = ChatInputPreferencesRepository(store)
PhysicalKeyboardEnterBehavior.entries.forEach { behavior ->
repository.setPhysicalKeyboardEnterBehavior(behavior)
assertEquals(behavior, repository.physicalKeyboardEnterBehavior.first())
assertEquals("keep-me", store.data.first()[unrelatedKey])
assertEquals(true, store.data.first()[unrelatedBoolean])
}
}
}
@@ -0,0 +1,35 @@
package com.hermesandroid.relay.data
import java.util.Base64
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatLargePasteTest {
@Test
fun `text transport materializes large paste and keeps ordinary attachments`() {
val paste = largePasteAttachment("line one\nline two")
val image = Attachment("image/png", "aW1hZ2U=", "image.png", 5)
val prepared = prepareTextTransportAttachments(
message = "Please review this context",
attachments = listOf(paste, image),
)
assertTrue(prepared.message.contains("line one\nline two"))
assertTrue(prepared.message.contains("pasted-text.txt"))
assertEquals(listOf(image), prepared.attachments)
}
@Test
fun `large paste attachment is utf8 text with stable metadata`() {
val attachment = largePasteAttachment("héllo")
assertEquals("text/plain; charset=utf-8", attachment.contentType)
assertEquals("pasted-text.txt", attachment.fileName)
assertEquals(6L, attachment.fileSize)
assertTrue(attachment.isLargePaste)
assertEquals("héllo", String(Base64.getDecoder().decode(attachment.content), Charsets.UTF_8))
}
}
@@ -0,0 +1,111 @@
package com.hermesandroid.relay.data
import java.nio.file.Files
import java.util.Base64
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PersistentChatComposerDraftStoreTest {
@Test
fun `draft and attachment survive store recreation`() = runTest {
val root = Files.createTempDirectory("composer-drafts").toFile()
try {
val key = ChatComposerDraftKey("connection", "profile", "session")
val attachment = Attachment(
contentType = "text/plain; charset=utf-8",
content = Base64.getEncoder().encodeToString("large pasted text".toByteArray()),
fileName = "pasted-text.txt",
fileSize = 17,
isLargePaste = true,
)
PersistentChatComposerDraftStore(root).save(
key,
ChatComposerDraft(
text = "survives process death",
selectionStart = 4,
selectionEnd = 9,
context = ChatComposerDraftContext(quotedMessageId = "quote"),
attachments = listOf(attachment),
),
)
val restored = PersistentChatComposerDraftStore(root).snapshot(key)
assertEquals("survives process death", restored.text)
assertEquals(4, restored.selectionStart)
assertEquals(9, restored.selectionEnd)
assertEquals("quote", restored.context.quotedMessageId)
assertEquals(listOf(attachment), restored.attachments)
} finally {
root.deleteRecursively()
}
}
@Test
fun `session removal is exact and clears persisted blobs when unreferenced`() = runTest {
val root = Files.createTempDirectory("composer-drafts").toFile()
try {
val store = PersistentChatComposerDraftStore(root)
val removed = ChatComposerDraftKey("connection", "profile", "removed")
val kept = ChatComposerDraftKey("connection", "profile", "kept")
val attachment = Attachment(
contentType = "text/plain",
content = Base64.getEncoder().encodeToString("private draft".toByteArray()),
fileName = "notes.txt",
fileSize = 13,
)
store.save(removed, ChatComposerDraft(text = "remove", attachments = listOf(attachment)))
store.save(kept, ChatComposerDraft(text = "keep"))
store.removeSession("connection", "profile", "removed")
assertTrue(store.snapshot(removed).isEmpty)
assertEquals("keep", store.snapshot(kept).text)
assertTrue(root.walkTopDown().none { it.isFile && it.extension == "blob" })
} finally {
root.deleteRecursively()
}
}
@Test
fun `preparing large paste restores as a ready attachment`() = runTest {
val root = Files.createTempDirectory("composer-drafts").toFile()
try {
val key = ChatComposerDraftKey("connection", "profile", "session")
PersistentChatComposerDraftStore(root).save(
key,
ChatComposerDraft(
attachments = listOf(
Attachment(
contentType = "text/plain; charset=utf-8",
content = "",
fileName = "pasted-text.txt",
fileSize = 14,
state = AttachmentState.LOADING,
isLargePaste = true,
composerId = "preparing",
composerRawText = "unfinished app",
),
),
),
)
val restored = PersistentChatComposerDraftStore(root)
.snapshot(key)
.attachments
.single()
assertEquals(AttachmentState.LOADED, restored.state)
assertEquals("preparing", restored.composerId)
assertEquals(
"unfinished app",
String(Base64.getDecoder().decode(restored.content), Charsets.UTF_8),
)
} finally {
root.deleteRecursively()
}
}
}
@@ -0,0 +1,36 @@
package com.hermesandroid.relay.network.shared
import okhttp3.Request
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Test
class CredentialHeadersTest {
@Test
fun harmlessOuterHorizontalWhitespaceIsNormalized() {
val request = Request.Builder()
.url("https://example.test/")
.bearerAuthorization(" \tcredential-value\t ", "API credential")
.build()
assertEquals("Bearer credential-value", request.header("Authorization"))
}
@Test
fun multilineAndEmbeddedWhitespaceAreRejectedWithoutCredentialDisclosure() {
for (raw in listOf("first\nsecond", "first\r\nsecond", "first second", "first\tsecond")) {
val failure = assertThrows(InvalidCredentialException::class.java) {
Request.Builder()
.url("https://example.test/")
.bearerAuthorization(raw, "API credential")
}
assertEquals(
"Invalid API credential — enter or import a single-line value.",
failure.message,
)
assertFalse(failure.message.orEmpty().contains("first"))
assertFalse(failure.message.orEmpty().contains("second"))
}
}
}
@@ -109,6 +109,30 @@ class HermesApiClientTest {
}
}
@Test
fun malformedMultilineApiCredentialFailsBeforeAuthorizationHeaderConstruction() = runTest {
val server = MockWebServer()
server.start()
try {
val result = HermesApiClient(
server.url("/").toString(),
"first-line\nsecond-line",
).checkHealthDetailed()
assertTrue(result is HealthCheckResult.Unhealthy)
val message = (result as HealthCheckResult.Unhealthy).message
assertEquals(
"Invalid API credential — enter or import a single-line value.",
message,
)
assertFalse(message.contains("first-line"))
assertFalse(message.contains("second-line"))
assertEquals(0, server.requestCount)
} finally {
server.shutdown()
}
}
// --- HealthCheckResult sealed interface ---
@Test
@@ -5,14 +5,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasImeAction
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.pressKey
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
@@ -136,13 +141,73 @@ class ChatInputBarTest {
}
@Test
fun `ime send uses the same composer action`() {
fun `software keyboard exposes newline without submitting`() {
var value = "Hello"
var sent = 0
setComposer(value = "Hello") { sent++ }
setComposer(value = value, onValueChange = { value = it }) { sent++ }
input().performClick().performImeAction()
input()
.assert(hasImeAction(ImeAction.Default))
.performClick()
.performTextInput("\nWorld")
compose.runOnIdle { assertEquals(1, sent) }
compose.runOnIdle {
assertEquals("Hello\nWorld", value)
assertEquals(0, sent)
}
}
@Test
fun `large insertion becomes attachment while surrounding draft stays in composer`() {
var value = "before after"
var pasted: String? = null
val largePaste = "x".repeat(5_000)
setComposer(
value = value,
onValueChange = { value = it },
largePasteThreshold = 5_000,
onLargePaste = { pasted = it },
)
input().performClick().performTextReplacement("before $largePaste after")
compose.runOnIdle {
assertEquals(largePaste, pasted)
assertEquals("before after", value)
}
}
@Test
fun `large paste conversion can be disabled`() {
var value = ""
var conversions = 0
val largePaste = "x".repeat(5_000)
setComposer(
value = value,
onValueChange = { value = it },
largePasteThreshold = null,
onLargePaste = { conversions++ },
charLimit = 8_000,
)
input().performClick().performTextInput(largePaste)
compose.runOnIdle {
assertEquals(largePaste, value)
assertEquals(0, conversions)
}
}
@Test
fun `send stays disabled while composer content is preparing`() {
var sent = 0
setComposer(value = "Ready soon", submitEnabled = false) { sent++ }
compose.onNodeWithContentDescription("Send message")
.assertIsNotEnabled()
input().performClick().performKeyInput { pressKey(Key.Enter) }
compose.runOnIdle { assertEquals(0, sent) }
}
private fun setComposer(
@@ -151,6 +216,10 @@ class ChatInputBarTest {
trailing: ChatInputTrailing = ChatInputTrailing.SEND,
physicalEnterSends: Boolean = true,
caption: String? = null,
largePasteThreshold: Int? = null,
onLargePaste: (String) -> Unit = {},
charLimit: Int = 4_000,
submitEnabled: Boolean = true,
onSend: () -> Unit = {},
) {
var currentValue by mutableStateOf(value)
@@ -172,13 +241,16 @@ class ChatInputBarTest {
onAttachCamera = {},
onPasteImage = {},
onLongPressAttach = {},
charLimit = 4_000,
charLimit = charLimit,
caption = caption,
voiceReady = true,
showVoiceHint = false,
onVoiceHintShown = {},
isDarkTheme = false,
physicalEnterSends = physicalEnterSends,
largePasteThreshold = largePasteThreshold,
onLargePaste = onLargePaste,
submitEnabled = submitEnabled,
)
}
}
@@ -26,6 +26,20 @@ import org.junit.Test
*/
class HermesPairingPayloadTest {
@Test
fun multilineApiCredentialIsRejectedAcrossPairingQrShapes() {
assertNull(
parseHermesPairingQr(
"""{"api_url":"https://example.test:8642","api_key":"first\nsecond"}""",
),
)
assertNull(
parseHermesPairingQr(
"""{"host":"example.test","port":8642,"key":"first\nsecond","tls":true}""",
),
)
}
@Test
fun v1LegacyPayload_synthesizesLanEndpoint() {
// v1 QR: no `hermes` field at all. Defaults to version 1. Parser
@@ -4,7 +4,6 @@ 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
@@ -66,78 +65,22 @@ 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",
)
fun partialSelection_keepsOneTopologyAcrossStreamingAndFinalization() {
val initialLive = messageSelectionTopologyKey(isPlainText = false)
val updatedLive = messageSelectionTopologyKey(isPlainText = false)
val settledMarkdown = messageSelectionTopologyKey(isPlainText = false)
val revisedMarkdown = messageSelectionTopologyKey(isPlainText = false)
assertEquals(initialLive, updatedLive)
assertEquals(initialLive, retainedLive)
assertNotEquals(initialLive, settledMarkdown)
assertNotEquals(settledMarkdown, revisedMarkdown)
assertEquals(initialLive, settledMarkdown)
assertEquals(settledMarkdown, revisedMarkdown)
}
@Test
fun completedAssistantSyntax_selectsMarkdownRendererWithoutChangingContent() {
val completedBodies = listOf(
"```kotlin\nval answer = 42\n```",
"- first\n- second",
"**bold** and *emphasis*",
"[Hermes](https://example.com)",
)
completedBodies.forEach { body ->
assertEquals(
MessageSelectionTopologyKey(renderer = "markdown", markdownBody = body),
messageSelectionTopologyKey(
isPlainText = false,
isStreaming = false,
retainStreamingLayout = false,
markdownBody = body,
),
)
}
}
@Test
fun interruptedPartialReply_selectsSettledMarkdownRenderer() {
val partialFence = "```kotlin\nval partial ="
fun completedAssistantSyntax_usesStableStreamingMarkdownRenderer() {
assertEquals(
MessageSelectionTopologyKey(renderer = "markdown", markdownBody = partialFence),
messageSelectionTopologyKey(
isPlainText = false,
isStreaming = false,
retainStreamingLayout = false,
markdownBody = partialFence,
),
MessageSelectionTopologyKey(renderer = "streaming-markdown", markdownBody = null),
messageSelectionTopologyKey(isPlainText = false),
)
}
}
@@ -78,7 +78,7 @@ class MessageBubbleCompletionLayoutTest {
}
@Test
fun `completion does not resize a retained live tail`() {
fun `completion keeps the incremental renderer height stable`() {
val streaming = mutableStateOf(true)
val message = ChatMessage(
id = "assistant-live",
@@ -87,33 +87,36 @@ class MessageBubbleCompletionLayoutTest {
timestamp = 1_700_000_000_000L,
)
compose.mainClock.autoAdvance = false
compose.setContent {
MaterialTheme {
MessageBubble(
message = message.copy(isStreaming = streaming.value),
retainStreamingLayout = true,
message = message.copy(
isStreaming = streaming.value,
inputTokens = if (streaming.value) null else 120,
outputTokens = if (streaming.value) null else 42,
),
onSpeakMessage = {},
modifier = Modifier.testTag("retained-live-tail"),
)
}
}
compose.waitForIdle()
compose.onNodeWithText("↑120 ↓42 tokens").assertDoesNotExist()
val streamingHeight = compose.onNodeWithTag("retained-live-tail")
.fetchSemanticsNode().boundsInRoot.height
compose.runOnIdle { streaming.value = false }
compose.mainClock.advanceTimeBy(1_000L)
compose.waitForIdle()
val completedHeight = compose.onNodeWithTag("retained-live-tail")
.fetchSemanticsNode().boundsInRoot.height
compose.onNodeWithText("↑120 ↓42 tokens").assertExists()
assertEquals(streamingHeight, completedHeight, 0.01f)
}
@Test
fun `releasing a completed live tail renders markdown without navigation`() {
fun `streaming markdown remains rendered across completion`() {
val streaming = mutableStateOf(true)
val retainStreamingLayout = mutableStateOf(true)
val message = ChatMessage(
id = "assistant-live",
role = MessageRole.ASSISTANT,
@@ -125,20 +128,93 @@ class MessageBubbleCompletionLayoutTest {
MaterialTheme {
MessageBubble(
message = message.copy(isStreaming = streaming.value),
retainStreamingLayout = retainStreamingLayout.value,
modifier = Modifier.testTag("completion-tail"),
)
}
}
compose.onNodeWithText("**bold**").assertExists()
compose.waitForIdle()
compose.onNodeWithText("**bold**").assertDoesNotExist()
compose.runOnIdle {
streaming.value = false
retainStreamingLayout.value = false
}
compose.waitForIdle()
compose.onNodeWithText("**bold**").assertDoesNotExist()
compose.onNodeWithTag("completion-tail").assertExists()
}
@Test
fun `stable and provisional inline syntax share one renderer`() {
val content = mutableStateOf("**bold**\n\nunfinished *tail")
val streaming = mutableStateOf(true)
val message = ChatMessage(
id = "assistant-segmented-live",
role = MessageRole.ASSISTANT,
content = "",
timestamp = 1_700_000_000_000L,
)
compose.setContent {
MaterialTheme {
MessageBubble(
message = message.copy(
content = content.value,
isStreaming = streaming.value,
),
modifier = Modifier.testTag("segmented-live-tail"),
)
}
}
compose.onNodeWithText("**bold**").assertDoesNotExist()
compose.onNodeWithText("unfinished *tail").assertExists()
compose.runOnIdle {
content.value = "**bold**\n\nunfinished *tail*"
}
compose.waitForIdle()
compose.onNodeWithText("unfinished *tail*").assertDoesNotExist()
compose.runOnIdle { streaming.value = false }
compose.waitForIdle()
compose.onNodeWithText("unfinished *tail*").assertDoesNotExist()
compose.onNodeWithTag("segmented-live-tail").assertExists()
}
@Test
fun `native streaming renderer keeps provisional structures formatted`() {
val content = mutableStateOf(
"- first\n- second\n\n" +
"| Name | Value |\n| --- | --- |\n| one | two |\n\n" +
"```kotlin\nval answer =",
)
val message = ChatMessage(
id = "assistant-native-stream",
role = MessageRole.ASSISTANT,
content = "",
isStreaming = true,
timestamp = 1_700_000_000_000L,
)
compose.setContent {
MaterialTheme {
MessageBubble(
message = message.copy(content = content.value),
modifier = Modifier.testTag("native-streaming-markdown"),
)
}
}
compose.waitForIdle()
compose.onNodeWithText("first").assertExists()
compose.onNodeWithText("val answer =").assertExists()
compose.onNodeWithText(content.value).assertDoesNotExist()
compose.runOnIdle { content.value += " 42\n```" }
compose.waitForIdle()
compose.onNodeWithText(content.value).assertDoesNotExist()
compose.onNodeWithTag("native-streaming-markdown").assertExists()
}
}
@@ -1,11 +1,13 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class StreamingMarkdownContentTest {
@Test
fun liveText_discardsOnlyLeadingBlankLines() {
fun liveContent_discardsOnlyLeadingBlankLines() {
assertEquals(
"First line\n\nSecond line",
"\n\r\n \t\nFirst line\n\nSecond line".withoutLeadingBlankLines(),
@@ -15,4 +17,52 @@ class StreamingMarkdownContentTest {
"\n\n indented code".withoutLeadingBlankLines(),
)
}
@Test
fun appendPlan_emitsOnlyTheNewSuffix() {
assertEquals(
StreamingMarkdownAppendPlan(resetRequired = false, delta = "Hello"),
planStreamingMarkdownAppend(renderedContent = "", nextContent = "Hello"),
)
assertEquals(
StreamingMarkdownAppendPlan(resetRequired = false, delta = " world"),
planStreamingMarkdownAppend(renderedContent = "Hello", nextContent = "Hello world"),
)
assertEquals(
StreamingMarkdownAppendPlan(resetRequired = false, delta = ""),
planStreamingMarkdownAppend(renderedContent = "Hello", nextContent = "Hello"),
)
}
@Test
fun arbitraryChunkBoundaries_reconstructTheExactMarkdownStream() {
val source = "Intro **bold**.\n\n- one\n- two\n\n\u0060\u0060\u0060kotlin\nval answer = 42\n\u0060\u0060\u0060"
var rendered = ""
source.indices.forEach { index ->
val snapshot = source.take(index + 1)
val plan = planStreamingMarkdownAppend(rendered, snapshot)
assertFalse(plan.resetRequired)
rendered += plan.delta
assertEquals(snapshot, rendered)
}
}
@Test
fun nonAppendReconciliation_requestsOneFreshStreamingGeneration() {
val plan = planStreamingMarkdownAppend(
renderedContent = "partial server draft",
nextContent = "authoritative final response",
)
assertTrue(plan.resetRequired)
assertEquals("", plan.delta)
assertEquals(
StreamingMarkdownAppendPlan(
resetRequired = false,
delta = "authoritative final response",
),
planStreamingMarkdownAppend("", "authoritative final response"),
)
}
}
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.ui.screens
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatScrollSnapshotTest {
@@ -59,12 +60,16 @@ class ChatScrollSnapshotTest {
}
@Test
fun `same transcript late viewport or tail layout is corrected`() {
fun `late viewport correction never takes completion tail ownership`() {
val previous = viewportSnapshot(
tailSizePx = 400,
viewportHeightPx = 1_000,
visibleBottomDistancePx = 0,
)
val completionRemeasure = previous.copy(
tailSizePx = 460,
visibleBottomDistancePx = null,
)
assertEquals(
true,
@@ -80,10 +85,10 @@ class ChatScrollSnapshotTest {
),
)
assertEquals(
true,
false,
shouldCorrectConversationBottomAfterLayout(
previous = previous,
current = previous.copy(tailSizePx = 460, visibleBottomDistancePx = null),
current = completionRemeasure,
atExactBottom = false,
userScrolledAway = false,
userDragging = false,
@@ -92,6 +97,7 @@ class ChatScrollSnapshotTest {
viewportFollowAllowed = true,
),
)
assertEquals(60, ownedBottomFollowScroll(previous, completionRemeasure))
}
@Test
@@ -366,77 +372,109 @@ class ChatScrollSnapshotTest {
}
@Test
fun `stream start captures the live tail renderer`() {
fun `bounded follow ramps without transcript-distance velocity`() {
val shortSteps = followSteps(distance = 480, viewportHeight = 840)
val longSteps = followSteps(distance = 48_000, viewportHeight = 840)
assertTrue(shortSteps.take(3).zipWithNext().all { (a, b) -> b >= a })
assertTrue(shortSteps.takeLast(3).zipWithNext().all { (a, b) -> b <= a })
assertTrue(longSteps.max() <= 56)
assertEquals(shortSteps.max(), longSteps.max())
assertEquals(480, shortSteps.sum())
assertEquals(48_000, longSteps.sum())
}
@Test
fun `reduced motion consumes the measured delta without animation frames`() {
assertEquals(
"assistant-live",
retainedLiveTailAfterTransition(
retainedUiKey = null,
streamStarted = true,
streamCompleted = false,
lastMessageUiKey = "assistant-live",
720,
boundedBottomFollowStep(
remainingPx = 720,
previousStepPx = 0,
viewportHeightPx = 840,
motionEnabled = false,
),
)
}
@Test
fun `same-tail completion releases the live renderer for markdown`() {
assertNull(
retainedLiveTailAfterTransition(
retainedUiKey = "assistant-live",
streamStarted = false,
streamCompleted = true,
lastMessageUiKey = "assistant-live",
fun `history refresh and resume preserve an already positioned session`() {
assertEquals(
false,
shouldInitiallyPositionConversation(
positionedSessionId = "session-a",
currentSessionId = "session-a",
isLoadingHistory = false,
hasMessages = true,
),
)
assertEquals(
true,
shouldInitiallyPositionConversation(
positionedSessionId = "session-a",
currentSessionId = "session-b",
isLoadingHistory = false,
hasMessages = true,
),
)
}
@Test
fun `late completion survives server id adoption on the same ui row`() {
val live = snapshot().copy(
lastMessageId = "assistant-client-id",
lastContentLength = 40,
isStreaming = true,
)
val completed = live.copy(
lastMessageId = "assistant-server-id",
lastContentLength = 120,
isStreaming = false,
fun `only clear user actions rearm bottom follow`() {
val passiveEvents = listOf(
ChatFollowEvent.StreamStarted,
ChatFollowEvent.StreamUpdated,
ChatFollowEvent.QueuedTurnStarted,
ChatFollowEvent.TurnCompleted,
ChatFollowEvent.HistoryRefreshed,
ChatFollowEvent.AppResumed,
)
assertEquals(true, completed.isCompletionAfter(live))
assertNull(
retainedLiveTailAfterTransition(
retainedUiKey = "assistant-ui-key",
streamStarted = false,
streamCompleted = completed.isCompletionAfter(live),
lastMessageUiKey = completed.lastMessageUiKey,
),
passiveEvents.forEach { event ->
assertEquals(true, reduceUserScrolledAway(current = true, event = event))
}
assertEquals(
false,
reduceUserScrolledAway(current = true, event = ChatFollowEvent.UserSend),
)
assertEquals(
false,
reduceUserScrolledAway(current = true, event = ChatFollowEvent.ReturnedToBottom),
)
assertEquals(
false,
reduceUserScrolledAway(current = true, event = ChatFollowEvent.JumpToLatest),
)
}
@Test
fun `restored settled history does not invent a live completion transition`() {
assertEquals(false, snapshot().isCompletionAfter(previous = null))
fun `user movement cancels follow until explicitly rearmed`() {
val movedAway = reduceUserScrolledAway(
current = false,
event = ChatFollowEvent.UserMovedAway,
)
val stillAway = reduceUserScrolledAway(
current = movedAway,
event = ChatFollowEvent.StreamUpdated,
)
assertEquals(true, movedAway)
assertEquals(true, stillAway)
}
@Test
fun `new tail releases the retained renderer`() {
assertNull(
retainedLiveTailAfterTransition(
retainedUiKey = "assistant-live",
streamStarted = false,
streamCompleted = false,
lastMessageUiKey = "next-row",
),
fun `settled markdown tail growth remains owned at the bottom`() {
val previous = viewportSnapshot(
tailSizePx = 420,
visibleBottomDistancePx = 0,
followTailGrowth = true,
)
assertNull(
retainedLiveTailAfterTransition(
retainedUiKey = null,
streamStarted = false,
streamCompleted = false,
lastMessageUiKey = "next-row",
),
val markdownPromotion = previous.copy(
tailSizePx = 510,
visibleBottomDistancePx = 90,
)
assertEquals(90, ownedBottomFollowScroll(previous, markdownPromotion))
}
@Test
@@ -473,4 +511,22 @@ class ChatScrollSnapshotTest {
followTailGrowth = followTailGrowth,
followViewportResize = followViewportResize,
)
private fun followSteps(distance: Int, viewportHeight: Int): List<Int> {
var remaining = distance
var previous = 0
return buildList {
while (remaining > 0) {
val step = boundedBottomFollowStep(
remainingPx = remaining,
previousStepPx = previous,
viewportHeightPx = viewportHeight,
motionEnabled = true,
)
add(step)
remaining -= step
previous = step
}
}
}
}
@@ -1544,6 +1544,26 @@ class ChatViewModelGatewayInboundTurnTest {
assertFalse(handler.isStreaming.value)
}
@Test
fun visibleChatReattachesWhenSocketClosesAfterForegroundPrewarmRace() {
persistedHistory = persistedAnswerHistory()
viewModel.setChatVisible(true)
// Foreground arrives while OkHttp still reports the old socket ready,
// so the one-shot prewarm is an intentional no-op. The delayed close
// callback must itself trigger an exact-session reattach.
viewModel.prewarmGateway()
serverWs.close(1012, "late background close")
awaitCondition { gatewayHarness.ticketMints.get() >= 2 }
serverWs = gatewayHarness.awaitServerSocket()
gatewayHarness.awaitRpcCount("session.resume", 2)
awaitCondition {
handler.messages.value.singleOrNull()?.content == BACKGROUND_ANSWER
}
assertFalse(handler.isStreaming.value)
}
@Test
fun gatewayVoiceTurnDoesNotRequireApiFallback() {
viewModel.sendVoiceMessage("local voice turn", "Respond for spoken playback")
+41
View File
@@ -3424,3 +3424,44 @@ silently destroying existing phone or Relay-host choices. Older Gateways keep
their prior behavior, credentials never return to or enter Android logs, and
phone-local pet packs remain local. Physical two-client/avatar/pet and
shared-versus-isolated first-turn/voice certification remains required.
---
## ADR 62 — Android composer drafts are durable and large pastes stay reviewable
**Status:** Accepted (2026-08-17).
**Context.** Session-scoped drafts previously lived only in the process-owned
ViewModel. They survived navigation and Activity recreation, but process death
discarded them. Profile and connection switches could also clear the shared
pending-attachment list before the composer's key-change effect saved it. The
4,096-character default message limit rejected larger clipboard inserts before
the user could review or send them as files.
**Decision.** The composer remains keyed by stable connection, owning profile,
session, and draft slot. Its production store writes small JSON metadata plus
content-addressed attachment blobs under Android's app-private no-backup
directory. Writes run on IO, use replace-safe temporary files, retain at most
64 drafts and 128 MB of blobs outside the active draft, and collect unreferenced
blobs after send, removal, or pruning. Chat debounces ordinary edits, flushes
the latest snapshot on lifecycle stop, removes the active draft immediately
after a successful dispatch, and saves the previous owner before restoring a
session/profile/connection destination. Pending attachments are no longer
cleared by the profile-switch owner before that handoff.
A device-wide Chat setting, on by default, treats one insertion of at least
5,000 characters as pasted text. Android immediately replaces that insertion
with a loading `pasted-text.txt` card, prepares UTF-8/Base64 content off the UI
thread, and disables only submission until the attachment is ready. The
surrounding composer text remains editable. Gateway delivers the file through
upstream `file.attach`. Because vanilla API-server SSE and proactive Thread
transports have no generic-file channel, Android materializes this client-made
text attachment back into the outgoing text for those paths while retaining
ordinary supported attachments and their existing failure behavior.
**Consequences.** Closing or recreating the app no longer discards reviewed
composer work, navigation cannot rebind a draft or attachment to another agent,
and large structured pastes remain visible before send without making the
default path depend on Relay. Draft content stays local, is excluded from cloud
backup, is cleared on uninstall, and remains subject to the app's attachment
size limit.
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "d4237ed777d91eec599270d3a88340fb5221fd3b3ac55041ea81f8a9547c17f3",
"main": "6c5f74d4f9a1b2391bbc7fc431b1e9f67386d71228a53c2513498b36d07d44cb",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "d4237ed777d91eec599270d3a88340fb5221fd3b3ac55041ea81f8a9547c17f3",
"main": "6c5f74d4f9a1b2391bbc7fc431b1e9f67386d71228a53c2513498b36d07d44cb",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "d4237ed777d91eec599270d3a88340fb5221fd3b3ac55041ea81f8a9547c17f3",
"main": "6c5f74d4f9a1b2391bbc7fc431b1e9f67386d71228a53c2513498b36d07d44cb",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "d4237ed777d91eec599270d3a88340fb5221fd3b3ac55041ea81f8a9547c17f3",
"main": "6c5f74d4f9a1b2391bbc7fc431b1e9f67386d71228a53c2513498b36d07d44cb",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "d4237ed777d91eec599270d3a88340fb5221fd3b3ac55041ea81f8a9547c17f3",
"main": "6c5f74d4f9a1b2391bbc7fc431b1e9f67386d71228a53c2513498b36d07d44cb",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "d4237ed777d91eec599270d3a88340fb5221fd3b3ac55041ea81f8a9547c17f3",
"main": "6c5f74d4f9a1b2391bbc7fc431b1e9f67386d71228a53c2513498b36d07d44cb",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+6 -6
View File
@@ -89,13 +89,13 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.9.1 - Profile identity that sticks
v1.10.0 - Chat that stays put
* Change shared avatars without oversized or native images disappearing.
* Select upstream animated pets that follow the Hermes profile.
* Keep phone-only animated icons and local overrides separate.
* Create profiles with explicit authentication choices.
* Harden attachment, recovery, ownership, and hosted sign-in behavior.
* Render Markdown while replies stream without a final rebuild or scroll jump.
* Resume the open Hermes session after returning from another app.
* Restore composer drafts and pending attachments after app restarts.
* Turn large pastes into reviewable text attachments.
* Use Return on the software keyboard while the dedicated button sends.
```
## Category
+3 -1
View File
@@ -447,10 +447,12 @@ Bottom navigation bar with 4 tabs:
- **Session drawer** (swipe from left or hamburger icon) — session list with title, timestamp, message count. Create, switch, rename, delete, pin/unpin, and archive/restore. Pin and archive are durable upstream session fields loaded and patched through the owning connection/profile's Dashboard session API; Android does not keep a second local flag registry. Archived rows are requested explicitly so they remain restorable after recreation. Failed mutations roll back the optimistic row, while refresh and deletion reconcile from server truth. When a persisted title is absent, use upstream's first-user-message `preview`, matching the Hermes Desktop session picker; show "Untitled" only when neither value exists.
- **Concurrent Gateway chats** — switching sessions, profiles, drafts, or Threads detaches the visible turn without sending `session.interrupt`; each running chat keeps a connection/profile/session-scoped checkpoint and reattaches to its live Gateway session when reopened. Explicit Stop still interrupts. SSE fallback stays single-stream and cancels on navigation.
- **Queued Gateway follow-ups** — every local queued item is immutably scoped to its originating connection, profile, stored session, transport, and run generation; only that run's completion can make it eligible, and switching sessions shows only that session's queue. Restored text queues retain the same scope, while unavailable/deleted destinations and non-restorable attachment queues fail visibly instead of following the current composer. Drained messages add `queued: true` to `prompt.submit`; ordinary sends omit the field. Authoritative submit rejections (`4004`, `4018`, `4028`, `4029`, `4030`, `4090`, `5008`, `5070`, and `5071`) preserve the server message and never fall through to API-server SSE.
- **Durable composer drafts** — each connection/profile/session owns one app-private draft containing text, quote/edit context, and pending attachment bytes. Metadata and content-addressed blobs live under Android's no-backup directory, are capped at 64 drafts and 128 MB of retained blobs outside the active draft, flush when Chat backgrounds, and are removed after a successful send. Session/profile/connection navigation saves the previous owner before restoring the destination; an opened cross-profile session uses its actual owning profile rather than the global picker.
- **Large paste review** — a default-on Chat setting converts any single insertion of at least 5,000 characters into a visible `pasted-text.txt` attachment before the normal message-length limit rejects it. Gateway uses upstream `file.attach`; API-server SSE and proactive Thread paths materialize the same UTF-8 text into the outgoing prompt and remove only the synthetic attachment from that transport, so the behavior never requires Relay or silently drops content.
- **Complete transcript reads** — Android requests the API-server and profile-scoped Dashboard message routes with an explicit 500-row contract. User-visible history pages oldest-first until complete, preserves legacy unpaginated envelopes, and stops with a clear 50,000-message/32 MB client safety bound. Short dropped-stream recovery polls one explicit latest page while the known transcript fits that window; longer histories retain complete paging so positional edit/recovery anchors cannot shift. Edit-and-regenerate submits always send `confirm_truncate`; ordinal zero alone also sends `confirm_empty_truncate`. Gateway history `row_id` values are retained as durable rewind targets (never UI keys), sent alongside the visible-user ordinal, and rebound from `survivor_user_row_ids` after each truncating rewrite. Histories with no row IDs remain ordinal-compatible with older Hermes; a mixed history fails closed when the selected row lacks its durable ID instead of guessing by position.
- **Bounded Gateway resume** — an authoritative `session.resume` rejection such as `4130` remains visible with the server's export/config guidance. Android does not create a replacement session, retry, or fall through to API-server SSE, so an oversized lineage cannot silently become a context-free turn.
- **Gateway command privacy** — `command.dispatch` skill/send results render the server's bounded `display` text (or the literal command when absent). Expanded skill bodies remain transport-only and are not copied into bubbles, titles, retries, or recovery checkpoints.
- **Chat view** — message bubbles with markdown rendering, streaming text, tool call cards (Off/Compact/Detailed display modes)
- **Chat view** — assistant rows use the native incremental Markdown state from first token through completion. The renderer retains stable AST node identities while parsing the provisional tail with the same typography and components, so paragraphs, lists, links, fences, and tables do not hard-swap from a plain-text tree at completion; a non-append authoritative reconciliation starts one fresh renderer generation. Bottom-follow is user-owned: explicit sends, returning to the bottom, or the latest affordance arm it; a deliberate read-up cancels it across later stream, queue, refresh, and resume events. Live growth and Markdown settlement share one cancellable viewport-bounded follow owner rather than bubble-size or transcript-distance animation. Tool call cards retain Off/Compact/Detailed display modes.
- **Three independent visual roles** — the profile image or letter fallback identifies the agent and appears on the first assistant message in a group; the optional Sphere is an ambient background visualization; and an optional pet is an app-level floating companion. A pet never replaces profile identity or the Sphere.
- **Floating pet companion** — one root-level overlay host preserves the selected pet across in-app navigation without reserving transcript, message, or control-bar layout space. The 60 dp art (50 dp with the IME or on a short screen) has a persisted **60–120%** size control, default 100%; that default equals the previous 125% physical size, while legacy saved values are rebased to retain their rendered size. Keyboard compaction changes only the footprint: typing does not dim, pause, or disable pet playback, roaming, tapping, or dragging. One multiplier drives the art, complete touch target, collision footprint, perch eligibility, and route clearance; larger pets skip terrain that cannot safely fit them. Only the pet target intercepts input and the surrounding positioning layer is click-through. A tap waves and opens the pet menu. Long hold lifts into `held`; direct dragging follows the finger anywhere inside the visible overlay without terrain or obstacle projection. On release, the pet visibly falls to the nearest valid measured surface below, or the nearest remaining safe surface when none is below, while preserving the roaming preference. Edge plus normalized vertical position keeps the durable fallback home stable across rotation, resizing, and RTL. The menu and TalkBack custom actions can move, reset, configure, pause roaming, or hide it.
- **Opt-in, route-aware roaming** — off by default. Screens deliberately register curated live-measured ledges and obstacles; Android does not scan arbitrary elements or infer safe geometry from accessibility semantics. Chat registers the composer, newest settled bubble, and eligible older visible message rails, Terminal registers its extra-keys toolbar, root Settings registers its summary/category card tops, Appearance registers its section-card tops, and Settings/Appearance/About expose the persistent bottom status strip. When settled at Chat's bottom, the pet prefers a text-free side pocket beside the latest bubble, uses the raised bubble top when its scaled footprint cannot fit beside it, then falls back to the outer composer corner; every transition starts at the live coordinate instead of teleporting. Curated non-chat surfaces use the same bounded planner for a multi-level out-and-back tour; a pet that lands on an upper card may route downward, while its numbered debug loop shows the exact selected stops and reverse return. Settings terrain can expand dynamically through the same measured registry, but each screen/card/header remains an explicit safe opt-in with current scroll/modal state—every arbitrary element never becomes terrain automatically. Registered bubble interiors remain protected obstacles: autonomous movement may use only a derived outer edge, top rail, or touchdown with exact collision-validated endpoints. When no exact bounded route validates, the pet waits at its current safe point instead of projecting or snapping onto invented terrain. If layout has already placed it inside a newly measured obstacle, a separately labeled recovery may use only the shortest bounded straight egress to a clear edge, then stops without authorizing further travel. No route inserts a spacer or takes text layout space. Chat's scroll-to-bottom button and Terminal's jump-to-latest control trim or block only the rail segments they occupy. During active scrolling, autonomous travel pauses while the pet lifts slightly and follows its ledge's live measured position. If support leaves the safe viewport, the pet retains its last safe screen coordinate in the falling state and, after scrolling settles, lands on the nearest valid visible lower rail or jumps to the nearest remaining rail when an exact recovery route exists. Recovery cleanup is cancellation-safe, so transient controls or changing measurements cannot leave roaming gated. App-wide interaction-layer ownership suppresses the companion while any platform dialog/modal window or registered same-window overlay is active; returning to a supported route replans from the live position. Other routes keep it docked.
+6 -6
View File
@@ -1,9 +1,9 @@
[versions]
appVersionName = "1.9.1"
appVersionCode = "44"
appVersionName = "1.10.0"
appVersionCode = "45"
agp = "9.3.1"
kotlin = "2.4.10"
compose-bom = "2026.06.01"
compose-bom = "2026.08.00"
navigation-compose = "2.9.8"
okhttp = "5.4.0"
kotlinx-serialization = "1.11.0"
@@ -16,7 +16,7 @@ tink-android = "1.23.0"
lifecycle = "2.11.0"
activity-compose = "1.13.0"
browser = "1.10.0"
appcompat = "1.7.1"
appcompat = "1.8.0"
core-ktx = "1.19.0"
exifinterface = "1.4.2"
datastore = "1.2.1"
@@ -27,11 +27,11 @@ haze = "1.7.2"
mlkit-barcode = "17.3.0"
zxing-core = "3.5.4"
camera = "1.6.1"
play-publisher = "4.0.0"
play-publisher = "4.1.1"
media3 = "1.11.0"
androidVad = "2.0.10"
sherpaOnnx = "v1.13.4"
onnxRuntime = "1.28.0"
onnxRuntime = "1.29.0"
spatialsdk = "0.13.2"
play-app-update = "2.1.0"