Compare commits

...
Author SHA1 Message Date
Bailey Dixon 390a4dd8d8 chore(ci): promote review candidate reporter to main 2026-08-24 09:51:08 -04:00
Bailey Dixon 443e347b43 Merge pull request #396 from Codename-11/dev
release(android): android-v1.12.1
2026-08-22 14:03:50 -04:00
Bailey Dixon 42f91c1462 release(android): android-v1.12.1 2026-08-22 13:23:13 -04:00
Bailey Dixon 8b9e92ccee Merge pull request #395 from Codename-11/fix/android-share-intents
fix(android): handle shared content drafts
2026-08-22 13:19:39 -04:00
Bailey Dixon 58f642dceb merge: sync Android share intents with dev
# Conflicts:
#	CHANGELOG.md
#	app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt
2026-08-22 13:08:24 -04:00
Bailey Dixon 3ed64ba251 merge: integrate Android connection recovery 2026-08-22 12:28:47 -04:00
Bailey Dixon a6467e84cb fix(android): recover connection setup failures 2026-08-22 12:28:33 -04:00
Bailey Dixon e69ca817e4 fix(android): handle shared content drafts 2026-08-21 23:54:13 -04:00
Bailey Dixon 733ece9523 merge: back-merge main after Android 1.12.0 and Server 1.9.0 2026-08-21 21:00:36 -04:00
42 changed files with 1847 additions and 234 deletions
+169
View File
@@ -0,0 +1,169 @@
'use strict';
const COMMENT_MARKER = '<!-- hermes-relay-review-candidate -->';
const ARTIFACT_NAME_RE = /^hermes-relay-review-pr-(\d+)-([0-9a-f]{12})$/;
function formatExpiry(value) {
if (!value) return 'the artifact retention window';
return new Intl.DateTimeFormat('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
}).format(new Date(value));
}
function buildReviewComment({ conclusion, prNumber, headSha, runUrl, artifact }) {
const shortSha = headSha.slice(0, 12);
if (conclusion === 'success' && artifact) {
const artifactUrl = `${runUrl}/artifacts/${artifact.id}`;
return `${COMMENT_MARKER}
## Review candidate ready
Built from PR #${prNumber} head \`${shortSha}\`.
[Download \`${artifact.name}\`](${artifactUrl}) — expires **${formatExpiry(artifact.expires_at)}**.
1. Unzip the bundle and verify its files against \`SHA256SUMS.txt\`.
2. Install the APK under \`android/\`. It appears as **Hermes Candidate**, leaves stable installs untouched, and must be paired separately.
3. Test the Relay package only in a disposable/staging Hermes instance or with an explicit snapshot and rollback plan. Confirm the source SHA in \`REVIEW_MANIFEST.json\`.
[View workflow run](${runUrl})`;
}
if (conclusion === 'action_required') {
return `${COMMENT_MARKER}
## Review candidate awaiting approval
GitHub held the build for PR #${prNumber} head \`${shortSha}\` at the first-time fork approval gate. A maintainer must approve the run before any candidate can be published.
[Review and approve the workflow run](${runUrl})`;
}
const result = conclusion || 'unknown';
return `${COMMENT_MARKER}
## Review candidate unavailable
The build for PR #${prNumber} head \`${shortSha}\` completed with **${result}** and did not publish a candidate bundle.
[View workflow run](${runUrl})`;
}
function artifactPrNumber(artifacts, headSha) {
const shortSha = headSha.slice(0, 12);
for (const artifact of artifacts) {
const match = ARTIFACT_NAME_RE.exec(artifact.name);
if (match && match[2] === shortSha) return Number(match[1]);
}
return null;
}
async function resolvePrNumber({ github, owner, repo, run, artifacts }) {
const payloadPr = run.pull_requests?.[0]?.number;
if (payloadPr) return payloadPr;
const artifactPr = artifactPrNumber(artifacts, run.head_sha);
if (artifactPr) return artifactPr;
const headOwner = run.head_repository?.owner?.login;
if (!headOwner || !run.head_branch) return null;
const { data: pulls } = await github.rest.pulls.list({
owner,
repo,
head: `${headOwner}:${run.head_branch}`,
state: 'all',
per_page: 100,
});
const exact = pulls.find((pull) =>
pull.head.sha === run.head_sha && pull.base.ref === 'dev'
);
return exact?.number ?? null;
}
async function resolveWorkflowRun({ github, context, core }) {
const completedRun = context.payload.workflow_run;
if (completedRun) return completedRun;
const requested = context.payload.inputs?.run_id;
const runId = Number(requested);
if (!Number.isSafeInteger(runId) || runId <= 0) {
core.setFailed(`Invalid Build Review Bundle run ID: ${requested ?? ''}`);
return null;
}
const { owner, repo } = context.repo;
const { data: run } = await github.rest.actions.getWorkflowRun({
owner,
repo,
run_id: runId,
});
return run;
}
async function reportReviewBundle({ github, context, core }) {
const run = await resolveWorkflowRun({ github, context, core });
const { owner, repo } = context.repo;
if (!run) return;
if (run.name !== 'Build Review Bundle' || run.event !== 'pull_request') {
core.info('Ignoring a review-bundle run that was not triggered by a pull request.');
return;
}
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts,
{ owner, repo, run_id: run.id, per_page: 100 },
);
const prNumber = await resolvePrNumber({ github, owner, repo, run, artifacts });
if (!prNumber) {
core.warning(`Could not resolve a pull request for review-bundle run ${run.id}.`);
return;
}
const expectedName = `hermes-relay-review-pr-${prNumber}-${run.head_sha.slice(0, 12)}`;
const artifact = artifacts.find((item) => item.name === expectedName && !item.expired);
const body = buildReviewComment({
conclusion: run.conclusion,
prNumber,
headSha: run.head_sha,
runUrl: run.html_url,
artifact,
});
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 },
);
const existing = comments.find((comment) =>
comment.user?.login === 'github-actions[bot]' &&
comment.body?.includes(COMMENT_MARKER)
);
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
core.info(`Updated review-candidate comment on PR #${prNumber}.`);
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
core.info(`Created review-candidate comment on PR #${prNumber}.`);
}
}
module.exports = {
ARTIFACT_NAME_RE,
COMMENT_MARKER,
artifactPrNumber,
buildReviewComment,
reportReviewBundle,
resolvePrNumber,
resolveWorkflowRun,
};
@@ -0,0 +1,145 @@
'use strict';
const assert = require('node:assert/strict');
const {
artifactPrNumber,
buildReviewComment,
reportReviewBundle,
} = require('./review-bundle-report.cjs');
const run = {
id: 32729383426,
name: 'Build Review Bundle',
event: 'pull_request',
conclusion: 'success',
head_sha: '90ab705a883ca963035f4f8ccda815619dbd4f3b',
head_branch: 'fix/gateway-history-attachments',
head_repository: { owner: { login: 'JackHunzicker' } },
html_url: 'https://github.com/Codename-11/hermes-relay/actions/runs/32729383426',
pull_requests: [],
};
const artifact = {
id: 9521126010,
name: 'hermes-relay-review-pr-398-90ab705a883c',
expired: false,
expires_at: '2026-08-31T12:52:24Z',
};
assert.equal(artifactPrNumber([artifact], run.head_sha), 398);
const successBody = buildReviewComment({
conclusion: 'success',
prNumber: 398,
headSha: run.head_sha,
runUrl: run.html_url,
artifact,
});
assert.match(successBody, /## Review candidate ready/);
assert.match(successBody, /hermes-relay-review-pr-398-90ab705a883c/);
assert.match(successBody, /expires \*\*August 31, 2026\*\*/);
assert.match(successBody, /Hermes Candidate/);
assert.match(successBody, /REVIEW_MANIFEST\.json/);
const blockedBody = buildReviewComment({
conclusion: 'action_required',
prNumber: 398,
headSha: run.head_sha,
runUrl: run.html_url,
});
assert.match(blockedBody, /## Review candidate awaiting approval/);
assert.doesNotMatch(blockedBody, /Download/);
async function testExistingCommentIsUpdated() {
const calls = { create: [], update: [] };
const github = {
rest: {
actions: { listWorkflowRunArtifacts() {} },
issues: {
listComments() {},
createComment: async (args) => calls.create.push(args),
updateComment: async (args) => calls.update.push(args),
},
pulls: { list: async () => ({ data: [] }) },
},
paginate: async (method) => {
if (method === github.rest.actions.listWorkflowRunArtifacts) return [artifact];
if (method === github.rest.issues.listComments) {
return [{
id: 77,
user: { login: 'github-actions[bot]' },
body: '<!-- hermes-relay-review-candidate -->\nold',
}];
}
throw new Error('Unexpected pagination method');
},
};
const messages = [];
await reportReviewBundle({
github,
context: {
repo: { owner: 'Codename-11', repo: 'hermes-relay' },
payload: { workflow_run: run },
},
core: {
info: (message) => messages.push(message),
warning: (message) => messages.push(message),
},
});
assert.equal(calls.create.length, 0);
assert.equal(calls.update.length, 1);
assert.equal(calls.update[0].comment_id, 77);
assert.match(calls.update[0].body, /## Review candidate ready/);
assert.deepEqual(messages, ['Updated review-candidate comment on PR #398.']);
}
async function testManualRunSelectionCreatesComment() {
const calls = { create: [], update: [] };
const github = {
rest: {
actions: {
getWorkflowRun: async ({ run_id: runId }) => {
assert.equal(runId, run.id);
return { data: run };
},
listWorkflowRunArtifacts() {},
},
issues: {
listComments() {},
createComment: async (args) => calls.create.push(args),
updateComment: async (args) => calls.update.push(args),
},
pulls: { list: async () => ({ data: [] }) },
},
paginate: async (method) => {
if (method === github.rest.actions.listWorkflowRunArtifacts) return [artifact];
if (method === github.rest.issues.listComments) return [];
throw new Error('Unexpected pagination method');
},
};
await reportReviewBundle({
github,
context: {
repo: { owner: 'Codename-11', repo: 'hermes-relay' },
payload: { inputs: { run_id: String(run.id) } },
},
core: {
info() {},
warning() {},
setFailed: (message) => assert.fail(message),
},
});
assert.equal(calls.update.length, 0);
assert.equal(calls.create.length, 1);
assert.equal(calls.create[0].issue_number, 398);
assert.match(calls.create[0].body, /## Review candidate ready/);
}
Promise.all([
testExistingCommentIsUpdated(),
testManualRunSelectionCreatesComment(),
])
.then(() => console.log('Review-bundle report tests passed.'))
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,55 @@
name: Report Review Bundle
on:
workflow_dispatch:
inputs:
run_id:
description: Completed Build Review Bundle run ID to report
required: true
type: string
workflow_run:
workflows:
- Build Review Bundle
types:
- completed
permissions:
actions: read
contents: read
issues: write
pull-requests: read
concurrency:
group: review-bundle-report-${{ github.event.workflow_run.id || inputs.run_id }}
cancel-in-progress: false
jobs:
report:
if: >-
${{
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.event == 'pull_request'
}}
name: Update pull request comment
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Check out only the trusted default branch. Never check out the PR head or
# execute/download its candidate artifact in this write-capable workflow.
- name: Checkout trusted reporter
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Test trusted reporter
run: node .github/scripts/review-bundle-report.test.cjs
- name: Report candidate status
uses: actions/github-script@v8
with:
script: |
const reporter = require(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/review-bundle-report.cjs`
);
await reporter.reportReviewBundle({ github, context, core });
+9
View File
@@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [Android 1.12.1] - 2026-08-22
### Fixed
- **Android shares open as complete reviewable drafts.** Shared links and text now survive fresh-chat draft restoration, while single or multiple shared images and files enter the same composer attachment flow. Mixed text-and-file shares are supported and nothing is sent automatically.
- **Adding or renewing an Android connection no longer stalls during local preparation.** Pair setup keeps its allocated target exact, performs an explicit validated handoff when renewing an existing connection, and continues with that connection's scoped authentication state.
- **Unavailable Android chat routes now fail visibly.** Send attempts with no usable Gateway or API fallback expose a retryable failure, while required profile-scoped history reads report an error instead of treating the wrong or missing history as an empty conversation.
- **Android Diagnostics reports secure-storage degradation and recovery without exposing credentials.** Keystore fallback, encrypted-store self-healing, and temporary in-memory storage are recorded with secret-free recovery guidance.
## [Android 1.12.0] - 2026-08-21
### Added
+28
View File
@@ -1,5 +1,33 @@
# Hermes-Relay — Dev Log
## 2026-08-21 — Android sharesheet draft handoff
Android's sharesheet target now accepts single and multiple text, link, image,
and file shares. Mixed payloads open a fresh reviewable chat draft, preserve the
shared text items in source order in the composer, and reuse the existing bounded
attachment ingestion pipeline without sending automatically.
The handoff remains pending until the exact destination session has been created
and its persisted composer draft has restored. This prevents the draft restore
introduced for conversation continuity from overwriting a shared link or text,
and identity fencing prevents an older asynchronous session creation from
consuming a newer share intent. Attachment ingestion now also preserves coroutine
cancellation so leaving the destination cannot consume a partially imported share.
External file payloads accept only grantable `content://` URIs; sender-controlled
file paths, web URLs, malformed opaque URIs, and custom schemes never reach
Relay's content resolver. Multi-file shares import at most ten attachments and
tell the user when additional eligible files were omitted, bounding aggregate
base64 memory and CPU work on the exported activity path.
API session-creation failures keep the identity-fenced share pending instead of
consuming it. The existing chat error remains visible, and returning to the app
explicitly re-arms one retry without creating an immediate failure loop.
Verification covered the focused sideload JVM regression suite, Kotlin compilation
for both Android flavors, Google Play app lint, the Android and user-doc locale
validators, the public route contract, sideload APK assembly, and inspection of
the packaged manifest's `SEND` and `SEND_MULTIPLE` wildcard MIME filters.
## 2026-08-20 — Android 1.11.0 Bridge access and lower idle power
Hermes-Relay Android 1.11.0 is published from the immutable
+11 -20
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.12.0
# Hermes-Relay-Android v1.12.1
**Release Date:** August 21, 2026
**Release Date:** August 22, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.12.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.12.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,27 +12,18 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This release adds saved custom themes and makes appearance shape consistent throughout Android. It also keeps profile and session identity intact across All Profiles navigation and language changes, recovers Gateway chats when a completion frame is missed, and accepts common Relay endpoint forms without producing invalid routes.
## Added
- Create up to 20 local custom themes with editable palette roles, Light or Dark ownership, saved shape, live chat preview, rename, duplicate, and delete controls.
## Changed
- Apply Soft, Balanced, or Sharp styling consistently across chat, settings, sheets, dialogs, terminal, voice, Bridge, and other shared surfaces.
- Activate a session's owning agent when selecting it from All Profiles; profile locks hide that browser and reject cross-profile opens.
This patch makes Android sharing and recovery dependable. Shared links, text, images, and files open as complete reviewable drafts; connection renewal no longer stalls; offline and history failures are visible; and secure-storage recovery appears in Diagnostics.
## Fixed
- Preserve the exact connection, agent, session, transcript, draft, and All Profiles state through an app-language change.
- Relocalize the persistent connection notification without restarting the active connection.
- Settle and reconcile active Gateway turns when the terminal completion frame was missed.
- Normalize Relay base, `/ws`, and `/health` endpoint forms without producing duplicate route segments.
- Open shared links, text, images, files, and mixed or multi-item shares as a fresh reviewable draft without sending automatically.
- Keep Add and Renew connection setup on the correct connection-scoped authentication store, with bounded Retry or Cancel recovery instead of an indefinite preparation screen.
- Surface unavailable chat routes and profile-history failures clearly instead of silently dropping Send or presenting missing history as an empty conversation.
- Report Android Keystore fallback, encrypted-store recovery, and temporary credential storage in Diagnostics without exposing credentials.
## Install / Verify
- App version: **1.12.0** (versionCode **47**).
- Standard Chat, sessions, Manage, profile switching, custom themes, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- App version: **1.12.1** (versionCode **48**).
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
- The optional Relay plugin is not required for standard Android chat, session continuity, themes, or Gateway recovery.
- The optional Relay plugin is not required for standard Android chat, sharing, session continuity, or Gateway recovery.
@@ -1 +1 @@
Create and save custom themes with full palette and shape controls. Shapes now apply consistently throughout the app. All Profiles sessions switch to their owning agent and survive language changes with the correct header, icon, and transcript. Gateway chats recover when a terminal frame is missed, persistent connection notifications relocalize without reconnecting, and Relay URLs normalize correctly from base, /ws, or /health forms.
Shared links, text, images, and files now open as complete reviewable drafts without sending automatically. Add and Renew connection setup no longer stalls. Offline chat and profile-history failures surface clear recovery guidance instead of doing nothing or showing empty history. Diagnostics now reports secure-storage fallback and recovery without exposing credentials.
@@ -1 +1 @@
创建并保存带完整配色和形状控制的自定义主题。形状现在会一致应用到整个应用。通过“所有配置文件”选择会话时会切换到其所属智能体,并在更改语言后保留正确的标题、图标和对话内容。Gateway 漏掉终止帧时可恢复聊天,持久连接通知会随语言更新而无需重连,Relay 基础、/ws 与 /health 地址也会正确规范化。
共享链接、文本、图片和文件现在会作为完整、可检查的草稿打开,不会自动发送。添加或续订连接时不再卡在准备阶段。离线聊天和配置文件历史记录失败会显示明确的恢复提示,而不是无响应或显示空历史记录。诊断现在会报告安全存储降级与恢复,且不会暴露凭据。
+8 -3
View File
@@ -48,12 +48,17 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- User-mediated text handoff. The app opens a fresh Chat draft
and fills the composer; it never sends from an external intent. -->
<!-- User-mediated sharesheet handoff. Shared text and files open in
a fresh reviewable Chat draft; external intents never send. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<!-- The loopback native-PKCE result page uses this fixed, tokenless
link only to bring the installed flavor back to the foreground.
+27
View File
@@ -1,5 +1,32 @@
{
"versions": [
{
"version": "1.12.1",
"title": "Sharing and recovery that work",
"date": "2026-08-22",
"sections": [
{
"header": "Share complete drafts",
"bullets": [
"Open shared links, text, images, files, and mixed or multi-item shares as one fresh reviewable draft.",
"Keep every share in the composer until you review it; Hermes never sends shared content automatically."
]
},
{
"header": "Recover connections and conversations",
"bullets": [
"Add or renew a connection without getting stuck during secure local preparation, with Retry and Cancel when setup cannot finish.",
"See clear recovery guidance when no chat route is available or a profile's conversation history cannot be reached."
]
},
{
"header": "Understand secure storage",
"bullets": [
"Review secret-free Diagnostics evidence when Android falls back from Keystore storage, repairs encrypted storage, or can keep credentials only temporarily."
]
}
]
},
{
"version": "1.12.0",
"title": "Themes and identity that stay put",
+5 -7
View File
@@ -1,8 +1,6 @@
v1.12.0 - Themes and identity that stay put
v1.12.1 - Sharing and recovery that work
* Create and save custom themes with full palette and shape controls.
* Apply Soft, Balanced, or Sharp styling consistently throughout the app.
* Switch All Profiles sessions with the correct owning agent, icon, and transcript.
* Preserve the active profile and session through app-language changes.
* Recover Gateway chats when a terminal completion frame is missed.
* Accept Relay base, /ws, and /health endpoint forms without invalid routes.
* Open shared links, text, images, and files as a reviewable draft without auto-sending.
* Add or renew a connection without getting stuck during secure setup.
* See clear recovery guidance when chat or profile history is unavailable.
* Find secret-free secure-storage fallback and recovery evidence in Diagnostics.
@@ -14,6 +14,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.animation.doOnEnd
import androidx.core.content.IntentCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
@@ -25,8 +26,8 @@ import com.hermesandroid.relay.notifications.TurnCompleteNotifier
import com.hermesandroid.relay.notifications.InteractionRequestNotifier
import com.hermesandroid.relay.ui.RelayApp
import com.hermesandroid.relay.util.NavRouteRequest
import com.hermesandroid.relay.util.SharedTextRequest
import com.hermesandroid.relay.util.extractSharedText
import com.hermesandroid.relay.util.SharedContentRequest
import com.hermesandroid.relay.util.extractSharedContent
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
@@ -129,7 +130,7 @@ class MainActivity : AppCompatActivity() {
// in RelayApp's NavRouteRequest collector — we just pump the request
// into the SharedFlow here.
consumeNavRouteIntent(intent)
consumeSharedTextIntent(intent)
consumeSharedContentIntent(intent)
val consumedAssistantActivation =
com.hermesandroid.relay.assistant.AssistantSessionProtocol.consumeActivation(
this,
@@ -156,7 +157,7 @@ class MainActivity : AppCompatActivity() {
// instead of onCreate. RelayApp's collector handles both cases.
setIntent(intent)
consumeNavRouteIntent(intent)
consumeSharedTextIntent(intent)
consumeSharedContentIntent(intent)
com.hermesandroid.relay.assistant.AssistantSessionProtocol.consumeActivation(this, intent)
// === END PHASE3-safety-rails-followup ===
}
@@ -167,13 +168,42 @@ class MainActivity : AppCompatActivity() {
NavRouteRequest.tryRequest(route)
}
private fun consumeSharedTextIntent(intent: Intent?) {
val sharedText = extractSharedText(
action = intent?.action,
mimeType = intent?.type,
text = intent?.getCharSequenceExtra(Intent.EXTRA_TEXT),
) ?: return
SharedTextRequest.tryRequest(sharedText)
private fun consumeSharedContentIntent(intent: Intent?) {
intent ?: return
val streamUris = buildList {
if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
IntentCompat.getParcelableArrayListExtra(
intent,
Intent.EXTRA_STREAM,
android.net.Uri::class.java,
)?.let(::addAll)
} else {
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, android.net.Uri::class.java)
?.let(::add)
}
}
val clipUris = buildList {
val clipData = intent.clipData ?: return@buildList
repeat(clipData.itemCount) { index -> clipData.getItemAt(index).uri?.let(::add) }
}
val clipTexts = buildList {
val clip = intent.clipData ?: return@buildList
repeat(clip.itemCount) { index -> clip.getItemAt(index).text?.let(::add) }
}
val sharedTexts = if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
intent.getCharSequenceArrayListExtra(Intent.EXTRA_TEXT).orEmpty()
} else {
listOfNotNull(intent.getCharSequenceExtra(Intent.EXTRA_TEXT))
}
val payload = extractSharedContent(
action = intent.action,
texts = sharedTexts,
subject = intent.getCharSequenceExtra(Intent.EXTRA_SUBJECT),
streamUriStrings = streamUris.map(android.net.Uri::toString),
clipTexts = clipTexts,
clipUriStrings = clipUris.map(android.net.Uri::toString),
)
SharedContentRequest.tryRequest(payload)
}
private fun configureAssistantWindow(intent: Intent?) {
@@ -212,6 +242,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
SharedContentRequest.retryFailed()
// Returning to the app clears the one-slot "Hermes finished
// responding" notification — the chat surface is the answer.
TurnCompleteNotifier.cancel(this)
@@ -6,6 +6,9 @@ import android.os.Build
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import java.util.concurrent.ConcurrentHashMap
/**
@@ -40,10 +43,87 @@ internal object SecureStoreCache {
* the token store and the dashboard cookie store so a given file always yields
* the SAME backend, via [SecureStoreCache].
*/
internal fun buildRawTokenStore(context: Context, prefsName: String): SessionTokenStore =
KeystoreTokenStore.tryCreate(context, prefsName)
?: runCatching { LegacyEncryptedPrefsTokenStore(context, prefsName) }
.getOrElse { InMemoryTokenStore() }
internal fun buildRawTokenStore(context: Context, prefsName: String): SessionTokenStore {
KeystoreTokenStore.tryCreate(context, prefsName)?.let { return it }
runCatching { LegacyEncryptedPrefsTokenStore(context, prefsName) }
.getOrNull()
?.let {
SecureStorageDiagnostics.preferredStoreUnavailable()
return it
}
SecureStorageDiagnostics.inMemoryStoreOnly()
return InMemoryTokenStore()
}
/** Secret-free diagnostics for credential-store degradation and recovery. */
internal object SecureStorageDiagnostics {
fun preferredStoreUnavailable() {
val title = "Secure credential storage fallback activated"
recordIfAbsent(title) {
DiagnosticsLog.record(
category = DiagnosticCategory.Auth,
severity = DiagnosticSeverity.Warning,
title = title,
detail = "Preferred Android Keystore storage could not initialize; using encrypted compatibility storage.",
operation = "Initialize secure credential storage",
suggestion = "Re-authenticate if saved credentials are unavailable.",
)
}
}
fun preferredStoreRecovered() {
val title = "Keystore credential storage recovered"
recordIfAbsent(title) {
DiagnosticsLog.record(
category = DiagnosticCategory.Auth,
severity = DiagnosticSeverity.Warning,
title = title,
detail = "Unreadable Keystore-backed credential storage was cleared and rebuilt; saved sign-in state may need to be restored.",
operation = "Recover secure credential storage",
suggestion = "Sign in or pair again if this connection no longer has credentials.",
)
}
}
fun legacyStoreRecovered() {
val title = "Encrypted credential storage recovered"
recordIfAbsent(title) {
DiagnosticsLog.record(
category = DiagnosticCategory.Auth,
severity = DiagnosticSeverity.Warning,
title = title,
detail = "Unreadable encrypted credential storage was cleared and rebuilt; saved sign-in state may need to be restored.",
operation = "Recover secure credential storage",
suggestion = "Sign in or pair again if this connection no longer has credentials.",
)
}
}
fun inMemoryStoreOnly() {
val title = "Credential storage is temporary"
recordIfAbsent(title) {
DiagnosticsLog.record(
category = DiagnosticCategory.Auth,
severity = DiagnosticSeverity.Error,
title = title,
detail = "Persistent encrypted storage is unavailable; credentials will last only until the app process stops.",
operation = "Initialize secure credential storage",
suggestion = "Restart the device and re-authenticate; include Diagnostics if the problem continues.",
)
}
}
private inline fun recordIfAbsent(title: String, record: () -> Unit) {
synchronized(this) {
val alreadyVisible = DiagnosticsLog.entries.value.any {
it.category == DiagnosticCategory.Auth && it.title == title
}
if (!alreadyVisible) record()
}
}
}
/**
* Abstraction over the storage backend for the relay session token + API key
@@ -161,6 +241,7 @@ class KeystoreTokenStore private constructor(
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e.message}")
}
prefs = buildPrefs()
SecureStorageDiagnostics.preferredStoreRecovered()
}
companion object {
@@ -328,7 +409,9 @@ class LegacyEncryptedPrefsTokenStore(
} catch (e2: Exception) {
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e2.message}")
}
buildPrefs()
buildPrefs().also {
SecureStorageDiagnostics.legacyStoreRecovered()
}
}
private fun buildPrefs(): SharedPreferences {
@@ -354,6 +437,7 @@ class LegacyEncryptedPrefsTokenStore(
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e.message}")
}
prefs = buildPrefs()
SecureStorageDiagnostics.legacyStoreRecovered()
}
// AES256_GCM via MasterKey is hardware-backed (TEE) on essentially every
@@ -49,6 +49,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.lifecycle.Lifecycle
@@ -214,6 +216,30 @@ suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
internal fun hasConfiguredStartupChat(connection: Connection?): Boolean =
connection?.capabilities?.chatConfigured == true
/**
* A Pair route is allowed to start once its target connection is active and
* persisted. Duplicate Renew may authorize one explicit existing-connection
* handoff; arbitrary active-id mismatches remain blocked so restored state
* cannot bypass connection/auth hydration.
*/
internal fun resolvePairSetupReady(
storeHydrated: Boolean,
connectionId: String?,
authorizedHandoffId: String?,
activeConnectionId: String?,
connectionIds: Set<String>,
): Boolean = connectionId == null || storeHydrated && activeConnectionId != null &&
activeConnectionId in connectionIds &&
(activeConnectionId == connectionId || activeConnectionId == authorizedHandoffId)
/** A user retry replaces even a still-active preparation attempt. */
internal fun shouldStartPairPreparation(hasActiveJob: Boolean, retryRequested: Boolean): Boolean =
retryRequested || !hasActiveJob
/** A replaced/canceled attempt must not evict the newer job from the route map. */
internal fun isCurrentPairPreparation(mappedJob: Any?, completingJob: Any): Boolean =
mappedJob === completingJob
/**
* App-root chat health derived only from the two transports that can carry a
* conversation. Optional Relay state is deliberately absent.
@@ -567,6 +593,26 @@ fun RelayApp() {
val pendingAddConnectionJobs = remember {
mutableMapOf<String, kotlinx.coroutines.Job>()
}
val prepareAddConnection: (String, Boolean) -> Unit = { id, retryRequested ->
val existingJob = pendingAddConnectionJobs[id]
if (shouldStartPairPreparation(existingJob?.isActive == true, retryRequested)) {
if (retryRequested) {
pendingAddConnectionJobs.remove(id)?.cancel()
}
val job = connectionSwitchScope.launch(
start = kotlinx.coroutines.CoroutineStart.LAZY,
) {
connectionViewModel.beginAddConnection(preAllocatedId = id)
}
job.invokeOnCompletion {
if (isCurrentPairPreparation(pendingAddConnectionJobs[id], job)) {
pendingAddConnectionJobs.remove(id)
}
}
pendingAddConnectionJobs[id] = job
job.start()
}
}
// One-time init: the terminal channel ViewModel registers with the shared
// multiplexer and observes the relay connection state so it can attach/
@@ -1096,19 +1142,44 @@ fun RelayApp() {
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
val appReady by connectionViewModel.isReady.collectAsState()
val initialChatSettled by chatViewModel.initialChatSettled.collectAsState()
val shareConnectionId by rememberUpdatedState(
activeConnection?.id?.takeIf(String::isNotBlank) ?: "offline"
)
val shareProfileId by rememberUpdatedState(
selectedProfile?.name?.takeIf(String::isNotBlank)
?: com.hermesandroid.relay.data.ChatComposerDraftKey.DEFAULT_PROFILE_ID
)
// Android sharesheet handoff: wait until the configured chat context is
// settled, then ask ChatViewModel to own the new draft and composer
// prefill. Navigation is presentation-only; no composable writes chat
// stores or sends the shared text.
// settled, then create a fresh draft. The request remains identity-fenced
// until ChatScreen restores that exact draft and ingests its text/files.
LaunchedEffect(navController, onboardingCompleted, initialChatSettled) {
if (!onboardingCompleted || !initialChatSettled) return@LaunchedEffect
com.hermesandroid.relay.util.SharedTextRequest.pending.collect { request ->
com.hermesandroid.relay.util.SharedContentRequest.pending.collect { request ->
request ?: return@collect
if (chatViewModel.openSharedTextDraft(request.text)) {
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
launchSingleTop = true
val targetConnectionId = shareConnectionId
val targetProfileId = shareProfileId
if (!request.ready && !request.preparing && !request.failed) {
com.hermesandroid.relay.util.SharedContentRequest.markPreparing(request.id)
val opened = chatViewModel.openSharedContentDraft(
onReady = { sessionId ->
com.hermesandroid.relay.util.SharedContentRequest.markReady(
id = request.id,
targetConnectionId = targetConnectionId,
targetProfileId = targetProfileId,
targetSessionId = sessionId,
)
},
onFailure = {
com.hermesandroid.relay.util.SharedContentRequest.markFailed(request.id)
},
)
if (opened) {
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
launchSingleTop = true
}
} else {
com.hermesandroid.relay.util.SharedContentRequest.markFailed(request.id)
}
com.hermesandroid.relay.util.SharedTextRequest.consume(request.id)
}
}
}
@@ -2452,14 +2523,7 @@ fun RelayApp() {
// underneath the discovery UI instead of blocking
// navigation on encrypted-store/client setup.
navController.navigate(Screen.Pair.route(connectionId = id))
val job = connectionSwitchScope.launch {
try {
connectionViewModel.beginAddConnection(preAllocatedId = id)
} finally {
pendingAddConnectionJobs.remove(id)
}
}
pendingAddConnectionJobs[id] = job
prepareAddConnection(id, false)
},
onBack = { navController.popBackStack() },
// Pass the VM so the list cards can read live status
@@ -2553,12 +2617,44 @@ fun RelayApp() {
?.getString(Screen.Pair.ARG_AUTO_START)
val pairConnections by connectionViewModel.connections.collectAsState()
val pairActiveId by connectionViewModel.activeConnectionId.collectAsState()
val pairSetupReady = connectionIdArg == null ||
(pairActiveId == connectionIdArg && pairConnections.any { it.id == connectionIdArg })
val pairStoreHydrated by connectionViewModel.connectionStore.isHydrated.collectAsState()
// Duplicate Renew authorizes one explicit route handoff
// before switching away from the placeholder. Persist the
// identity, not a bare readiness boolean, so Activity
// recreation remains safe and process restore still has
// to hydrate a real matching connection row.
var authorizedPairHandoffId by rememberSaveable(connectionIdArg) {
mutableStateOf<String?>(null)
}
val pairSetupReady = resolvePairSetupReady(
storeHydrated = pairStoreHydrated,
connectionId = connectionIdArg,
authorizedHandoffId = authorizedPairHandoffId,
activeConnectionId = pairActiveId,
connectionIds = pairConnections.mapTo(mutableSetOf()) { it.id },
)
com.hermesandroid.relay.ui.screens.PairScreen(
connectionViewModel = connectionViewModel,
autoStart = autoStartArg,
setupReady = pairSetupReady,
onSetupTimeout = if (connectionIdArg == null) null else ({
DiagnosticsLog.record(
category = DiagnosticCategory.Auth,
severity = DiagnosticSeverity.Warning,
title = "Connection setup did not become ready",
detail = "targetPresent=${pairConnections.any { it.id == connectionIdArg }} " +
"activeMatches=${pairActiveId == connectionIdArg} " +
"activePresent=${pairActiveId != null}",
operation = "Prepare connection-scoped local storage",
suggestion = "Retry setup or cancel and add the connection again.",
)
}),
onSetupRetry = if (connectionIdArg == null) null else ({
prepareAddConnection(connectionIdArg, true)
}),
onConnectionTargetChanged = { existingId ->
authorizedPairHandoffId = existingId
},
// Offer demo only on the bare "Connect" entry (the
// "No Hermes connection" path) — not on add-connection /
// re-pair flows, which have a placeholder connection in
@@ -191,6 +191,7 @@ fun ConnectionWizard(
*/
autoStart: String? = null,
setupReady: Boolean = true,
onConnectionTargetChanged: (String) -> Unit = {},
/**
* Optional "Try the demo" affordance shown atop the Method step. When
* non-null, the wizard surfaces an offline Demo / Explore entry point so a
@@ -923,6 +924,10 @@ fun ConnectionWizard(
onUpdate = {
val prompt = existing
duplicatePrompt = null
// Authorize the route's exact target handoff before the
// active-id emission changes. This keeps the wizard composed
// without turning readiness into an unscoped boolean latch.
onConnectionTargetChanged(prompt.id)
wizardScope.launch {
// Snapshot the placeholder id before we switch away —
// after switchConnection returns, activeConnectionId
@@ -1165,6 +1165,52 @@ fun ChatScreen(
activeComposerDraftKey = composerDraftKey
restoringComposerDraft = false
}
val sharedContentRequest by com.hermesandroid.relay.util.SharedContentRequest.pending.collectAsState()
LaunchedEffect(
sharedContentRequest,
composerDraftKey,
activeComposerDraftKey,
maxAttachmentMb,
charLimit,
) {
val request = sharedContentRequest ?: return@LaunchedEffect
if (!com.hermesandroid.relay.util.canApplySharedContent(
request = request,
composerConnectionId = composerDraftKey.connectionId,
composerProfileId = composerDraftKey.profileId,
composerSessionId = composerDraftKey.sessionId,
draftRestored = activeComposerDraftKey == composerDraftKey,
)
) return@LaunchedEffect
editingMessage = null
quotedMessage = null
inputText = request.payload.text.orEmpty().take(charLimit)
chatViewModel.replacePendingAttachments(emptyList())
request.payload.uriStrings.forEach { uriString ->
if (!com.hermesandroid.relay.util.isAllowedSharedContentUri(uriString)) {
return@forEach
}
runCatching { Uri.parse(uriString) }
.getOrNull()
?.let { uri ->
ingestAttachmentFromUri(context, uri, maxAttachmentMb) {
chatViewModel.addAttachment(it)
}
}
}
if (request.payload.omittedUriCount > 0) {
Toast.makeText(
context,
context.getString(
R.string.chat_shared_files_limited,
com.hermesandroid.relay.util.MAX_SHARED_CONTENT_ATTACHMENTS,
),
Toast.LENGTH_LONG,
).show()
}
com.hermesandroid.relay.util.SharedContentRequest.consume(request.id)
}
LaunchedEffect(
inputText,
editingMessage?.id,
@@ -5141,6 +5187,8 @@ private suspend fun ingestAttachmentFromUri(
fileSize = source.sizeBytes,
)
)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: AttachmentTooLargeException) {
Toast.makeText(
context,
@@ -7,19 +7,28 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -31,6 +40,9 @@ import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.ConnectionWizard
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.delay
private const val PAIR_SETUP_TIMEOUT_MS = 15_000L
/**
* Full-screen connection route. Wraps [ConnectionWizard] in a real Scaffold so
@@ -53,6 +65,9 @@ fun PairScreen(
onManageSignIn: (() -> Unit)? = null,
autoStart: String? = null,
setupReady: Boolean = true,
onSetupTimeout: (() -> Unit)? = null,
onSetupRetry: (() -> Unit)? = null,
onConnectionTargetChanged: (String) -> Unit = {},
/**
* Optional offline "Try the demo" entry, forwarded to [ConnectionWizard].
* Wired by [RelayApp] only for the bare Connect entry (no placeholder
@@ -61,6 +76,17 @@ fun PairScreen(
onTryDemo: (() -> Unit)? = null,
) {
val context = LocalContext.current
var setupTimedOut by remember { mutableStateOf(false) }
var setupAttempt by remember { mutableIntStateOf(0) }
LaunchedEffect(setupReady, setupAttempt) {
setupTimedOut = false
if (!setupReady) {
delay(PAIR_SETUP_TIMEOUT_MS)
setupTimedOut = true
onSetupTimeout?.invoke()
}
}
// Route system back / predictive back through the same discard path
// the TopAppBar arrow uses. Without this, the NavController just pops
@@ -111,16 +137,36 @@ fun PairScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator()
if (!setupTimedOut) CircularProgressIndicator()
Text(
text = stringResource(R.string.cw_preparing_connection),
text = stringResource(
if (setupTimedOut) R.string.cw_pairing_did_not_complete
else R.string.cw_preparing_connection,
),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.cw_preparing_connection_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!setupTimedOut) {
Text(
text = stringResource(R.string.cw_preparing_connection_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.cw_cancel_button))
}
Button(
onClick = {
setupAttempt += 1
onSetupRetry?.invoke()
},
enabled = onSetupRetry != null,
) {
Text(stringResource(R.string.cw_retry))
}
}
}
}
} else {
ConnectionWizard(
@@ -134,6 +180,7 @@ fun PairScreen(
showSkip = false,
autoStart = autoStart,
setupReady = true,
onConnectionTargetChanged = onConnectionTargetChanged,
onTryDemo = onTryDemo,
)
}
@@ -0,0 +1,138 @@
package com.hermesandroid.relay.util
import android.content.Intent
import java.net.URI
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class SharedContentPayload(
val text: String? = null,
val uriStrings: List<String> = emptyList(),
val omittedUriCount: Int = 0,
)
internal const val MAX_SHARED_CONTENT_ATTACHMENTS = 10
data class SharedContentDraftRequest(
val id: Long,
val payload: SharedContentPayload,
val preparing: Boolean = false,
val failed: Boolean = false,
val ready: Boolean = false,
val targetConnectionId: String? = null,
val targetProfileId: String? = null,
val targetSessionId: String? = null,
)
/**
* Process-local handoff from Android's sharesheet entry point to the app-owned
* chat runtime. The request stays pending until the exact fresh draft has
* restored and ingested it. Identity checks prevent an older async session
* creation from overwriting or consuming a newer share.
*/
object SharedContentRequest {
private val nextId = AtomicLong(0L)
private val _pending = MutableStateFlow<SharedContentDraftRequest?>(null)
val pending: StateFlow<SharedContentDraftRequest?> = _pending.asStateFlow()
fun tryRequest(payload: SharedContentPayload?): Boolean {
payload ?: return false
_pending.value = SharedContentDraftRequest(
id = nextId.incrementAndGet(),
payload = payload,
)
return true
}
fun markReady(
id: Long,
targetConnectionId: String,
targetProfileId: String,
targetSessionId: String?,
) {
_pending.update { request ->
request?.takeIf { it.id == id }?.copy(
preparing = false,
failed = false,
ready = true,
targetConnectionId = targetConnectionId,
targetProfileId = targetProfileId,
targetSessionId = targetSessionId,
) ?: request
}
}
fun markPreparing(id: Long) {
_pending.update { request ->
request?.takeIf { it.id == id }?.copy(preparing = true, failed = false) ?: request
}
}
fun markFailed(id: Long) {
_pending.update { request ->
request?.takeIf { it.id == id }?.copy(preparing = false, failed = true) ?: request
}
}
/** A foreground return is the explicit retry trigger for a failed fresh-draft creation. */
fun retryFailed() {
_pending.update { request ->
request?.takeIf { it.failed }?.copy(failed = false) ?: request
}
}
fun consume(id: Long) {
_pending.update { request -> request?.takeUnless { it.id == id } }
}
}
/** Accept Android's single- and multi-item shares when they carry reviewable content. */
internal fun extractSharedContent(
action: String?,
texts: List<CharSequence>,
subject: CharSequence?,
streamUriStrings: List<String>,
clipTexts: List<CharSequence>,
clipUriStrings: List<String>,
): SharedContentPayload? {
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
val sharedTextItems = (texts + clipTexts)
.map(CharSequence::toString)
.filter(String::isNotBlank)
.distinct()
val sharedText = sharedTextItems.takeIf(List<String>::isNotEmpty)?.joinToString("\n")
?: subject?.toString()?.takeIf { it.isNotBlank() }
val eligibleUris = (streamUriStrings + clipUriStrings)
.filter(::isAllowedSharedContentUri)
.distinct()
val uris = eligibleUris.take(MAX_SHARED_CONTENT_ATTACHMENTS)
if (sharedText == null && uris.isEmpty()) return null
return SharedContentPayload(
text = sharedText,
uriStrings = uris,
omittedUriCount = eligibleUris.size - uris.size,
)
}
/** Cross-app binary shares must use Android's grantable content-provider contract. */
internal fun isAllowedSharedContentUri(uriString: String): Boolean {
val uri = runCatching { URI(uriString) }.getOrNull() ?: return false
return uri.scheme == "content" && !uri.rawAuthority.isNullOrBlank()
}
internal fun canApplySharedContent(
request: SharedContentDraftRequest?,
composerConnectionId: String,
composerProfileId: String,
composerSessionId: String,
draftRestored: Boolean,
): Boolean {
if (request?.ready != true || !draftRestored) return false
return composerConnectionId == request.targetConnectionId &&
composerProfileId == request.targetProfileId &&
composerSessionId == (request.targetSessionId ?: "new-session")
}
@@ -1,50 +0,0 @@
package com.hermesandroid.relay.util
import android.content.Intent
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class SharedTextDraftRequest(
val id: Long,
val text: String,
)
/**
* Process-local handoff from Android's sharesheet entry point to the app-owned
* chat runtime. A StateFlow keeps a cold-start request alive until RelayApp has
* initialized its ViewModels; [consume] is identity-checked so an older UI
* completion cannot clear a newer share intent.
*/
object SharedTextRequest {
private val nextId = AtomicLong(0L)
private val _pending = MutableStateFlow<SharedTextDraftRequest?>(null)
val pending: StateFlow<SharedTextDraftRequest?> = _pending.asStateFlow()
fun tryRequest(text: CharSequence?): Boolean {
val value = text?.toString()?.takeIf { it.isNotBlank() } ?: return false
_pending.value = SharedTextDraftRequest(
id = nextId.incrementAndGet(),
text = value,
)
return true
}
fun consume(id: Long) {
_pending.update { request -> request?.takeUnless { it.id == id } }
}
}
/** Accept only Android's single-item text share contract. */
internal fun extractSharedText(
action: String?,
mimeType: String?,
text: CharSequence?,
): String? {
if (action != Intent.ACTION_SEND) return null
if (mimeType?.startsWith("text/", ignoreCase = true) != true) return null
return text?.toString()?.takeIf { it.isNotBlank() }
}
@@ -2648,19 +2648,17 @@ class ChatViewModel : ViewModel() {
private val _composerPrefill = Channel<String>(capacity = Channel.CONFLATED)
val composerPrefill = _composerPrefill.receiveAsFlow()
/**
* Open a fresh draft for text received through Android's sharesheet.
* The composer event is queued until Chat is composed and is never routed
* through [sendMessage]. Existing new-chat transport and background-turn
* ownership remain authoritative.
*/
fun openSharedTextDraft(text: String): Boolean {
if (text.isBlank() || chatHandler == null) return false
/** Open a fresh, reviewable draft for Android sharesheet content. */
fun openSharedContentDraft(
onReady: (String?) -> Unit,
onFailure: () -> Unit,
): Boolean {
if (chatHandler == null) return false
val canCreateDraft =
(streamingEndpoint == "gateway" && gatewayClient != null) || apiClient != null
if (!canCreateDraft) return false
createNewChat()
return _composerPrefill.trySend(text).isSuccess
createNewChat(onReady = onReady, onFailure = onFailure)
return true
}
// Navigation-safe draft handoff for explicit in-app workflows (for example,
@@ -2932,6 +2930,11 @@ class ChatViewModel : ViewModel() {
profileMessageLoader = loader
}
/** JVM-test seam for proving that required profile reads fail closed. */
internal fun clearProfileMessageLoader() {
profileMessageLoader = null
}
/**
* Transcript for [sessionId], preferring the profile-scoped dashboard path on
* gateway connections (so non-default-profile sessions resolve against their
@@ -2969,6 +2972,11 @@ class ChatViewModel : ViewModel() {
// empty/default transcript is not authoritative for this session.
return if (requireProfileScope) scoped.getOrThrow() else scoped.getOrElse { emptyList() }
}
if (requireProfileScope) {
throw IllegalStateException(
"Profile-scoped conversation history is unavailable for this connection.",
)
}
return apiClient?.getMessages(sessionId, mode) ?: emptyList()
}
@@ -3236,6 +3244,27 @@ class ChatViewModel : ViewModel() {
recordChatFailureDiagnostic(failure, liveSessionId)
}
private fun publishHistoryLoadFailure(sessionId: String, error: Throwable) {
val rawError = error.message?.takeIf { it.isNotBlank() }
?: "The active profile's conversation history could not be reached."
_chatFailure.value = ChatFailureNotice(
sessionId = sessionId,
turnId = "history-$sessionId",
rawError = rawError,
route = ChatFailureRoute.GATEWAY,
recoverable = false,
)
DiagnosticsLog.record(
category = DiagnosticCategory.Session,
severity = DiagnosticSeverity.Error,
title = "Hermes chat history failed",
detail = "stored_session=$sessionId; error=$rawError",
operation = "load chat history",
endpointRole = "gateway",
suggestion = "Reconnect the active profile and retry opening this conversation.",
)
}
/**
* Inject an agent-initiated ("proactive") message into the active session
* so it continues that conversation (the `phone` platform's
@@ -3811,7 +3840,11 @@ class ChatViewModel : ViewModel() {
false
}
if (!recovered) {
val messages = loadSessionHistory(sessionId, profileName = sessionProfileName)
val messages = loadSessionHistory(
sessionId,
requireProfileScope = streamingEndpoint == "gateway",
profileName = sessionProfileName,
)
if (stillCurrent()) {
handler.loadMessageHistory(messages)
if (streamingEndpoint == "gateway") gatewayClient?.prewarm(sessionId)
@@ -3825,6 +3858,7 @@ class ChatViewModel : ViewModel() {
// superseded switch can't wipe a newer one's content).
if (stillCurrent()) {
handler.clearMessages()
publishHistoryLoadFailure(sessionId, e)
}
} finally {
// finally (not tail code) so a throwing fetch can't strand
@@ -4017,7 +4051,10 @@ class ChatViewModel : ViewModel() {
}
}
fun createNewChat() {
fun createNewChat(
onReady: ((String?) -> Unit)? = null,
onFailure: (() -> Unit)? = null,
) {
val handler = chatHandler ?: return
recordPreResetEvidence(handler, "new_chat")
clearOpenedSessionOwner()
@@ -4054,6 +4091,7 @@ class ChatViewModel : ViewModel() {
pendingYolo = null
onSessionChanged?.invoke(null)
AppAnalytics.onSessionCreated()
onReady?.invoke(null)
return
}
@@ -4096,12 +4134,16 @@ class ChatViewModel : ViewModel() {
pendingYolo = null
onSessionChanged?.invoke(session.id)
AppAnalytics.onSessionCreated()
onReady?.invoke(session.id)
} else {
onFailure?.invoke()
}
},
onFailure = { error ->
if (historyLoadGeneration.get() == loadGeneration) {
emitError(error, context = "create_session")
}
onFailure?.invoke()
}
)
}
@@ -4300,15 +4342,33 @@ class ChatViewModel : ViewModel() {
false
}
if (!recovered) {
val messages = loadSessionHistory(sessionId, profileName = profileName)
if (
historyLoadGeneration.get() == loadGeneration &&
activeProfileContextKey == contextKey &&
currentSessionProfileName() == profileName &&
handler.currentSessionId.value == sessionId
) {
handler.loadMessageHistory(messages)
if (streamingEndpoint == "gateway") gatewayClient?.prewarm(sessionId)
try {
val messages = loadSessionHistory(
sessionId,
requireProfileScope = streamingEndpoint == "gateway",
profileName = profileName,
)
if (
historyLoadGeneration.get() == loadGeneration &&
activeProfileContextKey == contextKey &&
currentSessionProfileName() == profileName &&
handler.currentSessionId.value == sessionId
) {
handler.loadMessageHistory(messages)
if (streamingEndpoint == "gateway") gatewayClient?.prewarm(sessionId)
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
if (
historyLoadGeneration.get() == loadGeneration &&
activeProfileContextKey == contextKey &&
currentSessionProfileName() == profileName &&
handler.currentSessionId.value == sessionId
) {
handler.clearMessages()
publishHistoryLoadFailure(sessionId, e)
}
}
}
if (historyLoadGeneration.get() == loadGeneration) {
@@ -4551,8 +4611,33 @@ class ChatViewModel : ViewModel() {
val handler = chatHandler ?: return
val client = apiClient
if (streamingEndpoint != "gateway" && client == null) return
if (streamingEndpoint == "gateway" && gatewayClient == null && client == null) return
if (
(streamingEndpoint != "gateway" && client == null) ||
(streamingEndpoint == "gateway" && gatewayClient == null && client == null)
) {
val message = if (streamingEndpoint == "gateway") {
"Gateway is unavailable and no API fallback is configured for this connection."
} else {
"API fallback is not configured for this connection."
}
// The composer clears after invoking Send. Keep its text in the
// handler-owned retry slot even though no transport accepted it.
handler.setLastSentMessage(text.trim())
handler.onStreamError(message)
publishChatFailure(
ChatFailureNotice(
sessionId = handler.currentSessionId.value,
turnId = "offline-${UUID.randomUUID()}",
rawError = message,
route = if (streamingEndpoint == "gateway") {
ChatFailureRoute.GATEWAY
} else {
ChatFailureRoute.API_FALLBACK
},
),
)
return
}
// A new user action owns the recovery surface. The failed transcript
// row remains in history; only the composer-attached notice retires.
@@ -257,9 +257,44 @@ internal fun isChatTransportReady(
gatewayAvailability == GatewayAvailability.Ready ||
(apiClientPresent && apiReachable)
internal fun recordDashboardGatewayFailure(
dashboardUrl: String,
detail: String,
) {
val now = System.currentTimeMillis()
val duplicate = DiagnosticsLog.entries.value.lastOrNull {
it.category == DiagnosticCategory.Endpoint &&
it.operation == "Probe Dashboard / Gateway status"
}?.let { now - it.timestampMs < 60_000L } == true
if (duplicate) return
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Error,
title = "Dashboard / Gateway is unavailable",
detail = detail,
operation = "Probe Dashboard / Gateway status",
endpointRole = "gateway",
configuredUrl = dashboardUrl,
requestUrl = "${dashboardUrl.trimEnd('/')}/api/status",
suggestion = "Open the active connection and verify its Dashboard route and sign-in state.",
)
}
internal fun hasConfiguredHermesConnection(connection: Connection?): Boolean =
connection?.capabilities?.anySurfaceConfigured == true
internal fun reusablePlaceholderForAdd(
preAllocatedId: String?,
connections: List<Connection>,
): Connection? {
if (preAllocatedId != null) return null
return connections.firstOrNull { connection ->
connection.pairedAt == null &&
connection.apiServerUrl.isBlank() &&
connection.label == ConnectionViewModel.PLACEHOLDER_LABEL
}
}
/**
* Resolve the Dashboard/Gateway surface for the route the resolver selected.
*
@@ -3187,23 +3222,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
return@withLock existing.id
}
val reusable = connectionStore.connections.value.firstOrNull { c ->
c.pairedAt == null &&
c.apiServerUrl.isBlank() &&
c.label == PLACEHOLDER_LABEL
}
if (reusable != null) {
android.util.Log.i(
"ConnectionViewModel",
"beginAddConnection: reusing placeholder id=${reusable.id} " +
"instead of pre-allocated id=$preAllocatedId",
)
if (connectionStore.activeConnectionId.value != reusable.id) {
switchConnection(reusable.id).join()
}
return@withLock reusable.id
}
val placeholder = Connection(
id = preAllocatedId,
label = PLACEHOLDER_LABEL,
@@ -3228,11 +3246,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// id, the second `switchConnection` is a no-op (coordinator
// short-circuits when id == activeConnectionId), and we
// return the same string both times.
val existing = connectionStore.connections.value.firstOrNull { c ->
c.pairedAt == null &&
c.apiServerUrl.isBlank() &&
c.label == PLACEHOLDER_LABEL
}
val existing = reusablePlaceholderForAdd(
preAllocatedId = null,
connections = connectionStore.connections.value,
)
if (existing != null) {
android.util.Log.i(
"ConnectionViewModel",
@@ -4134,9 +4151,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// at a dead record.
viewModelScope.launch {
try {
// Let the legacy seed land first — it's a short
// writeMutex-guarded path, typically < 50ms.
val connections = connectionStore.connections.first()
// StateFlow is seeded empty, so reading connections.first()
// here can win the initial DataStore read and permanently
// miss persisted placeholders. Wait until the store has
// completed its initial read before deciding what is orphaned.
connectionStore.isHydrated.first { it }
val connections = connectionStore.connections.value
val orphans = connections.filter {
it.pairedAt == null &&
it.apiServerUrl.isBlank() &&
@@ -4678,6 +4698,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
try {
val status = client.getStatus().getOrNull()
if (status == null) {
recordDashboardGatewayFailure(
dashboardUrl = dashboardUrl,
detail = "Dashboard status probe returned no response.",
)
updateDashboardTopology(connectionId, null)
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
_standardAudioApiReachable.value = false
@@ -4723,6 +4747,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// app (see the currentSession() stale-connection crash). A probe
// failure must only degrade the UI, never be fatal.
android.util.Log.w("ConnectionVM", "probeStandardVoice failed: ${e.message}")
recordDashboardGatewayFailure(
dashboardUrl = dashboardUrl,
detail = "Dashboard status probe failed (${e.javaClass.simpleName}).",
)
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
_standardAudioApiReachable.value = false
_hostResourcePressure.value = HostResourcePressureStatus()
@@ -7,4 +7,5 @@
<string name="pending_attachment_status_loading">Preparando prévia</string>
<string name="pending_attachment_status_ready">Pronto</string>
<string name="pending_attachment_status_failed">Prévia indisponível</string>
<string name="chat_shared_files_limited">Somente os primeiros %1$d arquivos compartilhados foram adicionados.</string>
</resources>
@@ -7,4 +7,5 @@
<string name="pending_attachment_status_loading">正在准备预览</string>
<string name="pending_attachment_status_ready">已就绪</string>
<string name="pending_attachment_status_failed">预览不可用</string>
<string name="chat_shared_files_limited">仅添加了前 %1$d 个共享文件。</string>
</resources>
@@ -7,4 +7,5 @@
<string name="pending_attachment_status_loading">Vorschau wird vorbereitet</string>
<string name="pending_attachment_status_ready">Bereit</string>
<string name="pending_attachment_status_failed">Vorschau nicht verfügbar</string>
<string name="chat_shared_files_limited">Nur die ersten %1$d geteilten Dateien wurden hinzugefügt.</string>
</resources>
@@ -7,4 +7,5 @@
<string name="pending_attachment_status_loading">Preparando vista previa</string>
<string name="pending_attachment_status_ready">Listo</string>
<string name="pending_attachment_status_failed">Vista previa no disponible</string>
<string name="chat_shared_files_limited">Solo se añadieron los primeros %1$d archivos compartidos.</string>
</resources>
@@ -7,4 +7,5 @@
<string name="pending_attachment_status_loading">プレビューを準備中</string>
<string name="pending_attachment_status_ready">準備完了</string>
<string name="pending_attachment_status_failed">プレビューできません</string>
<string name="chat_shared_files_limited">共有されたファイルは最初の%1$d件のみ追加されました。</string>
</resources>
@@ -7,4 +7,5 @@
<string name="pending_attachment_status_loading">Подготовка предпросмотра</string>
<string name="pending_attachment_status_ready">Готово</string>
<string name="pending_attachment_status_failed">Предпросмотр недоступен</string>
<string name="chat_shared_files_limited">Добавлены только первые %1$d общих файлов.</string>
</resources>
@@ -7,4 +7,5 @@
<string name="pending_attachment_status_loading">Preparing preview</string>
<string name="pending_attachment_status_ready">Ready</string>
<string name="pending_attachment_status_failed">Preview unavailable</string>
<string name="chat_shared_files_limited">Only the first %1$d shared files were added.</string>
</resources>
@@ -0,0 +1,95 @@
package com.hermesandroid.relay.auth
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class SecureStorageDiagnosticsTest {
@Before
fun setUp() {
DiagnosticsLog.clear()
}
@After
fun tearDown() {
DiagnosticsLog.clear()
}
@Test
fun preferredStoreUnavailable_recordsSanitizedFallback() {
SecureStorageDiagnostics.preferredStoreUnavailable()
val entry = DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth)).single()
assertEquals(DiagnosticSeverity.Warning, entry.severity)
assertEquals("Secure credential storage fallback activated", entry.title)
assertTrue(entry.detail.orEmpty().contains("encrypted compatibility storage"))
assertSanitized(entry.toString())
}
@Test
fun legacyStoreRecovered_recordsCredentialLossGuidance() {
SecureStorageDiagnostics.legacyStoreRecovered()
val entry = DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth)).single()
assertEquals(DiagnosticSeverity.Warning, entry.severity)
assertEquals("Encrypted credential storage recovered", entry.title)
assertTrue(entry.detail.orEmpty().contains("cleared and rebuilt"))
assertTrue(entry.suggestion.orEmpty().contains("Sign in or pair again"))
assertSanitized(entry.toString())
}
@Test
fun preferredStoreRecovered_recordsCredentialLossGuidance() {
SecureStorageDiagnostics.preferredStoreRecovered()
val entry = DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth)).single()
assertEquals(DiagnosticSeverity.Warning, entry.severity)
assertEquals("Keystore credential storage recovered", entry.title)
assertTrue(entry.detail.orEmpty().contains("cleared and rebuilt"))
assertTrue(entry.suggestion.orEmpty().contains("Sign in or pair again"))
assertSanitized(entry.toString())
}
@Test
fun inMemoryStoreOnly_recordsPersistentStorageFailure() {
SecureStorageDiagnostics.inMemoryStoreOnly()
val entry = DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth)).single()
assertEquals(DiagnosticSeverity.Error, entry.severity)
assertEquals("Credential storage is temporary", entry.title)
assertTrue(entry.detail.orEmpty().contains("app process stops"))
assertSanitized(entry.toString())
}
@Test
fun repeatedEvent_isRecordedOnlyOnceWhileVisible() {
repeat(3) {
SecureStorageDiagnostics.preferredStoreRecovered()
}
assertEquals(1, DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth)).size)
}
@Test
fun clearingDiagnostics_allowsLaterIncidentToBeRecorded() {
SecureStorageDiagnostics.preferredStoreRecovered()
DiagnosticsLog.clear()
SecureStorageDiagnostics.preferredStoreRecovered()
assertEquals(1, DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth)).size)
}
private fun assertSanitized(text: String) {
assertFalse(text.contains("prefs"))
assertFalse(text.contains("connectionId"))
assertFalse(text.contains("AEADBadTagException"))
assertFalse(text.contains("secret"))
}
}
@@ -88,6 +88,70 @@ class RelayAppStatusTest {
)
}
@Test
fun `pair setup permits explicitly authorized duplicate renew handoff`() {
val initiallyReady = resolvePairSetupReady(
storeHydrated = true,
connectionId = "placeholder",
authorizedHandoffId = null,
activeConnectionId = "placeholder",
connectionIds = setOf("placeholder", "existing"),
)
assertTrue(initiallyReady)
assertTrue(
resolvePairSetupReady(
storeHydrated = true,
connectionId = "placeholder",
authorizedHandoffId = "existing",
activeConnectionId = "existing",
connectionIds = setOf("placeholder", "existing"),
),
)
}
@Test
fun `pair setup waits for its exact route target`() {
assertFalse(
resolvePairSetupReady(
storeHydrated = true,
connectionId = "new-placeholder",
authorizedHandoffId = null,
activeConnectionId = "stale-placeholder",
connectionIds = setOf("stale-placeholder"),
),
)
}
@Test
fun `pair setup never trusts a prior latch before store hydration`() {
assertFalse(
resolvePairSetupReady(
storeHydrated = false,
connectionId = "placeholder",
authorizedHandoffId = "existing",
activeConnectionId = "existing",
connectionIds = emptySet(),
),
)
}
@Test
fun `pair setup retry replaces a still active preparation attempt`() {
assertFalse(shouldStartPairPreparation(hasActiveJob = true, retryRequested = false))
assertTrue(shouldStartPairPreparation(hasActiveJob = true, retryRequested = true))
assertTrue(shouldStartPairPreparation(hasActiveJob = false, retryRequested = false))
}
@Test
fun `replaced pair preparation completion does not evict current job`() {
val oldJob = Any()
val replacementJob = Any()
assertFalse(isCurrentPairPreparation(replacementJob, oldJob))
assertTrue(isCurrentPairPreparation(replacementJob, replacementJob))
}
@Test
fun `dashboard-only connection counts as configured startup chat`() {
assertTrue(hasConfiguredStartupChat(connection(dashboardUrl = "https://host.ts.net:9119")))
@@ -0,0 +1,227 @@
package com.hermesandroid.relay.util
import android.content.Intent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SharedContentRequestTest {
@Test
fun textSendIsAcceptedWithoutChangingItsContent() {
assertEquals(
SharedContentPayload(text = " https://example.test/page "),
extractSharedContent(
action = Intent.ACTION_SEND,
texts = listOf(" https://example.test/page "),
subject = "Page title",
streamUriStrings = emptyList(),
clipTexts = emptyList(),
clipUriStrings = emptyList(),
),
)
}
@Test
fun mixedAndMultipleSharesPreserveTextAndDeduplicateUris() {
assertEquals(
SharedContentPayload(
text = "Review these",
uriStrings = listOf("content://one", "content://two"),
),
extractSharedContent(
action = Intent.ACTION_SEND_MULTIPLE,
texts = listOf("Review these"),
subject = null,
streamUriStrings = listOf("content://one", "content://two"),
clipTexts = emptyList(),
clipUriStrings = listOf("content://one"),
),
)
}
@Test
fun externalFileAndCustomSchemesAreRejected() {
assertEquals(
SharedContentPayload(uriStrings = listOf("content://provider/shared/image.png")),
extractSharedContent(
action = Intent.ACTION_SEND_MULTIPLE,
texts = emptyList(),
subject = null,
streamUriStrings = listOf(
"content://provider/shared/image.png",
"file:///data/user/0/com.axiomlabs.hermesrelay/files/private.txt",
"https://example.test/image.png",
"relay-private://secret",
"content:opaque",
"CONTENT://provider/not-canonical",
),
clipTexts = emptyList(),
clipUriStrings = emptyList(),
),
)
assertNull(
extractSharedContent(
Intent.ACTION_SEND,
emptyList(),
null,
listOf("file:///data/local/tmp/not-shareable"),
emptyList(),
emptyList(),
)
)
}
@Test
fun multipleShareIsBoundedAndReportsOmittedFiles() {
val payload = requireNotNull(
extractSharedContent(
Intent.ACTION_SEND_MULTIPLE,
emptyList(),
null,
(1..15).map { "content://provider/shared/$it" },
emptyList(),
emptyList(),
)
)
assertEquals(MAX_SHARED_CONTENT_ATTACHMENTS, payload.uriStrings.size)
assertEquals(5, payload.omittedUriCount)
}
@Test
fun clipTextAndSubjectAreFallbacksButEmptySharesAreRejected() {
assertEquals(
SharedContentPayload(text = "first\nsecond\nclip text"),
extractSharedContent(
Intent.ACTION_SEND_MULTIPLE,
listOf("first", "second"),
"subject",
emptyList(),
listOf("clip text", "first"),
emptyList(),
),
)
assertNull(
extractSharedContent(
Intent.ACTION_VIEW,
listOf("hello"),
null,
emptyList(),
emptyList(),
emptyList(),
)
)
assertNull(
extractSharedContent(
Intent.ACTION_SEND,
listOf(" "),
null,
emptyList(),
emptyList(),
emptyList(),
)
)
}
@Test
fun readinessAndConsumptionOnlyAffectTheMatchingRequest() {
SharedContentRequest.pending.value?.let { SharedContentRequest.consume(it.id) }
assertFalse(SharedContentRequest.tryRequest(null))
assertTrue(SharedContentRequest.tryRequest(SharedContentPayload(text = "first")))
val first = requireNotNull(SharedContentRequest.pending.value)
assertTrue(
SharedContentRequest.tryRequest(
SharedContentPayload(uriStrings = listOf("content://second"))
)
)
val second = requireNotNull(SharedContentRequest.pending.value)
SharedContentRequest.markReady(first.id, "connection", "profile", "old-session")
assertEquals(second, SharedContentRequest.pending.value)
SharedContentRequest.markReady(second.id, "connection", "profile", "new-session")
assertEquals(
second.copy(
ready = true,
targetConnectionId = "connection",
targetProfileId = "profile",
targetSessionId = "new-session",
),
SharedContentRequest.pending.value,
)
SharedContentRequest.consume(first.id)
assertTrue(SharedContentRequest.pending.value != null)
SharedContentRequest.consume(second.id)
assertNull(SharedContentRequest.pending.value)
}
@Test
fun failedPreparationStaysPendingUntilForegroundRetry() {
SharedContentRequest.pending.value?.let { SharedContentRequest.consume(it.id) }
assertTrue(SharedContentRequest.tryRequest(SharedContentPayload(text = "keep me")))
val request = requireNotNull(SharedContentRequest.pending.value)
SharedContentRequest.markPreparing(request.id)
assertTrue(requireNotNull(SharedContentRequest.pending.value).preparing)
SharedContentRequest.markFailed(request.id)
val failed = requireNotNull(SharedContentRequest.pending.value)
assertTrue(failed.failed)
assertFalse(failed.preparing)
SharedContentRequest.retryFailed()
val retriable = requireNotNull(SharedContentRequest.pending.value)
assertFalse(retriable.failed)
assertFalse(retriable.preparing)
assertEquals(request.payload, retriable.payload)
SharedContentRequest.consume(request.id)
}
@Test
fun shareWaitsForTheExactRestoredDestinationComposer() {
val request = SharedContentDraftRequest(
id = 1L,
payload = SharedContentPayload(text = "https://example.test"),
ready = true,
targetConnectionId = "connection-a",
targetProfileId = "profile-a",
targetSessionId = "destination-session",
)
assertFalse(
canApplySharedContent(
request, "connection-b", "profile-a", "destination-session", draftRestored = true
)
)
assertFalse(
canApplySharedContent(
request, "connection-a", "profile-b", "destination-session", draftRestored = true
)
)
assertFalse(
canApplySharedContent(
request, "connection-a", "profile-a", "old-session", draftRestored = true
)
)
assertFalse(
canApplySharedContent(
request, "connection-a", "profile-a", "destination-session", draftRestored = false
)
)
assertTrue(
canApplySharedContent(
request, "connection-a", "profile-a", "destination-session", draftRestored = true
)
)
assertTrue(
canApplySharedContent(
request.copy(targetSessionId = null),
"connection-a",
"profile-a",
"new-session",
draftRestored = true,
)
)
}
}
@@ -1,45 +0,0 @@
package com.hermesandroid.relay.util
import android.content.Intent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SharedTextRequestTest {
@Test
fun textSendIsAcceptedWithoutChangingItsContent() {
assertEquals(
" Review this\ncarefully ",
extractSharedText(
action = Intent.ACTION_SEND,
mimeType = "text/plain",
text = " Review this\ncarefully ",
),
)
}
@Test
fun nonTextAndBlankSharesAreRejected() {
assertNull(extractSharedText(Intent.ACTION_VIEW, "text/plain", "hello"))
assertNull(extractSharedText(Intent.ACTION_SEND, "image/png", "hello"))
assertNull(extractSharedText(Intent.ACTION_SEND, "text/markdown", " \n"))
}
@Test
fun consumeOnlyClearsTheMatchingRequest() {
SharedTextRequest.pending.value?.let { SharedTextRequest.consume(it.id) }
assertFalse(SharedTextRequest.tryRequest(null))
assertTrue(SharedTextRequest.tryRequest("first"))
val first = requireNotNull(SharedTextRequest.pending.value)
assertTrue(SharedTextRequest.tryRequest("second"))
val second = requireNotNull(SharedTextRequest.pending.value)
SharedTextRequest.consume(first.id)
assertEquals(second, SharedTextRequest.pending.value)
SharedTextRequest.consume(second.id)
assertNull(SharedTextRequest.pending.value)
}
}
@@ -13,6 +13,8 @@ import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.HermesCardDispatch
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
@@ -80,6 +82,7 @@ class ChatViewModelGatewayInboundTurnTest {
@Volatile
private var holdCompletionsStream = false
private val apiCompletionsRequestCount = AtomicInteger(0)
private val apiMessageRequestCount = AtomicInteger(0)
@Before
fun setUp() {
@@ -90,6 +93,9 @@ class ChatViewModelGatewayInboundTurnTest {
if (request.path == "/v1/chat/completions") {
apiCompletionsRequestCount.incrementAndGet()
}
if (request.path?.contains("/messages") == true) {
apiMessageRequestCount.incrementAndGet()
}
return if (holdCompletionsStream && request.path == "/v1/chat/completions") {
MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)
} else {
@@ -117,6 +123,7 @@ class ChatViewModelGatewayInboundTurnTest {
persistedHistory = emptyList()
holdCompletionsStream = false
apiCompletionsRequestCount.set(0)
apiMessageRequestCount.set(0)
viewModel = ChatViewModel().also {
it.initialize(
HermesApiClient(apiServer.url("/").toString(), "test-key"),
@@ -133,8 +140,157 @@ class ChatViewModelGatewayInboundTurnTest {
shadowOf(Looper.getMainLooper()).idle()
}
@Test
fun offlineGatewaySendPublishesRetryableFailureAndKeepsPrompt() {
DiagnosticsLog.clear()
viewModel.updateGatewayClient(null)
viewModel.initialize(null, handler)
viewModel.streamingEndpoint = "gateway"
viewModel.sendMessage("Retry this after reconnect")
val failure = viewModel.chatFailure.value
assertEquals(STORED_SESSION_ID, failure?.sessionId)
assertEquals(ChatFailureRoute.GATEWAY, failure?.route)
assertTrue(failure?.recoverable == true)
assertTrue(failure?.rawError.orEmpty().contains("no API fallback"))
assertEquals("Retry this after reconnect", handler.lastSentMessage.value)
assertTrue(handler.messages.value.isEmpty())
val diagnostic = DiagnosticsLog.recent(setOf(DiagnosticCategory.Session), 1).single()
assertEquals("gateway", diagnostic.endpointRole)
assertEquals("chat response", diagnostic.operation)
}
@Test
fun explicitProfileHistoryFailureSurfacesAndNeverFallsBackAcrossProfiles() {
DiagnosticsLog.clear()
apiMessageRequestCount.set(0)
val owner = Profile(name = "owner", model = "model-a", description = "Owner")
viewModel.setSelectedProfileProvider { owner }
viewModel.setSessionProfileNameProvider { owner.name }
viewModel.setProfileMessageLoaderWithMode { profileName, sessionId, _ ->
assertEquals(owner.name, profileName)
assertEquals("owner-session", sessionId)
Result.failure(IllegalStateException("profile history unavailable"))
}
assertTrue(
viewModel.openProfileSession(
profileName = owner.name,
profile = owner,
contextKey = AgentDisplay.profileContextKey("connection-a", owner.name),
sessionId = "owner-session",
),
)
awaitCondition { viewModel.chatFailure.value?.turnId == "history-owner-session" }
val failure = viewModel.chatFailure.value
assertEquals("owner-session", failure?.sessionId)
assertEquals(ChatFailureRoute.GATEWAY, failure?.route)
assertFalse(failure?.recoverable ?: true)
assertTrue(failure?.rawError.orEmpty().contains("profile history unavailable"))
assertEquals(0, apiMessageRequestCount.get())
val diagnostic = DiagnosticsLog.recent(setOf(DiagnosticCategory.Session), 1).single()
assertEquals("Hermes chat history failed", diagnostic.title)
assertEquals("load chat history", diagnostic.operation)
assertEquals("gateway", diagnostic.endpointRole)
}
@Test
fun missingRequiredProfileHistoryLoaderFailsClosedWithoutApiRead() {
DiagnosticsLog.clear()
apiMessageRequestCount.set(0)
val owner = Profile(name = "owner", model = "model-a", description = "Owner")
viewModel.setSelectedProfileProvider { owner }
viewModel.setSessionProfileNameProvider { owner.name }
viewModel.clearProfileMessageLoader()
assertTrue(
viewModel.openProfileSession(
profileName = owner.name,
profile = owner,
contextKey = AgentDisplay.profileContextKey("connection-a", owner.name),
sessionId = "missing-loader-session",
),
)
awaitCondition { viewModel.chatFailure.value?.sessionId == "missing-loader-session" }
assertFalse(viewModel.chatFailure.value?.recoverable ?: true)
assertTrue(
viewModel.chatFailure.value?.rawError.orEmpty()
.contains("Profile-scoped conversation history is unavailable"),
)
assertEquals(0, apiMessageRequestCount.get())
}
@Test
fun ordinarySessionSwitchFailureSettlesLoadingAndSurfacesError() {
DiagnosticsLog.clear()
viewModel.setProfileMessageLoaderWithMode { _, sessionId, _ ->
Result.failure(IllegalStateException("history failed for $sessionId"))
}
viewModel.switchSession("failed-switch-session")
awaitCondition {
!viewModel.isLoadingHistory.value &&
viewModel.chatFailure.value?.sessionId == "failed-switch-session"
}
val failure = viewModel.chatFailure.value
assertFalse(failure?.recoverable ?: true)
assertTrue(failure?.rawError.orEmpty().contains("failed-switch-session"))
assertEquals("failed-switch-session", handler.currentSessionId.value)
}
@Test
fun supersededHistoryFailureCannotClearOrErrorNewerSession() {
DiagnosticsLog.clear()
val oldLoadStarted = CompletableDeferred<Unit>()
val releaseOldLoad = CompletableDeferred<Unit>()
viewModel.setProfileMessageLoaderWithMode { _, sessionId, _ ->
when (sessionId) {
"old-session" -> {
oldLoadStarted.complete(Unit)
releaseOldLoad.await()
Result.failure(IllegalStateException("stale history failure"))
}
"new-session" -> Result.success(
listOf(
MessageItem(
id = "new-answer",
sessionId = sessionId,
role = "assistant",
content = JsonPrimitive("New session transcript"),
),
),
)
else -> Result.success(emptyList())
}
}
viewModel.switchSession("old-session")
awaitCondition { oldLoadStarted.isCompleted }
viewModel.switchSession("new-session")
awaitCondition {
!viewModel.isLoadingHistory.value &&
handler.messages.value.any { it.content == "New session transcript" }
}
releaseOldLoad.complete(Unit)
shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS)
assertEquals("new-session", handler.currentSessionId.value)
assertTrue(handler.messages.value.any { it.content == "New session transcript" })
assertNull(viewModel.chatFailure.value)
assertTrue(
DiagnosticsLog.recent(setOf(DiagnosticCategory.Session))
.none { it.detail.orEmpty().contains("stale history failure") },
)
}
@After
fun tearDown() {
DiagnosticsLog.clear()
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
@@ -1,16 +1,25 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.datastore.preferences.core.edit
import android.os.Looper
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.data.relayDataStore
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.ConnectionStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
@@ -23,9 +32,6 @@ class ConnectionViewModelColdStartTest {
@Before
fun setUp() {
application = ApplicationProvider.getApplicationContext()
runBlocking {
application.relayDataStore.edit { it.clear() }
}
}
@Test
@@ -36,4 +42,75 @@ class ConnectionViewModelColdStartTest {
assertEquals("", viewModel.effectiveApiServerUrl.value)
assertNull(viewModel.apiClient.value)
}
@Test
fun `preallocated add never reuses a different placeholder id`() {
val stale = Connection(
id = "stale-placeholder",
label = ConnectionViewModel.PLACEHOLDER_LABEL,
apiServerUrl = "",
relayUrl = "",
tokenStoreKey = Connection.buildTokenStoreKey("stale-placeholder"),
)
assertNull(
reusablePlaceholderForAdd(
preAllocatedId = "route-placeholder",
connections = listOf(stale),
),
)
assertEquals(
stale,
reusablePlaceholderForAdd(
preAllocatedId = null,
connections = listOf(stale),
),
)
}
@Test
fun `cold start orphan sweep observes persisted placeholders after hydration`() {
val seedStore = ConnectionStore(application)
awaitWithMainLooper { seedStore.isHydrated.first { it } }
awaitWithMainLooper {
seedStore.addConnection(
Connection(
id = "persisted-orphan",
label = ConnectionViewModel.PLACEHOLDER_LABEL,
apiServerUrl = "",
relayUrl = "",
tokenStoreKey = Connection.buildTokenStoreKey("persisted-orphan"),
),
)
}
val viewModel = ConnectionViewModel(application)
awaitWithMainLooper { viewModel.connectionStore.isHydrated.first { it } }
val deadline = System.currentTimeMillis() + 5_000L
while (
viewModel.connectionStore.connections.value.any { it.id == "persisted-orphan" } &&
System.currentTimeMillis() < deadline
) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
Thread.sleep(10)
}
val afterSweep = viewModel.connectionStore.connections.value
assertTrue(afterSweep.none { it.id == "persisted-orphan" })
}
private fun <T> awaitWithMainLooper(block: suspend () -> T): T {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val deferred = scope.async { block() }
val deadline = System.currentTimeMillis() + 5_000L
while (!deferred.isCompleted && System.currentTimeMillis() < deadline) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
Thread.sleep(10)
}
check(deferred.isCompleted) { "Suspend test operation did not finish within 5 seconds" }
return try {
runBlocking { deferred.await() }
} finally {
scope.cancel()
}
}
}
@@ -0,0 +1,38 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import org.junit.After
import org.junit.Before
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class DashboardGatewayDiagnosticsTest {
@Before
fun setUp() {
DiagnosticsLog.clear()
}
@After
fun tearDown() {
DiagnosticsLog.clear()
}
@Test
fun `dashboard failure records route and action without exposing host`() {
recordDashboardGatewayFailure(
dashboardUrl = "https://private-host.example:9119",
detail = "Dashboard status probe returned no response.",
)
val entry = DiagnosticsLog.recent(setOf(DiagnosticCategory.Endpoint), 1).single()
assertEquals(DiagnosticSeverity.Error, entry.severity)
assertEquals("gateway", entry.endpointRole)
assertEquals("Probe Dashboard / Gateway status", entry.operation)
assertEquals("https://[host]", entry.configuredUrl)
assertEquals("https://[host]/api/status", entry.requestUrl)
assertFalse(entry.toString().contains("private-host.example"))
}
}
@@ -3,12 +3,36 @@ package com.hermesandroid.relay.viewmodel.connection
import android.content.Context
import io.mockk.mockk
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.TimeUnit
class UpstreamTransportControllerAuthClientTest {
@Test
fun dashboardCookieStoresRemainConnectionScoped() {
val requestedKeys = mutableMapOf<String, String>()
val controller = UpstreamTransportController(
context = mockk<Context>(relaxed = true),
activeConnectionIdProvider = { null },
dashboardUrlProvider = { null },
gatewayKeepAliveProvider = { false },
tokenStoreKeyProvider = { connectionId ->
"token-store-$connectionId".also { requestedKeys[connectionId] = it }
},
)
val firstA = controller.dashboardCookieStoreFor("connection-a")
val secondA = controller.dashboardCookieStoreFor("connection-a")
val storeB = controller.dashboardCookieStoreFor("connection-b")
assertSame(firstA, secondA)
assertNotSame(firstA, storeB)
assertEquals("token-store-connection-a", requestedKeys["connection-a"])
assertEquals("token-store-connection-b", requestedKeys["connection-b"])
}
@Test
fun dashboardHttpClient_isReusedUntilRouteChangesThenDisposed() {
var dashboardUrl = "https://hermes.example.test"
+2 -2
View File
@@ -89,9 +89,9 @@ 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.12.0 - Themes and identity that stay put
v1.12.1 - Sharing and recovery that work
Create and save custom themes with full palette and shape controls. Shapes now apply consistently throughout the app. All Profiles sessions switch to their owning agent and survive language changes with the correct header, icon, and transcript. Gateway chats recover when a terminal frame is missed, persistent connection notifications relocalize without reconnecting, and Relay URLs normalize correctly from base, /ws, or /health forms.
Shared links, text, images, and files now open as complete reviewable drafts without sending automatically. Add and Renew connection setup no longer stalls. Offline chat and profile-history failures surface clear recovery guidance instead of doing nothing or showing empty history. Diagnostics now reports secure-storage fallback and recovery without exposing credentials.
```
## Category
+22 -3
View File
@@ -7,7 +7,7 @@ Android's declarative plugin surface is specified in
**Status:** v1.0.0 stable. The default path supports chat, Manage, and voice on vanilla upstream Hermes without installing the Relay plugin. Relay is additive: terminal, bridge/device control, notification companion, remote access, extra/provider-native voice, desktop tooling, and dashboard Relay management. Historical phase notes remain in this file for context; the current route ownership source of truth is [`docs/upstream-surface-matrix.md`](upstream-surface-matrix.md).
**Repo:** [Codename-11/hermes-relay](https://github.com/Codename-11/hermes-relay)
**Updated:** 2026-08-15
**Updated:** 2026-08-22
---
@@ -164,6 +164,13 @@ to attach the PR a coding session created, then the repo-scoped read-only
this metadata is optional; older Dashboard and API-server hosts retain the
ordinary session row.
Chat availability is derived only from the authenticated Gateway and supported
API-server fallback routes. A Send with no usable route remains fail-closed and
surfaces a retryable conversation failure plus secret-free Diagnostics evidence.
Profile-owned Gateway history is required to load through that exact profile;
an unavailable scoped reader surfaces a history failure instead of accepting an
empty or different profile's transcript as authoritative.
#### Channel: `terminal`
PTY streaming — raw terminal I/O.
@@ -197,6 +204,9 @@ The app owns an ephemeral five-minute loopback callback and stores the resulting
bearer session only for that connection and exact dashboard origin. Callback,
code-exchange, hosted-gateway, transport, response-shape, and secure-storage
failures surface as distinct secret-free recovery guidance.
Unreadable secure stores may be cleared and rebuilt, with any Keystore fallback,
self-heal, or temporary in-memory degradation recorded in Diagnostics without
credential values, cookie contents, endpoint URLs, or storage identifiers.
Self-hosted OIDC remains on the dashboard cookie flow: Android opens
`/auth/login` in a full-screen embedded browser destination, lets the provider
return through the public `/auth/callback`, imports only same-origin cookies,
@@ -210,6 +220,15 @@ by Manage, Gateway tickets, and standard voice.
Pairing is QR-driven. The operator runs the pair command on the host — `hermes pair`, `/hermes-relay-pair` from any Hermes chat surface, or the compatibility `hermes-pair` shell shim. All share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (plus the API server credentials and optional dashboard URL) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge.
Each Android Add Pair route owns its exact allocated target connection identity.
That target must be persisted and active before its wizard becomes ready; it is
never silently replaced by a differently identified placeholder. Duplicate
renewal performs one explicit validated handoff to the existing connection ID
before switching, which keeps the Compose-owned wizard alive while all
authentication stores follow that existing identity. The waiting surface is
bounded: a target that does not become ready exposes Retry and Cancel and
records only boolean, secret-free readiness evidence in Diagnostics.
The primary secure remote path today is Tailscale Serve, which exposes Relay as
WSS and the independently authenticated upstream API/Dashboard surfaces as
HTTPS. The optional Relay plugin **Hermes Secure Link** is a unified alternative: when
@@ -370,7 +389,7 @@ Implementation references:
| Auth envelope | `{pairing_code, ttl_seconds, grants, device_name, device_id}` for pairing mode; `{session_token, device_name, device_id}` for session-mode re-auth. Host metadata wins over phone metadata when both are present. |
| `auth.ok` response | `{session_token, expires_at, grants, transport_hint, profiles, server_version}`. `math.inf` expiries serialize as `null`. |
| Rate limiting | 5 auth attempts / 60s → 5-min block. **`/pairing/register` clears all blocks on success** so legitimate re-pair after a relay restart works immediately. |
| Token storage | `SessionTokenStore` — `KeystoreTokenStore` (StrongBox-preferred via `setRequestStrongBoxBacked`) with fallback to `LegacyEncryptedPrefsTokenStore` (TEE-backed `EncryptedSharedPreferences`). One-shot lossless migration on first launch post-upgrade. `hasHardwareBackedStorage` flag surfaced in UI. |
| Token storage | `SessionTokenStore` — `KeystoreTokenStore` (StrongBox-preferred via `setRequestStrongBoxBacked`) with fallback to `LegacyEncryptedPrefsTokenStore` (TEE-backed `EncryptedSharedPreferences`). One-shot lossless migration on first launch post-upgrade. `hasHardwareBackedStorage` is surfaced in UI; fallback, self-heal, and temporary in-memory degradation produce secret-free Diagnostics events. |
| Cert pinning | TOFU via `CertPinStore` — SHA-256 SPKI fingerprint recorded per `host:port` on first successful wss connect. Subsequent connects verify via OkHttp `CertificatePinner`. Pin wiped explicitly on QR re-pair (`applyServerIssuedCodeAndReset`). Plain ws:// short-circuits pinning entirely. |
| QR integrity | HMAC-SHA256 over canonicalized payload. Host-local secret at `~/.hermes/hermes-relay-qr-secret`. Phone parses + stores the signature but does NOT verify yet (secret distribution TBD). |
| Tailscale detection | Informational only — `tailscale0` interface + `100.64.0.0/10` CGNAT + `.ts.net` hostname checks. Displayed as a Connection-section chip. Does NOT auto-change TTL defaults. |
@@ -505,7 +524,7 @@ The bridge UI drives — and is driven by — Tier 5 safety-rails (`BridgeSafety
### Settings Tab
- **Active agent card (v0.6.0)** — top-of-screen summary card showing the current Connection / Profile / Personality. Tap navigates to Chat and auto-opens the agent sheet via the `openAgentSheet` nav arg, giving Settings-originating users a one-tap path to change agent context without leaving the flow.
- **Connections** (v0.6.0+) — lists every paired Hermes server with a per-card status chip. Actions: rename (inline), re-pair (reuses `ConnectionWizard` with `connectionId` nav arg), revoke, remove. Add-connection button launches the standard QR flow. Settings briefly treats a paired + disconnected relay as **Connecting** during the reconnect grace window, then promotes it to **Relay unreachable - tap to reconnect** if the live socket does not recover. API / Relay / Session detail sheets include compact sanitized recent-activity tails, and **Settings -> Diagnostics** shows the consolidated app-level API, relay, session, endpoint, and voice activity buffer. See `docs/decisions.md` §19.
- **Connections** (v0.6.0+) — lists every paired Hermes server with a per-card status chip. Actions: rename (inline), re-pair (reuses `ConnectionWizard` with `connectionId` nav arg), revoke, remove. Add-connection button launches the standard QR flow. Settings briefly treats a paired + disconnected relay as **Connecting** during the reconnect grace window, then promotes it to **Relay unreachable - tap to reconnect** if the live socket does not recover. API / Relay / Session detail sheets include compact sanitized recent-activity tails, and **Settings -> Diagnostics** shows the consolidated app-level API, relay, session, endpoint, voice, Pair-readiness, credential-store recovery, history-failure, and rejected-Send evidence without secrets. See `docs/decisions.md` §19.
- **Connection (single-server settings)** — summary-first detail for one Hermes installation. Dashboard/Gateway health drives standard Chat, Manage, Sessions, and Voice readiness. API fallback and Relay extensions appear as independently optional capabilities. Advanced configuration exposes manual Dashboard, API, and Relay endpoints plus their native credentials; missing API or Relay settings never make a healthy Dashboard/Gateway connection look broken. Pairing-code and QR fallbacks remain available for Relay and compatibility setups. Transport security posture and paired-device grants remain visible without leading the normal setup flow with ports or bearer keys.
- **Chat** — Show reasoning toggle, smooth auto-scroll toggle (live-follow streaming, default on), show token usage toggle, app context prompt toggle, tool call display (Off/Compact/Detailed), streaming endpoint selector (`auto` / `sessions` / `runs`), Stats for Nerds (analytics charts)
- **Voice** — route-aware voice engine selector (`Vanilla Hermes` via dashboard audio, `Relay Voice Output`, and experimental `Realtime Agent`), global interaction mode (tap / hold / continuous), silence threshold slider, a final-answer-only speech policy, Auto-TTS toggle, selected-engine cards for dashboard or relay-backed settings, language picker, and a Test Current Engine card. Final-answer-only keeps tool/service progress and intermediate commentary visual while both voice engines wait to speak the settled answer; approvals, confirmation questions, and blocking failures remain actionable. Vanilla Hermes voice depends on Manage/dashboard auth; Relay-backed engines run a fast relay health preflight before uploading audio or opening a realtime provider session so a hung relay surfaces as a connection error instead of an indefinite Thinking state.
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.12.0"
appVersionCode = "47"
appVersionName = "1.12.1"
appVersionCode = "48"
agp = "9.3.1"
kotlin = "2.4.10"
compose-bom = "2026.08.00"
+6 -1
View File
@@ -27,7 +27,12 @@ productFlavors {
}
```
Both flavors can coexist on the same device.
Both flavors can coexist on the same device. Their distinct application IDs
also give them completely separate Android app data: saved connections,
encrypted tokens and keysets, Dashboard cookies, selected profiles and
sessions, and composer drafts do not cross between the Play and sideload apps.
A working sideload install therefore does not validate or repair the Play
install's saved connection state, and vice versa.
## Source Set Layout
+3
View File
@@ -10,6 +10,9 @@ independent capabilities:
- Dashboard/Gateway URL (`http(s)://host:9119`) and dashboard session for primary Chat, sessions, Manage, and standard voice
- Optional API server URL (`http(s)://host:8642`) and API key for automatic chat fallback or advanced headless compatibility
- Optional Relay URL (`ws(s)://host:8767`) and pairing record for Terminal, Bridge, and relay-only power tools
- Connection-scoped Dashboard cookies or native sign-in tokens, API credentials,
and Relay session credentials; authentication never carries into another
saved connection
- Its own sessions, memory, personalities, and skill list (fetched from that server)
- Last-active session ID and explicit profile pick, so switching back takes you where you left off
+9 -5
View File
@@ -11,12 +11,16 @@ Phone (HTTP/SSE) → Hermes API Server (:8642) [fallback — sessions / runs /
Both paths are **vanilla upstream Hermes** surfaces. The dashboard gateway `/api/ws` is *not* the Hermes-Relay relay (`:8767`); it's a vanilla dashboard endpoint, reached with a short-lived ticket minted from your Manage dashboard session. The optional Relay plugin is never involved in chat — it only adds terminal, device control, media, and the like.
## Share text into a new chat
## Share into a new chat
Hermes Relay appears as a target when another Android app shares text. Choosing
it opens a new conversation for your currently active profile and places the
shared text in the composer. Nothing is sent automatically: edit or discard the
draft, then tap Send when it is ready.
Hermes Relay appears as a target when another Android app shares a link, text,
image, or file. Choosing it opens a new conversation for your currently active
profile, places shared text in the composer, and adds up to ten shared files as
reviewable attachments. Mixed text-and-file shares are supported; if more than
ten eligible files are shared, the app adds the first ten and tells you the rest
were omitted. Multi-text shares preserve each supplied text item in source order.
Nothing is sent automatically: edit, remove, reorder, or discard the draft, then
tap Send when it is ready.
When it falls back, the app uses the Hermes `/api/sessions` REST API: