Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dcb280eff | ||
|
|
a90067292a | ||
|
|
4447886e80 | ||
|
|
5d00885291 | ||
|
|
cce4b9b1d6 | ||
|
|
d3a4bd5423 | ||
|
|
20b75d9454 | ||
|
|
d04264a224 | ||
|
|
ee30ec4bf7 | ||
|
|
111c5409bf | ||
|
|
03ce9aa8d5 | ||
|
|
41c2d10700 | ||
|
|
c508392261 | ||
|
|
e9c0620d24 | ||
|
|
217746a243 | ||
|
|
a40e6a5b01 | ||
|
|
118bf07638 | ||
|
|
95f6721b06 | ||
|
|
ac023c0e24 | ||
|
|
cd7792918e | ||
|
|
e617809930 | ||
|
|
79532c6c9b | ||
|
|
0af1cda38e | ||
|
|
454bd44e7a | ||
|
|
b92a40174e | ||
|
|
c6ab26ef13 | ||
|
|
414ade5fa0 |
@@ -63,7 +63,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Issue Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: "Issue number to label again"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: issue-triage-${{ github.event.issue.number || github.event.inputs.issue_number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
auto-label:
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issues' && github.event.issue.user.type != 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Label from title prefix and issue area
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue_number }}
|
||||
with:
|
||||
script: |
|
||||
const issue_number = Number(process.env.ISSUE_NUMBER);
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number,
|
||||
});
|
||||
const title = (issue.title || '').toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const haystack = `${title}\n${body}`;
|
||||
const labels = [];
|
||||
|
||||
if (title.startsWith('[bug]')) labels.push('bug');
|
||||
else if (title.startsWith('[feature]') || title.startsWith('[feat]')) labels.push('enhancement');
|
||||
else if (title.startsWith('[docs]')) labels.push('documentation');
|
||||
|
||||
if (/\b(cli|desktop|terminal|daemon|pty|hermes-relay (install|binary|tray))\b/.test(haystack)) labels.push('area:cli');
|
||||
else if (/\b(dashboard|plugin ui|react)\b/.test(haystack)) labels.push('area:dashboard');
|
||||
else if (/\b(relay|plugin|aiohttp|python|pairing|voice (transcribe|synthesize)|bridge (endpoint|route))\b/.test(haystack)) labels.push('area:plugin');
|
||||
else if (/\b(readme|user-?docs|documentation)\b/.test(haystack)) labels.push('area:docs');
|
||||
else if (/\b(android|app|compose|apk|phone|samsung|gradle|chat|voice|notification|sphere|keystore)\b/.test(haystack)) labels.push('area:android');
|
||||
|
||||
if (!labels.length) {
|
||||
core.info('No deterministic label matched; leaving the issue for maintainer triage.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number, labels,
|
||||
});
|
||||
core.info(`Applied labels: ${labels.join(', ')}`);
|
||||
} catch (error) {
|
||||
core.warning(`Could not apply ${labels.join(', ')}: ${error.message}`);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
@@ -191,6 +191,19 @@ jobs:
|
||||
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
|
||||
run: ./gradlew bundleRelease assembleRelease
|
||||
|
||||
# The Play AAB carries its mapping for Play Console deobfuscation, but
|
||||
# sideload issue reports need the exact mapping from this immutable build.
|
||||
# Keep both variants as a workflow artifact (not a public release asset).
|
||||
- name: Retain R8 mappings for retrace
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-r8-mappings-${{ needs.validate.outputs.version }}-${{ github.sha }}
|
||||
path: |
|
||||
app/build/outputs/mapping/googlePlayRelease/mapping.txt
|
||||
app/build/outputs/mapping/sideloadRelease/mapping.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
- name: Scan release DEX for unsupported collection APIs
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
|
||||
@@ -6,10 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Android support information is local, redacted, and reviewable.** Fatal crashes and handled failures share a bounded on-device record, Diagnostics can copy or share the exact reviewed text, and nothing is uploaded automatically.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android chat and Voice keep one render identity through recovery.** Checkpoint restore, streamed callbacks, server-ID adoption, and replay now resolve the same owned transcript row before publication, preventing recurring Compose duplicate-key crashes.
|
||||
- **Issue area labels require maintainer review.** The unreliable keyword-based auto-labeling workflow no longer assigns ownership from ambiguous issue text.
|
||||
- **Android crash reports retain actionable release context.** Reports identify the Android surface, avoid exposing hosts and credentials, migrate earlier local crash records, and release automation retains exact Play and sideload R8 mappings for retrace.
|
||||
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
|
||||
|
||||
## [Android 1.6.1] - 2026-08-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice capture waits for the microphone to be released.** Manual recording no longer races barge-in teardown, and AudioRecord startup failures now explain how to free or permit the microphone before retrying.
|
||||
- **Android text selection stays stable as streamed replies finish.** Chat resets an active selection when live text becomes rich Markdown, preventing selection-handle drags from retaining removed text nodes.
|
||||
- **Android session history follows the upstream page-size contract.** The drawer keeps its 200-session window through bounded 100-row requests, avoiding HTTP 422 errors from current dashboard servers while preserving active-profile isolation.
|
||||
- **Android no longer mistakes optional-surface auth failures for expired Relay pairing.** Background session refreshes stay out of the global snackbar, Dashboard and API authorization errors name their owning credential, and Relay-only surfaces use consistent Optional, Ready, Reconnecting, Unavailable, and Needs re-pair states. Foreground recovery retries ordinary Relay backoff immediately while preserving server rate limits, and recovery prioritizes Dashboard or host session management while retained credentials are labeled as stored details instead of active pairing.
|
||||
- **Voice controls no longer collide with new-chat coaching.** The clean-view hint yields while Voice owns the composer so it cannot cover the expanding Voice drawer.
|
||||
|
||||
## [1.5.1] - 2026-08-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Re-pairing repairs one device instead of accumulating duplicate sessions.** An explicit host-approved pair replaces older sessions and refresh credentials for the same device, while the Dashboard and `/relay revoke <token-prefix>` remain available for operator cleanup.
|
||||
|
||||
## [Android 1.6.0] - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-08-04 — Android transcript identity ownership
|
||||
|
||||
ChatHandler now owns one render identity for every published transcript row.
|
||||
Checkpoint recovery and all streamed message mutations resolve both the mutable
|
||||
server/domain ID and the stable UI identity, so history adoption cannot leave a
|
||||
stale client reference that appends a second row. The publication boundary also
|
||||
coalesces repeated render identities before Chat or Voice can observe them,
|
||||
while keeping the first transcript position and latest state.
|
||||
|
||||
Focused coverage composes history reconciliation with checkpoint restore,
|
||||
exercises stale post-adoption callbacks, and runs deterministic transition
|
||||
sequences across restore, replay, deltas, thinking, and usage updates. Voice's
|
||||
temporary transcript row now occupies an auxiliary key namespace disjoint from
|
||||
real message rows.
|
||||
|
||||
## 2026-08-04 — Android reliability and support foundation
|
||||
|
||||
Android fatal capture and centrally classified handled failures now converge on
|
||||
a versioned, allowlisted reliability record. Reports are redacted before local
|
||||
persistence, capped at 20 records with 14-day retention, written atomically,
|
||||
and correlated only with random app/report identifiers. Expected cancellation
|
||||
and permission denial remain non-reportable. The pre-existing one-file crash
|
||||
format migrates locally on first launch.
|
||||
|
||||
Crash recovery leads with the recovery outcome and no-upload guarantee, then
|
||||
requires an explicit review before copy, share, or GitHub actions. Diagnostics
|
||||
adds an offline support-information review using the same exact redacted text.
|
||||
Android issue prefills now request the Android area while repository-wide issue
|
||||
ownership remains maintainer-reviewed, and the release workflow retains both
|
||||
variant R8 mappings for deterministic retrace.
|
||||
|
||||
The architecture audit defers an ANR watchdog, richer allowlisted breadcrumbs,
|
||||
hashed product correlation, and OOM emergency writing until their lifecycle,
|
||||
privacy, and false-positive behavior can be validated on devices.
|
||||
|
||||
## 2026-08-02 — Android Russian localization
|
||||
|
||||
Android now ships complete Russian catalogs for the main and sideload builds.
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# Hermes-Relay-Server v__VERSION__
|
||||
|
||||
**Release Date:** August 2, 2026
|
||||
**Release Date:** August 3, 2026
|
||||
|
||||
This release adds Realtime Agent final-answer-only speech and Relay-hosted declarative plugin pages for Android.
|
||||
This patch makes intentional re-pairing repair the existing device record instead of accumulating duplicate Relay sessions.
|
||||
|
||||
Android clients can keep voice progress visual until the settled answer and review agent-created native plugin pages before keeping them. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
### Fixed
|
||||
|
||||
- **Realtime Agent final-answer-only speech.** Session creation accepts an optional `final_answer_only` flag. When enabled, the provider skips routine acknowledgements, spoken progress, service updates, and intermediate commentary, then speaks the settled Hermes answer. Approval and confirmation prompts, along with blocking failures, remain audible so required user action is not hidden.
|
||||
- **Agent-created declarative Android plugin pages.** New Relay tools create bounded JSON-only drafts and expose them through authenticated plugin routes. Android owns enablement, write grants, exact-revision approval, publication, and persistent removal. Generated pages reject executable code, arbitrary network requests, Android intents, traversal, symlink entries, oversized documents, and backend action requests.
|
||||
- **Re-pairing replaces stale credentials for the same device.** After the host approves a new pair, Relay revokes older sessions and refresh credentials that belong to that device before issuing the replacement.
|
||||
- **Existing and unrelated sessions remain operator-controlled.** The Dashboard and `/relay revoke <token-prefix>` continue to provide explicit cleanup without treating optional Relay pairing as a requirement.
|
||||
|
||||
## Install / update
|
||||
|
||||
|
||||
@@ -494,6 +494,14 @@ the new app version and a higher `appVersionCode`.
|
||||
in `app/build.gradle.kts`. Never rename the sideload APK — the
|
||||
in-app update checker matches assets by `.apk` + `sideload` in the
|
||||
name, and user-docs verify steps cite the filename.
|
||||
The release workflow also retains
|
||||
`app/build/outputs/mapping/{googlePlayRelease,sideloadRelease}/mapping.txt`
|
||||
for 90 days in the `android-r8-mappings-<version>-<sha>` workflow
|
||||
artifact. It is intentionally not a GitHub Release asset. To symbolicate an
|
||||
in-app or sideload report, download the artifact for the exact version/SHA and
|
||||
run Android's retrace tool with the matching flavor mapping:
|
||||
`retrace <mapping.txt> <obfuscated-trace.txt>`. Play reports can additionally
|
||||
use the mapping bundled into the uploaded AAB through Play Console.
|
||||
- `app/src/main/assets/whats_new.txt` — in-app "What's New" content
|
||||
shown in the settings/about screen. Update with the version number
|
||||
and a brief feature summary. Gets stale silently if forgotten
|
||||
|
||||
+12
-27
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay-Android v1.6.0
|
||||
# Hermes-Relay-Android v1.6.1
|
||||
|
||||
**Release Date:** August 2, 2026
|
||||
**Release Date:** August 3, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.6.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).
|
||||
> Installing on your phone? Download `hermes-relay-1.6.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).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,34 +12,19 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This minor release brings personality and extensibility to Android: floating Petdex companions can explore the interface, Hermes plugins can contribute safe native pages, and Hermes can become the optional Android Digital Assistant. Voice, route failover, live-list identity, and Russian localization are also substantially improved.
|
||||
|
||||
## Added
|
||||
|
||||
- Choose, preview, and install Petdex companions from Appearance, or import your own pet. Pets remain separate from agent avatars and the background Sphere.
|
||||
- Hold and drag a pet anywhere, or enable optional UI-aware roaming across measured chat bubbles, the composer, settings cards, and other safe ledges.
|
||||
- Open native Android pages contributed by installed Hermes plugins. Pages use a host-rendered declarative schema, and scoped writes remain disabled until explicitly granted.
|
||||
- Use Relay 1.5.0 to review, keep, or remove approval-gated agent-created plugin-page drafts.
|
||||
- Select Hermes as Android’s default Digital Assistant, with an optional local “Hey Hermes” listener that keeps pre-activation audio on the device.
|
||||
- Use the complete AI-assisted Russian Android catalog from the in-app language picker.
|
||||
|
||||
## Improved
|
||||
|
||||
- Assistant and floating Voice controls start compact, expand for transcript and response detail, and hand off to full Voice without restarting the turn.
|
||||
- Voice interruption now covers generation and playback with upstream-aligned calibration, stop phrases, and private next-turn interruption context.
|
||||
- The Agent Passport presents profile configuration, skills, routing, reasoning, and scoped credentials more clearly.
|
||||
- Pet roaming follows measured terrain, bubble edges, scroll movement, obstacle recovery, animation capabilities, temperament, and reduced-motion/accessibility pauses.
|
||||
This patch clarifies optional Relay recovery, restores the full session drawer, and fixes several chat and Voice regressions.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Streaming voice output falls back when no first audio arrives, and long Standard Voice recordings upload without duplicate in-memory encoding.
|
||||
- Relay failover no longer allows competing reconnect loops to bounce rapidly between LAN and remote routes.
|
||||
- Streamed chat rows retain stable UI identity while server IDs and background-process state reconcile.
|
||||
- Pets recover from occupied or scrolling terrain, avoid text and the jump-to-latest control, and preserve walk, jump, held, and drop animation states.
|
||||
- OEM assistant picker activation, local wake completion, and empty-speech recovery are reliable across the supported lifecycle.
|
||||
- Optional Relay failures stay within Relay-only surfaces, use consistent status labels, and only request re-pairing when the stored Relay credential actually needs it.
|
||||
- Foreground recovery reconnects immediately after ordinary backoff, while retained credentials are described as stored details rather than an active in-memory session.
|
||||
- Session history loads its 200-row drawer window through upstream-compatible 100-row pages instead of failing with HTTP 422.
|
||||
- Selecting text remains stable when a streamed response changes from live text to rendered Markdown.
|
||||
- Manual Voice recording waits for barge-in microphone teardown and gives a useful recovery message when the microphone is unavailable.
|
||||
- New-chat coaching yields while Voice owns the composer, so it no longer covers the expanding Voice drawer.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.6.0** (versionCode **37**).
|
||||
- App version: **1.6.1** (versionCode **38**).
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- Petdex installation is built into Android. Native pages from ordinary installed plugins use the authenticated Dashboard; agent-created page drafts require the optional Relay 1.5.0 plugin.
|
||||
- Same-device Relay re-pair replacement requires the optional Server 1.5.1 plugin; Dashboard and `/relay revoke <token-prefix>` remain available for explicit cleanup.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Add floating Petdex companions that can roam across the interface, safe native pages from installed Hermes plugins, and an optional Android Digital Assistant with local “Hey Hermes.” This release also adds Russian and improves voice recovery, route failover, live chat stability, and pet movement.
|
||||
Optional Relay recovery now stays in the right surfaces with clear status labels and immediate foreground retry. Session history again loads its full window through upstream-compatible paging. Streamed text selection, Voice microphone handoff, and the expanding Voice drawer are also more reliable.
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.6.1",
|
||||
"title": "Clearer recovery, steadier chat",
|
||||
"date": "2026-08-03",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Relay stays optional",
|
||||
"bullets": [
|
||||
"Relay-only surfaces now use consistent Optional, Ready, Reconnecting, Unavailable, and Needs re-pair states without nagging from background session refreshes.",
|
||||
"Foreground recovery retries ordinary reconnect backoff immediately and explains whether Relay credentials are merely stored or actually need re-pairing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Sessions and chat stay stable",
|
||||
"bullets": [
|
||||
"The session drawer restores its 200-row window through upstream-compatible 100-row pages.",
|
||||
"Selecting streamed text stays stable when a completed response changes to rendered Markdown."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice controls stay reachable",
|
||||
"bullets": [
|
||||
"Manual recording waits for the previous microphone owner to release it and gives a useful recovery message if capture cannot start.",
|
||||
"New-chat coaching yields while Voice owns the composer so it cannot cover the expanding Voice drawer."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.6.0",
|
||||
"title": "Pets, plugins, and voice",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
v1.6.0 - Pets, plugins, and voice
|
||||
v1.6.1 - Clearer recovery, steadier chat
|
||||
|
||||
* Add floating Petdex companions that can roam across the interface.
|
||||
* Add safe native pages contributed by installed Hermes plugins.
|
||||
* Use Hermes as the optional Android Digital Assistant with local “Hey Hermes.”
|
||||
* Add Russian and improve voice, routing, chat, and pet stability.
|
||||
* Keep optional Relay recovery scoped to Relay surfaces with clear status labels.
|
||||
* Restore the 200-session drawer through upstream-compatible paging.
|
||||
* Stabilize streamed text selection and Voice microphone handoff.
|
||||
* Keep new-chat coaching clear of the expanding Voice drawer.
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.hermesandroid.relay.diagnostics
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.hermesandroid.relay.reliability.ReliabilityCenter
|
||||
import com.hermesandroid.relay.reliability.ReliabilityRedactor
|
||||
|
||||
enum class DiagnosticCategory(val label: String) {
|
||||
Api("API"),
|
||||
@@ -124,6 +126,7 @@ object DiagnosticsLog {
|
||||
endpointRole: String? = null,
|
||||
url: String? = null,
|
||||
elapsedMs: Long? = null,
|
||||
reliabilityContext: String? = null,
|
||||
) {
|
||||
record(
|
||||
category = category,
|
||||
@@ -135,6 +138,17 @@ object DiagnosticsLog {
|
||||
elapsedMs = elapsedMs,
|
||||
stacktrace = throwable?.let { stackTraceText(it) },
|
||||
)
|
||||
if (throwable != null) {
|
||||
runCatching {
|
||||
ReliabilityCenter.recordHandled(
|
||||
title = title,
|
||||
detail = detail ?: throwable.message,
|
||||
throwable = throwable,
|
||||
context = reliabilityContext,
|
||||
routeRole = endpointRole,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stackTraceText(t: Throwable): String =
|
||||
@@ -167,10 +181,8 @@ object DiagnosticsLog {
|
||||
val prefix = noQuery.substring(0, schemeEnd + 3)
|
||||
val rest = noQuery.substring(schemeEnd + 3)
|
||||
val slash = rest.indexOf('/').let { if (it < 0) rest.length else it }
|
||||
val authority = rest.substring(0, slash)
|
||||
val path = rest.substring(slash)
|
||||
val safeAuthority = authority.substringAfterLast('@')
|
||||
prefix + safeAuthority + path
|
||||
prefix + "[host]" + path
|
||||
} else {
|
||||
noQuery
|
||||
}
|
||||
@@ -197,7 +209,7 @@ object DiagnosticsLog {
|
||||
*/
|
||||
private fun redactTrace(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val redacted = redact(trimmed)
|
||||
val redacted = ReliabilityRedactor.redact(trimmed, MAX_TRACE_LENGTH)
|
||||
return if (redacted.length > MAX_TRACE_LENGTH) {
|
||||
redacted.take(MAX_TRACE_LENGTH) + "\n… (truncated)"
|
||||
} else {
|
||||
@@ -205,8 +217,5 @@ object DiagnosticsLog {
|
||||
}
|
||||
}
|
||||
|
||||
private fun redact(value: String): String =
|
||||
value.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
|
||||
"${it.groupValues[1]}=[hidden]"
|
||||
}
|
||||
private fun redact(value: String): String = ReliabilityRedactor.redact(value, MAX_TRACE_LENGTH)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.auth.CertPinStore
|
||||
@@ -20,6 +21,7 @@ import com.hermesandroid.relay.network.shared.EndpointSurface
|
||||
import com.hermesandroid.relay.network.shutdownOffMainThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -45,6 +47,20 @@ enum class ConnectionState {
|
||||
Reconnecting
|
||||
}
|
||||
|
||||
internal fun isRelayRateLimitBackoffActive(untilMs: Long, nowMs: Long): Boolean =
|
||||
untilMs > nowMs
|
||||
|
||||
internal fun canOverrideScheduledRelayReconnect(
|
||||
state: ConnectionState,
|
||||
backoffWaiting: Boolean,
|
||||
rateLimitBackoffActive: Boolean,
|
||||
): Boolean = !rateLimitBackoffActive && when (state) {
|
||||
ConnectionState.Disconnected -> true
|
||||
ConnectionState.Reconnecting -> backoffWaiting
|
||||
ConnectionState.Connecting,
|
||||
ConnectionState.Connected -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an OkHttp request for a relay socket URL, or `null` if the URL is
|
||||
* malformed. OkHttp's [Request.Builder.url] throws [IllegalArgumentException]
|
||||
@@ -163,6 +179,10 @@ class ConnectionManager(
|
||||
@Volatile
|
||||
private var serverUrl: String? = null
|
||||
private val reconnectState = RelayReconnectState()
|
||||
@Volatile
|
||||
private var reconnectJob: Job? = null
|
||||
@Volatile
|
||||
private var reconnectBackoffWaiting = false
|
||||
private var shouldReconnect = true
|
||||
// Last HTTP status seen during WSS upgrade, captured in onFailure.
|
||||
// Used by scheduleReconnect() to pick an appropriate backoff — notably
|
||||
@@ -170,6 +190,8 @@ class ConnectionManager(
|
||||
// we don't re-fill the ban bucket and brick our own auth window.
|
||||
@Volatile
|
||||
private var lastUpgradeResponseCode: Int? = null
|
||||
@Volatile
|
||||
private var rateLimitBackoffUntilMs: Long = 0L
|
||||
|
||||
// The relay requires the FIRST frame on a socket to be `system/auth` and
|
||||
// rejects the whole connection otherwise ("expected system/auth, got
|
||||
@@ -364,6 +386,41 @@ class ConnectionManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an ordinary scheduled reconnect with an immediate attempt.
|
||||
*
|
||||
* Foregrounding the app or opening Relay status is an explicit signal that
|
||||
* the route may be usable again, so exponential/slow-poll backoff should not
|
||||
* make the user wait. A server-issued 429 is different: retrying early would
|
||||
* extend the server block, so that protected backoff is never overridden.
|
||||
*/
|
||||
fun reconnectNowIfAllowed(url: String): Boolean {
|
||||
val rateLimitActive = isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
if (!canOverrideScheduledRelayReconnect(
|
||||
state = _connectionState.value,
|
||||
backoffWaiting = reconnectBackoffWaiting,
|
||||
rateLimitBackoffActive = rateLimitActive,
|
||||
)
|
||||
) {
|
||||
if (rateLimitActive) {
|
||||
Log.i(TAG, "reconnectNowIfAllowed: preserving rate-limit backoff")
|
||||
}
|
||||
return false
|
||||
}
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
// connectToUrlOnMainPath suppresses duplicate opens while the manager is
|
||||
// Reconnecting. Move to the honest idle state before starting the fresh
|
||||
// resolver/open path; the ViewModel's grace window prevents UI flicker.
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
connect(url)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [connect] but bypasses the resolver — used by the network-
|
||||
* change callback when we've already picked a winner and just want to
|
||||
@@ -405,6 +462,14 @@ class ConnectionManager(
|
||||
// hits the HTTP root and comes back as 404 Not Found during the
|
||||
// upgrade handshake. We still accept an explicit path if present.
|
||||
val normalized = normalizeRelayUrl(url)
|
||||
if (isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
) {
|
||||
Log.i(TAG, "connect: preserving active rate-limit backoff")
|
||||
return
|
||||
}
|
||||
val existingState = _connectionState.value
|
||||
if (serverUrl == normalized &&
|
||||
(existingState == ConnectionState.Connecting ||
|
||||
@@ -682,10 +747,30 @@ class ConnectionManager(
|
||||
if (relayResolved != null) activeRelayEndpoint = relayResolved
|
||||
val relayUrl = relayResolved?.relay?.url?.takeIf { it.isNotBlank() }
|
||||
?: return@launch
|
||||
if (isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
) {
|
||||
// Keep publishing the newly resolved standard/Relay routes, but
|
||||
// leave the protected retry job intact. It will resolve the
|
||||
// latest Relay winner again when the server cooldown expires.
|
||||
Log.i(TAG, "network change: preserving rate-limit retry job")
|
||||
return@launch
|
||||
}
|
||||
val normalizedNew = normalizeRelayUrl(relayUrl)
|
||||
if (normalizedNew != current) {
|
||||
Log.i(TAG, "network change: swapping $current → $normalizedNew")
|
||||
connectToUrlOnMainPath(relayUrl, closeReason)
|
||||
if (reconnectBackoffWaiting) {
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
}
|
||||
connectToUrlOnMainPath(
|
||||
relayUrl,
|
||||
closeReason,
|
||||
preserveReconnectBackoff = true,
|
||||
)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
reconnectGate()
|
||||
) {
|
||||
@@ -783,6 +868,11 @@ class ConnectionManager(
|
||||
|
||||
fun disconnect() {
|
||||
shouldReconnect = false
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
rateLimitBackoffUntilMs = 0L
|
||||
lastUpgradeResponseCode = null
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
@@ -838,13 +928,22 @@ class ConnectionManager(
|
||||
url: String,
|
||||
previousSocketToClose: WebSocket? = null,
|
||||
replaceReason: String = "Relay socket replaced",
|
||||
scheduledReconnect: Boolean = false,
|
||||
) {
|
||||
if (isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
) {
|
||||
Log.i(TAG, "doConnect: preserving active rate-limit backoff")
|
||||
return
|
||||
}
|
||||
val existingState = _connectionState.value
|
||||
if (previousSocketToClose == null &&
|
||||
serverUrl == url &&
|
||||
(existingState == ConnectionState.Connecting ||
|
||||
existingState == ConnectionState.Connected ||
|
||||
existingState == ConnectionState.Reconnecting)
|
||||
(existingState == ConnectionState.Reconnecting && !scheduledReconnect))
|
||||
) {
|
||||
Log.i(TAG, "doConnect: already ${existingState.name.lowercase()} to $url — skipping duplicate open")
|
||||
return
|
||||
@@ -908,6 +1007,10 @@ class ConnectionManager(
|
||||
return
|
||||
}
|
||||
reconnectState.connected(url)
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
rateLimitBackoffUntilMs = 0L
|
||||
lastUpgradeResponseCode = null
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
Log.i(TAG, "onOpen: WSS handshake complete ($url)")
|
||||
@@ -1067,6 +1170,7 @@ class ConnectionManager(
|
||||
// block window instead of re-filling the ban bucket at our normal
|
||||
// cadence.
|
||||
lastUpgradeResponseCode == 429 -> {
|
||||
rateLimitBackoffUntilMs = SystemClock.elapsedRealtime() + RATE_LIMIT_BACKOFF_MS
|
||||
Log.i(TAG, "scheduleReconnect: rate-limited (429) — backing off ${RATE_LIMIT_BACKOFF_MS}ms")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
@@ -1104,8 +1208,11 @@ class ConnectionManager(
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
reconnectJob?.cancel()
|
||||
reconnectBackoffWaiting = true
|
||||
val scheduledJob = scope.launch {
|
||||
delay(backoffMs)
|
||||
reconnectBackoffWaiting = false
|
||||
// Re-check the gate after the backoff — by the time the delay
|
||||
// expires, auth state may have changed (e.g., user hit Revoke
|
||||
// during the retry window).
|
||||
@@ -1128,12 +1235,19 @@ class ConnectionManager(
|
||||
preserveReconnectBackoff = true,
|
||||
)
|
||||
} else {
|
||||
doConnect(url)
|
||||
doConnect(url, scheduledReconnect = true)
|
||||
}
|
||||
} else if (!reconnectGate()) {
|
||||
Log.i(TAG, "scheduleReconnect: gate turned false during backoff — aborting retry")
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
reconnectJob = scheduledJob
|
||||
scheduledJob.invokeOnCompletion {
|
||||
if (reconnectJob === scheduledJob) {
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,8 +218,14 @@ class ChatHandler {
|
||||
*/
|
||||
private val activeAnnotationTools = mutableMapOf<String, String>()
|
||||
|
||||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||
// All Chat and Voice transcript publications pass through this invariant
|
||||
// boundary. A caller may address a row by its mutable server/domain id or
|
||||
// its retained client uiKey, but Compose must never observe both aliases.
|
||||
private val _messages = RenderedMessageState(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.flow
|
||||
|
||||
private fun ChatMessage.matchesIdentity(reference: String): Boolean =
|
||||
id == reference || uiKey == reference
|
||||
|
||||
/**
|
||||
* Latest gateway `status.update` lifecycle line for the in-flight turn
|
||||
@@ -324,8 +330,8 @@ class ChatHandler {
|
||||
// message.started can replace that domain id with the server id while
|
||||
// uiKey deliberately retains the client id. Resolve the current domain
|
||||
// id before every event so the tail keeps mutating the same visible row.
|
||||
val currentMessageId = _messages.value.findLast { message ->
|
||||
message.id == messageId || message.uiKey == messageId
|
||||
val currentMessageId = _messages.value.findLast {
|
||||
it.matchesIdentity(messageId)
|
||||
}?.id ?: messageId
|
||||
|
||||
when (envelope.event) {
|
||||
@@ -392,7 +398,7 @@ class ChatHandler {
|
||||
*/
|
||||
fun updateDeliveryStatus(messageId: String, status: MessageDeliveryStatus) {
|
||||
_messages.update { list ->
|
||||
list.map { if (it.id == messageId) it.copy(deliveryStatus = status) else it }
|
||||
list.map { if (it.matchesIdentity(messageId)) it.copy(deliveryStatus = status) else it }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +406,7 @@ class ChatHandler {
|
||||
fun setBackgroundTask(messageId: String, task: BackgroundTaskState) {
|
||||
_messages.update { list ->
|
||||
list.map { message ->
|
||||
if (message.id == messageId) message.copy(backgroundTask = task) else message
|
||||
if (message.matchesIdentity(messageId)) message.copy(backgroundTask = task) else message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,7 +418,7 @@ class ChatHandler {
|
||||
) {
|
||||
_messages.update { list ->
|
||||
list.map { message ->
|
||||
if (message.id == messageId && message.backgroundTask != null) {
|
||||
if (message.matchesIdentity(messageId) && message.backgroundTask != null) {
|
||||
message.copy(backgroundTask = transform(message.backgroundTask))
|
||||
} else {
|
||||
message
|
||||
@@ -493,7 +499,7 @@ class ChatHandler {
|
||||
*/
|
||||
fun appendAskCardMessage(messageId: String, card: HermesCard) {
|
||||
_messages.update { list ->
|
||||
if (list.any { it.id == messageId }) return@update list
|
||||
if (list.any { it.matchesIdentity(messageId) }) return@update list
|
||||
val msg = ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
@@ -515,7 +521,7 @@ class ChatHandler {
|
||||
*/
|
||||
fun truncateMessagesFrom(messageId: String) {
|
||||
_messages.update { list ->
|
||||
val idx = list.indexOfFirst { it.id == messageId }
|
||||
val idx = list.indexOfFirst { it.matchesIdentity(messageId) }
|
||||
if (idx < 0) list else list.take(idx)
|
||||
}
|
||||
}
|
||||
@@ -523,7 +529,7 @@ class ChatHandler {
|
||||
fun replaceMessageContent(messageId: String, content: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { message ->
|
||||
if (message.id == messageId) {
|
||||
if (message.matchesIdentity(messageId)) {
|
||||
message.copy(content = content)
|
||||
} else {
|
||||
message
|
||||
@@ -543,8 +549,9 @@ class ChatHandler {
|
||||
content: String,
|
||||
) {
|
||||
_messages.update { messages ->
|
||||
val interim = messages.firstOrNull { it.id == interimMessageId } ?: return@update messages
|
||||
val current = messages.firstOrNull { it.id == currentMessageId }
|
||||
val interim = messages.firstOrNull { it.matchesIdentity(interimMessageId) }
|
||||
?: return@update messages
|
||||
val current = messages.firstOrNull { it.matchesIdentity(currentMessageId) }
|
||||
val mergedTools = (interim.toolCalls + current?.toolCalls.orEmpty())
|
||||
.distinctBy { it.id ?: "${it.name}:${it.startedAt}" }
|
||||
val merged = interim.copy(
|
||||
@@ -563,14 +570,18 @@ class ChatHandler {
|
||||
backgroundTask = current?.backgroundTask ?: interim.backgroundTask,
|
||||
)
|
||||
messages
|
||||
.filterNot { it.id == currentMessageId && currentMessageId != interimMessageId }
|
||||
.map { if (it.id == interimMessageId) merged else it }
|
||||
.filterNot {
|
||||
current != null &&
|
||||
current.uiKey != interim.uiKey &&
|
||||
it.matchesIdentity(currentMessageId)
|
||||
}
|
||||
.map { if (it.matchesIdentity(interimMessageId)) merged else it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a provisional client-side message that never became a real turn. */
|
||||
fun removeMessage(messageId: String) {
|
||||
_messages.update { messages -> messages.filterNot { it.id == messageId } }
|
||||
_messages.update { messages -> messages.filterNot { it.matchesIdentity(messageId) } }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -752,7 +763,7 @@ class ChatHandler {
|
||||
_messages.update { messages ->
|
||||
var changed = false
|
||||
val mapped = messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
changed = true
|
||||
// A realtimeTurn trace is attached ONLY for provider-only
|
||||
// (non-Hermes-backed) turns — Hermes-backed ones leave it
|
||||
@@ -971,7 +982,12 @@ class ChatHandler {
|
||||
val user = checkpoint.user
|
||||
val assistant = checkpoint.assistant
|
||||
val upstreamText = upstreamAssistantText.orEmpty()
|
||||
val currentAssistant = _messages.value.lastOrNull { it.id == assistant.id }
|
||||
// A history poll may already have adopted the server id while the
|
||||
// checkpoint still names the original client identity. They are one
|
||||
// logical row, resolved through either side of that alias.
|
||||
val currentAssistant = _messages.value.lastOrNull {
|
||||
it.matchesIdentity(assistant.id) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
val restoredContent = listOf(
|
||||
assistant.content,
|
||||
upstreamText,
|
||||
@@ -1053,7 +1069,8 @@ class ChatHandler {
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: checkpointMoaReferences
|
||||
val restoredAssistant = ChatMessage(
|
||||
id = assistant.id,
|
||||
id = currentAssistant?.id ?: assistant.id,
|
||||
uiKey = currentAssistant?.uiKey ?: assistant.id,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = restoredContent,
|
||||
timestamp = assistant.timestamp,
|
||||
@@ -1080,10 +1097,12 @@ class ChatHandler {
|
||||
|
||||
activeAgentName = restoredAssistant.agentName ?: activeAgentName
|
||||
_messages.update { current ->
|
||||
val withoutOldAssistant = current.filterNot { it.id == assistant.id }
|
||||
val withoutOldAssistant = current.filterNot {
|
||||
it.matchesIdentity(assistant.id) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
val users = withoutOldAssistant.filter { it.role == MessageRole.USER }
|
||||
val positionalUser = users.getOrNull(checkpoint.priorUserMessageCount)
|
||||
val hasUser = withoutOldAssistant.any { it.id == user.id } ||
|
||||
val hasUser = withoutOldAssistant.any { it.matchesIdentity(user.id) } ||
|
||||
positionalUser?.content?.trim() == user.content.trim()
|
||||
val withUser = if (hasUser) {
|
||||
withoutOldAssistant
|
||||
@@ -1100,7 +1119,7 @@ class ChatHandler {
|
||||
// those corrections as ordinary user bubbles before the assistant
|
||||
// row. Consume matching already-present rows first so repeated
|
||||
// resume/checkpoint passes cannot duplicate them.
|
||||
val originalUserIndex = withUser.indexOfFirst { it.id == user.id }
|
||||
val originalUserIndex = withUser.indexOfFirst { it.matchesIdentity(user.id) }
|
||||
.takeIf { it >= 0 }
|
||||
?: withUser.indexOfFirst {
|
||||
it.role == MessageRole.USER && it.content.trim() == user.content.trim()
|
||||
@@ -1213,7 +1232,7 @@ class ChatHandler {
|
||||
fun replaceMessageId(oldId: String, newId: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == oldId && msg.content.isBlank() && msg.isStreaming) {
|
||||
if (msg.matchesIdentity(oldId) && msg.content.isBlank() && msg.isStreaming) {
|
||||
msg.copy(id = newId)
|
||||
} else msg
|
||||
}
|
||||
@@ -1236,7 +1255,7 @@ class ChatHandler {
|
||||
fun mutateMessage(messageId: String, transform: (ChatMessage) -> ChatMessage) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) transform(msg) else msg
|
||||
if (msg.matchesIdentity(messageId)) transform(msg) else msg
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2024,12 +2043,12 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
val existing = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(content = msg.content + processedDelta)
|
||||
} else {
|
||||
msg
|
||||
@@ -2262,7 +2281,7 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(cards = msg.cards + card)
|
||||
} else msg
|
||||
}
|
||||
@@ -2288,7 +2307,7 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
var cleaned = msg.content
|
||||
var changed = false
|
||||
for (rawLine in msg.content.lines()) {
|
||||
@@ -2355,7 +2374,7 @@ class ChatHandler {
|
||||
private fun stripLineFromContent(messageId: String, line: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
// Remove the line (with surrounding newlines) from content
|
||||
val cleaned = msg.content
|
||||
.replace("\n$line\n", "\n")
|
||||
@@ -2492,7 +2511,7 @@ class ChatHandler {
|
||||
// that raced with stripLineFromContent during streaming.
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
var cleaned = msg.content
|
||||
var changed = false
|
||||
for (rawLine in msg.content.lines()) {
|
||||
@@ -2546,7 +2565,7 @@ class ChatHandler {
|
||||
private fun finalizeAnnotations(messageId: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
|
||||
val existingToolNames = msg.toolCalls.map { it.name }.toSet()
|
||||
val newToolCalls = mutableListOf<ToolCall>()
|
||||
@@ -2629,7 +2648,7 @@ class ChatHandler {
|
||||
.take(4)
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(badges = cleaned)
|
||||
} else {
|
||||
msg
|
||||
@@ -2645,7 +2664,7 @@ class ChatHandler {
|
||||
if (cleaned.isEmpty()) return
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(badges = (msg.badges + cleaned).distinct().take(4))
|
||||
} else {
|
||||
msg
|
||||
@@ -2679,11 +2698,11 @@ class ChatHandler {
|
||||
)
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(toolCalls = msg.toolCalls + placeholder)
|
||||
} else {
|
||||
msg
|
||||
@@ -2714,7 +2733,7 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
// Adopt a pending "preparing" placeholder for this name
|
||||
@@ -2728,7 +2747,7 @@ class ChatHandler {
|
||||
.indexOfFirst { it.isGenerating && !it.isComplete && it.name.isEmpty() }
|
||||
.takeIf { it >= 0 }
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
if (!msg.matchesIdentity(messageId)) return@map msg
|
||||
if (genIdx != null) {
|
||||
val calls = msg.toolCalls.toMutableList()
|
||||
calls[genIdx] = calls[genIdx].copy(
|
||||
@@ -2786,7 +2805,7 @@ class ChatHandler {
|
||||
fun onMoaReference(messageId: String, event: GatewayMoaReference) {
|
||||
_messages.update { messages ->
|
||||
val targetIndex = messages.indexOfLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (targetIndex < 0) return@update messages
|
||||
_isStreaming.value = true
|
||||
@@ -2861,11 +2880,11 @@ class ChatHandler {
|
||||
)
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
if (!msg.matchesIdentity(messageId)) return@map msg
|
||||
val closed = msg.toolCalls.map { call ->
|
||||
if (call.taskIndex == event.taskIndex && !call.isComplete) {
|
||||
call.copy(
|
||||
@@ -2900,7 +2919,7 @@ class ChatHandler {
|
||||
val summaryId = "subagent-${event.taskIndex}-${syntheticToolSeq++}"
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
val hasLaneCalls = msg.toolCalls.any { it.taskIndex == event.taskIndex }
|
||||
val closed = msg.toolCalls.map { call ->
|
||||
if (call.taskIndex == event.taskIndex && !call.isComplete) {
|
||||
@@ -2951,14 +2970,14 @@ class ChatHandler {
|
||||
// Snapshot the matching tool call's name BEFORE mutating — we need it
|
||||
// to decide whether to emit a phone-action result bubble below.
|
||||
val toolName = _messages.value
|
||||
.firstOrNull { it.id == messageId && it.role == MessageRole.ASSISTANT }
|
||||
.firstOrNull { it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT }
|
||||
?.toolCalls
|
||||
?.firstOrNull { it.id == toolCallId }
|
||||
?.name
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
val updatedCalls = msg.toolCalls.map { call ->
|
||||
if (call.id == toolCallId && !call.isComplete) {
|
||||
call.copy(
|
||||
@@ -2995,14 +3014,14 @@ class ChatHandler {
|
||||
// Snapshot tool name before the update so the phone-action bubble
|
||||
// can label the failure correctly.
|
||||
val toolName = _messages.value
|
||||
.firstOrNull { it.id == messageId && it.role == MessageRole.ASSISTANT }
|
||||
.firstOrNull { it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT }
|
||||
?.toolCalls
|
||||
?.firstOrNull { it.id == toolCallId }
|
||||
?.name
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
val updatedCalls = msg.toolCalls.map { call ->
|
||||
if (call.id == toolCallId && !call.isComplete) {
|
||||
call.copy(
|
||||
@@ -3035,7 +3054,7 @@ class ChatHandler {
|
||||
fun onToolOutputRisk(messageId: String, outputRisk: GatewayToolOutputRisk) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
val updatedCalls = msg.toolCalls.map { call ->
|
||||
if (call.id == outputRisk.toolCallId) {
|
||||
call.copy(
|
||||
@@ -3066,7 +3085,7 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
@@ -3095,7 +3114,7 @@ class ChatHandler {
|
||||
fun markStopped(messageId: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && "Stopped" !in msg.badges) {
|
||||
if (msg.matchesIdentity(messageId) && "Stopped" !in msg.badges) {
|
||||
msg.copy(badges = msg.badges + "Stopped")
|
||||
} else {
|
||||
msg
|
||||
@@ -3122,7 +3141,7 @@ class ChatHandler {
|
||||
fun markError(messageId: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && "Error" !in msg.badges) {
|
||||
if (msg.matchesIdentity(messageId) && "Error" !in msg.badges) {
|
||||
msg.copy(badges = msg.badges + "Error", clientOnly = true)
|
||||
} else {
|
||||
msg
|
||||
@@ -3148,7 +3167,7 @@ class ChatHandler {
|
||||
_messages.update { messages ->
|
||||
messages
|
||||
.filterNot { msg ->
|
||||
msg.id == messageId &&
|
||||
msg.matchesIdentity(messageId) &&
|
||||
msg.role == MessageRole.ASSISTANT &&
|
||||
msg.toolCalls.isEmpty() &&
|
||||
msg.backgroundTask == null &&
|
||||
@@ -3156,7 +3175,7 @@ class ChatHandler {
|
||||
(msg.content.isBlank() || isIntentionalSilenceMarker(msg.content))
|
||||
}
|
||||
.map { msg ->
|
||||
if (msg.id == messageId || msg.isStreaming) {
|
||||
if (msg.matchesIdentity(messageId) || msg.isStreaming) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
@@ -3220,10 +3239,12 @@ class ChatHandler {
|
||||
fun onThinkingDelta(messageId: String, delta: String) {
|
||||
_isStreaming.value = true
|
||||
_messages.update { messages ->
|
||||
val existing = messages.findLast { it.id == messageId && it.role == MessageRole.ASSISTANT }
|
||||
val existing = messages.findLast {
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (existing != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(
|
||||
thinkingContent = msg.thinkingContent + delta,
|
||||
isThinkingStreaming = true
|
||||
@@ -3248,7 +3269,7 @@ class ChatHandler {
|
||||
fun onUsageReceived(messageId: String, inputTokens: Int?, outputTokens: Int?, totalTokens: Int?, cost: Double?) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(
|
||||
inputTokens = inputTokens,
|
||||
outputTokens = outputTokens,
|
||||
@@ -3281,7 +3302,7 @@ class ChatHandler {
|
||||
)
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
if (!msg.matchesIdentity(messageId)) return@map msg
|
||||
val alreadyDispatched = msg.cardDispatches.any {
|
||||
it.cardKey == cardKey && it.actionValue == actionValue
|
||||
}
|
||||
|
||||
+26
-15
@@ -720,25 +720,36 @@ class DashboardApiClient(
|
||||
*/
|
||||
suspend fun listSessions(
|
||||
profile: String? = null,
|
||||
limit: Int = 200,
|
||||
limit: Int = SESSION_LIST_WINDOW_LIMIT,
|
||||
archived: String? = null,
|
||||
): Result<List<SessionItem>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val query = buildList {
|
||||
add("limit=${limit.coerceIn(1, 200)}")
|
||||
add("order=recent")
|
||||
add("min_messages=1")
|
||||
val name = profile?.trim().orEmpty()
|
||||
if (name.isNotBlank()) add("profile=${pathSegment(name)}")
|
||||
// Upstream `archived` filter: exclude (default) | only | include.
|
||||
// Omitted unless requested so older hosts see an unchanged request.
|
||||
val archivedMode = archived?.trim().orEmpty()
|
||||
if (archivedMode.isNotBlank()) add("archived=${pathSegment(archivedMode)}")
|
||||
}.joinToString(prefix = "?", separator = "&")
|
||||
getJson("/api/sessions$query").mapCatching { root ->
|
||||
val parsed = json.decodeFromJsonElement(SessionListResponse.serializer(), root)
|
||||
parsed.sessions ?: parsed.items ?: parsed.data ?: emptyList()
|
||||
val sessions = linkedMapOf<String, SessionItem>()
|
||||
for (page in sessionListPages(limit)) {
|
||||
val query = buildList {
|
||||
// Upstream dashboard GET /api/sessions rejects pages over 100.
|
||||
// Keep Android's 200-row drawer window via two bounded pages.
|
||||
add("limit=${page.limit}")
|
||||
add("offset=${page.offset}")
|
||||
add("order=recent")
|
||||
add("min_messages=1")
|
||||
val name = profile?.trim().orEmpty()
|
||||
if (name.isNotBlank()) add("profile=${pathSegment(name)}")
|
||||
// Upstream `archived` filter: exclude (default) | only | include.
|
||||
// Omitted unless requested so older hosts see an unchanged request.
|
||||
val archivedMode = archived?.trim().orEmpty()
|
||||
if (archivedMode.isNotBlank()) add("archived=${pathSegment(archivedMode)}")
|
||||
}.joinToString(prefix = "?", separator = "&")
|
||||
val pageResult = getJson("/api/sessions$query").mapCatching { root ->
|
||||
val parsed = json.decodeFromJsonElement(SessionListResponse.serializer(), root)
|
||||
parsed.sessions ?: parsed.items ?: parsed.data ?: emptyList()
|
||||
}
|
||||
if (pageResult.isFailure) return@withContext pageResult
|
||||
val pageSessions = pageResult.getOrThrow()
|
||||
pageSessions.forEach { sessions.putIfAbsent(it.id, it) }
|
||||
if (pageSessions.size < page.limit) break
|
||||
}
|
||||
Result.success(sessions.values.take(limit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -596,27 +596,35 @@ class HermesApiClient(
|
||||
|
||||
// --- Session CRUD ---
|
||||
|
||||
suspend fun listSessionsResult(limit: Int = 200): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
|
||||
suspend fun listSessionsResult(limit: Int = SESSION_LIST_WINDOW_LIMIT): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=$limit").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "List sessions"))
|
||||
val sessions = linkedMapOf<String, SessionItem>()
|
||||
for (page in sessionListPages(limit)) {
|
||||
val request = authRequest(
|
||||
"$baseUrl/api/sessions?limit=${page.limit}&offset=${page.offset}",
|
||||
).get().build()
|
||||
val pageSessions = client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "List sessions"))
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("List sessions returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
parsed.data ?: parsed.items ?: parsed.sessions ?: emptyList()
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("List sessions returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
Result.success(parsed.data ?: parsed.items ?: parsed.sessions ?: emptyList())
|
||||
pageSessions.forEach { sessions.putIfAbsent(it.id, it) }
|
||||
if (pageSessions.size < page.limit) break
|
||||
}
|
||||
Result.success(sessions.values.take(limit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listSessions(limit: Int = 200): List<SessionItem> =
|
||||
suspend fun listSessions(limit: Int = SESSION_LIST_WINDOW_LIMIT): List<SessionItem> =
|
||||
listSessionsResult(limit).getOrElse { emptyList() }
|
||||
|
||||
suspend fun createSessionResult(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* The publication boundary for Chat and Voice transcript rows.
|
||||
*
|
||||
* [ChatMessage.id] may change when the server adopts a client-created row,
|
||||
* while [ChatMessage.uiKey] is that row's stable render identity. Every value
|
||||
* emitted from this state therefore has exactly one row per render identity.
|
||||
*/
|
||||
internal class RenderedMessageState(initialValue: List<ChatMessage>) {
|
||||
private val mutable = MutableStateFlow(normalizeRenderedMessages(initialValue))
|
||||
val flow: StateFlow<List<ChatMessage>> = mutable.asStateFlow()
|
||||
|
||||
var value: List<ChatMessage>
|
||||
get() = mutable.value
|
||||
set(value) {
|
||||
mutable.value = normalizeRenderedMessages(value)
|
||||
}
|
||||
|
||||
fun update(transform: (List<ChatMessage>) -> List<ChatMessage>) {
|
||||
mutable.update { current -> normalizeRenderedMessages(transform(current)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce aliases before they can escape to keyed Compose consumers.
|
||||
*
|
||||
* The first slot owns transcript position and the latest snapshot owns row
|
||||
* state. This is the same ordering contract used for replayed server history.
|
||||
*/
|
||||
internal fun normalizeRenderedMessages(messages: List<ChatMessage>): List<ChatMessage> {
|
||||
if (messages.size < 2) return messages
|
||||
|
||||
val firstSlotByUiKey = HashMap<String, Int>()
|
||||
val normalized = ArrayList<ChatMessage>(messages.size)
|
||||
for (message in messages) {
|
||||
val existingSlot = firstSlotByUiKey[message.uiKey]
|
||||
if (existingSlot == null) {
|
||||
firstSlotByUiKey[message.uiKey] = normalized.size
|
||||
normalized += message
|
||||
} else {
|
||||
normalized[existingSlot] = message
|
||||
}
|
||||
}
|
||||
return if (normalized.size == messages.size) messages else normalized
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
internal const val SESSION_LIST_PAGE_LIMIT = 100
|
||||
internal const val SESSION_LIST_WINDOW_LIMIT = 200
|
||||
|
||||
internal data class SessionListPage(
|
||||
val limit: Int,
|
||||
val offset: Int,
|
||||
)
|
||||
|
||||
internal fun sessionListPages(requestedLimit: Int): List<SessionListPage> {
|
||||
val window = requestedLimit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)
|
||||
return buildList {
|
||||
var offset = 0
|
||||
while (offset < window) {
|
||||
add(
|
||||
SessionListPage(
|
||||
limit = minOf(SESSION_LIST_PAGE_LIMIT, window - offset),
|
||||
offset = offset,
|
||||
),
|
||||
)
|
||||
offset += SESSION_LIST_PAGE_LIMIT
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.hermesandroid.relay.reliability
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Android boundary for the local reliability store. Nothing in this object has
|
||||
* a network path; writes stay in app-private storage until a user explicitly
|
||||
* reviews and shares text through the UI.
|
||||
*/
|
||||
object ReliabilityCenter {
|
||||
private val writer = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "hermes-reliability-writer").apply { isDaemon = true }
|
||||
}
|
||||
private val appSessionId = ReliabilityReport.newId("app")
|
||||
|
||||
@Volatile
|
||||
private var store: ReliabilityStore? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (store != null) return
|
||||
synchronized(this) {
|
||||
if (store == null) {
|
||||
store = ReliabilityStore(
|
||||
java.io.File(context.applicationContext.filesDir, "reliability/reports-v1.json"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun recordFatal(
|
||||
context: Context,
|
||||
throwable: Throwable,
|
||||
threadName: String,
|
||||
timeIso: String = Instant.now().toString(),
|
||||
): ReliabilityReport {
|
||||
initialize(context)
|
||||
val summary = buildString {
|
||||
append(throwable.javaClass.simpleName.ifBlank { "Unexpected crash" })
|
||||
throwable.message?.takeIf { it.isNotBlank() }?.let { append(": ").append(it) }
|
||||
}
|
||||
val report = ReliabilityReport(
|
||||
reportId = ReliabilityReport.newId(),
|
||||
appSessionId = appSessionId,
|
||||
timeIso = timeIso,
|
||||
kind = ReliabilityKind.FatalCrash,
|
||||
owner = ReliabilityOwner.Android,
|
||||
severity = ReliabilitySeverity.Fatal,
|
||||
summary = summary,
|
||||
recovery = "The app restarted. Work already running on Hermes may still be active.",
|
||||
reportRecommended = true,
|
||||
technicalDetail = "Thread: $threadName\n${stackTraceText(throwable)}",
|
||||
environment = environment(),
|
||||
pendingReview = true,
|
||||
)
|
||||
// Fatal capture must complete before the platform terminates the process.
|
||||
store?.append(report)
|
||||
return report
|
||||
}
|
||||
|
||||
fun recordHandled(
|
||||
title: String,
|
||||
detail: String?,
|
||||
throwable: Throwable,
|
||||
context: String?,
|
||||
routeRole: String? = null,
|
||||
) {
|
||||
val target = store ?: return
|
||||
val classification = ReliabilityClassifier.classify(throwable, context)
|
||||
if (!classification.shouldPersist) return
|
||||
val report = ReliabilityReport(
|
||||
reportId = ReliabilityReport.newId(),
|
||||
appSessionId = appSessionId,
|
||||
timeIso = Instant.now().toString(),
|
||||
kind = classification.kind,
|
||||
owner = classification.owner,
|
||||
severity = ReliabilitySeverity.Error,
|
||||
summary = title,
|
||||
recovery = detail ?: "The failure was handled; retry or review Diagnostics if it continues.",
|
||||
reportRecommended = classification.reportRecommended,
|
||||
technicalDetail = stackTraceText(throwable),
|
||||
routeRole = routeRole,
|
||||
environment = environment(),
|
||||
)
|
||||
writer.execute { runCatching { target.append(report) } }
|
||||
}
|
||||
|
||||
fun reports(context: Context): List<ReliabilityReport> {
|
||||
initialize(context)
|
||||
return store?.readAll().orEmpty()
|
||||
}
|
||||
|
||||
fun pendingCrash(context: Context): ReliabilityReport? =
|
||||
reports(context).lastOrNull { it.kind == ReliabilityKind.FatalCrash && it.pendingReview }
|
||||
|
||||
fun markReviewed(context: Context, reportId: String) {
|
||||
initialize(context)
|
||||
store?.markReviewed(reportId)
|
||||
}
|
||||
|
||||
fun import(context: Context, report: ReliabilityReport) {
|
||||
initialize(context)
|
||||
store?.append(report)
|
||||
}
|
||||
|
||||
fun environment(): ReliabilityEnvironment = ReliabilityEnvironment(
|
||||
versionName = BuildConfig.VERSION_NAME,
|
||||
versionCode = BuildConfig.VERSION_CODE,
|
||||
flavor = BuildConfig.FLAVOR,
|
||||
manufacturer = Build.MANUFACTURER.orEmpty().ifBlank { "?" },
|
||||
model = Build.MODEL.orEmpty().ifBlank { "?" },
|
||||
androidRelease = Build.VERSION.RELEASE.orEmpty().ifBlank { "?" },
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
)
|
||||
|
||||
private fun stackTraceText(throwable: Throwable): String =
|
||||
StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package com.hermesandroid.relay.reliability
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
const val RELIABILITY_SCHEMA_VERSION = 1
|
||||
|
||||
@Serializable
|
||||
enum class ReliabilityKind {
|
||||
FatalCrash,
|
||||
AnrSignal,
|
||||
RecoverableProductError,
|
||||
Connectivity,
|
||||
Authentication,
|
||||
RateLimit,
|
||||
ServiceUnavailable,
|
||||
ExpectedCancellation,
|
||||
UserDenial,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ReliabilityOwner(val label: String) {
|
||||
Android("Android"),
|
||||
Dashboard("Dashboard"),
|
||||
Api("API"),
|
||||
Relay("Relay"),
|
||||
UpstreamGateway("Upstream Gateway"),
|
||||
Voice("Voice"),
|
||||
Unknown("Unknown"),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ReliabilitySeverity { Info, Warning, Error, Fatal }
|
||||
|
||||
@Serializable
|
||||
data class ReliabilityEnvironment(
|
||||
val versionName: String,
|
||||
val versionCode: Int,
|
||||
val flavor: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val androidRelease: String,
|
||||
val sdkInt: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Allowlisted local reliability record. There are deliberately no fields for
|
||||
* prompts, messages, profile names, product session IDs, URLs, media, or paths.
|
||||
*/
|
||||
@Serializable
|
||||
data class ReliabilityReport(
|
||||
val schemaVersion: Int = RELIABILITY_SCHEMA_VERSION,
|
||||
val reportId: String,
|
||||
val appSessionId: String,
|
||||
val timeIso: String,
|
||||
val kind: ReliabilityKind,
|
||||
val owner: ReliabilityOwner,
|
||||
val severity: ReliabilitySeverity,
|
||||
val summary: String,
|
||||
val recovery: String,
|
||||
val reportRecommended: Boolean,
|
||||
val technicalDetail: String? = null,
|
||||
val routeRole: String? = null,
|
||||
val environment: ReliabilityEnvironment,
|
||||
val pendingReview: Boolean = false,
|
||||
) {
|
||||
fun shortTitle(): String = summary.lineSequence().firstOrNull().orEmpty().ifBlank {
|
||||
kind.name
|
||||
}.take(90)
|
||||
|
||||
fun versionLine(): String =
|
||||
"${environment.versionName} (code ${environment.versionCode}) ${environment.flavor}"
|
||||
|
||||
fun environmentBlock(): String = buildString {
|
||||
appendLine("- Hermes-Relay version/tag: ${environment.versionName} (code ${environment.versionCode})")
|
||||
appendLine(
|
||||
"- Install surface: " +
|
||||
if (environment.flavor.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play",
|
||||
)
|
||||
appendLine(
|
||||
"- Android device and OS: ${environment.manufacturer} ${environment.model} — " +
|
||||
"Android ${environment.androidRelease} (SDK ${environment.sdkInt})",
|
||||
)
|
||||
append("- Connection mode: ${routeRole ?: "unknown"}")
|
||||
}
|
||||
|
||||
/** Exact local review/copy/share payload. Redaction is repeated for legacy defense in depth. */
|
||||
fun toPlainText(): String = ReliabilityRedactor.redact(
|
||||
buildString {
|
||||
appendLine("Hermes-Relay support information")
|
||||
appendLine("Report: $reportId")
|
||||
appendLine("Session: $appSessionId")
|
||||
appendLine("Time: $timeIso")
|
||||
appendLine("Type: ${kind.name}")
|
||||
appendLine("Owner: ${owner.label}")
|
||||
appendLine("App: ${versionLine()}")
|
||||
appendLine(
|
||||
"Device: ${environment.manufacturer} ${environment.model} — " +
|
||||
"Android ${environment.androidRelease} (SDK ${environment.sdkInt})",
|
||||
)
|
||||
routeRole?.let { appendLine("Route: $it") }
|
||||
appendLine()
|
||||
appendLine("What happened: $summary")
|
||||
appendLine("Recovery: $recovery")
|
||||
technicalDetail?.let {
|
||||
appendLine()
|
||||
appendLine("Technical detail (redacted)")
|
||||
append(it)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun newId(prefix: String = "rpt"): String =
|
||||
"$prefix-${UUID.randomUUID().toString().replace("-", "").take(16)}"
|
||||
}
|
||||
}
|
||||
|
||||
/** Old `files/crash/last-crash.json` shape, retained only for one-way migration. */
|
||||
@Serializable
|
||||
data class LegacyCrashSnapshot(
|
||||
val timeIso: String,
|
||||
val versionName: String,
|
||||
val versionCode: Int,
|
||||
val flavor: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val androidRelease: String,
|
||||
val sdkInt: Int,
|
||||
val threadName: String,
|
||||
val exceptionSummary: String,
|
||||
val stackTrace: String,
|
||||
)
|
||||
|
||||
fun migrateLegacyCrash(
|
||||
old: LegacyCrashSnapshot,
|
||||
reportId: String = ReliabilityReport.newId(),
|
||||
appSessionId: String = ReliabilityReport.newId("legacy"),
|
||||
): ReliabilityReport = ReliabilityReport(
|
||||
reportId = reportId,
|
||||
appSessionId = appSessionId,
|
||||
timeIso = runCatching { Instant.parse(old.timeIso).toString() }.getOrDefault(old.timeIso),
|
||||
kind = ReliabilityKind.FatalCrash,
|
||||
owner = ReliabilityOwner.Android,
|
||||
severity = ReliabilitySeverity.Fatal,
|
||||
summary = ReliabilityRedactor.redact(old.exceptionSummary, 240),
|
||||
recovery = "The app restarted. Work already running on Hermes may still be active.",
|
||||
reportRecommended = true,
|
||||
technicalDetail = ReliabilityRedactor.redact("Thread: ${old.threadName}\n${old.stackTrace}"),
|
||||
environment = ReliabilityEnvironment(
|
||||
old.versionName, old.versionCode, old.flavor, old.manufacturer, old.model,
|
||||
old.androidRelease, old.sdkInt,
|
||||
),
|
||||
pendingReview = true,
|
||||
)
|
||||
|
||||
data class ReliabilityClassification(
|
||||
val kind: ReliabilityKind,
|
||||
val owner: ReliabilityOwner,
|
||||
val reportRecommended: Boolean,
|
||||
val shouldPersist: Boolean,
|
||||
)
|
||||
|
||||
object ReliabilityClassifier {
|
||||
fun classify(throwable: Throwable, context: String? = null): ReliabilityClassification {
|
||||
val message = throwable.message.orEmpty().lowercase()
|
||||
val owner = ownerForContext(context)
|
||||
return when {
|
||||
throwable is CancellationException -> ReliabilityClassification(
|
||||
ReliabilityKind.ExpectedCancellation, owner, reportRecommended = false, shouldPersist = false,
|
||||
)
|
||||
throwable is SecurityException && ("denied" in message || "permission" in message) ->
|
||||
ReliabilityClassification(
|
||||
ReliabilityKind.UserDenial, ReliabilityOwner.Android,
|
||||
reportRecommended = false, shouldPersist = false,
|
||||
)
|
||||
"429" in message || "rate limit" in message || "too many requests" in message ->
|
||||
ReliabilityClassification(
|
||||
ReliabilityKind.RateLimit, owner, reportRecommended = false, shouldPersist = true,
|
||||
)
|
||||
"401" in message || "403" in message || "unauthorized" in message || "forbidden" in message ->
|
||||
ReliabilityClassification(
|
||||
ReliabilityKind.Authentication, owner, reportRecommended = false, shouldPersist = true,
|
||||
)
|
||||
throwable is java.net.UnknownHostException ||
|
||||
throwable is java.net.ConnectException ||
|
||||
throwable is java.net.SocketTimeoutException ||
|
||||
"timeout" in message -> ReliabilityClassification(
|
||||
ReliabilityKind.Connectivity, owner, reportRecommended = false, shouldPersist = true,
|
||||
)
|
||||
"503" in message || "service unavailable" in message || "gateway_draining" in message ->
|
||||
ReliabilityClassification(
|
||||
ReliabilityKind.ServiceUnavailable, owner,
|
||||
reportRecommended = false, shouldPersist = true,
|
||||
)
|
||||
else -> ReliabilityClassification(
|
||||
ReliabilityKind.RecoverableProductError, owner,
|
||||
reportRecommended = true, shouldPersist = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ownerForContext(context: String?): ReliabilityOwner = when (context?.lowercase()) {
|
||||
"dashboard", "manage", "dashboard_auth" -> ReliabilityOwner.Dashboard
|
||||
"gateway", "gateway_chat", "upstream_gateway" -> ReliabilityOwner.UpstreamGateway
|
||||
"transcribe", "synthesize", "voice_config", "record", "voice" -> ReliabilityOwner.Voice
|
||||
"pair", "save_and_test", "media_fetch", "relay" -> ReliabilityOwner.Relay
|
||||
"send_message", "load_sessions", "create_session", "api" -> ReliabilityOwner.Api
|
||||
"android", "permission", "ui" -> ReliabilityOwner.Android
|
||||
else -> ReliabilityOwner.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/** Local, deterministic redaction. It runs before persistence and again before export. */
|
||||
object ReliabilityRedactor {
|
||||
const val MAX_TECHNICAL_LENGTH = 8_000
|
||||
private const val HIDDEN = "[hidden]"
|
||||
|
||||
private val secretAssignment = Regex(
|
||||
"""(?i)\b(authorization|bearer|cookie|set-cookie|token|api[_-]?key|session[_-]?token|pairing[_-]?code|password|secret|oauth[_-]?code)\s*[:=]\s*((?:Bearer\s+)?[^\s,;]+)""",
|
||||
)
|
||||
private val sensitiveHeader = Regex("""(?im)^\s*(authorization|cookie|set-cookie)\s*:\s*.+$""")
|
||||
private val standaloneBearer = Regex("""(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+""")
|
||||
private val sensitivePayload = Regex(
|
||||
"""(?i)\b(prompt|message|content|transcript|reasoning|tool[_-]?(args|result)|profile[_-]?name)\s*[:=]\s*([^\r\n]+)""",
|
||||
)
|
||||
private val url = Regex("""(?i)\b(?:https?|wss?)://[^\s)\]}>,]+""")
|
||||
private val ipv4 = Regex("""(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?(?![\w.])""")
|
||||
private val uuid = Regex("""(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b""")
|
||||
private val email = Regex("""(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b""")
|
||||
private val namedHost = Regex("""(?i)\b(host|hostname)\s*[:=]\s*[^\s,;]+""")
|
||||
private val unresolvedHost = Regex("""(?i)(?:resolve|resolved|host)\s+[\"']([^\"']+)[\"']""")
|
||||
private val windowsPath = Regex("""(?i)\b[A-Z]:\\(?:[^\s\\]+\\)+[^\s]+""")
|
||||
private val unixPrivatePath = Regex("""(?i)(?:/home/|/Users/|/data/user/\d+/|/sdcard/)[^\s]+""")
|
||||
|
||||
fun redact(value: String, maxLength: Int = MAX_TECHNICAL_LENGTH): String {
|
||||
var result = value
|
||||
result = sensitiveHeader.replace(result) { "${it.groupValues[1]}: $HIDDEN" }
|
||||
result = secretAssignment.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
|
||||
result = standaloneBearer.replace(result, "Bearer $HIDDEN")
|
||||
result = sensitivePayload.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
|
||||
result = url.replace(result, "[url hidden]")
|
||||
result = ipv4.replace(result, "[host hidden]")
|
||||
result = uuid.replace(result, "[id hidden]")
|
||||
result = email.replace(result, "[email hidden]")
|
||||
result = namedHost.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
|
||||
result = unresolvedHost.replace(result, "host \"$HIDDEN\"")
|
||||
result = windowsPath.replace(result, "[path hidden]")
|
||||
result = unixPrivatePath.replace(result, "[path hidden]")
|
||||
return if (result.length > maxLength) {
|
||||
result.take(maxLength) + "\n… (truncated)"
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class ReliabilityEnvelope(
|
||||
val schemaVersion: Int = RELIABILITY_SCHEMA_VERSION,
|
||||
val reports: List<ReliabilityReport> = emptyList(),
|
||||
)
|
||||
|
||||
/** Pure file store so retention, bounds, and migration behavior are JVM-testable. */
|
||||
class ReliabilityStore(
|
||||
private val file: File,
|
||||
private val maxReports: Int = 20,
|
||||
private val retentionDays: Long = 14,
|
||||
) {
|
||||
private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }
|
||||
private val lock = Any()
|
||||
|
||||
fun readAll(now: Instant = Instant.now()): List<ReliabilityReport> = synchronized(lock) {
|
||||
val decoded = decode()
|
||||
val retained = prune(decoded, now)
|
||||
if (retained != decoded) write(retained)
|
||||
retained
|
||||
}
|
||||
|
||||
fun append(report: ReliabilityReport, now: Instant = Instant.now()) = synchronized(lock) {
|
||||
write(prune(decode() + sanitize(report), now))
|
||||
}
|
||||
|
||||
fun markReviewed(reportId: String, now: Instant = Instant.now()) = synchronized(lock) {
|
||||
write(
|
||||
prune(
|
||||
decode().map { if (it.reportId == reportId) it.copy(pendingReview = false) else it },
|
||||
now,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sanitize(report: ReliabilityReport): ReliabilityReport = report.copy(
|
||||
summary = ReliabilityRedactor.redact(report.summary, 240),
|
||||
recovery = ReliabilityRedactor.redact(report.recovery, 240),
|
||||
technicalDetail = report.technicalDetail?.let(ReliabilityRedactor::redact),
|
||||
routeRole = report.routeRole?.let { ReliabilityRedactor.redact(it, 40) },
|
||||
)
|
||||
|
||||
private fun prune(reports: List<ReliabilityReport>, now: Instant): List<ReliabilityReport> {
|
||||
val cutoff = now.minusSeconds(retentionDays * 24 * 60 * 60)
|
||||
return reports
|
||||
.distinctBy { it.reportId }
|
||||
.filter { report -> runCatching { Instant.parse(report.timeIso) >= cutoff }.getOrDefault(true) }
|
||||
.sortedBy { it.timeIso }
|
||||
.takeLast(maxReports.coerceAtLeast(1))
|
||||
}
|
||||
|
||||
private fun decode(): List<ReliabilityReport> = runCatching {
|
||||
if (!file.isFile) return emptyList()
|
||||
json.decodeFromString<ReliabilityEnvelope>(file.readText()).reports
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun write(reports: List<ReliabilityReport>) {
|
||||
file.parentFile?.mkdirs()
|
||||
val temp = File(file.parentFile, "${file.name}.tmp")
|
||||
temp.writeText(json.encodeToString(ReliabilityEnvelope(reports = reports)))
|
||||
runCatching {
|
||||
java.nio.file.Files.move(
|
||||
temp.toPath(),
|
||||
file.toPath(),
|
||||
java.nio.file.StandardCopyOption.ATOMIC_MOVE,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
}.recoverCatching {
|
||||
java.nio.file.Files.move(
|
||||
temp.toPath(),
|
||||
file.toPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
}.getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
object SupportBundleBuilder {
|
||||
const val MAX_REPORTS = 10
|
||||
|
||||
fun build(reports: List<ReliabilityReport>): String {
|
||||
val selected = reports.sortedByDescending { it.timeIso }.take(MAX_REPORTS)
|
||||
return ReliabilityRedactor.redact(
|
||||
buildString {
|
||||
appendLine("Hermes-Relay support bundle")
|
||||
appendLine("Local-only export · review before sharing")
|
||||
appendLine("Reports: ${selected.size}")
|
||||
selected.forEachIndexed { index, report ->
|
||||
appendLine()
|
||||
appendLine("===== Report ${index + 1} =====")
|
||||
append(report.toPlainText())
|
||||
appendLine()
|
||||
}
|
||||
},
|
||||
maxLength = 64_000,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -586,6 +586,7 @@ fun RelayApp() {
|
||||
}
|
||||
|
||||
val coldStartAuthState by connectionViewModel.authState.collectAsState()
|
||||
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val effectiveSessionProfileName by connectionViewModel.effectiveSessionProfileName.collectAsState()
|
||||
val currentChatSessionId by chatViewModel.currentSessionId.collectAsState()
|
||||
@@ -2228,10 +2229,18 @@ fun RelayApp() {
|
||||
}
|
||||
// === END PHASE3-safety-rails ===
|
||||
composable(Screen.PairedDevices.route) {
|
||||
if (coldStartAuthState is AuthState.Paired) {
|
||||
if (
|
||||
coldStartAuthState is AuthState.Paired ||
|
||||
currentPairedSession != null
|
||||
) {
|
||||
PairedDevicesScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onManageSessions = {
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onRequestRepair = {
|
||||
navController.navigate(Screen.Pair.route())
|
||||
}
|
||||
@@ -2315,7 +2324,7 @@ fun RelayApp() {
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onReconnect = {
|
||||
connectionViewModel.connectRelay()
|
||||
connectionViewModel.reconnectIfStale()
|
||||
UiMessageBus.status(reconnectingRelayLabel)
|
||||
},
|
||||
onRename = { id, newLabel ->
|
||||
|
||||
+74
-44
@@ -96,7 +96,6 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
|
||||
import com.hermesandroid.relay.viewmodel.asBadgeState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -216,17 +215,45 @@ fun ActiveCardRelayStatusSection(
|
||||
|
||||
// Pre-resolve strings for Toast (non-composable context)
|
||||
val reconnectingRelayToast = stringResource(R.string.active_section_reconnecting_relay)
|
||||
val connectedLabel = stringResource(R.string.conn_info_connected)
|
||||
val relayStatusText = when (relayRowState.phase) {
|
||||
RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
|
||||
RelayUiState.Connected -> stringResource(R.string.relay_state_ready)
|
||||
RelayUiState.Connecting -> stringResource(R.string.relay_state_reconnecting)
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected -> stringResource(R.string.relay_state_unavailable)
|
||||
RelayUiState.Expired -> stringResource(R.string.relay_state_needs_repair)
|
||||
}
|
||||
val relayRoleLabel = relayRowState.activeEndpointRole
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { role ->
|
||||
when (role.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"tailscale" -> "Tailscale"
|
||||
"public" -> "Public"
|
||||
else -> role
|
||||
}
|
||||
}
|
||||
val relayStatusWithRole = when (relayRowState.phase) {
|
||||
RelayUiState.Connected,
|
||||
RelayUiState.Connecting,
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected -> relayRoleLabel
|
||||
?.let { "$relayStatusText \u00B7 $it" }
|
||||
?: relayStatusText
|
||||
RelayUiState.NotConfigured,
|
||||
RelayUiState.Expired -> relayStatusText
|
||||
}
|
||||
|
||||
// ADR 24: relayRowState carries both the phase and the active endpoint
|
||||
// role. statusText appends " · <Role>" when the resolver has picked one.
|
||||
// ADR 24: keep the selected route visible without changing the Relay
|
||||
// phase vocabulary shared with the rest of Settings.
|
||||
ConnectionStatusRow(
|
||||
label = stringResource(R.string.active_section_relay),
|
||||
state = relayRowState.asBadgeState(),
|
||||
statusText = relayRowState.statusText(connectedLabel = connectedLabel),
|
||||
statusText = relayStatusWithRole,
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.connectRelay()
|
||||
connectionViewModel.reconnectIfStale()
|
||||
Toast.makeText(
|
||||
context,
|
||||
reconnectingRelayToast,
|
||||
@@ -240,20 +267,15 @@ fun ActiveCardRelayStatusSection(
|
||||
)
|
||||
|
||||
// Pre-resolve strings for Session status
|
||||
val pairedLabel = stringResource(R.string.conn_info_paired)
|
||||
val pairingLabel = stringResource(R.string.conn_info_pairing)
|
||||
val unpairedLabel = stringResource(R.string.conn_info_unpaired)
|
||||
val failedReasonLabel = stringResource(R.string.active_section_failed_reason)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = stringResource(R.string.active_section_session),
|
||||
isConnected = authState is AuthState.Paired,
|
||||
isConnecting = authState is AuthState.Pairing,
|
||||
statusText = when (authState) {
|
||||
is AuthState.Paired -> pairedLabel
|
||||
is AuthState.Pairing -> pairingLabel
|
||||
is AuthState.Unpaired -> unpairedLabel
|
||||
is AuthState.Failed -> failedReasonLabel.format((authState as AuthState.Failed).reason)
|
||||
is AuthState.Paired -> stringResource(R.string.relay_state_ready)
|
||||
is AuthState.Pairing -> stringResource(R.string.conn_info_pairing)
|
||||
is AuthState.Unpaired -> stringResource(R.string.relay_state_optional)
|
||||
is AuthState.Failed -> stringResource(R.string.relay_state_needs_repair)
|
||||
},
|
||||
onClick = onOpenSessionInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -281,7 +303,6 @@ fun ActiveCardFeaturesSection(
|
||||
connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
|
||||
val relayReady by connectionViewModel.relayReady.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
|
||||
@@ -347,29 +368,27 @@ fun ActiveCardFeaturesSection(
|
||||
StandardVoiceAvailability.Unknown -> CapabilityTone.Neutral
|
||||
}
|
||||
|
||||
val relayValue = when {
|
||||
!relayConfigured -> stringResource(R.string.active_section_optional)
|
||||
relayReady -> stringResource(R.string.active_section_ready)
|
||||
relayUiState == RelayUiState.Stale -> stringResource(R.string.active_section_reconnect)
|
||||
else -> stringResource(R.string.active_section_configured)
|
||||
val relayValue = when (relayUiState) {
|
||||
RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
|
||||
RelayUiState.Connected -> stringResource(R.string.relay_state_ready)
|
||||
RelayUiState.Connecting -> stringResource(R.string.relay_state_reconnecting)
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected -> stringResource(R.string.relay_state_unavailable)
|
||||
RelayUiState.Expired -> stringResource(R.string.relay_state_needs_repair)
|
||||
}
|
||||
val relayTone = when {
|
||||
!relayConfigured -> CapabilityTone.Neutral
|
||||
relayReady -> CapabilityTone.Good
|
||||
relayUiState == RelayUiState.Stale -> CapabilityTone.Warning
|
||||
else -> CapabilityTone.Info
|
||||
val relayTone = when (relayUiState) {
|
||||
RelayUiState.NotConfigured -> CapabilityTone.Neutral
|
||||
RelayUiState.Connected -> CapabilityTone.Good
|
||||
RelayUiState.Connecting -> CapabilityTone.Info
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected,
|
||||
RelayUiState.Expired -> CapabilityTone.Warning
|
||||
}
|
||||
|
||||
val terminalValue = when {
|
||||
authState is AuthState.Paired -> stringResource(R.string.active_section_ready)
|
||||
relayConfigured -> stringResource(R.string.active_section_pair_relay)
|
||||
else -> stringResource(R.string.active_section_optional)
|
||||
}
|
||||
val terminalTone = when {
|
||||
authState is AuthState.Paired -> CapabilityTone.Good
|
||||
relayConfigured && authState !is AuthState.Paired -> CapabilityTone.Info
|
||||
else -> CapabilityTone.Neutral
|
||||
}
|
||||
// Terminal rides the same Relay session, so it must never contradict the
|
||||
// Relay tools row with a second, independently-derived directive.
|
||||
val terminalValue = relayValue
|
||||
val terminalTone = relayTone
|
||||
|
||||
val proxyValue = if (secureProxyAdvertised) stringResource(R.string.active_section_available) else stringResource(R.string.active_section_not_advertised)
|
||||
val proxyTone = if (secureProxyAdvertised) CapabilityTone.Good else CapabilityTone.Neutral
|
||||
@@ -1439,6 +1458,7 @@ fun ActiveCardSecurityPosture(
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
|
||||
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
|
||||
val pairedDevices by connectionViewModel.pairedDevices.collectAsState()
|
||||
var showSecurityDetails by remember { mutableStateOf(false) }
|
||||
@@ -1450,11 +1470,13 @@ fun ActiveCardSecurityPosture(
|
||||
dashboardUrl.startsWith("http://", ignoreCase = true) -> "HTTP"
|
||||
else -> stringResource(R.string.active_section_not_configured)
|
||||
}
|
||||
val pairedLabel = if (currentPairedSession != null) {
|
||||
stringResource(R.string.active_section_paired)
|
||||
} else {
|
||||
stringResource(R.string.active_section_not_paired)
|
||||
val pairedLabel = when (authState) {
|
||||
is AuthState.Paired -> stringResource(R.string.relay_state_ready)
|
||||
AuthState.Pairing -> stringResource(R.string.relay_state_reconnecting)
|
||||
is AuthState.Failed -> stringResource(R.string.relay_state_needs_repair)
|
||||
AuthState.Unpaired -> stringResource(R.string.relay_state_optional)
|
||||
}
|
||||
val relaySessionUsable = authState is AuthState.Paired
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Surface(
|
||||
@@ -1540,7 +1562,7 @@ fun ActiveCardSecurityPosture(
|
||||
icon = Icons.Filled.Link,
|
||||
label = stringResource(R.string.active_section_relay_session),
|
||||
value = pairedLabel,
|
||||
positive = currentPairedSession != null,
|
||||
positive = relaySessionUsable,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1555,14 +1577,22 @@ fun ActiveCardSecurityPosture(
|
||||
SecurityAccessRow(
|
||||
icon = Icons.Filled.Devices,
|
||||
label = stringResource(R.string.active_section_paired_devices),
|
||||
value = stringResource(R.string.active_section_device_count, pairedDevices.size),
|
||||
value = if (relaySessionUsable) {
|
||||
stringResource(R.string.active_section_device_count, pairedDevices.size)
|
||||
} else {
|
||||
pairedLabel
|
||||
},
|
||||
onClick = onNavigateToPairedDevices,
|
||||
)
|
||||
HorizontalDivider()
|
||||
SecurityAccessRow(
|
||||
icon = Icons.Filled.Schedule,
|
||||
label = stringResource(R.string.active_section_session_activity),
|
||||
value = stringResource(R.string.active_section_last_checked_just_now),
|
||||
value = if (relaySessionUsable) {
|
||||
stringResource(R.string.active_section_last_checked_just_now)
|
||||
} else {
|
||||
pairedLabel
|
||||
},
|
||||
onClick = onNavigateToPairedDevices,
|
||||
)
|
||||
}
|
||||
@@ -1579,7 +1609,7 @@ fun ActiveCardSecurityPosture(
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onRevokeRelay,
|
||||
enabled = currentPairedSession != null,
|
||||
enabled = relaySessionUsable && currentPairedSession != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.LinkOff, contentDescription = null)
|
||||
|
||||
@@ -212,7 +212,7 @@ private fun connectionChip(state: ConnectionState) {
|
||||
private fun authStateChip(state: AuthState) {
|
||||
val (label, bg, fg) = when (state) {
|
||||
is AuthState.Unpaired -> Triple(
|
||||
stringResource(R.string.conn_info_unpaired),
|
||||
stringResource(R.string.relay_state_optional),
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -222,12 +222,12 @@ private fun authStateChip(state: AuthState) {
|
||||
MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
is AuthState.Paired -> Triple(
|
||||
stringResource(R.string.conn_info_paired),
|
||||
stringResource(R.string.relay_state_ready),
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
is AuthState.Failed -> Triple(
|
||||
stringResource(R.string.conn_info_failed_reason, state.reason),
|
||||
stringResource(R.string.relay_state_needs_repair),
|
||||
MaterialTheme.colorScheme.errorContainer,
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
@@ -358,6 +358,21 @@ fun SessionInfoSheet(
|
||||
// Security overhaul (2026-04-11) — show expiry + grants + storage.
|
||||
pairedSession?.let { paired ->
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = if (authState is AuthState.Paired) {
|
||||
stringResource(R.string.conn_info_session_details)
|
||||
} else {
|
||||
stringResource(R.string.conn_info_stored_session_details)
|
||||
},
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
if (authState !is AuthState.Paired) {
|
||||
Text(
|
||||
text = stringResource(R.string.conn_info_stored_session_details_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
val expiryLabel = when {
|
||||
paired.expiresAt == null -> stringResource(R.string.conn_info_never)
|
||||
else -> java.text.DateFormat
|
||||
|
||||
@@ -46,7 +46,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.util.CrashReport
|
||||
import com.hermesandroid.relay.reliability.ReliabilityReport
|
||||
import com.hermesandroid.relay.util.CrashReporter
|
||||
import com.hermesandroid.relay.util.IssueReport
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -57,16 +57,16 @@ import kotlinx.coroutines.withContext
|
||||
* crashed). Render it inside the app theme so the dialog picks up Material
|
||||
* colors — see RelayApp.
|
||||
*
|
||||
* Peeks the report on first composition (does NOT delete on read) and clears it
|
||||
* only when the user acknowledges it (Dismiss/Report). A report the user merely
|
||||
* Peeks the report on first composition (does not mark it reviewed on read) and
|
||||
* acknowledges it only on Dismiss/Report. A report the user merely
|
||||
* glanced at — or never reached because the app was backgrounded — therefore
|
||||
* survives relaunches instead of being lost after one view; once acknowledged
|
||||
* it's deleted and won't reappear.
|
||||
* survives relaunches instead of being lost after one view. Once acknowledged,
|
||||
* it remains in bounded Diagnostics history but does not interrupt startup again.
|
||||
*/
|
||||
@Composable
|
||||
fun CrashReportGate() {
|
||||
val context = LocalContext.current
|
||||
var report by remember { mutableStateOf<CrashReport?>(null) }
|
||||
var report by remember { mutableStateOf<ReliabilityReport?>(null) }
|
||||
var checked by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -79,19 +79,26 @@ fun CrashReportGate() {
|
||||
CrashReportDialog(
|
||||
report = pending,
|
||||
onDismiss = {
|
||||
// Acknowledged (Dismiss/Report) → delete so it won't reappear.
|
||||
// Acknowledged (Dismiss/Report) → retain as reviewed history without showing again.
|
||||
// Copy does NOT route through here, so the report stays available
|
||||
// across relaunches until the user actually dismisses or reports it.
|
||||
CrashReporter.clearPending(context)
|
||||
CrashReporter.clearPending(context, pending.reportId)
|
||||
report = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val reportText = remember(report) { report.toPlainText() }
|
||||
var showDetails by remember(report.reportId) { mutableStateOf(false) }
|
||||
val copiedMessage = stringResource(R.string.crash_toast_copied)
|
||||
val noShareMessage = stringResource(R.string.crash_toast_no_share)
|
||||
val shareTitle = stringResource(R.string.crash_share_title)
|
||||
val reportSubject = stringResource(R.string.crash_share_subject, report.shortTitle())
|
||||
val openedMessage = stringResource(R.string.crash_toast_opened)
|
||||
val noBrowserMessage = stringResource(R.string.crash_toast_no_browser)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -126,25 +133,34 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 300.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = reportText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.crash_privacy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
|
||||
if (showDetails) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 300.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = reportText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,45 +173,45 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, "Crash report copied")
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
if (!showDetails) {
|
||||
Button(onClick = { showDetails = true }) {
|
||||
Text(stringResource(R.string.crash_review))
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, copiedMessage)
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
// Universal, GitHub-free path: hand the full report to the
|
||||
// system share sheet (email, chat apps, notes, Drive…). The
|
||||
// user picks the destination, so nothing leaves the device
|
||||
// until they choose to send it — same privacy posture as Copy.
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
"Hermes-Relay crash report — ${report.shortTitle()}",
|
||||
reportText,
|
||||
chooserTitle = "Share crash report",
|
||||
)
|
||||
if (!shared) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
reportSubject,
|
||||
reportText,
|
||||
chooserTitle = shareTitle,
|
||||
)
|
||||
if (!shared) {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, noShareMessage)
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
Button(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, "Report copied — no app found to share to")
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
Button(
|
||||
onClick = {
|
||||
// Copy the FULL report first; the URL only carries the
|
||||
// head of the trace, so the user can paste the rest.
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
|
||||
toast(
|
||||
context,
|
||||
if (opened) "Full report copied — paste into the issue if it's truncated"
|
||||
else "Report copied — no browser found to open GitHub",
|
||||
)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_report)) }
|
||||
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
|
||||
toast(context, if (opened) openedMessage else noBrowserMessage)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_report)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -552,14 +553,24 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Conversation voice owns long-press with its action menu.
|
||||
// Disable partial-text selection in that state so Android's
|
||||
// floating selection toolbar does not stack over Copy/Quote/
|
||||
// Speak response. Normal chat retains selectable message text.
|
||||
// Compose's SelectionManager assumes that selectable IDs
|
||||
// captured by a drag remain registered. Reset its owner when
|
||||
// a live Text node becomes a Markdown tree, or settled
|
||||
// Markdown content changes its node topology, so a handle
|
||||
// cannot keep pointing at a removed selectable.
|
||||
if (showSpeakAction) {
|
||||
DisableSelection { messageTextContent() }
|
||||
} else {
|
||||
SelectionContainer { messageTextContent() }
|
||||
key(
|
||||
messageSelectionTopologyKey(
|
||||
isPlainText = isUser || isSystem,
|
||||
isStreaming = message.isStreaming,
|
||||
retainStreamingLayout = retainStreamingLayout,
|
||||
markdownBody = markdownBody,
|
||||
),
|
||||
) {
|
||||
SelectionContainer { messageTextContent() }
|
||||
}
|
||||
}
|
||||
|
||||
// Inline generated images (assistant only) — rendered OUTSIDE
|
||||
@@ -791,6 +802,23 @@ fun MessageBubble(
|
||||
} // end CompositionLocalProvider(LocalMediaBlurMode)
|
||||
}
|
||||
|
||||
internal data class MessageSelectionTopologyKey(
|
||||
val renderer: String,
|
||||
val markdownBody: String?,
|
||||
)
|
||||
|
||||
internal fun messageSelectionTopologyKey(
|
||||
isPlainText: Boolean,
|
||||
isStreaming: Boolean,
|
||||
retainStreamingLayout: Boolean,
|
||||
markdownBody: String,
|
||||
): MessageSelectionTopologyKey = when {
|
||||
isPlainText -> MessageSelectionTopologyKey(renderer = "plain", markdownBody = null)
|
||||
isStreaming || retainStreamingLayout ->
|
||||
MessageSelectionTopologyKey(renderer = "live", markdownBody = null)
|
||||
else -> MessageSelectionTopologyKey(renderer = "markdown", markdownBody = markdownBody)
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a settled, plain assistant response can become a transient pet visit
|
||||
* target. Rich cards, attachments, and tool/action rows remain interaction
|
||||
|
||||
@@ -45,7 +45,7 @@ enum class PowerFeatureGateStatus(
|
||||
explanationRes = R.string.power_feature_requires_pairing_explain,
|
||||
),
|
||||
PairingExpired(
|
||||
labelRes = R.string.power_feature_pairing_expired_label,
|
||||
labelRes = R.string.relay_state_needs_repair,
|
||||
actionLabelRes = R.string.power_feature_pairing_expired_action,
|
||||
explanationRes = R.string.power_feature_pairing_expired_explain,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.reliability.ReliabilityReport
|
||||
import com.hermesandroid.relay.reliability.SupportBundleBuilder
|
||||
import com.hermesandroid.relay.util.IssueReport
|
||||
|
||||
data class SupportReviewState(
|
||||
val reportCount: Int,
|
||||
val text: String,
|
||||
val shareEnabled: Boolean,
|
||||
)
|
||||
|
||||
internal fun buildSupportReviewState(reports: List<ReliabilityReport>): SupportReviewState =
|
||||
SupportReviewState(
|
||||
reportCount = reports.takeLast(SupportBundleBuilder.MAX_REPORTS).size,
|
||||
text = SupportBundleBuilder.build(reports),
|
||||
shareEnabled = reports.isNotEmpty(),
|
||||
)
|
||||
|
||||
/** Exact review surface for the local text handed to clipboard/share. */
|
||||
@Composable
|
||||
fun SupportBundleDialog(state: SupportReviewState, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val copied = stringResource(R.string.support_bundle_copied)
|
||||
val noShare = stringResource(R.string.support_bundle_no_share)
|
||||
val chooser = stringResource(R.string.support_bundle_share_title)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.support_bundle_privacy, state.reportCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 160.dp, max = 420.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
|
||||
RoundedCornerShape(12.dp),
|
||||
),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = state.text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
|
||||
OutlinedButton(
|
||||
enabled = state.shareEnabled,
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, state.text)
|
||||
Toast.makeText(context, copied, Toast.LENGTH_LONG).show()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
Button(
|
||||
enabled = state.shareEnabled,
|
||||
onClick = {
|
||||
if (!IssueReport.share(
|
||||
context,
|
||||
subject = chooser,
|
||||
text = state.text,
|
||||
chooserTitle = chooser,
|
||||
)
|
||||
) {
|
||||
IssueReport.copyToClipboard(context, state.text)
|
||||
Toast.makeText(context, noShare, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,7 +427,7 @@ fun VoiceModeOverlay(
|
||||
)
|
||||
}
|
||||
if (pendingTranscriptText != null) {
|
||||
item(key = "pending-voice-transcript") {
|
||||
item(key = "aux:pending-voice-transcript") {
|
||||
CompactTranscriptRow(
|
||||
message = ChatMessage(
|
||||
id = "pending-voice-transcript",
|
||||
@@ -741,7 +741,8 @@ internal fun pendingVoiceTranscriptText(
|
||||
return if (alreadyRendered) null else transcribed
|
||||
}
|
||||
|
||||
internal fun voiceTranscriptItemKey(message: ChatMessage): String = message.uiKey
|
||||
/** Message rows and Voice-only auxiliary rows occupy disjoint key namespaces. */
|
||||
internal fun voiceTranscriptItemKey(message: ChatMessage): String = "message:${message.uiKey}"
|
||||
|
||||
/**
|
||||
* Conversation-mode voice controls that live inside the chat composer.
|
||||
|
||||
@@ -3472,9 +3472,13 @@ fun ChatScreen(
|
||||
|
||||
// Clean-mode discoverability hint — a quiet, persistent pill teaching
|
||||
// the long-press entry. Shown ONLY on the empty / new-chat view; it
|
||||
// disappears the moment a conversation exists or clean mode is entered.
|
||||
// yields the bottom area whenever clean or voice mode owns it.
|
||||
AnimatedVisibility(
|
||||
visible = messages.isEmpty() && !ambientMode,
|
||||
visible = shouldShowCleanViewHint(
|
||||
hasMessages = messages.isNotEmpty(),
|
||||
ambientMode = ambientMode,
|
||||
voiceMode = voiceUiState.voiceMode,
|
||||
),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
@@ -4177,6 +4181,12 @@ private fun createCameraCaptureUri(context: android.content.Context): Uri {
|
||||
|
||||
private val CHAT_INPUT_REASONING_EFFORTS = listOf("none", "minimal", "low", "medium", "high", "xhigh")
|
||||
|
||||
internal fun shouldShowCleanViewHint(
|
||||
hasMessages: Boolean,
|
||||
ambientMode: Boolean,
|
||||
voiceMode: Boolean,
|
||||
): Boolean = !hasMessages && !ambientMode && !voiceMode
|
||||
|
||||
private fun compactModelChipLabel(model: String?, defaultLabel: String): String {
|
||||
val raw = model?.trim().orEmpty()
|
||||
if (raw.isBlank()) return defaultLabel
|
||||
|
||||
@@ -78,7 +78,6 @@ import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -621,7 +620,12 @@ private fun ConnectionSurfaceSummary(
|
||||
|
||||
val relayText = when {
|
||||
!relayConfigured -> stringResource(R.string.conn_relay_optional)
|
||||
liveState != null -> liveState.statusText(connectedLabel = stringResource(R.string.conn_relay_ready))
|
||||
liveState == RelayUiState.Connected -> stringResource(R.string.relay_state_ready)
|
||||
liveState == RelayUiState.Connecting -> stringResource(R.string.relay_state_reconnecting)
|
||||
liveState == RelayUiState.Stale || liveState == RelayUiState.Disconnected ->
|
||||
stringResource(R.string.relay_state_unavailable)
|
||||
liveState == RelayUiState.Expired -> stringResource(R.string.relay_state_needs_repair)
|
||||
liveState == RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
|
||||
connection.pairedAt != null -> stringResource(R.string.conn_relay_paired)
|
||||
connection.relayUrl.isNotBlank() -> stringResource(R.string.conn_relay_configured)
|
||||
else -> stringResource(R.string.conn_relay_configure)
|
||||
@@ -629,7 +633,9 @@ private fun ConnectionSurfaceSummary(
|
||||
val relayTone = when {
|
||||
!relayConfigured -> SummaryTone.Neutral
|
||||
liveState == RelayUiState.Connected -> SummaryTone.Good
|
||||
liveState == RelayUiState.Stale || liveState == RelayUiState.Disconnected -> SummaryTone.Warning
|
||||
liveState == RelayUiState.Stale ||
|
||||
liveState == RelayUiState.Disconnected ||
|
||||
liveState == RelayUiState.Expired -> SummaryTone.Warning
|
||||
else -> SummaryTone.Info
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.ui.screens
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -10,6 +11,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -24,6 +26,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -42,6 +45,13 @@ import com.hermesandroid.relay.network.upstream.ServerCapabilities
|
||||
import com.hermesandroid.relay.ui.components.DiagnosticDetailDialog
|
||||
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
|
||||
import com.hermesandroid.relay.ui.components.StatusCheckTimeline
|
||||
import com.hermesandroid.relay.ui.components.SupportBundleDialog
|
||||
import com.hermesandroid.relay.ui.components.SupportReviewState
|
||||
import com.hermesandroid.relay.ui.components.buildSupportReviewState
|
||||
import com.hermesandroid.relay.reliability.ReliabilityCenter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportPath
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
@@ -118,6 +128,8 @@ fun DiagnosticsScreen(
|
||||
|
||||
// Tapping a check backed by a concrete log entry opens its full detail.
|
||||
var selectedEntry by remember { mutableStateOf<DiagnosticLogEntry?>(null) }
|
||||
var supportReview by remember { mutableStateOf<SupportReviewState?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -232,6 +244,19 @@ fun DiagnosticsScreen(
|
||||
},
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
supportReview = withContext(Dispatchers.IO) {
|
||||
buildSupportReviewState(ReliabilityCenter.reports(context))
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.support_bundle_review))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.diag_recent_diagnostics),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@@ -256,6 +281,9 @@ fun DiagnosticsScreen(
|
||||
selectedEntry?.let { entry ->
|
||||
DiagnosticDetailDialog(entry = entry, onDismiss = { selectedEntry = null })
|
||||
}
|
||||
supportReview?.let { state ->
|
||||
SupportBundleDialog(state = state, onDismiss = { supportReview = null })
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -91,6 +91,7 @@ import java.util.Date
|
||||
fun PairedDevicesScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onManageSessions: () -> Unit,
|
||||
onRequestRepair: () -> Unit,
|
||||
) {
|
||||
val devices by connectionViewModel.pairedDevices.collectAsState()
|
||||
@@ -157,7 +158,9 @@ fun PairedDevicesScreen(
|
||||
loading && devices.isEmpty() -> LoadingState()
|
||||
loadError != null && devices.isEmpty() -> ErrorState(
|
||||
message = loadError!!,
|
||||
onRetry = { connectionViewModel.loadPairedDevices() }
|
||||
onRetry = { connectionViewModel.loadPairedDevices() },
|
||||
onManageSessions = onManageSessions,
|
||||
onRequestRepair = onRequestRepair,
|
||||
)
|
||||
devices.isEmpty() -> EmptyState(onRequestRepair = onRequestRepair)
|
||||
else -> DeviceList(
|
||||
@@ -376,7 +379,12 @@ private fun LoadingState() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorState(message: String, onRetry: () -> Unit) {
|
||||
private fun ErrorState(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
onManageSessions: () -> Unit,
|
||||
onRequestRepair: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -402,8 +410,22 @@ private fun ErrorState(message: String, onRetry: () -> Unit) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.paired_devices_invalid_session_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = onRetry) {
|
||||
Button(onClick = onManageSessions) {
|
||||
Text(stringResource(R.string.paired_devices_manage_sessions))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(onClick = onRequestRepair) {
|
||||
Text(stringResource(R.string.paired_devices_repair_this_phone))
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TextButton(onClick = onRetry) {
|
||||
Text(stringResource(R.string.paired_devices_try_again))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
@@ -209,14 +208,12 @@ fun SettingsScreen(
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val apiServerReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val apiServerHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val devOptionsUnlocked by FeatureFlags.devOptionsUnlocked(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
val relayPaired = authState is AuthState.Paired
|
||||
val chatRuntimeStatus = resolveChatRuntimeStatus(
|
||||
gateway = when (gatewayAvailability) {
|
||||
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
@@ -276,17 +273,28 @@ fun SettingsScreen(
|
||||
// The Power tools below all ride the relay plugin. Rather than stamp an
|
||||
// identical badge on every card (noise, not signal), the dependency is
|
||||
// surfaced ONCE on the section header as a single plugin-state badge.
|
||||
val pluginBadge = when {
|
||||
!relayPaired ->
|
||||
SettingsStatusPillModel(label = stringResource(R.string.settings_plugin_required), tone = SettingsStatusTone.Info)
|
||||
relayUiState == RelayUiState.Disconnected ->
|
||||
SettingsStatusPillModel(label = stringResource(R.string.settings_plugin_offline), tone = SettingsStatusTone.Warning)
|
||||
relayUiState == RelayUiState.Stale ->
|
||||
SettingsStatusPillModel(label = stringResource(R.string.settings_plugin_stale), tone = SettingsStatusTone.Warning)
|
||||
relayUiState == RelayUiState.Connecting ->
|
||||
SettingsStatusPillModel(label = stringResource(R.string.settings_plugin_connecting), tone = SettingsStatusTone.Info)
|
||||
else ->
|
||||
SettingsStatusPillModel(label = stringResource(R.string.settings_plugin_active), tone = SettingsStatusTone.Good)
|
||||
val pluginBadge = when (relayUiState) {
|
||||
RelayUiState.NotConfigured -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.relay_state_optional),
|
||||
tone = SettingsStatusTone.Info,
|
||||
)
|
||||
RelayUiState.Connected -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.relay_state_ready),
|
||||
tone = SettingsStatusTone.Good,
|
||||
)
|
||||
RelayUiState.Connecting -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.relay_state_reconnecting),
|
||||
tone = SettingsStatusTone.Info,
|
||||
)
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.relay_state_unavailable),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
RelayUiState.Expired -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.relay_state_needs_repair),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
}
|
||||
|
||||
// Kick a WSS reconnect when Settings first composes so the Connections
|
||||
|
||||
@@ -1,81 +1,43 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import kotlinx.serialization.Serializable
|
||||
import com.hermesandroid.relay.reliability.ReliabilityCenter
|
||||
import com.hermesandroid.relay.reliability.LegacyCrashSnapshot
|
||||
import com.hermesandroid.relay.reliability.ReliabilityRedactor
|
||||
import com.hermesandroid.relay.reliability.ReliabilityReport
|
||||
import com.hermesandroid.relay.reliability.migrateLegacyCrash
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* Lightweight, privacy-respecting crash capture — no Firebase/Crashlytics, no
|
||||
* network, no third-party SDK.
|
||||
*
|
||||
* On a fatal uncaught exception we persist a structured report to the app's
|
||||
* private storage, then **re-raise to the platform's previous handler** so the
|
||||
* system "app stopped" dialog still shows and Google Play Android vitals still
|
||||
* records the crash. We only observe; we never swallow.
|
||||
*
|
||||
* On the next launch [CrashReportGate] reads the pending report and offers the
|
||||
* user three GitHub-free-friendly actions (see [CrashReportDialog]): copy the
|
||||
* full report, **share** it via the system sheet (email / chat / notes — the
|
||||
* path for users without a GitHub account and for sideload installs Play vitals
|
||||
* never sees), or open a pre-filled `bug_report.yml` issue. The GitHub path
|
||||
* turns a one-star "it keeps crashing" review into an actionable issue with a
|
||||
* stack trace attached; share/copy cover everyone else. Every outbound path is
|
||||
* user-initiated — nothing is transmitted automatically.
|
||||
*/
|
||||
/** Local-only fatal capture. The platform handler still owns termination and Play vitals. */
|
||||
object CrashReporter {
|
||||
|
||||
private const val TAG = "CrashReporter"
|
||||
private const val DIR = "crash"
|
||||
private const val FILE = "last-crash.json"
|
||||
|
||||
/**
|
||||
* Cap the stack trace we inline into the GitHub URL. Browsers + GitHub
|
||||
* truncate very long URLs, so we ship the head of the trace in the form and
|
||||
* copy the *full* report to the clipboard for the user to paste if needed.
|
||||
*/
|
||||
private const val MAX_TRACE_FOR_URL = 3000
|
||||
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = false
|
||||
}
|
||||
private const val LEGACY_DIR = "crash"
|
||||
private const val LEGACY_FILE = "last-crash.json"
|
||||
private const val MAX_TRACE_FOR_URL = 3_000
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Volatile
|
||||
private var installed = false
|
||||
|
||||
/**
|
||||
* Install the process-wide uncaught-exception handler. Idempotent; call as
|
||||
* early as possible in [com.hermesandroid.relay.HermesRelayApp.onCreate] so
|
||||
* crashes during the rest of app init are captured too.
|
||||
*/
|
||||
fun install(context: Context) {
|
||||
ReliabilityCenter.initialize(context)
|
||||
migrateLegacy(context)
|
||||
if (installed) return
|
||||
installed = true
|
||||
val appContext = context.applicationContext
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
persist(appContext, thread, throwable)
|
||||
} catch (t: Throwable) {
|
||||
// Never let the reporter itself worsen the crash.
|
||||
Log.e(TAG, "Failed to persist crash report", t)
|
||||
ReliabilityCenter.recordFatal(appContext, throwable, thread.name.orEmpty().ifBlank { "?" })
|
||||
} catch (captureFailure: Throwable) {
|
||||
// Never let the reporter worsen or replace the original crash.
|
||||
Log.e(TAG, "Failed to persist local crash report", captureFailure)
|
||||
}
|
||||
// Re-raise so the platform behaves exactly as it would without us:
|
||||
// system dialog + Play vitals collection.
|
||||
if (previous != null) {
|
||||
previous.uncaughtException(thread, throwable)
|
||||
} else {
|
||||
@@ -85,155 +47,70 @@ object CrashReporter {
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist(context: Context, thread: Thread, throwable: Throwable) {
|
||||
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||
val report = CrashReport(
|
||||
timeIso = isoNow(),
|
||||
versionName = BuildConfig.VERSION_NAME,
|
||||
versionCode = BuildConfig.VERSION_CODE,
|
||||
flavor = BuildConfig.FLAVOR,
|
||||
manufacturer = Build.MANUFACTURER.orEmpty().ifBlank { "?" },
|
||||
model = Build.MODEL.orEmpty().ifBlank { "?" },
|
||||
androidRelease = Build.VERSION.RELEASE.orEmpty().ifBlank { "?" },
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
threadName = thread.name.orEmpty().ifBlank { "?" },
|
||||
exceptionSummary = "${throwable.javaClass.name}: ${throwable.message.orEmpty()}".trim(),
|
||||
stackTrace = trace,
|
||||
)
|
||||
val dir = File(context.filesDir, DIR).apply { mkdirs() }
|
||||
File(dir, FILE).writeText(json.encodeToString(report))
|
||||
fun peekPending(context: Context): ReliabilityReport? {
|
||||
migrateLegacy(context)
|
||||
return ReliabilityCenter.pendingCrash(context)
|
||||
}
|
||||
|
||||
/** Read the pending report without clearing it. */
|
||||
fun peekPending(context: Context): CrashReport? = readFile(context)
|
||||
|
||||
/** Read the pending report and delete it (show-once semantics). */
|
||||
fun consumePending(context: Context): CrashReport? {
|
||||
val report = readFile(context)
|
||||
clearPending(context)
|
||||
return report
|
||||
fun clearPending(context: Context, reportId: String? = null) {
|
||||
val target = reportId ?: peekPending(context)?.reportId ?: return
|
||||
ReliabilityCenter.markReviewed(context, target)
|
||||
}
|
||||
|
||||
fun clearPending(context: Context) {
|
||||
runCatching { reportFile(context).delete() }
|
||||
}
|
||||
|
||||
private fun readFile(context: Context): CrashReport? = runCatching {
|
||||
val file = reportFile(context)
|
||||
if (!file.exists()) return null
|
||||
json.decodeFromString<CrashReport>(file.readText())
|
||||
}.getOrNull()
|
||||
|
||||
private fun reportFile(context: Context): File =
|
||||
File(File(context.filesDir, DIR), FILE)
|
||||
|
||||
/**
|
||||
* Build a pre-filled GitHub "new issue" URL.
|
||||
*
|
||||
* Uses the **stable** classic `title` + `body` + `labels` query params, NOT
|
||||
* issue-form field-`id` prefilling (`template=...&<id>=...`). The latter is
|
||||
* a GitHub public-preview feature and was observed to silently not apply
|
||||
* (only `title` carried), which is unacceptable for a crash reporter that
|
||||
* fires on devices we can't retry from. `blank_issues_enabled: true` in
|
||||
* `.github/ISSUE_TEMPLATE/config.yml` guarantees `?body=` opens a prefilled
|
||||
* issue. The body mirrors `bug_report.yml`'s sections in markdown so triage
|
||||
* structure is preserved without depending on the preview path.
|
||||
*/
|
||||
fun buildGithubIssueUrl(report: CrashReport): String = IssueReport.buildGithubIssueUrl(
|
||||
title = "[Bug]: Crash — ${report.shortTitle()}",
|
||||
fun buildGithubIssueUrl(report: ReliabilityReport): String = IssueReport.buildGithubIssueUrl(
|
||||
title = "[Bug]: Android crash — ${ReliabilityRedactor.redact(report.shortTitle(), 90)}",
|
||||
bodyMarkdown = buildIssueBody(report),
|
||||
labels = "bug",
|
||||
labels = "bug,area:android",
|
||||
)
|
||||
|
||||
private fun buildIssueBody(report: CrashReport): String {
|
||||
val trace = report.stackTrace.let {
|
||||
private fun buildIssueBody(report: ReliabilityReport): String {
|
||||
val trace = report.technicalDetail.orEmpty().let {
|
||||
if (it.length > MAX_TRACE_FOR_URL) {
|
||||
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — full report copied to your clipboard)"
|
||||
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — review/copy the local report for the remainder)"
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
return buildString {
|
||||
appendLine(
|
||||
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
|
||||
"or personal data from the trace below.",
|
||||
)
|
||||
appendLine()
|
||||
appendLine("### Affected area")
|
||||
appendLine("Android app")
|
||||
appendLine()
|
||||
appendLine("### What happened?")
|
||||
appendLine("The app closed unexpectedly. Auto-captured crash report below.")
|
||||
appendLine()
|
||||
appendLine("### Environment")
|
||||
appendLine(report.environmentBlock())
|
||||
appendLine()
|
||||
appendLine("### Crash")
|
||||
appendLine("```")
|
||||
appendLine(trace)
|
||||
appendLine("```")
|
||||
appendLine()
|
||||
append("<sub>Captured by the Hermes-Relay in-app crash reporter · ${report.timeIso}</sub>")
|
||||
return ReliabilityRedactor.redact(
|
||||
buildString {
|
||||
appendLine("> No report was uploaded automatically. This is the locally reviewed, redacted copy.")
|
||||
appendLine()
|
||||
appendLine("### Affected area")
|
||||
appendLine("Android app")
|
||||
appendLine()
|
||||
appendLine("### What happened?")
|
||||
appendLine(report.summary)
|
||||
appendLine()
|
||||
appendLine("### Recovery")
|
||||
appendLine(report.recovery)
|
||||
appendLine()
|
||||
appendLine("### Environment")
|
||||
appendLine(report.environmentBlock())
|
||||
appendLine("- Report ID: ${report.reportId}")
|
||||
appendLine("- App session ID: ${report.appSessionId}")
|
||||
appendLine()
|
||||
appendLine("### Redacted technical detail")
|
||||
appendLine("```")
|
||||
appendLine(trace)
|
||||
appendLine("```")
|
||||
appendLine()
|
||||
append("<sub>Captured locally by Hermes-Relay · ${report.timeIso}</sub>")
|
||||
},
|
||||
maxLength = 12_000,
|
||||
)
|
||||
}
|
||||
|
||||
/** Import the pre-v1 one-file crash format once, redacting before the new store sees it. */
|
||||
private fun migrateLegacy(context: Context) {
|
||||
val file = File(File(context.filesDir, LEGACY_DIR), LEGACY_FILE)
|
||||
if (!file.isFile) return
|
||||
runCatching {
|
||||
val old = json.decodeFromString<LegacyCrashSnapshot>(file.readText())
|
||||
val report = migrateLegacyCrash(old)
|
||||
ReliabilityCenter.import(context, report)
|
||||
file.delete()
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Legacy crash report could not be migrated", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isoNow(): String = runCatching {
|
||||
OffsetDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS).toString()
|
||||
}.getOrDefault(java.util.Date().toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured, serializable crash snapshot. Persisted as JSON between the
|
||||
* crashing session and the next launch.
|
||||
*/
|
||||
@Serializable
|
||||
data class CrashReport(
|
||||
val timeIso: String,
|
||||
val versionName: String,
|
||||
val versionCode: Int,
|
||||
val flavor: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val androidRelease: String,
|
||||
val sdkInt: Int,
|
||||
val threadName: String,
|
||||
val exceptionSummary: String,
|
||||
val stackTrace: String,
|
||||
) {
|
||||
fun deviceLine(): String = "$manufacturer $model"
|
||||
|
||||
fun androidLine(): String = "Android $androidRelease (SDK $sdkInt)"
|
||||
|
||||
fun versionLine(): String = "$versionName (code $versionCode) $flavor"
|
||||
|
||||
/** A short, human title for the GitHub issue — class name + trimmed message. */
|
||||
fun shortTitle(): String {
|
||||
val firstLine = exceptionSummary.lineSequence().firstOrNull().orEmpty()
|
||||
val simpleClass = firstLine.substringBefore(':').substringAfterLast('.').ifBlank { "crash" }
|
||||
val message = firstLine.substringAfter(':', "").trim()
|
||||
return (if (message.isBlank()) simpleClass else "$simpleClass: $message").take(90)
|
||||
}
|
||||
|
||||
/** Full, copy-paste-ready report shown in the dialog and copied to clipboard. */
|
||||
fun toPlainText(): String = buildString {
|
||||
appendLine("Hermes-Relay crash report")
|
||||
appendLine("Time: $timeIso")
|
||||
appendLine("App: ${versionLine()}")
|
||||
appendLine("Device: ${deviceLine()}")
|
||||
appendLine("Android: ${androidLine()}")
|
||||
appendLine("Thread: $threadName")
|
||||
appendLine()
|
||||
append(stackTrace)
|
||||
}
|
||||
|
||||
/** Matches the `environment` textarea default in `bug_report.yml`. */
|
||||
fun environmentBlock(): String = buildString {
|
||||
appendLine("- Hermes-Relay version/tag: $versionName (code $versionCode)")
|
||||
appendLine(
|
||||
"- Install surface: " +
|
||||
if (flavor.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play",
|
||||
)
|
||||
appendLine("- Android device and OS: ${deviceLine()} — ${androidLine()}")
|
||||
append("- Connection mode: LAN / Tailscale / public TLS / other")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,12 +36,13 @@ object DiagnosticIssuePrefill {
|
||||
}
|
||||
|
||||
/**
|
||||
* `bug` for Error entries; `question` (an existing repo label) for
|
||||
* Info/Warning so routine diagnostics don't pollute the bug queue.
|
||||
* `bug,area:android` for Error entries; `question,area:android` for
|
||||
* Info/Warning so routine diagnostics don't pollute the bug queue and all
|
||||
* in-app reports reach the owning Android surface.
|
||||
*/
|
||||
fun issueLabels(entry: DiagnosticLogEntry): String = when (entry.severity) {
|
||||
DiagnosticSeverity.Error -> "bug"
|
||||
else -> "question"
|
||||
DiagnosticSeverity.Error -> "bug,area:android"
|
||||
else -> "question,area:android"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +77,7 @@ object DiagnosticIssuePrefill {
|
||||
val whatHappened = DiagnosticsLog.redactReportText(expectation)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: DEFAULT_WHAT_HAPPENED
|
||||
return buildString {
|
||||
val body = buildString {
|
||||
appendLine(
|
||||
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
|
||||
"or personal data from the detail below.",
|
||||
@@ -111,5 +112,6 @@ object DiagnosticIssuePrefill {
|
||||
appendLine()
|
||||
append("<sub>Captured by the Hermes-Relay in-app diagnostics log</sub>")
|
||||
}
|
||||
return DiagnosticsLog.redactReportText(body).orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +105,19 @@ private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): Hum
|
||||
retryable = false,
|
||||
actionLabel = ctx?.getString(R.string.error_classify_voice_settings) ?: "Voice settings",
|
||||
)
|
||||
context == "load_profile_sessions" &&
|
||||
("401" in msg || "403" in msg || "unauthorized" in msg || "forbidden" in msg) -> HumanError(
|
||||
title = ctx?.getString(R.string.power_feature_dashboard_signin_label) ?: "Dashboard sign-in required",
|
||||
body = ctx?.getString(R.string.power_feature_dashboard_signin_explain)
|
||||
?: "Sign in to the Hermes Dashboard to load profile sessions.",
|
||||
retryable = false,
|
||||
)
|
||||
(
|
||||
"api key" in msg ||
|
||||
"sessions auth failed" in msg ||
|
||||
"api auth" in msg ||
|
||||
(context in setOf("load_sessions", "create_session") &&
|
||||
("401" in msg || "403" in msg || "unauthorized" in msg || "forbidden" in msg)) ||
|
||||
(context == "send_message" && ("401" in msg || "unauthorized" in msg))
|
||||
) -> HumanError(
|
||||
title = ctx?.getString(R.string.error_classify_api_key) ?: "API key rejected",
|
||||
@@ -222,6 +231,7 @@ fun classifyError(t: Throwable?, context: String? = null, ctx: Context? = null):
|
||||
title = human.title,
|
||||
detail = human.body,
|
||||
throwable = t,
|
||||
reliabilityContext = context,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -233,6 +243,16 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
|
||||
|
||||
val msg = t.message.orEmpty().lowercase()
|
||||
|
||||
if ("cannot create audiorecord" in msg || "audiorecord failed to initialize" in msg) {
|
||||
return HumanError(
|
||||
title = ctx?.getString(R.string.error_classify_mic_unavailable) ?: "Microphone unavailable",
|
||||
body = ctx?.getString(R.string.error_classify_mic_unavailable_body)
|
||||
?: "The microphone is busy or blocked. Close other apps using the mic and check Microphone permission in Settings.",
|
||||
retryable = true,
|
||||
actionLabel = ctx?.getString(R.string.error_classify_retry) ?: "Retry",
|
||||
)
|
||||
}
|
||||
|
||||
// Upstream rejects new API work with this stable code while an intentional
|
||||
// shutdown/external drain is in progress. Keep it distinct from provider
|
||||
// 503s: the server is healthy and will accept work after the drain clears.
|
||||
|
||||
@@ -152,6 +152,14 @@ internal fun shouldReloadHistoryAfterSuccessfulTurn(
|
||||
actualTransport == "sessions" ||
|
||||
(actualTransport == "gateway" && gatewayReconcileRequired)
|
||||
|
||||
internal fun shouldSuppressPassiveSessionError(context: String?, error: Throwable?): Boolean {
|
||||
if (context != "load_sessions" && context != "load_profile_sessions") return false
|
||||
if (isConnectivityError(error)) return true
|
||||
val message = error?.message?.lowercase().orEmpty()
|
||||
return "401" in message || "403" in message ||
|
||||
"unauthorized" in message || "forbidden" in message
|
||||
}
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
|
||||
private var apiClient: HermesApiClient? = null
|
||||
@@ -415,12 +423,12 @@ class ChatViewModel : ViewModel() {
|
||||
// the server" failure there is non-actionable noise — the themed
|
||||
// connection banner + startup sphere already surface the unreachable
|
||||
// state. Keep the diagnostics record (classifyError above) but suppress
|
||||
// the redundant, scary "server isn't accepting connections" snackbar
|
||||
// that used to flash from the bottom on first load. Actionable failures
|
||||
// (auth rejected, server error) and all interactive contexts
|
||||
// (send_message, …) still surface normally.
|
||||
if ((context == "load_sessions" || context == "create_session") &&
|
||||
isConnectivityError(t)
|
||||
// the redundant, scary "server isn't accepting connections" snackbar.
|
||||
// Passive session-list auth belongs to Dashboard/API setup and must not
|
||||
// become a global Relay re-pair nag. Interactive create/send/media
|
||||
// failures still surface normally.
|
||||
if (shouldSuppressPassiveSessionError(context, t) ||
|
||||
(context == "create_session" && isConnectivityError(t))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -136,13 +136,28 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private data class RelayUiInputs(
|
||||
internal data class RelayUiInputs(
|
||||
val auth: AuthState,
|
||||
val conn: ConnectionState,
|
||||
val url: String,
|
||||
val configured: Boolean,
|
||||
)
|
||||
|
||||
internal fun RelayUiInputs.requiresReconnectGrace(): Boolean =
|
||||
configured &&
|
||||
url.isNotBlank() &&
|
||||
auth is AuthState.Paired &&
|
||||
(conn == ConnectionState.Disconnected || conn == ConnectionState.Reconnecting)
|
||||
|
||||
internal fun RelayUiInputs.resolveRelayUiState(graceElapsed: Boolean = false): RelayUiState = when {
|
||||
!configured || url.isBlank() -> RelayUiState.NotConfigured
|
||||
auth is AuthState.Failed -> RelayUiState.Expired
|
||||
auth is AuthState.Paired && conn == ConnectionState.Connected -> RelayUiState.Connected
|
||||
conn == ConnectionState.Connecting || auth is AuthState.Pairing -> RelayUiState.Connecting
|
||||
requiresReconnectGrace() -> if (graceElapsed) RelayUiState.Stale else RelayUiState.Connecting
|
||||
else -> RelayUiState.Disconnected
|
||||
}
|
||||
|
||||
private data class ConnectionHealthInputs(
|
||||
val connection: Connection?,
|
||||
val relayRow: RelayRowState,
|
||||
@@ -3429,14 +3444,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
.collect { inputs ->
|
||||
pendingStaleJob?.cancel()
|
||||
pendingStaleJob = null
|
||||
_relayUiState.value = when {
|
||||
!inputs.configured || inputs.url.isBlank() -> RelayUiState.NotConfigured
|
||||
inputs.conn == ConnectionState.Connected -> RelayUiState.Connected
|
||||
inputs.conn == ConnectionState.Connecting ||
|
||||
inputs.conn == ConnectionState.Reconnecting ->
|
||||
RelayUiState.Connecting
|
||||
inputs.auth is AuthState.Paired &&
|
||||
inputs.conn == ConnectionState.Disconnected -> {
|
||||
_relayUiState.value = if (inputs.requiresReconnectGrace()) {
|
||||
// Start the grace-window timer. If the WSS
|
||||
// doesn't come up within RELAY_RECONNECT_GRACE_MS,
|
||||
// we promote to Stale so the UI stops lying
|
||||
@@ -3444,7 +3452,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// tap-to-retry affordance.
|
||||
pendingStaleJob = launch {
|
||||
delay(RELAY_RECONNECT_GRACE_MS)
|
||||
_relayUiState.value = RelayUiState.Stale
|
||||
_relayUiState.value = inputs.resolveRelayUiState(graceElapsed = true)
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
@@ -3454,14 +3462,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
)
|
||||
}
|
||||
RelayUiState.Connecting
|
||||
} else {
|
||||
inputs.resolveRelayUiState()
|
||||
}
|
||||
// The relay rejected our token (revoked, or wiped by a
|
||||
// relay restart). Reconnecting won't help — surface a
|
||||
// distinct "pair again" state instead of a generic
|
||||
// Disconnected the user can't act on.
|
||||
inputs.auth is AuthState.Failed -> RelayUiState.Expired
|
||||
else -> RelayUiState.Disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4273,6 +4276,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
* that can't measure it (or want to force a probe) pass [Long.MAX_VALUE].
|
||||
*/
|
||||
fun revalidateOnResume(awayMs: Long) {
|
||||
// Relay recovery is independent of standard API health. Even a brief
|
||||
// resume should replace ordinary WSS backoff with an immediate attempt.
|
||||
reconnectIfStale()
|
||||
val healthy = _apiServerHealth.value == HealthStatus.Reachable
|
||||
if (awayMs in 0 until BRIEF_RESUME_REVALIDATE_MS && healthy) {
|
||||
return
|
||||
@@ -6177,17 +6183,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
* and from the "Reconnect" button / tap-to-reconnect action on the
|
||||
* Relay status row.
|
||||
*
|
||||
* No-op when not paired, already connected, connecting, or reconnecting —
|
||||
* avoids duplicate connect calls that would interrupt an in-flight auth.
|
||||
* No-op when not paired, already connected, or actively handshaking. A
|
||||
* scheduled ordinary reconnect is replaced with an immediate attempt; the
|
||||
* connection manager preserves server-issued rate-limit backoff.
|
||||
*/
|
||||
fun reconnectIfStale() {
|
||||
if (isDemoMode.value) return // Demo mode is offline — never open a socket.
|
||||
val paired = authState.value is AuthState.Paired
|
||||
val disconnected = relayConnectionState.value == ConnectionState.Disconnected
|
||||
val retryableState = relayConnectionState.value == ConnectionState.Disconnected ||
|
||||
relayConnectionState.value == ConnectionState.Reconnecting
|
||||
val relayUrl = effectiveRelayUrlSnapshot()
|
||||
val hasUrl = relayUrl.isNotBlank()
|
||||
if (paired && disconnected && hasUrl) {
|
||||
connectionManager.connect(relayUrl)
|
||||
if (paired && retryableState && hasUrl) {
|
||||
connectionManager.reconnectNowIfAllowed(relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ import com.hermesandroid.relay.ui.components.BadgeState
|
||||
* underlying moment.
|
||||
*
|
||||
* Grace-window behavior: when the session is paired but the WSS is
|
||||
* currently [com.hermesandroid.relay.network.relay.ConnectionState.Disconnected],
|
||||
* currently [com.hermesandroid.relay.network.relay.ConnectionState.Disconnected]
|
||||
* or waiting in scheduled [com.hermesandroid.relay.network.relay.ConnectionState.Reconnecting] backoff,
|
||||
* the VM emits [Connecting] for a short grace window (see
|
||||
* `RELAY_RECONNECT_GRACE_MS` in [ConnectionViewModel]) and only promotes
|
||||
* to [Stale] if the WSS doesn't come up in time. This avoids the "flash
|
||||
@@ -45,9 +46,9 @@ sealed interface RelayUiState {
|
||||
data object Connected : RelayUiState
|
||||
|
||||
/**
|
||||
* Either an in-flight [Connecting]/[com.hermesandroid.relay.network.relay.ConnectionState.Reconnecting]
|
||||
* WSS attempt OR the grace window right after a Paired-but-Disconnected
|
||||
* transition. UI renders this in amber — "we're trying, hold on."
|
||||
* An in-flight [Connecting] WSS attempt, or the grace window right after a
|
||||
* paired socket enters Disconnected/scheduled-Reconnecting state. UI renders
|
||||
* this in amber only while a prompt recovery can still reasonably settle.
|
||||
*/
|
||||
data object Connecting : RelayUiState
|
||||
|
||||
@@ -60,19 +61,18 @@ sealed interface RelayUiState {
|
||||
data object Stale : RelayUiState
|
||||
|
||||
/**
|
||||
* The relay rejected our session token (revoked, or wiped by a relay
|
||||
* restart) — i.e. [com.hermesandroid.relay.auth.AuthState.Failed]. Unlike
|
||||
* [Stale], reconnecting won't help: the fix is to pair again. Rendered red
|
||||
* with a "tap to pair again" hint, and the row's tap opens the relay info /
|
||||
* re-pair surface rather than firing another doomed reconnect. This is the
|
||||
* highest-frequency real failure because the relay's session store is
|
||||
* in-memory and wiped on every restart.
|
||||
* The relay rejected our session token (for example, because it expired
|
||||
* or was revoked) — i.e. [com.hermesandroid.relay.auth.AuthState.Failed]. Unlike
|
||||
* [Stale], reconnecting won't help: the fix is to pair again. Rendered as
|
||||
* "Needs re-pair" only on Relay status and Relay-only feature gates. Retained
|
||||
* pairing metadata remains available so recovery can identify the prior
|
||||
* session without presenting it as active.
|
||||
*/
|
||||
data object Expired : RelayUiState
|
||||
|
||||
/**
|
||||
* No paired session (or auth failed). User action required — usually
|
||||
* re-pair via the Connections sub-screen.
|
||||
* Relay is configured but no usable connection is available. This is a
|
||||
* neutral "Unavailable" state; only [Expired] directs the user to re-pair.
|
||||
*/
|
||||
data object Disconnected : RelayUiState
|
||||
}
|
||||
@@ -181,12 +181,12 @@ fun RelayRowState.asBadgeState(): BadgeState = phase.asBadgeState()
|
||||
* "Connected" word (Connection sub-screen).
|
||||
*/
|
||||
fun RelayUiState.statusText(connectedLabel: String): String = when (this) {
|
||||
RelayUiState.NotConfigured -> "Not configured"
|
||||
RelayUiState.NotConfigured -> "Optional"
|
||||
RelayUiState.Connected -> connectedLabel
|
||||
RelayUiState.Connecting -> "Reconnecting…"
|
||||
RelayUiState.Stale -> "Relay unreachable - tap to reconnect"
|
||||
RelayUiState.Expired -> "Pairing expired — tap to pair again"
|
||||
RelayUiState.Disconnected -> "Disconnected"
|
||||
RelayUiState.Connecting -> "Reconnecting"
|
||||
RelayUiState.Stale -> "Unavailable"
|
||||
RelayUiState.Expired -> "Needs re-pair"
|
||||
RelayUiState.Disconnected -> "Unavailable"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,9 +211,9 @@ fun RelayRowState.statusText(connectedLabel: String): String {
|
||||
return when (phase) {
|
||||
RelayUiState.Connected -> "$base \u00B7 $display"
|
||||
RelayUiState.Connecting -> "$base \u00B7 $display"
|
||||
RelayUiState.Stale -> "Unreachable \u00B7 $display - tap to reconnect"
|
||||
RelayUiState.Stale -> "$base \u00B7 $display"
|
||||
RelayUiState.Expired -> base
|
||||
RelayUiState.Disconnected -> "$base (last via $display)"
|
||||
RelayUiState.Disconnected -> "$base \u00B7 $display"
|
||||
RelayUiState.NotConfigured -> base
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ import com.hermesandroid.relay.voice.createVoiceBridgeIntentHandler
|
||||
// === END PHASE3-voice-intents ===
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -830,6 +831,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Post-response audio completion retry. Continuous mode uses the same
|
||||
// finalizer as tap/hold, then re-arms listening only when explicitly armed.
|
||||
private var continuousResumeJob: Job? = null
|
||||
private var pendingListeningStartJob: Job? = null
|
||||
private var listeningStartEpoch: Long = 0L
|
||||
private var realtimeAmplitudeDecayJob: Job? = null
|
||||
private var firstFrameWatchdogJob: Job? = null
|
||||
private var continuousLoopArmed: Boolean = false
|
||||
@@ -1882,6 +1885,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
setError("Recorder not initialized")
|
||||
return
|
||||
}
|
||||
if (pendingListeningStartJob?.isActive == true) return
|
||||
if (rec.isRecording()) return
|
||||
if (_uiState.value.state == VoiceState.Listening) {
|
||||
// Listening is reserved for a live AudioRecord. Reconcile a stale
|
||||
@@ -1903,7 +1907,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// B4: user manually started a turn → tear down the barge-in
|
||||
// listener and cancel any pending resume. Normal "tap mic to
|
||||
// talk" path also lands here, so we always leave Speaking cleanly.
|
||||
stopBargeInListener()
|
||||
val microphoneRelease = stopBargeInListener()
|
||||
cancelStandardSpeechStream("microphone capture started")
|
||||
resumeWatchdog?.cancel(); resumeWatchdog = null
|
||||
lastInterruptedAtChunkIndex = null
|
||||
@@ -1914,6 +1918,29 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
try { player?.stop() } catch (_: Exception) { /* ignore */ }
|
||||
try { realtimePcmPlayer?.stop() } catch (_: Exception) { /* ignore */ }
|
||||
|
||||
if (microphoneRelease == null || microphoneRelease.isCompleted) {
|
||||
startVoiceCapture(rec)
|
||||
return
|
||||
}
|
||||
|
||||
val startEpoch = ++listeningStartEpoch
|
||||
val pendingStart = viewModelScope.launch(start = CoroutineStart.LAZY) {
|
||||
try {
|
||||
microphoneRelease.join()
|
||||
if (listeningStartEpoch == startEpoch) {
|
||||
startVoiceCapture(rec)
|
||||
}
|
||||
} finally {
|
||||
if (listeningStartEpoch == startEpoch) {
|
||||
pendingListeningStartJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingListeningStartJob = pendingStart
|
||||
pendingStart.start()
|
||||
}
|
||||
|
||||
private fun startVoiceCapture(rec: VoiceRecorder) {
|
||||
try {
|
||||
rec.startRecording()
|
||||
listeningStartedAtMs = System.currentTimeMillis()
|
||||
@@ -1943,6 +1970,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
fun stopListening() {
|
||||
if (cancelPendingListeningStart()) return
|
||||
val rec = recorder ?: return
|
||||
if (!rec.isRecording()) {
|
||||
if (_uiState.value.state == VoiceState.Listening) {
|
||||
@@ -2000,6 +2028,24 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelPendingListeningStart(): Boolean {
|
||||
val pendingStart = pendingListeningStartJob
|
||||
if (pendingStart?.isActive != true) return false
|
||||
|
||||
listeningStartEpoch++
|
||||
pendingStart.cancel()
|
||||
pendingListeningStartJob = null
|
||||
listeningStartedAtMs = 0L
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Idle,
|
||||
amplitude = 0f,
|
||||
outputAudioActive = false,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun listeningDurationMs(): Long {
|
||||
val startedAt = listeningStartedAtMs
|
||||
return if (startedAt > 0L) {
|
||||
@@ -2188,6 +2234,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* new turn on the next mic tap).
|
||||
*/
|
||||
fun interruptSpeaking(): Job? {
|
||||
cancelPendingListeningStart()
|
||||
Log.i(
|
||||
TAG,
|
||||
"Interrupting speech pipeline",
|
||||
@@ -6280,6 +6327,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
continuousLoopArmed = false
|
||||
continuousResumeJob?.cancel()
|
||||
continuousResumeJob = null
|
||||
pendingListeningStartJob?.cancel()
|
||||
pendingListeningStartJob = null
|
||||
listeningStartEpoch++
|
||||
realtimeAmplitudeDecayJob?.cancel()
|
||||
realtimeAmplitudeDecayJob = null
|
||||
try { recorder?.cancel() } catch (_: Exception) { /* ignore */ }
|
||||
|
||||
@@ -238,6 +238,8 @@
|
||||
<string name="error_classify_synthesize">Falha na reprodução de voz</string>
|
||||
<string name="error_classify_voice_config">Configuração de voz indisponível</string>
|
||||
<string name="error_classify_record">Não foi possível gravar</string>
|
||||
<string name="error_classify_mic_unavailable">Microfone indisponível</string>
|
||||
<string name="error_classify_mic_unavailable_body">O microfone está ocupado ou bloqueado. Feche outros apps que estejam usando o microfone e verifique a permissão de microfone nas Configurações.</string>
|
||||
<string name="error_classify_pair">Falha no pareamento</string>
|
||||
<string name="error_classify_save_and_test">Falha na verificação do Relay</string>
|
||||
<string name="error_classify_media_fetch">Não foi possível buscar o anexo</string>
|
||||
@@ -583,6 +585,11 @@
|
||||
<string name="settings_plugin_stale">Plugin desatualizado</string>
|
||||
<string name="settings_plugin_connecting">Conectando o plugin</string>
|
||||
<string name="settings_plugin_active">Plugin ativo</string>
|
||||
<string name="relay_state_optional">Opcional</string>
|
||||
<string name="relay_state_ready">Pronto</string>
|
||||
<string name="relay_state_reconnecting">Reconectando</string>
|
||||
<string name="relay_state_unavailable">Indisponível</string>
|
||||
<string name="relay_state_needs_repair">Precisa parear novamente</string>
|
||||
<string name="settings_no_connection">Sem conexão</string>
|
||||
<string name="settings_server_default">Padrão do servidor</string>
|
||||
<string name="settings_connections">Conexões</string>
|
||||
@@ -1311,7 +1318,10 @@
|
||||
<string name="paired_devices_channel_revoked">Acesso a %1$s revogado</string>
|
||||
<string name="paired_devices_channel_revoke_failed">Falha ao revogar o acesso a %1$s</string>
|
||||
<string name="paired_devices_load_error">Não foi possível carregar as sessões do relay</string>
|
||||
<string name="paired_devices_invalid_session_help">Se a sessão Relay deste telefone não for mais válida, remova os dispositivos antigos em Dashboard → Relay ou execute /relay revoke <token-prefix> no host Hermes. Faça o pareamento novamente somente se você usa extensões do Relay.</string>
|
||||
<string name="paired_devices_try_again">Tentar novamente</string>
|
||||
<string name="paired_devices_manage_sessions">Abrir Dashboard</string>
|
||||
<string name="paired_devices_repair_this_phone">Parear este telefone novamente</string>
|
||||
<string name="paired_devices_empty_title">Nenhuma sessão do relay (ainda)</string>
|
||||
<string name="paired_devices_empty_body">Pareie este celular com seu relay para vê-lo aqui.</string>
|
||||
<string name="paired_devices_pair_now">Parear agora</string>
|
||||
@@ -2251,6 +2261,9 @@
|
||||
<string name="conn_info_server_default_model">Padrão do servidor: %1$s</string>
|
||||
<string name="conn_info_session_desc">Detalhes e segurança do pareamento com o Relay.</string>
|
||||
<string name="conn_info_session_title">Sessão</string>
|
||||
<string name="conn_info_session_details">Detalhes da sessão</string>
|
||||
<string name="conn_info_stored_session_details">Detalhes da sessão armazenada</string>
|
||||
<string name="conn_info_stored_session_details_desc">Estes dados foram mantidos da última sessão Relay válida e podem não estar atualizados.</string>
|
||||
<string name="conn_info_session_token_present">Token de sessão</string>
|
||||
<string name="conn_info_show_less">Mostrar menos</string>
|
||||
<string name="conn_info_skills_count">%1$d habilidades</string>
|
||||
@@ -2505,7 +2518,14 @@
|
||||
<string name="attachment_type_file">Arquivo</string>
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">O Hermes-Relay fechou inesperadamente</string>
|
||||
<string name="crash_body">A última sessão falhou. Enviar este relatório ajuda a corrigir o problema mais rápido.</string>
|
||||
<string name="crash_body">O Hermes-Relay reiniciou após um problema inesperado. Você pode continuar usando o app.</string>
|
||||
<string name="crash_privacy">Nada foi enviado. Revise o relatório editado localmente antes de compartilhar.</string>
|
||||
<string name="crash_review">Revisar relatório</string>
|
||||
<string name="crash_share_title">Compartilhar relatório de falha</string>
|
||||
<string name="crash_share_subject">Relatório de falha do Hermes-Relay — %1$s</string>
|
||||
<string name="crash_toast_no_share">Relatório copiado — nenhum app encontrado para compartilhar</string>
|
||||
<string name="crash_toast_opened">Relatório completo copiado — revise o problema no GitHub antes de enviar</string>
|
||||
<string name="crash_toast_no_browser">Relatório copiado — nenhum navegador encontrado para abrir o GitHub</string>
|
||||
<string name="crash_dismiss">Dispensar</string>
|
||||
<string name="crash_copy">Copiar</string>
|
||||
<string name="crash_share">Compartilhar</string>
|
||||
@@ -3666,4 +3686,10 @@
|
||||
<string name="plugins_keep">Manter</string>
|
||||
<string name="plugins_remove">Remover</string>
|
||||
<string name="plugins_remove_confirm">Remover “%1$s”? Esta página do plugin não aparecerá mais nos dispositivos Android conectados.</string>
|
||||
<string name="support_bundle_review">Revisar informações de suporte</string>
|
||||
<string name="support_bundle_title">Informações de suporte</string>
|
||||
<string name="support_bundle_privacy">Nada é enviado automaticamente. Revise a exportação local com até %1$d relatórios.</string>
|
||||
<string name="support_bundle_copied">Informações de suporte copiadas</string>
|
||||
<string name="support_bundle_no_share">Informações de suporte copiadas — nenhum app encontrado para compartilhar</string>
|
||||
<string name="support_bundle_share_title">Compartilhar informações de suporte do Hermes-Relay</string>
|
||||
</resources>
|
||||
|
||||
@@ -258,6 +258,8 @@
|
||||
<string name="error_classify_synthesize">语音播放失败</string>
|
||||
<string name="error_classify_voice_config">语音配置不可用</string>
|
||||
<string name="error_classify_record">无法录音</string>
|
||||
<string name="error_classify_mic_unavailable">麦克风不可用</string>
|
||||
<string name="error_classify_mic_unavailable_body">麦克风正被占用或已被阻止。请关闭其他正在使用麦克风的应用,并在“设置”中检查麦克风权限。</string>
|
||||
<string name="error_classify_pair">配对失败</string>
|
||||
<string name="error_classify_save_and_test">Relay 检查失败</string>
|
||||
<string name="error_classify_media_fetch">无法获取附件</string>
|
||||
@@ -624,6 +626,11 @@
|
||||
<string name="settings_plugin_stale">插件过期</string>
|
||||
<string name="settings_plugin_connecting">插件连接中</string>
|
||||
<string name="settings_plugin_active">插件已激活</string>
|
||||
<string name="relay_state_optional">可选</string>
|
||||
<string name="relay_state_ready">就绪</string>
|
||||
<string name="relay_state_reconnecting">正在重新连接</string>
|
||||
<string name="relay_state_unavailable">不可用</string>
|
||||
<string name="relay_state_needs_repair">需要重新配对</string>
|
||||
<string name="settings_no_connection">无连接</string>
|
||||
<string name="settings_server_default">服务器默认</string>
|
||||
<string name="settings_connections">连接</string>
|
||||
@@ -1370,7 +1377,10 @@
|
||||
<string name="paired_devices_channel_revoked">%1$s 访问权限已撤销</string>
|
||||
<string name="paired_devices_channel_revoke_failed">撤销 %1$s 访问权限失败</string>
|
||||
<string name="paired_devices_load_error">无法加载 Relay 会话</string>
|
||||
<string name="paired_devices_invalid_session_help">如果此手机的 Relay 会话已失效,请在 Dashboard → Relay 中移除旧设备,或在 Hermes 主机上运行 /relay revoke <token-prefix>。仅当你使用 Relay 扩展功能时才重新配对。</string>
|
||||
<string name="paired_devices_try_again">重试</string>
|
||||
<string name="paired_devices_manage_sessions">打开 Dashboard</string>
|
||||
<string name="paired_devices_repair_this_phone">重新配对此手机</string>
|
||||
<string name="paired_devices_empty_title">(暂无 Relay 会话)</string>
|
||||
<string name="paired_devices_empty_body">将此手机与您的 Relay 配对即可在此处查看。</string>
|
||||
<string name="paired_devices_pair_now">立即配对</string>
|
||||
@@ -2345,6 +2355,9 @@
|
||||
<string name="conn_info_server_default_model">服务器默认:%1$s</string>
|
||||
<string name="conn_info_session_desc">Relay 配对详情和安全信息。</string>
|
||||
<string name="conn_info_session_title">会话</string>
|
||||
<string name="conn_info_session_details">会话详情</string>
|
||||
<string name="conn_info_stored_session_details">已存储的会话详情</string>
|
||||
<string name="conn_info_stored_session_details_desc">这些信息保留自上次有效的 Relay 会话,可能已不是最新状态。</string>
|
||||
<string name="conn_info_session_token_present">会话令牌</string>
|
||||
<string name="conn_info_show_less">收起</string>
|
||||
<string name="conn_info_skills_count">%1$d 项技能</string>
|
||||
@@ -2623,7 +2636,14 @@
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay 意外关闭</string>
|
||||
<string name="crash_body">上次会话崩溃了。发送此报告有助于更快修复问题。</string>
|
||||
<string name="crash_body">Hermes-Relay 在意外问题后已重新启动。你可以继续使用应用。</string>
|
||||
<string name="crash_privacy">未发送任何内容。分享前请查看在本地脱敏的报告。</string>
|
||||
<string name="crash_review">查看报告</string>
|
||||
<string name="crash_share_title">分享崩溃报告</string>
|
||||
<string name="crash_share_subject">Hermes-Relay 崩溃报告 — %1$s</string>
|
||||
<string name="crash_toast_no_share">报告已复制 — 未找到可分享的应用</string>
|
||||
<string name="crash_toast_opened">完整报告已复制 — 提交前请检查 GitHub 问题</string>
|
||||
<string name="crash_toast_no_browser">报告已复制 — 未找到可打开 GitHub 的浏览器</string>
|
||||
<string name="crash_dismiss">忽略</string>
|
||||
<string name="crash_copy">复制</string>
|
||||
<string name="crash_share">分享</string>
|
||||
@@ -3754,4 +3774,10 @@
|
||||
<string name="plugins_keep">保留</string>
|
||||
<string name="plugins_remove">移除</string>
|
||||
<string name="plugins_remove_confirm">要移除“%1$s”吗?此插件页面将不再显示在已连接的 Android 设备上。</string>
|
||||
<string name="support_bundle_review">查看支持信息</string>
|
||||
<string name="support_bundle_title">支持信息</string>
|
||||
<string name="support_bundle_privacy">不会自动上传任何内容。请查看最多包含 %1$d 个报告的本地导出。</string>
|
||||
<string name="support_bundle_copied">支持信息已复制</string>
|
||||
<string name="support_bundle_no_share">支持信息已复制 — 未找到可分享的应用</string>
|
||||
<string name="support_bundle_share_title">分享 Hermes-Relay 支持信息</string>
|
||||
</resources>
|
||||
|
||||
@@ -258,6 +258,8 @@
|
||||
<string name="error_classify_synthesize">Sprachwiedergabe fehlgeschlagen</string>
|
||||
<string name="error_classify_voice_config">Sprachkonfiguration nicht verfügbar</string>
|
||||
<string name="error_classify_record">Aufnahme nicht möglich</string>
|
||||
<string name="error_classify_mic_unavailable">Mikrofon nicht verfügbar</string>
|
||||
<string name="error_classify_mic_unavailable_body">Das Mikrofon ist belegt oder blockiert. Schließe andere Apps, die das Mikrofon verwenden, und überprüfe die Mikrofonberechtigung in den Einstellungen.</string>
|
||||
<string name="error_classify_pair">Kopplung fehlgeschlagen</string>
|
||||
<string name="error_classify_save_and_test">Relay-Prüfung fehlgeschlagen</string>
|
||||
<string name="error_classify_media_fetch">Anhang konnte nicht abgerufen werden</string>
|
||||
@@ -624,6 +626,11 @@
|
||||
<string name="settings_plugin_stale">Plugin veraltet</string>
|
||||
<string name="settings_plugin_connecting">Plugin verbindet sich</string>
|
||||
<string name="settings_plugin_active">Plugin aktiv</string>
|
||||
<string name="relay_state_optional">Optional</string>
|
||||
<string name="relay_state_ready">Bereit</string>
|
||||
<string name="relay_state_reconnecting">Verbindung wird wiederhergestellt</string>
|
||||
<string name="relay_state_unavailable">Nicht verfügbar</string>
|
||||
<string name="relay_state_needs_repair">Erneut koppeln</string>
|
||||
<string name="settings_no_connection">Keine Verbindung</string>
|
||||
<string name="settings_server_default">Serverstandard</string>
|
||||
<string name="settings_connections">Verbindungen</string>
|
||||
@@ -1373,7 +1380,10 @@
|
||||
<string name="paired_devices_channel_revoked">%1$s-Zugriff widerrufen</string>
|
||||
<string name="paired_devices_channel_revoke_failed">%1$s-Zugriff konnte nicht widerrufen werden</string>
|
||||
<string name="paired_devices_load_error">Relay-Sitzungen konnten nicht geladen werden</string>
|
||||
<string name="paired_devices_invalid_session_help">Wenn die Relay-Sitzung dieses Telefons nicht mehr gültig ist, entferne veraltete Geräte unter Dashboard → Relay oder führe /relay revoke <token-prefix> auf dem Hermes-Host aus. Kopple nur erneut, wenn du Relay-Erweiterungen verwendest.</string>
|
||||
<string name="paired_devices_try_again">Erneut versuchen</string>
|
||||
<string name="paired_devices_manage_sessions">Dashboard öffnen</string>
|
||||
<string name="paired_devices_repair_this_phone">Dieses Telefon erneut koppeln</string>
|
||||
<string name="paired_devices_empty_title">Noch keine Relay-Sitzungen</string>
|
||||
<string name="paired_devices_empty_body">Kopple dieses Smartphone mit deinem Relay, damit es hier erscheint.</string>
|
||||
<string name="paired_devices_pair_now">Jetzt koppeln</string>
|
||||
@@ -2350,6 +2360,9 @@
|
||||
<string name="conn_info_server_default_model">Serverstandard: %1$s</string>
|
||||
<string name="conn_info_session_desc">Relay-Kopplungsdetails und Sicherheit.</string>
|
||||
<string name="conn_info_session_title">Sitzung</string>
|
||||
<string name="conn_info_session_details">Sitzungsdetails</string>
|
||||
<string name="conn_info_stored_session_details">Gespeicherte Sitzungsdetails</string>
|
||||
<string name="conn_info_stored_session_details_desc">Diese Angaben stammen aus der letzten gültigen Relay-Sitzung und sind möglicherweise nicht mehr aktuell.</string>
|
||||
<string name="conn_info_session_token_present">Sitzungstoken</string>
|
||||
<string name="conn_info_show_less">Weniger anzeigen</string>
|
||||
<string name="conn_info_skills_count">%1$d Skills</string>
|
||||
@@ -2627,7 +2640,14 @@
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay wurde unerwartet beendet</string>
|
||||
<string name="crash_body">Die letzte Sitzung ist abgestürzt. Dieser Bericht hilft, den Fehler schneller zu beheben.</string>
|
||||
<string name="crash_body">Hermes-Relay wurde nach einem unerwarteten Problem neu gestartet. Sie können die App weiter verwenden.</string>
|
||||
<string name="crash_privacy">Es wurde nichts gesendet. Prüfen Sie den lokal bereinigten Bericht vor dem Teilen.</string>
|
||||
<string name="crash_review">Bericht prüfen</string>
|
||||
<string name="crash_share_title">Absturzbericht teilen</string>
|
||||
<string name="crash_share_subject">Hermes-Relay-Absturzbericht — %1$s</string>
|
||||
<string name="crash_toast_no_share">Bericht kopiert — keine App zum Teilen gefunden</string>
|
||||
<string name="crash_toast_opened">Vollständiger Bericht kopiert — GitHub-Problem vor dem Senden prüfen</string>
|
||||
<string name="crash_toast_no_browser">Bericht kopiert — kein Browser für GitHub gefunden</string>
|
||||
<string name="crash_dismiss">Schließen</string>
|
||||
<string name="crash_copy">Kopieren</string>
|
||||
<string name="crash_share">Teilen</string>
|
||||
@@ -3826,4 +3846,10 @@
|
||||
<string name="plugins_keep">Behalten</string>
|
||||
<string name="plugins_remove">Entfernen</string>
|
||||
<string name="plugins_remove_confirm">„%1$s“ entfernen? Diese Plugin-Seite wird auf verbundenen Android-Geräten nicht mehr angezeigt.</string>
|
||||
<string name="support_bundle_review">Supportinformationen prüfen</string>
|
||||
<string name="support_bundle_title">Supportinformationen</string>
|
||||
<string name="support_bundle_privacy">Nichts wird automatisch hochgeladen. Prüfen Sie den lokalen Export mit bis zu %1$d Berichten.</string>
|
||||
<string name="support_bundle_copied">Supportinformationen kopiert</string>
|
||||
<string name="support_bundle_no_share">Supportinformationen kopiert — keine App zum Teilen gefunden</string>
|
||||
<string name="support_bundle_share_title">Hermes-Relay-Supportinformationen teilen</string>
|
||||
</resources>
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
<string name="error_classify_synthesize">Error en la reproducción de voz</string>
|
||||
<string name="error_classify_voice_config">Configuración de voz no disponible</string>
|
||||
<string name="error_classify_record">No se puede grabar</string>
|
||||
<string name="error_classify_mic_unavailable">Micrófono no disponible</string>
|
||||
<string name="error_classify_mic_unavailable_body">El micrófono está ocupado o bloqueado. Cierra otras aplicaciones que estén usando el micrófono y comprueba el permiso del micrófono en Ajustes.</string>
|
||||
<string name="error_classify_pair">El emparejamiento falló</string>
|
||||
<string name="error_classify_save_and_test">Error en la comprobación de Relay</string>
|
||||
<string name="error_classify_media_fetch">No se ha podido recuperar el archivo adjunto</string>
|
||||
@@ -551,6 +553,11 @@
|
||||
<string name="settings_plugin_stale">Complemento obsoleto</string>
|
||||
<string name="settings_plugin_connecting">Conexión de complementos</string>
|
||||
<string name="settings_plugin_active">Complemento activo</string>
|
||||
<string name="relay_state_optional">Opcional</string>
|
||||
<string name="relay_state_ready">Listo</string>
|
||||
<string name="relay_state_reconnecting">Reconectando</string>
|
||||
<string name="relay_state_unavailable">No disponible</string>
|
||||
<string name="relay_state_needs_repair">Requiere volver a emparejar</string>
|
||||
<string name="settings_no_connection">Sin conexión</string>
|
||||
<string name="settings_server_default">Valor predeterminado del servidor</string>
|
||||
<string name="settings_connections">Conexiones</string>
|
||||
@@ -1252,7 +1259,10 @@
|
||||
<string name="paired_devices_channel_revoked">Acceso %1$s revocado</string>
|
||||
<string name="paired_devices_channel_revoke_failed">No se pudo revocar el acceso a %1$s</string>
|
||||
<string name="paired_devices_load_error">No se han podido cargar las sesiones relay</string>
|
||||
<string name="paired_devices_invalid_session_help">Si la sesión Relay de este teléfono ya no es válida, elimina los dispositivos obsoletos en Dashboard → Relay o ejecuta /relay revoke <token-prefix> en el host de Hermes. Vuelve a emparejar solo si usas extensiones de Relay.</string>
|
||||
<string name="paired_devices_try_again">Intentar otra vez</string>
|
||||
<string name="paired_devices_manage_sessions">Abrir Dashboard</string>
|
||||
<string name="paired_devices_repair_this_phone">Volver a emparejar este teléfono</string>
|
||||
<string name="paired_devices_empty_title">No hay sesiones de relay (todavía)</string>
|
||||
<string name="paired_devices_empty_body">Empareje este teléfono con su relay para verlo aquí.</string>
|
||||
<string name="paired_devices_pair_now">Emparejar ahora</string>
|
||||
@@ -2149,6 +2159,9 @@
|
||||
<string name="conn_info_server_default_model">Valor predeterminado del servidor: %1$s</string>
|
||||
<string name="conn_info_session_desc">Detalles de emparejamiento y seguridad Relay.</string>
|
||||
<string name="conn_info_session_title">Sesión</string>
|
||||
<string name="conn_info_session_details">Detalles de la sesión</string>
|
||||
<string name="conn_info_stored_session_details">Detalles de sesión almacenados</string>
|
||||
<string name="conn_info_stored_session_details_desc">Estos datos se conservan de la última sesión Relay válida y podrían no estar actualizados.</string>
|
||||
<string name="conn_info_session_token_present">token de sesión</string>
|
||||
<string name="conn_info_show_less">Mostrar menos</string>
|
||||
<string name="conn_info_skills_count">Habilidades %1$d</string>
|
||||
@@ -2380,7 +2393,14 @@
|
||||
<string name="attachment_type_text">Texto</string>
|
||||
<string name="attachment_type_file">Archivo</string>
|
||||
<string name="crash_title">Hermes-Relay cerró inesperadamente</string>
|
||||
<string name="crash_body">La última sesión fracasó. Enviar este informe ayuda a solucionarlo más rápido.</string>
|
||||
<string name="crash_body">Hermes-Relay se reinició tras un problema inesperado. Puedes seguir usando la aplicación.</string>
|
||||
<string name="crash_privacy">No se envió nada. Revisa el informe redactado localmente antes de compartirlo.</string>
|
||||
<string name="crash_review">Revisar informe</string>
|
||||
<string name="crash_share_title">Compartir informe de fallo</string>
|
||||
<string name="crash_share_subject">Informe de fallo de Hermes-Relay — %1$s</string>
|
||||
<string name="crash_toast_no_share">Informe copiado — no se encontró una aplicación para compartir</string>
|
||||
<string name="crash_toast_opened">Informe completo copiado — revisa la incidencia de GitHub antes de enviarla</string>
|
||||
<string name="crash_toast_no_browser">Informe copiado — no se encontró un navegador para GitHub</string>
|
||||
<string name="crash_dismiss">Descartar</string>
|
||||
<string name="crash_copy">Copiar</string>
|
||||
<string name="crash_share">Compartir</string>
|
||||
@@ -3511,4 +3531,10 @@
|
||||
<string name="plugins_keep">Conservar</string>
|
||||
<string name="plugins_remove">Eliminar</string>
|
||||
<string name="plugins_remove_confirm">¿Eliminar «%1$s»? Esta página del plugin dejará de aparecer en los dispositivos Android conectados.</string>
|
||||
<string name="support_bundle_review">Revisar información de soporte</string>
|
||||
<string name="support_bundle_title">Información de soporte</string>
|
||||
<string name="support_bundle_privacy">Nada se sube automáticamente. Revisa la exportación local con hasta %1$d informes.</string>
|
||||
<string name="support_bundle_copied">Información de soporte copiada</string>
|
||||
<string name="support_bundle_no_share">Información de soporte copiada — no se encontró una aplicación para compartir</string>
|
||||
<string name="support_bundle_share_title">Compartir información de soporte de Hermes-Relay</string>
|
||||
</resources>
|
||||
|
||||
@@ -258,6 +258,8 @@
|
||||
<string name="error_classify_synthesize">音声再生に失敗しました</string>
|
||||
<string name="error_classify_voice_config">音声設定が利用できません</string>
|
||||
<string name="error_classify_record">録音できません</string>
|
||||
<string name="error_classify_mic_unavailable">マイクを使用できません</string>
|
||||
<string name="error_classify_mic_unavailable_body">マイクが使用中かブロックされています。マイクを使用している他のアプリを閉じ、設定でマイクの権限を確認してください。</string>
|
||||
<string name="error_classify_pair">ペアリングに失敗しました</string>
|
||||
<string name="error_classify_save_and_test">Relay チェックに失敗しました</string>
|
||||
<string name="error_classify_media_fetch">添付ファイルを取得できませんでした</string>
|
||||
@@ -624,6 +626,11 @@
|
||||
<string name="settings_plugin_stale">プラグインが古い</string>
|
||||
<string name="settings_plugin_connecting">プラグイン接続中</string>
|
||||
<string name="settings_plugin_active">プラグインがアクティブです</string>
|
||||
<string name="relay_state_optional">オプション</string>
|
||||
<string name="relay_state_ready">準備完了</string>
|
||||
<string name="relay_state_reconnecting">再接続中</string>
|
||||
<string name="relay_state_unavailable">利用不可</string>
|
||||
<string name="relay_state_needs_repair">再ペアリングが必要</string>
|
||||
<string name="settings_no_connection">接続がありません</string>
|
||||
<string name="settings_server_default">サーバーのデフォルト</string>
|
||||
<string name="settings_connections">接続</string>
|
||||
@@ -1386,7 +1393,10 @@
|
||||
<string name="paired_devices_channel_revoked">%1$s アクセスが取り消されました</string>
|
||||
<string name="paired_devices_channel_revoke_failed">%1$s アクセスを取り消すことができませんでした</string>
|
||||
<string name="paired_devices_load_error">Relayセッションをロードできませんでした</string>
|
||||
<string name="paired_devices_invalid_session_help">このスマートフォンの Relay セッションが無効になった場合は、Dashboard → Relay から古いデバイスを削除するか、Hermes ホストで /relay revoke <token-prefix> を実行してください。Relay 拡張機能を使用する場合のみ、再度ペアリングしてください。</string>
|
||||
<string name="paired_devices_try_again">もう一度やり直してください</string>
|
||||
<string name="paired_devices_manage_sessions">Dashboard を開く</string>
|
||||
<string name="paired_devices_repair_this_phone">このスマートフォンを再ペアリング</string>
|
||||
<string name="paired_devices_empty_title">Relayセッションは(まだ)ありません</string>
|
||||
<string name="paired_devices_empty_body">この電話をRelayとペアリングすると、ここで表示されます。</string>
|
||||
<string name="paired_devices_pair_now">今すぐペアリング</string>
|
||||
@@ -2361,6 +2371,9 @@
|
||||
<string name="conn_info_server_default_model">サーバーのデフォルト: %1$s</string>
|
||||
<string name="conn_info_session_desc">Relay ペアリングの詳細とセキュリティ。</string>
|
||||
<string name="conn_info_session_title">セッション</string>
|
||||
<string name="conn_info_session_details">セッションの詳細</string>
|
||||
<string name="conn_info_stored_session_details">保存されたセッションの詳細</string>
|
||||
<string name="conn_info_stored_session_details_desc">これらは最後に有効だった Relay セッションの情報で、現在の状態とは異なる場合があります。</string>
|
||||
<string name="conn_info_session_token_present">セッショントークン</string>
|
||||
<string name="conn_info_show_less">表示を少なくする</string>
|
||||
<string name="conn_info_skills_count">%1$d スキル</string>
|
||||
@@ -2637,7 +2650,14 @@
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay が予期せず終了しました</string>
|
||||
<string name="crash_body">最後のセッションがクラッシュしました。このレポートを送信すると、問題をより早く修正できます。</string>
|
||||
<string name="crash_body">予期しない問題の後、Hermes-Relay が再起動しました。アプリは引き続き使用できます。</string>
|
||||
<string name="crash_privacy">何も送信されていません。共有する前に端末内で編集されたレポートを確認してください。</string>
|
||||
<string name="crash_review">レポートを確認</string>
|
||||
<string name="crash_share_title">クラッシュレポートを共有</string>
|
||||
<string name="crash_share_subject">Hermes-Relay クラッシュレポート — %1$s</string>
|
||||
<string name="crash_toast_no_share">レポートをコピーしました — 共有できるアプリがありません</string>
|
||||
<string name="crash_toast_opened">完全なレポートをコピーしました — 送信前に GitHub の内容を確認してください</string>
|
||||
<string name="crash_toast_no_browser">レポートをコピーしました — GitHub を開けるブラウザがありません</string>
|
||||
<string name="crash_dismiss">却下する</string>
|
||||
<string name="crash_copy">コピー</string>
|
||||
<string name="crash_share">共有</string>
|
||||
@@ -3825,4 +3845,10 @@
|
||||
<string name="plugins_keep">保持</string>
|
||||
<string name="plugins_remove">削除</string>
|
||||
<string name="plugins_remove_confirm">「%1$s」を削除しますか?接続された Android 端末にこのプラグインページは表示されなくなります。</string>
|
||||
<string name="support_bundle_review">サポート情報を確認</string>
|
||||
<string name="support_bundle_title">サポート情報</string>
|
||||
<string name="support_bundle_privacy">自動アップロードはありません。最大 %1$d 件のレポートを含む端末内エクスポートを確認してください。</string>
|
||||
<string name="support_bundle_copied">サポート情報をコピーしました</string>
|
||||
<string name="support_bundle_no_share">サポート情報をコピーしました — 共有できるアプリがありません</string>
|
||||
<string name="support_bundle_share_title">Hermes-Relay サポート情報を共有</string>
|
||||
</resources>
|
||||
|
||||
@@ -242,6 +242,8 @@
|
||||
<string name="error_classify_synthesize">Воспроизведение голоса не удалось</string>
|
||||
<string name="error_classify_voice_config">Конфигурация голоса недоступна</string>
|
||||
<string name="error_classify_record">Нельзя записать</string>
|
||||
<string name="error_classify_mic_unavailable">Микрофон недоступен</string>
|
||||
<string name="error_classify_mic_unavailable_body">Микрофон занят или заблокирован. Закройте другие приложения, использующие микрофон, и проверьте разрешение на доступ к микрофону в настройках.</string>
|
||||
<string name="error_classify_pair">Сопряжение не удалось</string>
|
||||
<string name="error_classify_save_and_test">Проверка Relay не удалась</string>
|
||||
<string name="error_classify_media_fetch">Не удалось получить вложение</string>
|
||||
@@ -594,6 +596,11 @@
|
||||
<string name="settings_plugin_stale">Плагин устарел</string>
|
||||
<string name="settings_plugin_connecting">Подключение плагина</string>
|
||||
<string name="settings_plugin_active">Плагин активен</string>
|
||||
<string name="relay_state_optional">Необязательно</string>
|
||||
<string name="relay_state_ready">Готово</string>
|
||||
<string name="relay_state_reconnecting">Переподключение</string>
|
||||
<string name="relay_state_unavailable">Недоступно</string>
|
||||
<string name="relay_state_needs_repair">Требуется повторное сопряжение</string>
|
||||
<string name="settings_no_connection">Нет соединения</string>
|
||||
<string name="settings_server_default">Сервер по умолчанию</string>
|
||||
<string name="settings_connections">Подключения</string>
|
||||
@@ -1379,7 +1386,10 @@
|
||||
<string name="paired_devices_channel_revoked">Доступ %1$s отозван</string>
|
||||
<string name="paired_devices_channel_revoke_failed">Не удалось отозвать доступ %1$s</string>
|
||||
<string name="paired_devices_load_error">Не удалось загрузить сессии Relay</string>
|
||||
<string name="paired_devices_invalid_session_help">Если сессия Relay этого телефона больше недействительна, удалите устаревшие устройства в Dashboard → Relay или выполните /relay revoke <token-prefix> на хосте Hermes. Выполняйте повторное сопряжение, только если используете расширения Relay.</string>
|
||||
<string name="paired_devices_try_again">Попробовать снова</string>
|
||||
<string name="paired_devices_manage_sessions">Открыть Dashboard</string>
|
||||
<string name="paired_devices_repair_this_phone">Повторно сопрячь этот телефон</string>
|
||||
<string name="paired_devices_empty_title">Нет сессий Relay (пока)</string>
|
||||
<string name="paired_devices_empty_body">Сопоставьте этот телефон с вашим Relay, чтобы увидеть его здесь.</string>
|
||||
<string name="paired_devices_pair_now">Сопоставить сейчас</string>
|
||||
@@ -2339,6 +2349,9 @@
|
||||
<string name="conn_info_server_default_model">По умолчанию сервера: %1$s</string>
|
||||
<string name="conn_info_session_desc">Детали сопряжения Relay и безопасность.</string>
|
||||
<string name="conn_info_session_title">Сессия</string>
|
||||
<string name="conn_info_session_details">Сведения о сеансе</string>
|
||||
<string name="conn_info_stored_session_details">Сохранённые сведения о сеансе</string>
|
||||
<string name="conn_info_stored_session_details_desc">Эти сведения сохранены из последнего действительного сеанса Relay и могут быть устаревшими.</string>
|
||||
<string name="conn_info_session_token_present">Токен сессии</string>
|
||||
<string name="conn_info_show_less">Показать меньше</string>
|
||||
<string name="conn_info_skills_count">%1$d навыков</string>
|
||||
@@ -2587,7 +2600,14 @@
|
||||
<string name="attachment_type_text">Текст</string>
|
||||
<string name="attachment_type_file">Файл</string>
|
||||
<string name="crash_title">Hermes-Relay неожиданно закрылся</string>
|
||||
<string name="crash_body">Последняя сессия завершилась аварийно. Отправка этого отчета поможет быстрее исправить проблему.</string>
|
||||
<string name="crash_body">Hermes-Relay перезапустился после непредвиденной проблемы. Приложением можно продолжать пользоваться.</string>
|
||||
<string name="crash_privacy">Ничего не отправлено. Перед отправкой проверьте локально обезличенный отчёт.</string>
|
||||
<string name="crash_review">Проверить отчёт</string>
|
||||
<string name="crash_share_title">Поделиться отчётом о сбое</string>
|
||||
<string name="crash_share_subject">Отчёт о сбое Hermes-Relay — %1$s</string>
|
||||
<string name="crash_toast_no_share">Отчёт скопирован — приложение для отправки не найдено</string>
|
||||
<string name="crash_toast_opened">Полный отчёт скопирован — проверьте проблему GitHub перед отправкой</string>
|
||||
<string name="crash_toast_no_browser">Отчёт скопирован — браузер для GitHub не найден</string>
|
||||
<string name="crash_dismiss">Закрыть</string>
|
||||
<string name="crash_copy">Скопировать</string>
|
||||
<string name="crash_share">Поделиться</string>
|
||||
@@ -3547,4 +3567,10 @@
|
||||
<string name="plugins_keep">Оставить</string>
|
||||
<string name="plugins_remove">Удалить</string>
|
||||
<string name="plugins_remove_confirm">Удалить «%1$s»? Эта страница плагина больше не будет отображаться на подключённых устройствах Android.</string>
|
||||
<string name="support_bundle_review">Проверить сведения для поддержки</string>
|
||||
<string name="support_bundle_title">Сведения для поддержки</string>
|
||||
<string name="support_bundle_privacy">Ничего не загружается автоматически. Проверьте локальный экспорт с не более чем %1$d отчётами.</string>
|
||||
<string name="support_bundle_copied">Сведения для поддержки скопированы</string>
|
||||
<string name="support_bundle_no_share">Сведения для поддержки скопированы — приложение для отправки не найдено</string>
|
||||
<string name="support_bundle_share_title">Поделиться сведениями поддержки Hermes-Relay</string>
|
||||
</resources>
|
||||
|
||||
@@ -260,6 +260,8 @@
|
||||
<string name="error_classify_synthesize">Voice playback failed</string>
|
||||
<string name="error_classify_voice_config">Voice config unavailable</string>
|
||||
<string name="error_classify_record">Can\'t record</string>
|
||||
<string name="error_classify_mic_unavailable">Microphone unavailable</string>
|
||||
<string name="error_classify_mic_unavailable_body">The microphone is busy or blocked. Close other apps using the mic and check Microphone permission in Settings.</string>
|
||||
<string name="error_classify_pair">Pairing failed</string>
|
||||
<string name="error_classify_save_and_test">Relay check failed</string>
|
||||
<string name="error_classify_media_fetch">Couldn\'t fetch attachment</string>
|
||||
@@ -643,6 +645,11 @@
|
||||
<string name="settings_plugin_stale">Plugin stale</string>
|
||||
<string name="settings_plugin_connecting">Plugin connecting</string>
|
||||
<string name="settings_plugin_active">Plugin active</string>
|
||||
<string name="relay_state_optional">Optional</string>
|
||||
<string name="relay_state_ready">Ready</string>
|
||||
<string name="relay_state_reconnecting">Reconnecting</string>
|
||||
<string name="relay_state_unavailable">Unavailable</string>
|
||||
<string name="relay_state_needs_repair">Needs re-pair</string>
|
||||
<string name="settings_no_connection">No connection</string>
|
||||
<string name="settings_server_default">Server default</string>
|
||||
<string name="settings_connections">Connections</string>
|
||||
@@ -1478,7 +1485,10 @@
|
||||
<string name="paired_devices_channel_revoked">%1$s access revoked</string>
|
||||
<string name="paired_devices_channel_revoke_failed">Failed to revoke %1$s access</string>
|
||||
<string name="paired_devices_load_error">Couldn\'t load relay sessions</string>
|
||||
<string name="paired_devices_invalid_session_help">If this phone\'s Relay session is no longer valid, remove stale devices from Dashboard → Relay or run /relay revoke <token-prefix> on the Hermes host. Pair again only if you use Relay extensions.</string>
|
||||
<string name="paired_devices_try_again">Try again</string>
|
||||
<string name="paired_devices_manage_sessions">Open Dashboard</string>
|
||||
<string name="paired_devices_repair_this_phone">Re-pair this phone</string>
|
||||
<string name="paired_devices_empty_title">No relay sessions (yet)</string>
|
||||
<string name="paired_devices_empty_body">Pair this phone with your relay to see it here.</string>
|
||||
<string name="paired_devices_pair_now">Pair now</string>
|
||||
@@ -2470,6 +2480,9 @@
|
||||
<string name="conn_info_server_default_model">Server default: %1$s</string>
|
||||
<string name="conn_info_session_desc">Relay pairing details and security.</string>
|
||||
<string name="conn_info_session_title">Session</string>
|
||||
<string name="conn_info_session_details">Session details</string>
|
||||
<string name="conn_info_stored_session_details">Stored session details</string>
|
||||
<string name="conn_info_stored_session_details_desc">These details are retained from the last valid Relay session and may no longer be current.</string>
|
||||
<string name="conn_info_session_token_present">Session token</string>
|
||||
<string name="conn_info_show_less">Show less</string>
|
||||
<string name="conn_info_skills_count">%1$d skills</string>
|
||||
@@ -2759,7 +2772,14 @@
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay closed unexpectedly</string>
|
||||
<string name="crash_body">The last session crashed. Sending this report helps get it fixed faster.</string>
|
||||
<string name="crash_body">Hermes-Relay restarted after an unexpected problem. You can continue using the app.</string>
|
||||
<string name="crash_privacy">Nothing was sent. Review the locally redacted report before choosing copy, share, or GitHub.</string>
|
||||
<string name="crash_review">Review report</string>
|
||||
<string name="crash_share_title">Share crash report</string>
|
||||
<string name="crash_share_subject">Hermes-Relay crash report — %1$s</string>
|
||||
<string name="crash_toast_no_share">Report copied — no app found to share to</string>
|
||||
<string name="crash_toast_opened">Full report copied — review the GitHub issue before submitting</string>
|
||||
<string name="crash_toast_no_browser">Report copied — no browser found to open GitHub</string>
|
||||
<string name="crash_dismiss">Dismiss</string>
|
||||
<string name="crash_copy">Copy</string>
|
||||
<string name="crash_share">Share</string>
|
||||
@@ -3832,4 +3852,10 @@
|
||||
<string name="plugins_keep">Keep</string>
|
||||
<string name="plugins_remove">Remove</string>
|
||||
<string name="plugins_remove_confirm">Remove “%1$s”? This plugin page will no longer appear on connected Android devices.</string>
|
||||
<string name="support_bundle_review">Review support information</string>
|
||||
<string name="support_bundle_title">Support information</string>
|
||||
<string name="support_bundle_privacy">Nothing is uploaded automatically. Review the exact local export below. It contains up to %1$d recent reports.</string>
|
||||
<string name="support_bundle_copied">Support information copied</string>
|
||||
<string name="support_bundle_no_share">Support information copied — no app found to share to</string>
|
||||
<string name="support_bundle_share_title">Share Hermes-Relay support information</string>
|
||||
</resources>
|
||||
|
||||
@@ -8,9 +8,9 @@ import org.junit.Test
|
||||
|
||||
class DiagnosticsLogTest {
|
||||
@Test
|
||||
fun sanitizeUrlDropsSecretsAndKeepsRoute() {
|
||||
fun sanitizeUrlDropsSecretsAndHostWhileKeepingPath() {
|
||||
assertEquals(
|
||||
"https://relay.example.test:8767/health",
|
||||
"https://[host]/health",
|
||||
DiagnosticsLog.sanitizeUrl(
|
||||
"https://user:secret@relay.example.test:8767/health?token=abc#frag",
|
||||
),
|
||||
|
||||
@@ -5,6 +5,40 @@ import org.junit.Test
|
||||
|
||||
class RelayReconnectStateTest {
|
||||
|
||||
@Test
|
||||
fun scheduledReconnectPolicyOnlyOverridesOrdinaryWaitingBackoff() {
|
||||
assertEquals(
|
||||
true,
|
||||
canOverrideScheduledRelayReconnect(
|
||||
state = ConnectionState.Reconnecting,
|
||||
backoffWaiting = true,
|
||||
rateLimitBackoffActive = false,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
false,
|
||||
canOverrideScheduledRelayReconnect(
|
||||
state = ConnectionState.Reconnecting,
|
||||
backoffWaiting = false,
|
||||
rateLimitBackoffActive = false,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
false,
|
||||
canOverrideScheduledRelayReconnect(
|
||||
state = ConnectionState.Reconnecting,
|
||||
backoffWaiting = true,
|
||||
rateLimitBackoffActive = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rateLimitBackoffRemainsActiveUntilItsDeadline() {
|
||||
assertEquals(true, isRelayRateLimitBackoffActive(untilMs = 10_000, nowMs = 9_999))
|
||||
assertEquals(false, isRelayRateLimitBackoffActive(untilMs = 10_000, nowMs = 10_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun consecutiveFailuresAreScopedToTheSocketRoute() {
|
||||
val state = RelayReconnectState()
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Unit tests for ChatHandler message state management.
|
||||
@@ -2189,6 +2190,165 @@ class ChatHandlerTest {
|
||||
assertEquals("Running terminal", handler.turnStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_thenRestoreCheckpoint_mergesReboundAssistantWithoutDuplicateUiIdentity() {
|
||||
val clientId = "c32c93a8-b54b-4943-87b0-dfd36adb9c3f"
|
||||
val serverId = "server-assistant-id"
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = clientId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "partial answer",
|
||||
timestamp = 2L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = serverId,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("partial answer"),
|
||||
timestamp = 2.0,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(serverId, handler.messages.value.single().id)
|
||||
assertEquals(clientId, handler.messages.value.single().uiKey)
|
||||
|
||||
handler.restoreInFlightTurn(
|
||||
ChatTurnCheckpoint(
|
||||
contextKey = "connection/profile",
|
||||
sessionId = "session-1",
|
||||
liveSessionId = "session-1",
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("user-client-id", "question", 1L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = clientId,
|
||||
content = "partial answer",
|
||||
timestamp = 2L,
|
||||
thinkingContent = "checkpoint reasoning",
|
||||
),
|
||||
priorUserMessageCount = 0,
|
||||
baselineAssistantCount = 0,
|
||||
startedAt = 1L,
|
||||
updatedAt = 3L,
|
||||
),
|
||||
)
|
||||
|
||||
val assistants = handler.messages.value.filter { it.role == MessageRole.ASSISTANT }
|
||||
assertEquals(1, assistants.size)
|
||||
assertEquals(serverId, assistants.single().id)
|
||||
assertEquals(clientId, assistants.single().uiKey)
|
||||
assertEquals("checkpoint reasoning", assistants.single().thinkingContent)
|
||||
assertEquals(
|
||||
handler.messages.value.size,
|
||||
handler.messages.value.map(ChatMessage::uiKey).distinct().size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleClientCallbacks_afterServerIdAdoption_mutateTheReboundRow() {
|
||||
val clientId = "b229a0f6-4f15-4f86-bce3-a939d5acce82"
|
||||
val serverId = "server-assistant-id"
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = clientId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = 1L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
handler.replaceMessageId(clientId, serverId)
|
||||
|
||||
// Gateway recovery callbacks retain the checkpoint/client id even
|
||||
// after history or message.started has adopted the server id.
|
||||
handler.onTextDelta(clientId, "answer")
|
||||
handler.onThinkingDelta(clientId, "reasoning")
|
||||
handler.onToolGenerating(clientId, "terminal")
|
||||
|
||||
val assistant = handler.messages.value.single()
|
||||
assertEquals(serverId, assistant.id)
|
||||
assertEquals(clientId, assistant.uiKey)
|
||||
assertEquals("answer", assistant.content)
|
||||
assertEquals("reasoning", assistant.thinkingContent)
|
||||
assertEquals(1, assistant.toolCalls.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveryTransitionModel_neverPublishesDuplicateRenderIdentity() {
|
||||
repeat(64) { seed ->
|
||||
val modelHandler = ChatHandler()
|
||||
val clientId = "client-$seed"
|
||||
val serverId = "server-$seed"
|
||||
val checkpoint = ChatTurnCheckpoint(
|
||||
contextKey = "connection/profile",
|
||||
sessionId = "session-$seed",
|
||||
liveSessionId = "session-$seed",
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("user-$seed", "question", 1L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = clientId,
|
||||
content = "partial",
|
||||
timestamp = 2L,
|
||||
thinkingContent = "checkpoint",
|
||||
),
|
||||
priorUserMessageCount = 0,
|
||||
baselineAssistantCount = 0,
|
||||
startedAt = 1L,
|
||||
updatedAt = 2L,
|
||||
)
|
||||
modelHandler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = clientId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "partial",
|
||||
timestamp = 2L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
var serverIdAdopted = false
|
||||
val random = Random(seed)
|
||||
repeat(20) { step ->
|
||||
when (random.nextInt(5)) {
|
||||
0 -> {
|
||||
val content = modelHandler.messages.value
|
||||
.last { it.role == MessageRole.ASSISTANT }
|
||||
.content
|
||||
modelHandler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = serverId,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(content),
|
||||
timestamp = 2.0,
|
||||
),
|
||||
),
|
||||
)
|
||||
serverIdAdopted = true
|
||||
}
|
||||
1 -> modelHandler.restoreInFlightTurn(checkpoint)
|
||||
2 -> modelHandler.onTextDelta(clientId, ".$step")
|
||||
3 -> modelHandler.onThinkingDelta(clientId, "t$step")
|
||||
else -> modelHandler.onUsageReceived(clientId, step, step + 1, null, null)
|
||||
}
|
||||
|
||||
val snapshot = modelHandler.messages.value
|
||||
assertEquals(
|
||||
"seed=$seed step=$step keys=${snapshot.map(ChatMessage::uiKey)}",
|
||||
snapshot.size,
|
||||
snapshot.map(ChatMessage::uiKey).distinct().size,
|
||||
)
|
||||
val assistants = snapshot.filter { it.role == MessageRole.ASSISTANT }
|
||||
assertEquals("seed=$seed step=$step", 1, assistants.size)
|
||||
assertEquals(clientId, assistants.single().uiKey)
|
||||
if (serverIdAdopted) assertEquals(serverId, assistants.single().id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onMoaReference_upsertsByCanonicalIndexAndResetsOnNewSequence() {
|
||||
handler.addPlaceholderMessage(
|
||||
|
||||
+35
@@ -820,6 +820,8 @@ class DashboardApiClientTest {
|
||||
// carry profile=mizu (the desktop's `_open_session_db_for_profile` path).
|
||||
val url = request.requestUrl!!
|
||||
assertEquals("/api/sessions", url.encodedPath)
|
||||
assertEquals("100", url.queryParameter("limit"))
|
||||
assertEquals("0", url.queryParameter("offset"))
|
||||
assertEquals("mizu", url.queryParameter("profile"))
|
||||
assertEquals("1", url.queryParameter("min_messages"))
|
||||
assertEquals(2, sessions.size)
|
||||
@@ -832,6 +834,39 @@ class DashboardApiClientTest {
|
||||
assertEquals("Review title fallbacks", sessions[1].preview)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listSessions_pagesAtUpstreamMaximumWhilePreservingTwoHundredRowWindow() = runTest {
|
||||
val firstPage = (0 until 100).joinToString(",") { "{\"id\":\"sess-$it\"}" }
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("{\"sessions\":[$firstPage],\"total\":102,\"limit\":100,\"offset\":0}"),
|
||||
)
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""{"sessions":[{"id":"sess-100"},{"id":"sess-101"}],"total":102,"limit":100,"offset":100}""",
|
||||
),
|
||||
)
|
||||
|
||||
val sessions = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
.listSessions(profile = "mizu")
|
||||
.getOrThrow()
|
||||
|
||||
val firstRequest = server.takeRequest().requestUrl!!
|
||||
val secondRequest = server.takeRequest().requestUrl!!
|
||||
assertEquals("100", firstRequest.queryParameter("limit"))
|
||||
assertEquals("0", firstRequest.queryParameter("offset"))
|
||||
assertEquals("mizu", firstRequest.queryParameter("profile"))
|
||||
assertEquals("100", secondRequest.queryParameter("limit"))
|
||||
assertEquals("100", secondRequest.queryParameter("offset"))
|
||||
assertEquals("mizu", secondRequest.queryParameter("profile"))
|
||||
assertEquals(102, sessions.size)
|
||||
assertEquals("sess-0", sessions.first().id)
|
||||
assertEquals("sess-101", sessions.last().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listSessions_omitsProfileParamForTheDefaultSelection() = runTest {
|
||||
server.enqueue(
|
||||
|
||||
+16
-2
@@ -406,8 +406,22 @@ class HermesApiClientTest {
|
||||
@Test
|
||||
fun urlConstruction_sessionsEndpoint() {
|
||||
val baseUrl = "http://localhost:8642"
|
||||
val url = "$baseUrl/api/sessions?limit=200"
|
||||
assertEquals("http://localhost:8642/api/sessions?limit=200", url)
|
||||
val page = sessionListPages(200).first()
|
||||
val url = "$baseUrl/api/sessions?limit=${page.limit}&offset=${page.offset}"
|
||||
assertEquals("http://localhost:8642/api/sessions?limit=100&offset=0", url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionListPages_preservesWindowWithoutExceedingUpstreamMaximum() {
|
||||
assertEquals(
|
||||
listOf(SessionListPage(limit = 100, offset = 0), SessionListPage(limit = 100, offset = 100)),
|
||||
sessionListPages(200),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(SessionListPage(limit = 100, offset = 0), SessionListPage(limit = 100, offset = 100)),
|
||||
sessionListPages(999),
|
||||
)
|
||||
assertEquals(listOf(SessionListPage(limit = 25, offset = 0)), sessionListPages(25))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RenderedMessageStateTest {
|
||||
|
||||
@Test
|
||||
fun publicationBoundary_keepsFirstPositionAndLatestSnapshotForEachUiIdentity() {
|
||||
val state = RenderedMessageState(emptyList())
|
||||
val first = message(id = "client-a", uiKey = "row-a", content = "partial")
|
||||
val middle = message(id = "server-b", uiKey = "row-b", content = "other")
|
||||
val replay = message(id = "server-a", uiKey = "row-a", content = "final")
|
||||
|
||||
state.update { listOf(first, middle, replay) }
|
||||
|
||||
assertEquals(listOf("row-a", "row-b"), state.value.map(ChatMessage::uiKey))
|
||||
assertEquals(listOf("server-a", "server-b"), state.value.map(ChatMessage::id))
|
||||
assertEquals("final", state.value.first().content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publicationBoundary_normalizesEveryMutationApi() {
|
||||
val first = message(id = "client", uiKey = "stable", content = "partial")
|
||||
val latest = message(id = "server", uiKey = "stable", content = "final")
|
||||
val state = RenderedMessageState(listOf(first, latest))
|
||||
assertEquals(1, state.value.size)
|
||||
|
||||
state.value = listOf(first, latest)
|
||||
assertEquals(1, state.value.size)
|
||||
|
||||
state.update { listOf(first, latest) }
|
||||
assertEquals(1, state.value.size)
|
||||
}
|
||||
|
||||
private fun message(id: String, uiKey: String, content: String) = ChatMessage(
|
||||
id = id,
|
||||
uiKey = uiKey,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = content,
|
||||
timestamp = 1L,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.hermesandroid.relay.reliability
|
||||
|
||||
import java.io.File
|
||||
import java.net.SocketTimeoutException
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.net.URLDecoder
|
||||
import com.hermesandroid.relay.util.CrashReporter
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ReliabilityReportTest {
|
||||
private val environment = ReliabilityEnvironment(
|
||||
versionName = "1.6.1",
|
||||
versionCode = 38,
|
||||
flavor = "sideload",
|
||||
manufacturer = "Example",
|
||||
model = "Phone",
|
||||
androidRelease = "16",
|
||||
sdkInt = 36,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun redactorRemovesSecretsHostsIdentifiersPathsAndContent() {
|
||||
val raw = """
|
||||
authorization: Bearer-secret
|
||||
token=abc123
|
||||
https://private.example.test:8767/path?q=secret
|
||||
host 192.168.1.4:8642
|
||||
session 123e4567-e89b-12d3-a456-426614174000
|
||||
C:\Users\person\private\file.txt
|
||||
prompt=private words from a conversation
|
||||
""".trimIndent()
|
||||
|
||||
val redacted = ReliabilityRedactor.redact(raw)
|
||||
|
||||
listOf(
|
||||
"Bearer-secret", "abc123", "private.example.test", "192.168.1.4",
|
||||
"123e4567-e89b-12d3-a456-426614174000", "person", "private words",
|
||||
).forEach { assertFalse("leaked $it", redacted.contains(it)) }
|
||||
assertTrue(redacted.contains("[url hidden]"))
|
||||
assertTrue(redacted.contains("prompt=[hidden]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun classifierSeparatesCancellationConnectivityAuthRateLimitAndProductErrors() {
|
||||
assertEquals(
|
||||
ReliabilityKind.ExpectedCancellation,
|
||||
ReliabilityClassifier.classify(CancellationException(), "gateway").kind,
|
||||
)
|
||||
assertFalse(ReliabilityClassifier.classify(CancellationException(), "gateway").shouldPersist)
|
||||
assertEquals(
|
||||
ReliabilityKind.Connectivity,
|
||||
ReliabilityClassifier.classify(SocketTimeoutException("slow"), "gateway").kind,
|
||||
)
|
||||
assertEquals(
|
||||
ReliabilityOwner.UpstreamGateway,
|
||||
ReliabilityClassifier.classify(SocketTimeoutException("slow"), "gateway").owner,
|
||||
)
|
||||
assertEquals(
|
||||
ReliabilityKind.Authentication,
|
||||
ReliabilityClassifier.classify(IllegalStateException("HTTP 401"), "dashboard").kind,
|
||||
)
|
||||
assertEquals(
|
||||
ReliabilityKind.RateLimit,
|
||||
ReliabilityClassifier.classify(IllegalStateException("HTTP 429"), "api").kind,
|
||||
)
|
||||
assertTrue(
|
||||
ReliabilityClassifier.classify(IllegalArgumentException("duplicate key"), "ui")
|
||||
.reportRecommended,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storeEnforcesAgeCountRedactionAndReviewedState() {
|
||||
val dir = kotlin.io.path.createTempDirectory("reliability-store").toFile()
|
||||
val store = ReliabilityStore(File(dir, "reports.json"), maxReports = 3, retentionDays = 14)
|
||||
val now = Instant.parse("2026-08-04T12:00:00Z")
|
||||
|
||||
store.append(report("old", "2026-07-01T00:00:00Z"), now)
|
||||
store.append(report("one", "2026-08-01T00:00:00Z"), now)
|
||||
store.append(report("two", "2026-08-02T00:00:00Z"), now)
|
||||
store.append(report("three", "2026-08-03T00:00:00Z"), now)
|
||||
store.append(
|
||||
report("four", "2026-08-04T00:00:00Z").copy(summary = "token=do-not-store"),
|
||||
now,
|
||||
)
|
||||
|
||||
val stored = store.readAll(now)
|
||||
assertEquals(listOf("two", "three", "four"), stored.map { it.reportId })
|
||||
assertFalse(stored.last().summary.contains("do-not-store"))
|
||||
assertTrue(stored.last().pendingReview)
|
||||
|
||||
store.markReviewed("four", now)
|
||||
assertFalse(store.readAll(now).last().pendingReview)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun legacyMigrationIsVersionedPendingAndRedacted() {
|
||||
val migrated = migrateLegacyCrash(
|
||||
LegacyCrashSnapshot(
|
||||
timeIso = "2026-08-03T15:12:26Z",
|
||||
versionName = "1.6.0",
|
||||
versionCode = 37,
|
||||
flavor = "googlePlay",
|
||||
manufacturer = "Example",
|
||||
model = "Phone",
|
||||
androidRelease = "17",
|
||||
sdkInt = 37,
|
||||
threadName = "main",
|
||||
exceptionSummary = "Crash token=secret-value",
|
||||
stackTrace = "at Example https://private.example.test/path",
|
||||
),
|
||||
reportId = "rpt-test",
|
||||
appSessionId = "legacy-test",
|
||||
)
|
||||
|
||||
assertEquals(RELIABILITY_SCHEMA_VERSION, migrated.schemaVersion)
|
||||
assertEquals(ReliabilityKind.FatalCrash, migrated.kind)
|
||||
assertTrue(migrated.pendingReview)
|
||||
assertFalse(migrated.toPlainText().contains("secret-value"))
|
||||
assertFalse(migrated.toPlainText().contains("private.example.test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportBundleIsBoundedAndUsesExactRedactedReports() {
|
||||
val reports = (1..12).map { index ->
|
||||
report("r$index", "2026-08-${index.toString().padStart(2, '0')}T00:00:00Z")
|
||||
.copy(technicalDetail = "token=secret-$index")
|
||||
}
|
||||
val bundle = SupportBundleBuilder.build(reports)
|
||||
|
||||
assertFalse(bundle.contains("secret-"))
|
||||
assertFalse(bundle.contains("Report: r1\n"))
|
||||
assertTrue(bundle.contains("Report: r12"))
|
||||
assertEquals(10, Regex("===== Report ").findAll(bundle).count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashIssuePrefillTargetsAndroidAndContainsOnlyRedactedDetail() {
|
||||
val report = report("rpt-prefill", "2026-08-04T00:00:00Z").copy(
|
||||
summary = "Crash token=private-value",
|
||||
technicalDetail = "at Example https://private.example.test/path",
|
||||
)
|
||||
|
||||
val url = CrashReporter.buildGithubIssueUrl(report)
|
||||
val decoded = URLDecoder.decode(url, "UTF-8")
|
||||
|
||||
assertTrue(decoded.contains("labels=bug,area:android"))
|
||||
assertTrue(decoded.contains("### Affected area\nAndroid app"))
|
||||
assertFalse(decoded.contains("private-value"))
|
||||
assertFalse(decoded.contains("private.example.test"))
|
||||
}
|
||||
|
||||
private fun report(id: String, time: String): ReliabilityReport = ReliabilityReport(
|
||||
reportId = id,
|
||||
appSessionId = "app-test",
|
||||
timeIso = time,
|
||||
kind = ReliabilityKind.FatalCrash,
|
||||
owner = ReliabilityOwner.Android,
|
||||
severity = ReliabilitySeverity.Fatal,
|
||||
summary = "Unexpected problem",
|
||||
recovery = "The app restarted",
|
||||
reportRecommended = true,
|
||||
technicalDetail = "java.lang.IllegalStateException",
|
||||
environment = environment,
|
||||
pendingReview = true,
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -37,4 +39,43 @@ class MessageBubbleActionTest {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialSelection_resetsOnlyWhenSelectableTopologyChanges() {
|
||||
val initialLive = messageSelectionTopologyKey(
|
||||
isPlainText = false,
|
||||
isStreaming = true,
|
||||
retainStreamingLayout = false,
|
||||
markdownBody = "First",
|
||||
)
|
||||
val updatedLive = messageSelectionTopologyKey(
|
||||
isPlainText = false,
|
||||
isStreaming = true,
|
||||
retainStreamingLayout = false,
|
||||
markdownBody = "First paragraph\n\nSecond",
|
||||
)
|
||||
val retainedLive = messageSelectionTopologyKey(
|
||||
isPlainText = false,
|
||||
isStreaming = false,
|
||||
retainStreamingLayout = true,
|
||||
markdownBody = "First paragraph\n\nSecond",
|
||||
)
|
||||
val settledMarkdown = messageSelectionTopologyKey(
|
||||
isPlainText = false,
|
||||
isStreaming = false,
|
||||
retainStreamingLayout = false,
|
||||
markdownBody = "First paragraph\n\nSecond",
|
||||
)
|
||||
val revisedMarkdown = messageSelectionTopologyKey(
|
||||
isPlainText = false,
|
||||
isStreaming = false,
|
||||
retainStreamingLayout = false,
|
||||
markdownBody = "First paragraph\n\nSecond\n\nThird",
|
||||
)
|
||||
|
||||
assertEquals(initialLive, updatedLive)
|
||||
assertEquals(initialLive, retainedLive)
|
||||
assertNotEquals(initialLive, settledMarkdown)
|
||||
assertNotEquals(settledMarkdown, revisedMarkdown)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.reliability.ReliabilityEnvironment
|
||||
import com.hermesandroid.relay.reliability.ReliabilityKind
|
||||
import com.hermesandroid.relay.reliability.ReliabilityOwner
|
||||
import com.hermesandroid.relay.reliability.ReliabilityReport
|
||||
import com.hermesandroid.relay.reliability.ReliabilitySeverity
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupportBundleDialogTest {
|
||||
@Test
|
||||
fun emptyStateCannotShare() {
|
||||
val state = buildSupportReviewState(emptyList())
|
||||
assertEquals(0, state.reportCount)
|
||||
assertFalse(state.shareEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun populatedStateShowsExactlyTheBundleThatCanBeShared() {
|
||||
val report = ReliabilityReport(
|
||||
reportId = "rpt-visible",
|
||||
appSessionId = "app-visible",
|
||||
timeIso = "2026-08-04T12:00:00Z",
|
||||
kind = ReliabilityKind.RecoverableProductError,
|
||||
owner = ReliabilityOwner.Voice,
|
||||
severity = ReliabilitySeverity.Error,
|
||||
summary = "Focus controls stopped responding",
|
||||
recovery = "Voice closed and chat remained available",
|
||||
reportRecommended = true,
|
||||
environment = ReliabilityEnvironment("1.6.1", 38, "googlePlay", "Example", "Phone", "13", 33),
|
||||
)
|
||||
|
||||
val state = buildSupportReviewState(listOf(report))
|
||||
|
||||
assertEquals(1, state.reportCount)
|
||||
assertTrue(state.shareEnabled)
|
||||
assertTrue(state.text.contains("Focus controls stopped responding"))
|
||||
assertTrue(state.text.contains("Owner: Voice"))
|
||||
}
|
||||
}
|
||||
+11
-20
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.viewmodel.realtimeTranscriptState
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTurnActiveAfterPromotion
|
||||
import com.hermesandroid.relay.viewmodel.voiceSessionExitState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
@@ -63,29 +64,19 @@ class VoiceModeOverlayStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcriptKeys_remainDistinctWhenRowsShareReconciledServerId() {
|
||||
fun transcriptKey_staysStableAcrossServerIdAdoption() {
|
||||
val serverId = "7c4af8b7-1bb2-4830-a4e5-0332d5ddcd1f"
|
||||
val messages = listOf(
|
||||
ChatMessage(
|
||||
id = serverId,
|
||||
uiKey = "persisted-assistant-row",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Earlier snapshot",
|
||||
timestamp = 1L,
|
||||
),
|
||||
ChatMessage(
|
||||
id = serverId,
|
||||
uiKey = "live-assistant-row",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Reconciled live snapshot",
|
||||
timestamp = 2L,
|
||||
),
|
||||
val live = ChatMessage(
|
||||
id = "client-assistant-id",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Earlier snapshot",
|
||||
timestamp = 1L,
|
||||
)
|
||||
val reconciled = live.copy(id = serverId, content = "Reconciled snapshot")
|
||||
|
||||
assertEquals(
|
||||
listOf("persisted-assistant-row", "live-assistant-row"),
|
||||
messages.map(::voiceTranscriptItemKey),
|
||||
)
|
||||
assertEquals(voiceTranscriptItemKey(live), voiceTranscriptItemKey(reconciled))
|
||||
assertEquals("message:client-assistant-id", voiceTranscriptItemKey(reconciled))
|
||||
assertNotEquals("aux:pending-voice-transcript", voiceTranscriptItemKey(reconciled))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatCleanViewHintTest {
|
||||
|
||||
@Test
|
||||
fun emptyTextChatShowsCleanViewHint() {
|
||||
assertTrue(
|
||||
shouldShowCleanViewHint(
|
||||
hasMessages = false,
|
||||
ambientMode = false,
|
||||
voiceMode = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceModeOwnsBottomAreaOnEmptyChat() {
|
||||
assertFalse(
|
||||
shouldShowCleanViewHint(
|
||||
hasMessages = false,
|
||||
ambientMode = false,
|
||||
voiceMode = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun existingConversationOrCleanModeSuppressesHint() {
|
||||
assertFalse(
|
||||
shouldShowCleanViewHint(
|
||||
hasMessages = true,
|
||||
ambientMode = false,
|
||||
voiceMode = false,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
shouldShowCleanViewHint(
|
||||
hasMessages = false,
|
||||
ambientMode = true,
|
||||
voiceMode = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ class IssueReportAndDiagnosticsTest {
|
||||
fun errorEntriesKeepBugTitleAndLabel() {
|
||||
val entry = sampleEntry(DiagnosticSeverity.Error, title = "API key rejected")
|
||||
assertEquals("[Bug]: API key rejected", DiagnosticIssuePrefill.issueTitle(entry))
|
||||
assertEquals("bug", DiagnosticIssuePrefill.issueLabels(entry))
|
||||
assertEquals("bug,area:android", DiagnosticIssuePrefill.issueLabels(entry))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -124,7 +124,7 @@ class IssueReportAndDiagnosticsTest {
|
||||
val entry = sampleEntry(severity)
|
||||
assertEquals("[Diagnostic]: Testing API connection", DiagnosticIssuePrefill.issueTitle(entry))
|
||||
// "question" already exists on the repo — the prefill must not invent labels.
|
||||
assertEquals("question", DiagnosticIssuePrefill.issueLabels(entry))
|
||||
assertEquals("question,area:android", DiagnosticIssuePrefill.issueLabels(entry))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ class IssueReportAndDiagnosticsTest {
|
||||
)
|
||||
assertTrue(body.contains("- Connection mode: tailscale"))
|
||||
assertFalse(body.contains("LAN / Tailscale / public TLS / other"))
|
||||
assertFalse(body.contains("10.0.0.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -52,6 +52,29 @@ class RelayErrorClassifierTest {
|
||||
assertFalse(err.body.contains("re-pair", ignoreCase = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun apiSessionLoadUnauthorizedPointsAtApiKeyInsteadOfRepairingRelay() {
|
||||
val err = classifyError(
|
||||
IOException("List sessions unauthorized - check your API key"),
|
||||
context = "load_sessions",
|
||||
)
|
||||
|
||||
assertEquals("API key rejected", err.title)
|
||||
assertFalse(err.body.contains("re-pair", ignoreCase = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardProfileSessionUnauthorizedDoesNotBlameRelayPairing() {
|
||||
val err = classifyError(
|
||||
IOException("Profile sessions unauthorized - HTTP 401"),
|
||||
context = "load_profile_sessions",
|
||||
)
|
||||
|
||||
assertEquals("Dashboard sign-in required", err.title)
|
||||
assertFalse(err.body.contains("re-pair", ignoreCase = true))
|
||||
assertEquals(null, err.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayUnauthorizedStillPointsAtPairing() {
|
||||
val err = classifyError(
|
||||
@@ -106,4 +129,27 @@ class RelayErrorClassifierTest {
|
||||
assertEquals("Realtime provider auth unavailable", err.title)
|
||||
assertFalse(err.body.contains("server refused", ignoreCase = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioRecordCannotCreateMapsToMicUnavailableHint() {
|
||||
val err = classifyError(
|
||||
UnsupportedOperationException("Cannot create AudioRecord"),
|
||||
context = "record",
|
||||
)
|
||||
|
||||
assertEquals("Microphone unavailable", err.title)
|
||||
assertTrue(err.body.contains("microphone", ignoreCase = true))
|
||||
assertTrue(err.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioRecordFailedToInitializeMapsToMicUnavailableHint() {
|
||||
val err = classifyError(
|
||||
IllegalStateException("AudioRecord failed to initialize"),
|
||||
context = "record",
|
||||
)
|
||||
|
||||
assertEquals("Microphone unavailable", err.title)
|
||||
assertTrue(err.retryable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatPassiveErrorPolicyTest {
|
||||
@Test
|
||||
fun backgroundSessionAuthAndConnectivityStayOutOfGlobalSnackbar() {
|
||||
assertTrue(shouldSuppressPassiveSessionError("load_sessions", ConnectException("refused")))
|
||||
assertTrue(shouldSuppressPassiveSessionError("load_sessions", IOException("401 Unauthorized")))
|
||||
assertTrue(shouldSuppressPassiveSessionError("load_profile_sessions", IOException("HTTP 403")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serverAndInteractiveErrorsStillSurface() {
|
||||
assertFalse(shouldSuppressPassiveSessionError("load_sessions", IOException("HTTP 500")))
|
||||
assertFalse(shouldSuppressPassiveSessionError("create_session", IOException("401 Unauthorized")))
|
||||
assertFalse(shouldSuppressPassiveSessionError("send_message", IOException("401 Unauthorized")))
|
||||
assertFalse(shouldSuppressPassiveSessionError("media_fetch", IOException("401 Unauthorized")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.network.relay.ConnectionState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RelayUiStateTest {
|
||||
@Test
|
||||
fun `relay phases use the standard user-facing vocabulary`() {
|
||||
assertEquals("Optional", RelayUiState.NotConfigured.statusText("Ready"))
|
||||
assertEquals("Ready", RelayUiState.Connected.statusText("Ready"))
|
||||
assertEquals("Reconnecting", RelayUiState.Connecting.statusText("Ready"))
|
||||
assertEquals("Unavailable", RelayUiState.Stale.statusText("Ready"))
|
||||
assertEquals("Needs re-pair", RelayUiState.Expired.statusText("Ready"))
|
||||
assertEquals("Unavailable", RelayUiState.Disconnected.statusText("Ready"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scheduled reconnect becomes unavailable after grace`() {
|
||||
val inputs = RelayUiInputs(
|
||||
auth = AuthState.Paired("token"),
|
||||
conn = ConnectionState.Reconnecting,
|
||||
url = "wss://relay.example/ws",
|
||||
configured = true,
|
||||
)
|
||||
|
||||
assertEquals(RelayUiState.Connecting, inputs.resolveRelayUiState())
|
||||
assertEquals(RelayUiState.Stale, inputs.resolveRelayUiState(graceElapsed = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed auth takes precedence over reconnecting transport`() {
|
||||
val inputs = RelayUiInputs(
|
||||
auth = AuthState.Failed("expired"),
|
||||
conn = ConnectionState.Reconnecting,
|
||||
url = "wss://relay.example/ws",
|
||||
configured = true,
|
||||
)
|
||||
|
||||
assertEquals(RelayUiState.Expired, inputs.resolveRelayUiState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `socket is not ready until pairing auth succeeds`() {
|
||||
val inputs = RelayUiInputs(
|
||||
auth = AuthState.Pairing,
|
||||
conn = ConnectionState.Connected,
|
||||
url = "wss://relay.example/ws",
|
||||
configured = true,
|
||||
)
|
||||
|
||||
assertEquals(RelayUiState.Connecting, inputs.resolveRelayUiState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `route detail does not replace the standard relay phase`() {
|
||||
assertEquals(
|
||||
"Unavailable \u00B7 Tailscale",
|
||||
RelayRowState(
|
||||
phase = RelayUiState.Stale,
|
||||
activeEndpointRole = "tailscale",
|
||||
).statusText("Ready"),
|
||||
)
|
||||
assertEquals(
|
||||
"Needs re-pair",
|
||||
RelayRowState(
|
||||
phase = RelayUiState.Expired,
|
||||
activeEndpointRole = "lan",
|
||||
).statusText("Ready"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
@@ -233,6 +234,45 @@ class VoiceViewModelBargeInTest {
|
||||
verify(atLeast = 1) { recorder.startRecording() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual mic waits for barge-in reader release before starting capture`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Hello."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.startListening()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual mic release cancels capture waiting on barge-in shutdown`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Hello."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.startListening()
|
||||
runCurrent()
|
||||
vm.stopListening()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Test 2 — resume with resumeAfterInterruption=true + silence
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
# Android reliability and support audit
|
||||
|
||||
**Date:** 2026-08-04
|
||||
**Scope:** Android fatal crashes, handled failures, diagnostics, symbolication,
|
||||
privacy, recovery, and user-initiated support submission.
|
||||
|
||||
## Executive summary
|
||||
|
||||
Hermes-Relay already has the right product posture: crash capture is local,
|
||||
reports are never uploaded automatically, the platform crash handler still runs,
|
||||
and users can copy, share, or open GitHub themselves. The implementation is not
|
||||
yet one reliability system, however. Fatal crashes use a private one-file JSON
|
||||
format, handled errors use an in-memory diagnostics ring, issue builders duplicate
|
||||
environment and truncation rules, and release mappings are not retained outside
|
||||
the transient build workspace. The UI therefore asks users to review developer
|
||||
traces without enough interaction context while maintainers receive obfuscated or
|
||||
truncated reports that cannot always be retraced.
|
||||
|
||||
The foundational change should align those seams without adding telemetry:
|
||||
|
||||
1. use one allowlisted, versioned reliability event contract for fatal and handled
|
||||
failures;
|
||||
2. redact locally before persistence, display, copy, share, or issue prefill;
|
||||
3. retain a small deterministic local history and build a support export entirely
|
||||
on device;
|
||||
4. make the recovery UI lead with outcome and next steps, with technical detail
|
||||
behind an explicit review action;
|
||||
5. retain release mapping files outside public release assets by immutable
|
||||
version/SHA and make Android's own issue prefills request the Android area.
|
||||
|
||||
No automatic upload, analytics SDK, remote crash service, prompt/message capture,
|
||||
or background logcat collection is justified.
|
||||
|
||||
## Current end-to-end architecture
|
||||
|
||||
### Fatal crashes
|
||||
|
||||
- `HermesRelayApp.onCreate()` installs `CrashReporter` before other app setup.
|
||||
- The process-wide uncaught-exception handler synchronously writes
|
||||
`files/crash/last-crash.json`, then delegates to Android's previous handler.
|
||||
This preserves the platform crash path and Google Play Android vitals.
|
||||
- The next main-app launch displays `CrashReportGate`. The report remains until
|
||||
the user dismisses, shares, or opens GitHub; copy alone does not acknowledge it.
|
||||
- The persisted record contains time, version/code/flavor, manufacturer/model,
|
||||
Android release/API, thread, exception summary, and the full Java trace.
|
||||
- GitHub prefill truncates the trace to 3,000 characters because it is encoded in
|
||||
a browser URL. Copy/share use the full persisted trace.
|
||||
|
||||
Failure modes:
|
||||
|
||||
- the raw exception message and trace are written before privacy redaction;
|
||||
- a single non-atomic file means a second crash replaces the first and an
|
||||
interrupted write can leave no readable report;
|
||||
- the format has no explicit schema version, retention period, size bound, report
|
||||
identifier, or migration contract;
|
||||
- the primary recovery experience is a large stack trace rather than what
|
||||
happened, what recovered, and what the user can do;
|
||||
- a crash in a secondary process can compete for the same file;
|
||||
- fatal OOM may leave too little memory for serialization or file I/O.
|
||||
|
||||
### Handled errors and coroutine failures
|
||||
|
||||
- `classifyError()` maps common network, auth, HTTP, SSL, permission, and voice
|
||||
failures to `HumanError`, then records every non-null throwable in
|
||||
`DiagnosticsLog`.
|
||||
- `DiagnosticsLog` is an in-memory 200-entry ring. List fields are capped at 180
|
||||
characters and error traces at 8,000 characters. It is cleared on process death.
|
||||
- Network ownership is partially represented by `DiagnosticCategory` (`API`,
|
||||
`Relay`, `Session`, `Voice`, `Route`, `Auth`), but `send_message` defaults to
|
||||
API even when Gateway owns the turn. Dashboard and upstream Gateway do not have
|
||||
first-class categories.
|
||||
- Multiple long-lived `SupervisorJob` scopes exist in services, clients, and the
|
||||
process runtime. There is no process-wide `CoroutineExceptionHandler`; failures
|
||||
are visible only where a caller catches/classifies them or where they become
|
||||
uncaught exceptions.
|
||||
- Expected cancellation and user denial are not a first-class taxonomy. A caught
|
||||
cancellation passed to `classifyError()` can appear as an error and become
|
||||
reportable noise.
|
||||
|
||||
### Connectivity, auth, HTTP, WebSocket, and voice
|
||||
|
||||
- Typed DNS, connection-refused, timeout, TLS, permission, and generic I/O
|
||||
failures receive humane copy and retry/repair hints.
|
||||
- HTTP status handling is message-based. 401/403/404/413/500/503 have distinct
|
||||
outcomes, but rate limiting is not first-class and ownership depends on a small
|
||||
caller-provided context string.
|
||||
- Gateway, Dashboard, API fallback, optional Relay, and Voice have separate live
|
||||
checks in Diagnostics, but the captured error data contract cannot represent
|
||||
all five owners precisely.
|
||||
- `isConnectivityError()` lets startup UI avoid duplicate scary snackbars, but
|
||||
those failures are still recorded as generic errors.
|
||||
|
||||
### OOM and ANR boundaries
|
||||
|
||||
- OOM is only captured if the uncaught handler has enough memory and storage to
|
||||
finish. The current reporter allocates a `StringWriter` containing the complete
|
||||
trace, which is specifically fragile during OOM.
|
||||
- ANRs do not throw through the uncaught handler. Google Play can observe Play
|
||||
builds, while sideload builds currently have no local ANR signal.
|
||||
- Adding a main-thread watchdog immediately would introduce false positives during
|
||||
debugger pauses, device sleep, startup, and legitimate long frames. It belongs
|
||||
in a later opt-in/bounded phase after lifecycle-aware design and device testing.
|
||||
|
||||
### Diagnostics, breadcrumbs, and correlation
|
||||
|
||||
- Diagnostics has read-only subsystem checks plus the in-memory activity ring.
|
||||
- Relay envelopes and some bridge/TUI paths have request IDs, and chat has durable
|
||||
session/run/message IDs. These identifiers are not joined to crash reports.
|
||||
- Raw session, profile, and connection identifiers may themselves be sensitive.
|
||||
The safe foundation is a new random per-launch app-session ID and per-event
|
||||
report ID. Product/session IDs should only be added later as short local hashes
|
||||
after a demonstrated diagnostic need.
|
||||
- There is no bounded breadcrumb contract. Capturing arbitrary diagnostic detail
|
||||
would risk prompts, message text, media paths, profile names, and host data.
|
||||
|
||||
### R8, ProGuard, and symbolication
|
||||
|
||||
- Release builds enable R8 and preserve `SourceFile`/`LineNumberTable`; source file
|
||||
names are normalized to `SourceFile`.
|
||||
- AGP writes `mapping.txt` under `app/build/outputs/mapping/<variant>/`. The file
|
||||
is overwritten by subsequent builds.
|
||||
- The Play AAB contains its mapping, so Play reports can be deobfuscated in Play
|
||||
Console. Sideload reports need the exact locally retained variant mapping.
|
||||
- The release workflow publishes the sideload APK and Play AAB but does not retain
|
||||
either release mapping as a versioned workflow artifact. A GitHub issue containing
|
||||
a sideload trace therefore may be practically irretrievable after the runner is
|
||||
gone.
|
||||
- Maintainer procedure should be deterministic:
|
||||
`retrace <mapping-for-exact-version-and-flavor> <trace-file>`.
|
||||
|
||||
### Recent-report evidence
|
||||
|
||||
- #289 (`1.6.0`, Play) contains a `NoSuchElementException` trace with application
|
||||
frames reduced to names such as `gn5.g(SourceFile:2)` and is truncated mid-frame.
|
||||
The interaction that preceded the crash is absent.
|
||||
- #292 (`1.6.0-sideload`) contains a duplicate Compose lazy-list key but only
|
||||
obfuscated application frames and a trace truncated by URL limits.
|
||||
- #298 (`1.6.1`, Play) shows the same duplicate-key class on another device, again
|
||||
with obfuscated application frames. There is no route/session/interaction
|
||||
context to distinguish the owning list.
|
||||
- #299 (`1.6.1`, Play) provides the missing human context—Focus-mode controls
|
||||
animate but taps usually do not complete—but has no diagnostic event, app-session
|
||||
correlation, or technical trail.
|
||||
- All four Android issues received `area:plugin`. The former issue-triage workflow
|
||||
tested broad `relay|plugin|...` keywords before Android terms, so the
|
||||
repository/app name won before `Android app`, device, Compose, or voice context
|
||||
was considered. Current `dev` has since retired that unreliable keyword
|
||||
labeler in favor of maintainer review; it should not be reintroduced.
|
||||
|
||||
The reports demonstrate both halves of the gap: traces without interaction context
|
||||
and interaction context without a safe technical trail.
|
||||
|
||||
## Privacy threat review
|
||||
|
||||
The following must never be collected by the reliability contract:
|
||||
|
||||
- authentication headers, cookies, API keys, Relay/session/pairing tokens, OAuth
|
||||
codes, or signed URLs;
|
||||
- prompt, response, transcript, reasoning, tool arguments/results, or notification
|
||||
content;
|
||||
- real hostnames, IP addresses, full URLs, SSIDs, proxy routes, or private
|
||||
infrastructure names;
|
||||
- profile/agent/person names, raw connection/session/run/message IDs, contacts, or
|
||||
account identifiers;
|
||||
- local/media/workspace paths, attachment names, clipboard contents, screenshots,
|
||||
audio, or camera data.
|
||||
|
||||
The contract should allow only enumerated owner/kind/status values, version/device
|
||||
metadata, random local correlation IDs, bounded redacted summaries/traces, route
|
||||
roles (for example `lan` or `public TLS`, never the host), and bounded allowlisted
|
||||
breadcrumbs with no arbitrary payload.
|
||||
|
||||
Redaction is defense in depth, not permission to collect prohibited fields. It
|
||||
must run before disk persistence and again when rendering/exporting legacy data.
|
||||
|
||||
## Shared taxonomy and data contract
|
||||
|
||||
### Kinds
|
||||
|
||||
| Kind | Persistence / UI policy |
|
||||
|---|---|
|
||||
| Fatal crash | Persist synchronously; show recovery once; reporting is useful |
|
||||
| ANR/watchdog signal | Contract reserved; later lifecycle-aware implementation |
|
||||
| Recoverable product error | Persist bounded history; show owned recovery action |
|
||||
| Connectivity | Low-noise; retry/offline guidance; do not nag for reports |
|
||||
| Authentication | Name owning surface; repair/sign-in guidance |
|
||||
| Rate limit | Show retry timing when safely known; do not report by default |
|
||||
| Service unavailable | Retry guidance; report only if repeated/unexpected |
|
||||
| Expected cancellation | Do not persist or offer reporting |
|
||||
| User denial | Do not persist or offer reporting; explain how to change permission |
|
||||
|
||||
### Owners
|
||||
|
||||
`Android`, `Dashboard`, `API`, `Relay`, `Upstream Gateway`, `Voice`, and `Unknown`.
|
||||
Standard Dashboard/Gateway/API ownership remains upstream; Relay is optional and
|
||||
must never be presented as required for standard recovery.
|
||||
|
||||
### Versioned record
|
||||
|
||||
Each record contains: schema version, random report ID, random app-session ID,
|
||||
timestamp, kind, owner, severity, humane summary, recovery outcome, whether a
|
||||
report is recommended, bounded redacted technical detail, and allowlisted app /
|
||||
OS / device / flavor metadata. Optional context contains route role and bounded
|
||||
allowlisted breadcrumbs only.
|
||||
|
||||
## UX recommendation
|
||||
|
||||
### Post-crash recovery
|
||||
|
||||
Lead with “Hermes-Relay restarted after an unexpected problem.” Explain that work
|
||||
on the Hermes server may still be running, no report was sent, and the user can
|
||||
continue. Technical information stays collapsed behind “Review report.” Copy,
|
||||
share, and GitHub use the same reviewed redacted payload. Dismiss remains the
|
||||
lowest-friction path and never nags again for that event.
|
||||
|
||||
### Inline handled errors
|
||||
|
||||
Use owner + outcome + action: “Dashboard sign-in expired — chat can use API
|
||||
fallback” or “Relay unavailable — standard Chat and Manage are unaffected.” Do not
|
||||
offer reporting for connectivity, expected cancellation, user denial, or a missing
|
||||
optional Relay feature. Keep report actions in Diagnostics rather than snackbars.
|
||||
|
||||
### Settings / About / Diagnostics
|
||||
|
||||
Diagnostics should expose “Review support information,” showing exactly the
|
||||
bounded text that copy/share will receive. It should work offline and include no
|
||||
new probe. About should continue to show version/flavor; duplicating export entry
|
||||
points there is unnecessary in the foundation.
|
||||
|
||||
### Accessibility and localization
|
||||
|
||||
The crash dialog must support narrow/foldable layouts, scrolling, screen-reader
|
||||
labels, large text, and an explicit technical-detail toggle. All new visible copy
|
||||
must use resources across supported locales. Clipboard/share/browser absence must
|
||||
fall back without losing the local report.
|
||||
|
||||
## Phased plan
|
||||
|
||||
### Immediate coherent foundation
|
||||
|
||||
- Add the versioned taxonomy/record and centralized local redactor.
|
||||
- Persist fatal and centrally classified handled failures in a bounded atomic
|
||||
local store; migrate the legacy one-file crash record.
|
||||
- Suppress persistence/report prompting for expected cancellation and user denial.
|
||||
- Build crash/support text and GitHub prefill from the same redacted contract.
|
||||
- Add explicit review-before-sharing UI and a Diagnostics support export.
|
||||
- Route Android crash and Diagnostics issue prefills to `area:android`; keep the
|
||||
unreliable repository-wide keyword labeler disabled.
|
||||
- Retain Play and sideload `mapping.txt` files as non-release version/SHA workflow
|
||||
artifacts and document retrace.
|
||||
- Add focused privacy, bounds, classification, migration, issue-prefill, and UI
|
||||
state tests.
|
||||
|
||||
### Later, evidence-gated follow-up
|
||||
|
||||
- Lifecycle-aware ANR watchdog with debugger/sleep/startup suppression and device
|
||||
false-positive testing.
|
||||
- Strict allowlisted breadcrumbs at high-value transitions (screen/feature owner,
|
||||
route transition, retry outcome), never user content.
|
||||
- Hashed product correlation IDs only where a concrete diagnosis requires them.
|
||||
- OOM emergency record preallocation / minimal writer.
|
||||
- Broader structured error adoption at WebSocket, coroutine-scope, and service
|
||||
boundaries that currently bypass `classifyError()`.
|
||||
- Maintainer tooling that downloads the exact release mapping and runs retrace from
|
||||
a report's version/code/flavor tuple.
|
||||
|
||||
Automatic telemetry, remote upload, full logcat collection, prompt/transcript
|
||||
capture, and third-party crash SDKs remain out of scope unless separately proposed
|
||||
and approved.
|
||||
@@ -134,6 +134,7 @@ Phone (WSS) → Relay Server (:8767) [bridge, terminal]
|
||||
- Pairing codes are user-friendly and don't require pre-shared secrets.
|
||||
- Session tokens avoid re-pairing on every app restart.
|
||||
- Tokens stored in EncryptedSharedPreferences (Android Keystore-backed AES-256-GCM).
|
||||
- An explicit re-pair replaces older sessions and trusted credentials for the same non-empty device ID; it does not accumulate duplicate entries for one app installation. Other devices and legacy entries without an identity remain independent.
|
||||
|
||||
#### 6a. QR Carries Both API and Relay Credentials (updated 2026-05-03)
|
||||
|
||||
|
||||
+11
-16
@@ -1,7 +1,7 @@
|
||||
# Issue → fix dev-loop
|
||||
|
||||
How an incoming issue becomes a worktree you (or a local agent) can start working
|
||||
in. This documents deterministic issue labeling, subscription-backed Codex review,
|
||||
in. This documents maintainer issue labeling, subscription-backed Codex review,
|
||||
path-aware required CI, and the local bridge `scripts/start-issue.sh`.
|
||||
|
||||
It complements — does not replace — `RELEASE.md` (how a fix ships) and `CLAUDE.md`
|
||||
@@ -11,7 +11,7 @@ public-repo writing hygiene).
|
||||
## The loop at a glance
|
||||
|
||||
```
|
||||
issue opened ──▶ deterministic type + area labels ──▶ maintainer triage
|
||||
issue opened ──▶ maintainer triage + labels
|
||||
│
|
||||
▼
|
||||
scripts/start-issue.sh <N> ── local bridge
|
||||
@@ -25,15 +25,12 @@ issue opened ──▶ deterministic type + area labels ──▶ maintainer tri
|
||||
└─▶ Codex review
|
||||
```
|
||||
|
||||
## Deterministic issue triage
|
||||
## Manual issue triage
|
||||
|
||||
`.github/workflows/issue-triage.yml` runs a no-LLM keyword classifier when an
|
||||
issue opens. It applies a title-derived type label and, when the issue text is
|
||||
clear, one `area:*` label. It never closes an issue, edits its body, diagnoses a
|
||||
root cause, or posts an automated opinion.
|
||||
|
||||
To label an older issue again, use the workflow's `workflow_dispatch` input or
|
||||
run `gh workflow run issue-triage.yml -f issue_number=NNN`.
|
||||
Maintainers review new issues and apply the appropriate type and `area:*` labels.
|
||||
Area labels are deliberately manual: generic words such as “relay”, “plugin”,
|
||||
“chat”, and “voice” cross product boundaries and cannot reliably identify the
|
||||
owning implementation surface from issue text alone.
|
||||
|
||||
## PR review
|
||||
|
||||
@@ -99,9 +96,7 @@ gh label create "area:docs" -c "bfd4f2" -d "docs/ or user-docs/"
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Activation lag.** Issue-triggered workflows run the copy on the default branch
|
||||
(`main`). Changes stay dormant on `dev` until a release merge lands them on main.
|
||||
- **No model cost for issue labeling.** The issue workflow is deterministic
|
||||
`github-script`; Codex review usage is accounted through the connected Codex plan.
|
||||
- **No write-access escalation here.** Issue triage cannot push code or open PRs.
|
||||
Auto-attempting a fix from untrusted issue text remains intentionally out of scope.
|
||||
- **Manual issue labels.** Type and area labels are applied during maintainer
|
||||
triage; no issue-open workflow guesses ownership from keywords.
|
||||
- **No write-access escalation from issues.** Auto-attempting a fix from
|
||||
untrusted issue text remains intentionally out of scope.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "ea322945453cb843adc7c77273945137ec26c46a76896f009089e6b00a240a64",
|
||||
"main": "e26a371ee7d835b718706bef24f4b21773897c1b6537728136d4879468722ffe",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "ea322945453cb843adc7c77273945137ec26c46a76896f009089e6b00a240a64",
|
||||
"main": "e26a371ee7d835b718706bef24f4b21773897c1b6537728136d4879468722ffe",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "ea322945453cb843adc7c77273945137ec26c46a76896f009089e6b00a240a64",
|
||||
"main": "e26a371ee7d835b718706bef24f4b21773897c1b6537728136d4879468722ffe",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "ea322945453cb843adc7c77273945137ec26c46a76896f009089e6b00a240a64",
|
||||
"main": "e26a371ee7d835b718706bef24f4b21773897c1b6537728136d4879468722ffe",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "ea322945453cb843adc7c77273945137ec26c46a76896f009089e6b00a240a64",
|
||||
"main": "e26a371ee7d835b718706bef24f4b21773897c1b6537728136d4879468722ffe",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "ea322945453cb843adc7c77273945137ec26c46a76896f009089e6b00a240a64",
|
||||
"main": "e26a371ee7d835b718706bef24f4b21773897c1b6537728136d4879468722ffe",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
@@ -89,12 +89,12 @@ 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.6.0 - Pets, plugins, and voice
|
||||
v1.6.1 - Clearer recovery, steadier chat
|
||||
|
||||
* Floating Petdex companions that can roam across the UI.
|
||||
* Safe native pages from installed Hermes plugins.
|
||||
* Optional Android Digital Assistant with local "Hey Hermes."
|
||||
* Russian plus voice, route, chat, and pet stability improvements.
|
||||
* Clear optional Relay status and recovery guidance.
|
||||
* Full session history through compatible paging.
|
||||
* Stable streamed text selection and Voice microphone handoff.
|
||||
* New-chat coaching stays clear of Voice controls.
|
||||
```
|
||||
|
||||
## Category
|
||||
|
||||
@@ -31,6 +31,7 @@ All app data is stored on-device in the app's private sandbox:
|
||||
| Relay session token | EncryptedSharedPreferences | Same encryption as API key |
|
||||
| Theme and display preferences | DataStore preferences | Tool display mode, reasoning toggle, voice preferences |
|
||||
| Stats for Nerds counters | DataStore preferences | Response times, token counts, health stats — local only |
|
||||
| Reliability reports | App-private JSON | Up to 20 locally redacted crash/handled-error records, retained for 14 days; no prompts, messages, profile names, hosts, tokens, or media |
|
||||
|
||||
Chat messages are **not cached locally**. They are loaded from the Hermes API server on demand and exist only in memory while the app is running.
|
||||
|
||||
@@ -74,6 +75,9 @@ Notification companion is opt-in. The app only forwards notification metadata af
|
||||
|
||||
From Settings, users can:
|
||||
|
||||
- **Review support information** in Diagnostics, then explicitly copy or share
|
||||
the exact redacted local text. Nothing is uploaded automatically.
|
||||
|
||||
- **Export** a full connection backup. The file includes server URLs,
|
||||
preferences, API keys, relay session tokens, device IDs, and dashboard
|
||||
cookies so restored connections can work without manual re-entry. Keep it
|
||||
|
||||
@@ -295,7 +295,7 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
|
||||
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard and desktop pair/repair flows. Reads `API_SERVER_KEY` from the host-local config chain when the dashboard does not pass `api_key` explicitly. Optional request field `dashboard_url` is mirrored into the QR payload and response. |
|
||||
| `/pairing/approve` | POST | **Loopback only, Phase 3 stub.** Same wire shape and loopback gate as `/pairing/register` — present so the Android client can target the route today. The semantic difference (operator reviewing a phone-initiated pending code before approval) still needs the pending-codes store + approval UX, marked `# TODO(Phase 3)` in the handler. |
|
||||
| `/sessions` | GET | Bearer-auth'd. Returns `{"sessions": [ {token_prefix, device_name, device_id, created_at, last_seen, expires_at, grants, transport_hint, is_current}, ... ]}` for all currently-active paired devices. `token_prefix` is the first 8 characters of the session token — full tokens are NEVER included, so a caller holding one session token can't extract another. `expires_at` and grant values that are `math.inf` serialize as `null` (never expire). `is_current` is true for the session matching the caller's bearer. 401 on missing/invalid bearer. Used by the Android Paired Devices screen. **Loopback branch (2026-04-18):** callers on `127.0.0.1` / `::1` may skip the bearer and receive the same `{sessions: [...]}` payload without the `is_current` flag (no caller context). Added so the dashboard plugin proxy can list paired devices without needing to mint its own bearer. Non-loopback callers still require the bearer and retain `is_current`. |
|
||||
| `/sessions/{token_prefix}` | DELETE | Bearer-auth'd. Revoke a paired device by first-N-char token prefix (N ≥ 4). Returns 200 `{"ok": true, "revoked_self": bool}` on exact match; 404 on zero matches; 409 on ambiguous (2+) matches with the count in the body. Self-revoke is allowed and flagged via `revoked_self: true` so the caller knows to wipe local state. Any paired device can revoke any other — see ADR 15 for the trade-off rationale. |
|
||||
| `/sessions/{token_prefix}` | DELETE | Bearer-auth'd for network callers; loopback callers may omit the bearer for host-operator management. Revoke a paired device by first-N-char token prefix (N ≥ 4). Returns 200 `{"ok": true, "revoked_self": bool}` on exact match; 404 on zero matches; 409 on ambiguous (2+) matches with the count in the body. Self-revoke is allowed and flagged via `revoked_self: true` so the caller knows to wipe local state. Any paired phone can revoke any other — see ADR 15 for the trade-off rationale. The Dashboard Relay tab and `/relay revoke <token-prefix>` use the loopback operator path. |
|
||||
| `/sessions/{token_prefix}` | PATCH | Bearer-auth'd, self-targeted, and reduction-only. Body `{"ttl_seconds": 3600}`, `{"grants": {"terminal": 600}}`, or both may shorten the caller's current session policy. A bearer cannot target another session, extend its lifetime, add or lengthen grants, or change a finite expiry to never-expire; authority-increasing changes require a fresh operator-approved pairing flow. Omitted grants retain their existing absolute ceilings and are clamped if the parent session is shortened. Returns 200 with the reduced `{expires_at, grants}`; 400 on missing/invalid or unknown grants; 403 on cross-session targets or policy expansion; 404 on prefix miss; 409 on ambiguous prefix. |
|
||||
| `/chat/image-activity` | GET | Optional read-only Standard Gateway compatibility route. Requires a valid Relay bearer with an active `chat` grant and query parameters `profile`, `session_id`, and `since` (Unix seconds). Reads the selected profile's Hermes `state.db` without mutation and returns persisted `image_generate` calls as `running` or `completed`. Android polls only during an active turn, deduplicates against native Gateway tool events, and silently disables the bridge when the route is absent. |
|
||||
| `/clipboard/inbox` | POST | Bearer-auth'd clipboard rendezvous used by remote clients before native platform clipboard fallback. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.6.0"
|
||||
appVersionCode = "37"
|
||||
appVersionName = "1.6.1"
|
||||
appVersionCode = "38"
|
||||
agp = "9.3.1"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.06.01"
|
||||
@@ -29,7 +29,7 @@ play-publisher = "4.0.0"
|
||||
media3 = "1.10.1"
|
||||
androidVad = "2.0.10"
|
||||
sherpaOnnx = "v1.13.4"
|
||||
onnxRuntime = "1.27.0"
|
||||
onnxRuntime = "1.28.0"
|
||||
spatialsdk = "0.13.2"
|
||||
play-app-update = "2.1.0"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"label": "Relay",
|
||||
"description": "Paired devices, bridge activity, media inspection, and remote access for hermes-relay",
|
||||
"icon": "Activity",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"tab": {
|
||||
"path": "/relay",
|
||||
"position": "after:skills"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.12",
|
||||
"qrcode": "^1.5.4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"private": true,
|
||||
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name: hermes-relay
|
||||
manifest_version: 1
|
||||
version: 1.5.0
|
||||
version: 1.5.1
|
||||
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
|
||||
author: Axiom Labs
|
||||
# All three are OPTIONAL — only needed if you use the relay's extra /
|
||||
|
||||
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
|
||||
# Desktop releases use desktop/package.json and desktop-v* tags. The /health endpoint
|
||||
# reports this plugin version, and stale values make live diagnosis harder than
|
||||
# it should be.
|
||||
__version__ = "1.5.0"
|
||||
__version__ = "1.5.1"
|
||||
|
||||
from .server import create_app, main # noqa: E402 — must come after __version__
|
||||
|
||||
|
||||
+30
-1
@@ -893,7 +893,9 @@ class SessionManager:
|
||||
issue_refresh_token:
|
||||
When True, also create a persisted trusted-device credential and
|
||||
attach the raw one-time refresh token to the returned
|
||||
:class:`Session` for inclusion in ``auth.ok``.
|
||||
:class:`Session` for inclusion in ``auth.ok``. This is an explicit
|
||||
pairing operation, so any older sessions and trusted-device
|
||||
credentials for the same non-empty ``device_id`` are replaced.
|
||||
"""
|
||||
if ttl_seconds is None:
|
||||
ttl_seconds = DEFAULT_TTL_SECONDS
|
||||
@@ -907,6 +909,33 @@ class SessionManager:
|
||||
resolved_grants = _materialize_grants(grants, float(ttl_seconds), now)
|
||||
refresh_token: str | None = None
|
||||
if issue_refresh_token:
|
||||
# A fresh operator-approved pair repairs this device; it does not
|
||||
# authorize another indefinite row for the same app installation.
|
||||
# Keep different devices independent and leave legacy clients with
|
||||
# no device_id alone because an empty id cannot identify ownership.
|
||||
normalized_device_id = device_id.strip()
|
||||
if normalized_device_id:
|
||||
replaced_sessions = [
|
||||
token
|
||||
for token, existing in self._sessions.items()
|
||||
if existing.device_id == normalized_device_id
|
||||
]
|
||||
for token in replaced_sessions:
|
||||
del self._sessions[token]
|
||||
replaced_devices = [
|
||||
refresh_hash
|
||||
for refresh_hash, existing in self._trusted_devices.items()
|
||||
if existing.device_id == normalized_device_id
|
||||
]
|
||||
for refresh_hash in replaced_devices:
|
||||
del self._trusted_devices[refresh_hash]
|
||||
if replaced_sessions or replaced_devices:
|
||||
logger.info(
|
||||
"Re-pair replaced %d session(s) and %d trusted credential(s) for device %s",
|
||||
len(replaced_sessions),
|
||||
len(replaced_devices),
|
||||
normalized_device_id,
|
||||
)
|
||||
refresh_token = _generate_refresh_token()
|
||||
refresh_hash = _refresh_token_hash(refresh_token)
|
||||
self._trusted_devices[refresh_hash] = TrustedDevice(
|
||||
|
||||
@@ -9,6 +9,7 @@ Subcommands (parsed out of ``raw_args`` by :func:`relay_slash_handler`):
|
||||
|
||||
/relay status Relay reachability + connected-phone summary
|
||||
/relay devices Paired-device list (loopback ``GET /sessions``)
|
||||
/relay revoke ID Revoke a paired device by token prefix
|
||||
/relay pair Mint a fresh 6-char pairing code on the running relay
|
||||
/relay help This help text
|
||||
|
||||
@@ -25,6 +26,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -142,6 +144,36 @@ def _cmd_devices() -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _cmd_revoke(token_prefix: str) -> str:
|
||||
"""Revoke one paired device through the relay's loopback operator path."""
|
||||
prefix = token_prefix.strip()
|
||||
if len(prefix) < 4:
|
||||
return "Usage: `/relay revoke <token-prefix>` (at least 4 characters)."
|
||||
|
||||
port = _relay_port()
|
||||
encoded_prefix = urllib.parse.quote(prefix, safe="")
|
||||
url = f"http://127.0.0.1:{port}/sessions/{encoded_prefix}"
|
||||
req = urllib.request.Request(url, method="DELETE")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=2.0) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return f"No paired device matches `{prefix}`. Run `/relay devices` to refresh."
|
||||
if exc.code == 409:
|
||||
return f"More than one device matches `{prefix}`. Use a longer token prefix."
|
||||
return f"Could not revoke `{prefix}` — relay returned HTTP {exc.code}."
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
return (
|
||||
f"Could not revoke `{prefix}` — relay unreachable on "
|
||||
f"127.0.0.1:{port} ({exc})."
|
||||
)
|
||||
|
||||
if isinstance(payload, dict) and payload.get("ok") is True:
|
||||
return f"Revoked paired device `{prefix}`."
|
||||
return f"Relay did not confirm revocation for `{prefix}`."
|
||||
|
||||
|
||||
def _seconds_since(epoch_ts: Any) -> Optional[float]:
|
||||
"""Convert an absolute epoch ``last_seen`` to seconds-ago (best effort)."""
|
||||
if not isinstance(epoch_ts, (int, float)):
|
||||
@@ -188,6 +220,7 @@ _HELP = (
|
||||
"/relay — Hermes-Relay control\n"
|
||||
" status Relay reachability + connected-phone summary\n"
|
||||
" devices List paired devices\n"
|
||||
" revoke Revoke a device by token prefix\n"
|
||||
" pair Mint a fresh 6-char pairing code\n"
|
||||
" help Show this help"
|
||||
)
|
||||
@@ -211,6 +244,8 @@ def relay_slash_handler(raw_args: str) -> str:
|
||||
return _cmd_status()
|
||||
if sub == "devices":
|
||||
return _cmd_devices()
|
||||
if sub == "revoke":
|
||||
return _cmd_revoke(argv[1] if len(argv) > 1 else "")
|
||||
if sub == "pair":
|
||||
return _cmd_pair()
|
||||
return f"Unknown subcommand '{sub}'.\n\n{_HELP}"
|
||||
|
||||
@@ -170,6 +170,52 @@ class SessionPersistenceRoundtripTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIsNone(recovered)
|
||||
|
||||
def test_explicit_repair_replaces_same_device_session_and_refresh(self) -> None:
|
||||
mgr = SessionManager(persistence_path=self.path)
|
||||
original = mgr.create_session(
|
||||
device_name="Phone-A",
|
||||
device_id="dev-a",
|
||||
ttl_seconds=0,
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
original_refresh = original.refresh_token
|
||||
assert original_refresh is not None
|
||||
|
||||
replacement = mgr.create_session(
|
||||
device_name="Phone-A",
|
||||
device_id="dev-a",
|
||||
ttl_seconds=0,
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
|
||||
self.assertEqual(mgr.active_count(), 1)
|
||||
self.assertIsNone(mgr.get_session(original.token))
|
||||
self.assertIsNotNone(mgr.get_session(replacement.token))
|
||||
self.assertIsNone(
|
||||
mgr.refresh_session(
|
||||
original_refresh,
|
||||
device_name="Phone-A",
|
||||
device_id="dev-a",
|
||||
)
|
||||
)
|
||||
|
||||
def test_explicit_pair_keeps_other_devices(self) -> None:
|
||||
mgr = SessionManager(persistence_path=self.path)
|
||||
phone_a = mgr.create_session(
|
||||
device_name="Phone-A",
|
||||
device_id="dev-a",
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
phone_b = mgr.create_session(
|
||||
device_name="Phone-B",
|
||||
device_id="dev-b",
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
|
||||
self.assertEqual(mgr.active_count(), 2)
|
||||
self.assertIsNotNone(mgr.get_session(phone_a.token))
|
||||
self.assertIsNotNone(mgr.get_session(phone_b.token))
|
||||
|
||||
def test_existing_session_can_be_upgraded_with_refresh_token(self) -> None:
|
||||
mgr = SessionManager(persistence_path=self.path)
|
||||
session = mgr.create_session(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Focused tests for host-side Relay session management commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from plugin import slash
|
||||
|
||||
|
||||
class RelaySlashRevokeTests(unittest.TestCase):
|
||||
def test_revoke_requires_token_prefix(self) -> None:
|
||||
self.assertIn("at least 4", slash.relay_slash_handler("revoke abc"))
|
||||
|
||||
@patch("plugin.slash.urllib.request.urlopen")
|
||||
@patch("plugin.slash._relay_port", return_value=8767)
|
||||
def test_revoke_uses_loopback_delete(self, _port: MagicMock, urlopen: MagicMock) -> None:
|
||||
response = MagicMock()
|
||||
response.read.return_value = json.dumps({"ok": True}).encode("utf-8")
|
||||
urlopen.return_value.__enter__.return_value = response
|
||||
|
||||
result = slash.relay_slash_handler("revoke abcdef12")
|
||||
|
||||
request = urlopen.call_args.args[0]
|
||||
self.assertEqual(request.get_method(), "DELETE")
|
||||
self.assertEqual(request.full_url, "http://127.0.0.1:8767/sessions/abcdef12")
|
||||
self.assertEqual(result, "Revoked paired device `abcdef12`.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-relay"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -177,7 +177,7 @@ BRIEF="${WTDIR}/ISSUE-BRIEF.md"
|
||||
if [ -n "$BOTNOTES" ]; then
|
||||
printf '%s\n' "$BOTNOTES"
|
||||
else
|
||||
echo "_No automated triage comments yet. Add the \`triage:deep\` label on the issue for a code-level analysis, or run the triage workflow._"
|
||||
echo "_No automated triage comments yet. Add the \`triage:deep\` label on the issue for a code-level analysis._"
|
||||
fi
|
||||
} > "$BRIEF"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user