Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97231eb291 | ||
|
|
f7c707be03 | ||
|
|
331fad0827 | ||
|
|
f131fa08cc | ||
|
|
cbc81513bd | ||
|
|
3ad5ad8dc7 | ||
|
|
d695553c0b | ||
|
|
6b324fa822 | ||
|
|
52ee97f2b5 | ||
|
|
76bfe97c78 | ||
|
|
c8a3072704 | ||
|
|
a7612d7f05 | ||
|
|
e82ed47573 | ||
|
|
c6e4a2877d | ||
|
|
79335fea16 | ||
|
|
9c8b6c30bf | ||
|
|
4cf89df627 | ||
|
|
ad98ca9486 | ||
|
|
9d50f401ad | ||
|
|
2bc62c84e0 | ||
|
|
c3011e286c | ||
|
|
9458fea4f7 | ||
|
|
94b29c4a01 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-5
@@ -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) {
|
||||
|
||||
+12
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+111
@@ -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
|
||||
|
||||
+10
-67
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+86
-10
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+51
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user