Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this

### Fixed

- Android avoids redundant conversation history in Gateway send/recovery responses and pauses automatic conversation reopening after a recorded out-of-memory crash, preserving saved conversations and drafts. (#700)
- Android preserves native Dashboard sign-in during reverse-proxy access failures and retries refresh on a later request when the route is reachable again. (#711)
- Secure Link accepts PEM certificate chains, exports only the leaf certificate for pairing, and reports invalid certificate/key configurations without exposing private file paths. (#712)
- Android keeps ONNX Runtime on 1.27.0 so sherpa-onnx native libs match the packaged runtime ABI.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.hermesandroid.relay.viewmodel

import android.graphics.Bitmap
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.test.platform.app.InstrumentationRegistry
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.reliability.ReliabilityCenter
import com.hermesandroid.relay.ui.components.CrashReportGate
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.util.CrashReporter
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class ChatMemoryRecoveryInstrumentedTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()

@Test
fun memoryReportDismissalAndLifecycleDoNotReopenHistoryUntilSelection() {
val reads = AtomicInteger()
val owner = AgentDisplay.profileContextKey("fixture", null)
lateinit var vm: ChatViewModel
compose.runOnUiThread {
val context = compose.activity
ReliabilityCenter.recordFatal(context, OutOfMemoryError("synthetic bounded recovery test"), "fixture")
vm = ChatViewModel().also {
it.pauseAutomaticSessionRestoreAfterMemoryFailure(
CrashReporter.isMemoryFailure(CrashReporter.peekPending(context)),
)
it.initialize(null, ChatHandler())
it.streamingEndpoint = "gateway"
it.setProfileMessageLoader {
reads.incrementAndGet()
Result.success(listOf(MessageItem(id = "row", role = "assistant", content = JsonPrimitive("Saved answer"))))
}
it.switchProfileContext(owner, "stored-long")
}
}
compose.setContent {
val messages by vm.messages.collectAsStateWithLifecycle()
HermesRelayTheme(themePreference = "dark") {
Column {
Button(onClick = { vm.switchSession("stored-long") }) { Text("Open saved conversation") }
messages.forEach { Text(it.content) }
}
CrashReportGate()
}
}
val explanation = compose.activity.getString(R.string.crash_memory_recovery_body)
compose.onNodeWithText(explanation).assertIsDisplayed()
compose.onNode(isDialog()).captureToImage().asAndroidBitmap().let { bitmap ->
val output = InstrumentationRegistry.getArguments().getString("additionalTestOutputDir")
?.let(::File) ?: compose.activity.cacheDir
output.mkdirs()
File(output, "memory-recovery-dialog.png").outputStream().use {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
}
}
compose.onNodeWithText(compose.activity.getString(R.string.common_dismiss)).performClick()
compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.runOnUiThread { vm.reconcileProfileContext(owner, "stored-long") }
compose.waitForIdle()
assertEquals(0, reads.get())
compose.onNodeWithText("Open saved conversation").performClick()
compose.waitUntil(5_000) { reads.get() == 1 }
compose.onNodeWithText("Saved answer").assertIsDisplayed()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1430,6 +1430,7 @@ class GatewayChatClient(
"session.activate",
buildJsonObject {
put("session_id", preferredLiveId)
put("omit_messages", true)
},
)
response = activated.getOrNull()
Expand Down Expand Up @@ -1457,6 +1458,7 @@ class GatewayChatClient(
put("session_id", storedId)
put("cols", DEFAULT_COLS)
put("source", sessionSource)
put("omit_messages", true)
requestedProfile?.let { put("profile", it) }
},
).getOrElse { error ->
Expand Down Expand Up @@ -3645,6 +3647,8 @@ class GatewayChatClient(
put("session_id", requestedStoredId)
put("cols", DEFAULT_COLS)
put("source", sessionSource)
// Visible history comes from Dashboard HTTP, not this binding response.
put("omit_messages", true)
requestedProfile?.let { put("profile", it) }
},
)
Expand Down Expand Up @@ -4544,7 +4548,10 @@ class GatewayChatClient(
val socket = webSocket ?: throw GatewayRpcException("gateway socket changed")
val activated = rpc(
"session.activate",
buildJsonObject { put("session_id", preservedLiveId) },
buildJsonObject {
put("session_id", preservedLiveId)
put("omit_messages", true)
},
expectedConnection = ready,
expectedSocket = socket,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ class HermesProcessRuntime internal constructor(

val chatViewModel: ChatViewModel by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
viewModelProvider[ChatViewModel::class.java].also { chat ->
chat.pauseAutomaticSessionRestoreAfterMemoryFailure(
com.hermesandroid.relay.util.CrashReporter.pauseAutomaticSessionRestore,
)
chat.installComposerDraftStore(
PersistentChatComposerDraftStore(
File(application.noBackupFilesDir, "chat-composer-drafts"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,10 @@ private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit)

Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.crash_body),
text = stringResource(
if (CrashReporter.isMemoryFailure(report)) R.string.crash_memory_recovery_body
else R.string.crash_body,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/kotlin/com/hermesandroid/relay/util/CrashReporter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,18 @@ object CrashReporter {
@Volatile
private var installed = false

/** Latched before UI/report dismissal; acknowledging a report cannot reopen the failed chat. */
var pauseAutomaticSessionRestore: Boolean = false
private set

fun install(context: Context) {
ReliabilityCenter.initialize(context)
migrateLegacy(context)
if (installed) return
installed = true
pauseAutomaticSessionRestore = runCatching {
isMemoryFailure(ReliabilityCenter.pendingCrash(context))
}.getOrDefault(false)
val appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Expand All @@ -52,6 +59,9 @@ object CrashReporter {
return ReliabilityCenter.pendingCrash(context)
}

fun isMemoryFailure(report: ReliabilityReport?): Boolean =
report?.summary?.let { it == "OutOfMemoryError" || it.startsWith("OutOfMemoryError:") } == true

fun clearPending(context: Context, reportId: String? = null) {
val target = reportId ?: peekPending(context)?.reportId ?: return
ReliabilityCenter.markReviewed(context, target)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,12 @@ class ChatViewModel : ViewModel() {
)
private var deferredGatewayPrewarm: DeferredGatewayPrewarm? = null
private var automaticGatewayPrewarmBlocked = false
private var memoryRecoverySelectionRequired = false

/** Startup-only latch. Keep saved session slots, drafts and checkpoints until explicit selection. */
fun pauseAutomaticSessionRestoreAfterMemoryFailure(paused: Boolean) {
memoryRecoverySelectionRequired = paused
}
private var imageActivityJob: Job? = null
private val historyLoadGeneration = AtomicInteger(0)
private val sessionRefreshGeneration = AtomicInteger(0)
Expand Down Expand Up @@ -1769,6 +1775,7 @@ class ChatViewModel : ViewModel() {
return@launch
}
if (startingSessionId == null) {
memoryRecoverySelectionRequired = false
handler.setSessionId(preparedSessionId)
selectBackgroundProcessSession(preparedSessionId)
gatewayProcessController.sessionReady(preparedSessionId)
Expand Down Expand Up @@ -5055,6 +5062,7 @@ class ChatViewModel : ViewModel() {
sessionId: String,
): Boolean {
if (!selectConversationProfile(profileName, profile)) return false
memoryRecoverySelectionRequired = false
if (activeProfileContextKey == contextKey && chatHandler?.currentSessionId?.value == sessionId &&
currentSessionProfileName() == profileName
) {
Expand Down Expand Up @@ -5091,6 +5099,7 @@ class ChatViewModel : ViewModel() {
contextKey: String,
): Boolean {
if (!selectConversationProfile(profileName, profile)) return false
memoryRecoverySelectionRequired = false
// A provisional phone Thread belongs to its original connection/chat_id
// and cannot transfer to another profile. Exit it before binding or
// persisting the destination draft so the next send uses session.create.
Expand Down Expand Up @@ -5147,7 +5156,7 @@ class ChatViewModel : ViewModel() {

fun switchProfileContext(contextKey: String, sessionId: String?) {
clearOpenedSessionOwner()
switchProfileContextInternal(contextKey, sessionId)
switchProfileContextInternal(contextKey, sessionId.takeUnless { memoryRecoverySelectionRequired })
}

/**
Expand All @@ -5159,7 +5168,9 @@ class ChatViewModel : ViewModel() {
* profile/session tuple, the reducer converges it to ordinary global state.
*/
fun reconcileProfileContext(contextKey: String, sessionId: String?) {
switchProfileContextInternal(contextKey, sessionId, reconciliation = true)
switchProfileContextInternal(
contextKey, sessionId.takeUnless { memoryRecoverySelectionRequired }, reconciliation = true,
)
}

private fun switchProfileContextInternal(
Expand Down Expand Up @@ -5622,6 +5633,9 @@ class ChatViewModel : ViewModel() {
}

private fun automaticGatewayWorkDeferred(sessionId: String?): Boolean {
// The empty recovery view still needs socket readiness. Only the saved
// session must stay detached until an explicit selection.
if (memoryRecoverySelectionRequired && sessionId != null) return true
// The directory barrier exists only when this connection exposes a
// Dashboard-backed, profile-scoped session directory. Legacy/test
// Gateway bindings without that surface must retain socket readiness
Expand Down Expand Up @@ -5977,6 +5991,8 @@ class ChatViewModel : ViewModel() {
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.newChat) return
val handler = chatHandler ?: return
recordPreResetEvidence(handler, "new_chat")
if (memoryRecoverySelectionRequired) persistFreshDraft(currentSessionProfileName())
memoryRecoverySelectionRequired = false
// A new chat clears only the durable session identity. Keep the bound
// profile/context so an All Profiles conversation becomes a fresh
// draft for that same owner instead of falling back to the globally
Expand Down Expand Up @@ -6229,6 +6245,7 @@ class ChatViewModel : ViewModel() {
val handler = chatHandler ?: return
dismissChatFailure()
if (streamingEndpoint != "gateway" && apiClient == null) return
memoryRecoverySelectionRequired = false
exitProvisionalThread()

// Keep a Gateway sibling alive and detach its callbacks. SSE remains a
Expand Down Expand Up @@ -8213,6 +8230,7 @@ class ChatViewModel : ViewModel() {
handler: ChatHandler,
sessionId: String,
): Boolean {
if (memoryRecoverySelectionRequired) return false
val contextKey = activeProfileContextKey ?: return false
val key = TurnCheckpointKey(contextKey, sessionId)
val checkpoint = backgroundTurnCheckpoints.remove(key)
Expand Down Expand Up @@ -9104,6 +9122,11 @@ class ChatViewModel : ViewModel() {
isolateComposer: Boolean = false,
) {
AppAnalytics.onMessageSent()
// Sending from the recovery view explicitly chooses a fresh draft. Clear
// only its remembered selection, or a later readiness emission could
// reopen the old conversation while the new session is being created.
if (memoryRecoverySelectionRequired) persistFreshDraft(currentSessionProfileName())
memoryRecoverySelectionRequired = false
val displayText = text.trim()
val outboundText = transportText.trim()
val interfaceContextPrompt = if (isolateComposer) {
Expand Down Expand Up @@ -9292,6 +9315,8 @@ class ChatViewModel : ViewModel() {

fun startRealtimeAgentTurn(userText: String, chatSessionId: String?): String {
val handler = chatHandler ?: return UUID.randomUUID().toString()
if (memoryRecoverySelectionRequired && chatSessionId == null) persistFreshDraft(currentSessionProfileName())
memoryRecoverySelectionRequired = false
AppAnalytics.onMessageSent()
val trimmed = userText.trim().ifBlank { "Listening..." }
val userMessageId = UUID.randomUUID().toString()
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-b+pt+BR/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4475,4 +4475,5 @@
<string name="paired_devices_policy_failed">Não foi possível reduzir a duração da sessão. Atualize as sessões do Relay e tente uma duração menor, ou pareie novamente com um novo código aprovado pelo operador para renovar o acesso.</string>
<string name="paired_devices_updating">Atualizando sessão…</string>
<string name="paired_devices_no_shorter_duration">Não há uma duração predefinida mais curta. Você pode revogar esta sessão ou parear novamente para renovar o acesso.</string>
<string name="crash_memory_recovery_body">O aplicativo ficou sem memória. A reabertura automática de conversas está pausada nesta inicialização. Feche este relatório e escolha uma conversa em Sessões ou inicie um novo chat. As conversas e os rascunhos salvos não foram excluídos. O Hermes pode continuar trabalhando.</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-b+zh+Hans/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4556,4 +4556,5 @@
<string name="paired_devices_policy_failed">无法缩短会话有效期。请刷新 Relay 会话并尝试更短的时长,或使用经操作员批准的新配对码重新配对以续期访问权限。</string>
<string name="paired_devices_updating">正在更新会话…</string>
<string name="paired_devices_no_shorter_duration">没有更短的预设时长。你可以撤销此会话,或重新配对以续期访问权限。</string>
<string name="crash_memory_recovery_body">应用内存不足。本次启动已暂停自动重新打开对话。关闭此报告后,请从“会话”中选择一个对话或开始新聊天。已保存的对话和草稿未被删除。Hermes 上的任务可能仍在运行。</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4632,4 +4632,5 @@
<string name="paired_devices_policy_failed">Die Sitzung konnte nicht verkürzt werden. Aktualisiere die Relay-Sitzungen und versuche eine kürzere Dauer, oder kopple erneut mit einem neuen, vom Betreiber genehmigten Code, um den Zugriff zu erneuern.</string>
<string name="paired_devices_updating">Sitzung wird aktualisiert…</string>
<string name="paired_devices_no_shorter_duration">Keine kürzere Vorgabe verfügbar. Du kannst diese Sitzung widerrufen oder zur Erneuerung erneut koppeln.</string>
<string name="crash_memory_recovery_body">Der App ist der Arbeitsspeicher ausgegangen. Das automatische Wiederöffnen von Unterhaltungen ist für diesen Start pausiert. Schließe diesen Bericht und wähle dann unter Sitzungen eine Unterhaltung aus oder starte einen neuen Chat. Gespeicherte Unterhaltungen und Entwürfe wurden nicht gelöscht. Aufgaben auf Hermes können noch laufen.</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4323,4 +4323,5 @@
<string name="paired_devices_policy_failed">No se pudo acortar la sesión. Actualiza las sesiones de Relay y prueba una duración menor, o vuelve a emparejar con un nuevo código aprobado por el operador para renovar el acceso.</string>
<string name="paired_devices_updating">Actualizando sesión…</string>
<string name="paired_devices_no_shorter_duration">No queda ninguna duración predefinida más corta. Puedes revocar esta sesión o volver a emparejar para renovar el acceso.</string>
<string name="crash_memory_recovery_body">La aplicación se quedó sin memoria. La reapertura automática de conversaciones está pausada durante este inicio. Cierra este informe y elige una conversación en Sesiones o inicia un chat nuevo. No se han eliminado las conversaciones ni los borradores guardados. Hermes podría seguir trabajando.</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-ja/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4627,4 +4627,5 @@
<string name="paired_devices_policy_failed">セッションの期間を短縮できませんでした。Relay セッションを更新してより短い期間を試すか、オペレーターが承認した新しいコードで再度ペアリングしてアクセスを更新してください。</string>
<string name="paired_devices_updating">セッションを更新中…</string>
<string name="paired_devices_no_shorter_duration">これより短い選択肢はありません。このセッションを取り消すか、再度ペアリングしてアクセスを更新できます。</string>
<string name="crash_memory_recovery_body">アプリのメモリが不足しました。今回は会話を自動で開き直しません。このレポートを閉じてから、セッションで会話を選ぶか、新しいチャットを開始してください。保存済みの会話と下書きは削除されていません。Hermes では処理が続いている可能性があります。</string>
</resources>
Loading
Loading