Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
390a4dd8d8 | ||
|
|
443e347b43 | ||
|
|
42f91c1462 | ||
|
|
8b9e92ccee | ||
|
|
58f642dceb | ||
|
|
3ed64ba251 | ||
|
|
a6467e84cb | ||
|
|
e69ca817e4 | ||
|
|
733ece9523 |
@@ -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 });
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 地址也会正确规范化。
|
||||
共享链接、文本、图片和文件现在会作为完整、可检查的草稿打开,不会自动发送。添加或续订连接时不再卡在准备阶段。离线聊天和配置文件历史记录失败会显示明确的恢复提示,而不是无响应或显示空历史记录。诊断现在会报告安全存储降级与恢复,且不会暴露凭据。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+156
@@ -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()
|
||||
|
||||
+82
-5
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -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"))
|
||||
}
|
||||
}
|
||||
+24
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user