Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5cc0104bf | ||
|
|
86a0bebc0d | ||
|
|
04d9421c74 | ||
|
|
14401aa3c3 | ||
|
|
99897274c6 | ||
|
|
20c5b690a8 | ||
|
|
dc86c043bc |
@@ -3,8 +3,11 @@
|
||||
function classifyCiPaths(paths) {
|
||||
const forceAll = paths.some((path) => [
|
||||
'.github/workflows/ci-required.yml',
|
||||
'.github/workflows/release-backmerge.yml',
|
||||
'.github/scripts/classify-ci-paths.cjs',
|
||||
'.github/scripts/classify-ci-paths.test.cjs',
|
||||
'scripts/plan_release_backmerge.py',
|
||||
'scripts/tests/plan_release_backmerge_test.py',
|
||||
].includes(path));
|
||||
const exact = (values) => paths.some((path) => values.includes(path));
|
||||
const under = (prefixes) => paths.some((path) => prefixes.some((prefix) => path.startsWith(prefix)));
|
||||
|
||||
@@ -30,5 +30,13 @@ assert.deepEqual(classifyCiPaths(['.github/workflows/ci-required.yml']), {
|
||||
contract: true,
|
||||
docs: true,
|
||||
});
|
||||
assert.deepEqual(classifyCiPaths(['.github/workflows/release-backmerge.yml']), {
|
||||
android: true,
|
||||
desktop: true,
|
||||
plugin: true,
|
||||
dashboard: true,
|
||||
contract: true,
|
||||
docs: true,
|
||||
});
|
||||
|
||||
console.log('CI path classification tests passed.');
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
name: CI — Upstream Contract
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
@@ -40,45 +43,64 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout hermes-relay
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve upstream ref
|
||||
id: ref
|
||||
env:
|
||||
REQUESTED_REF: ${{ github.event.inputs.upstream_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# PR/push runs use a known-good NousResearch/hermes-agent commit so
|
||||
# normal CI is stable. The weekly schedule below intentionally tracks
|
||||
# main as the upstream-drift siren.
|
||||
DEFAULT_REF="ef4b897a1843cd32c4f141f55db60f0f0602cc98"
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
REF="main" # weekly drift siren
|
||||
elif [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
|
||||
REF="${{ github.event.inputs.upstream_ref }}" # manual override
|
||||
elif [ -n "$REQUESTED_REF" ]; then
|
||||
REF="$REQUESTED_REF" # manual override
|
||||
else
|
||||
REF="$DEFAULT_REF"
|
||||
fi
|
||||
|
||||
# The ref is passed to git below, so reject option-like or malformed
|
||||
# values before it reaches that boundary. Full commit IDs and normal
|
||||
# branch/tag names remain supported for manual contract checks.
|
||||
if [[ "$REF" == -* ]] ||
|
||||
! git check-ref-format --allow-onelevel "$REF" >/dev/null; then
|
||||
echo "FAIL: invalid upstream branch or tag name." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
echo "Checking standard-path route contract against upstream ref: $REF"
|
||||
|
||||
- name: Checkout vanilla upstream (no plugin, no bootstrap)
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: NousResearch/hermes-agent
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
path: _upstream
|
||||
fetch-depth: 1
|
||||
- name: Extract trusted upstream contract sources
|
||||
env:
|
||||
UPSTREAM_REF: ${{ steps.ref.outputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
UPSTREAM_GIT="$RUNNER_TEMP/hermes-agent-contract.git"
|
||||
git init --bare "$UPSTREAM_GIT"
|
||||
git -C "$UPSTREAM_GIT" remote add origin \
|
||||
"https://github.com/NousResearch/hermes-agent.git"
|
||||
git -C "$UPSTREAM_GIT" fetch --no-tags --depth=1 origin -- "$UPSTREAM_REF"
|
||||
UPSTREAM_COMMIT="$(git -C "$UPSTREAM_GIT" rev-parse 'FETCH_HEAD^{commit}')"
|
||||
|
||||
mkdir -p _upstream/gateway/platforms _upstream/hermes_cli
|
||||
git -C "$UPSTREAM_GIT" show \
|
||||
"$UPSTREAM_COMMIT:gateway/platforms/api_server.py" \
|
||||
> _upstream/gateway/platforms/api_server.py
|
||||
git -C "$UPSTREAM_GIT" show \
|
||||
"$UPSTREAM_COMMIT:hermes_cli/web_server.py" \
|
||||
> _upstream/hermes_cli/web_server.py
|
||||
echo "Extracted contract sources from upstream commit: $UPSTREAM_COMMIT"
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Assert upstream checkout is vanilla (no relay bootstrap/plugin)
|
||||
run: |
|
||||
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
|
||||
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
|
||||
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
|
||||
echo "FAIL: upstream checkout contains a relay bootstrap — not vanilla."; exit 1
|
||||
fi
|
||||
echo "OK: upstream checkout carries no relay plugin/bootstrap."
|
||||
|
||||
- name: Run route-surface contract
|
||||
run: python scripts/check-upstream-route-contract.py "_upstream"
|
||||
|
||||
@@ -10,6 +10,16 @@ on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base_sha:
|
||||
description: "Exact base commit for a trusted release-backmerge candidate"
|
||||
required: true
|
||||
type: string
|
||||
head_sha:
|
||||
description: "Exact candidate commit to check"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -31,22 +41,63 @@ jobs:
|
||||
contract: ${{ steps.filter.outputs.contract }}
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: Checkout pull request merge
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Checkout exact dispatched candidate
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.head_sha }}
|
||||
|
||||
- name: Test path classifier
|
||||
run: node .github/scripts/classify-ci-paths.test.cjs
|
||||
|
||||
- name: Classify changed files
|
||||
id: filter
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
DISPATCH_BASE_SHA: ${{ inputs.base_sha }}
|
||||
DISPATCH_HEAD_SHA: ${{ inputs.head_sha }}
|
||||
with:
|
||||
script: |
|
||||
let diffArgs;
|
||||
if (context.eventName === 'workflow_dispatch') {
|
||||
const base = process.env.DISPATCH_BASE_SHA || '';
|
||||
const head = process.env.DISPATCH_HEAD_SHA || '';
|
||||
const shaPattern = /^[0-9a-f]{40}$/;
|
||||
if (!shaPattern.test(base) || !shaPattern.test(head)) {
|
||||
core.setFailed('Exact-tree dispatch requires full 40-character base/head SHAs.');
|
||||
return;
|
||||
}
|
||||
const { stdout: checkedOut } = await exec.getExecOutput(
|
||||
'git',
|
||||
['rev-parse', 'HEAD'],
|
||||
);
|
||||
if (checkedOut.trim() !== head) {
|
||||
core.setFailed(`Checked out ${checkedOut.trim()}, expected ${head}.`);
|
||||
return;
|
||||
}
|
||||
const ancestry = await exec.exec(
|
||||
'git',
|
||||
['merge-base', '--is-ancestor', base, head],
|
||||
{ ignoreReturnCode: true },
|
||||
);
|
||||
if (ancestry !== 0) {
|
||||
core.setFailed(`Candidate ${head} does not descend from base ${base}.`);
|
||||
return;
|
||||
}
|
||||
diffArgs = ['diff', '--name-only', base, head];
|
||||
} else {
|
||||
diffArgs = ['diff', '--name-only', 'HEAD^1', 'HEAD^2'];
|
||||
}
|
||||
const { stdout } = await exec.getExecOutput(
|
||||
'git',
|
||||
['diff', '--name-only', 'HEAD^1', 'HEAD^2'],
|
||||
diffArgs,
|
||||
);
|
||||
const paths = stdout.split(/\r?\n/).filter(Boolean);
|
||||
const { classifyCiPaths } = require(
|
||||
|
||||
@@ -365,3 +365,22 @@ jobs:
|
||||
find app/build/outputs/apk -name '*.apk' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
find app/build/outputs/bundle -name '*.aab' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
request-backmerge:
|
||||
name: Request stable release backmerge
|
||||
needs: [validate, release]
|
||||
if: needs.validate.outputs.prerelease != 'true'
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch fail-closed release reconciliation
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: android-v${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
gh workflow run release-backmerge.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref main \
|
||||
-f release_tag="$RELEASE_TAG"
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# Reconcile a completed stable hotfix into dev without adding a ceremonial PR
|
||||
# merge commit. Normal dev -> main releases are detected and intentionally no-op.
|
||||
# A conflicted merge, failed exact-tree CI, stale dev ref, or denied branch update
|
||||
# stops without mutating dev and falls back to the normal reconciliation PR path.
|
||||
|
||||
name: Release Backmerge
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Published stable tag to reconcile (android-v*, server-v*, or desktop-v*)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-backmerge-dev
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Prepare exact backmerge candidate
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
outcome: ${{ steps.prepare.outputs.outcome }}
|
||||
base_dev_sha: ${{ steps.prepare.outputs.base_dev_sha }}
|
||||
candidate_branch: ${{ steps.prepare.outputs.candidate_branch }}
|
||||
candidate_sha: ${{ steps.prepare.outputs.candidate_sha }}
|
||||
release_commit: ${{ steps.prepare.outputs.release_commit }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Validate release and prepare merge commit
|
||||
id: prepare
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! "$RELEASE_TAG" =~ ^(android|server|desktop)-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Release Backmerge accepts stable SemVer production tags only; got $RELEASE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main" \
|
||||
"+refs/heads/dev:refs/remotes/origin/dev" \
|
||||
"+refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
|
||||
release_commit="$(git rev-parse "${RELEASE_TAG}^{commit}")"
|
||||
base_dev_sha="$(git rev-parse origin/dev)"
|
||||
echo "release_commit=$release_commit" >> "$GITHUB_OUTPUT"
|
||||
echo "base_dev_sha=$base_dev_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if ! git merge-base --is-ancestor "$release_commit" origin/main; then
|
||||
echo "::error::$RELEASE_TAG ($release_commit) is not contained in origin/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r is_draft is_prerelease < <(
|
||||
gh release view "$RELEASE_TAG" --json isDraft,isPrerelease \
|
||||
--jq '[.isDraft, .isPrerelease] | @tsv'
|
||||
)
|
||||
if [ "$is_draft" != "false" ] || [ "$is_prerelease" != "false" ]; then
|
||||
echo "::error::$RELEASE_TAG is not a published stable GitHub release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plan="$(
|
||||
python3 scripts/plan_release_backmerge.py \
|
||||
--release-commit "$release_commit" \
|
||||
--dev-commit "$base_dev_sha"
|
||||
)"
|
||||
case "$plan" in
|
||||
already-contained)
|
||||
echo "outcome=noop" >> "$GITHUB_OUTPUT"
|
||||
echo "## Release backmerge not needed" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "\`$RELEASE_TAG\` is already contained in \`dev\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
;;
|
||||
normal-release)
|
||||
echo "outcome=noop" >> "$GITHUB_OUTPUT"
|
||||
echo "## Normal release: no backmerge" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The released merge's integration parent is already contained in \`dev\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
;;
|
||||
hotfix) ;;
|
||||
*)
|
||||
echo "::error::Unknown release-backmerge plan: $plan"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
candidate_branch="chore/release-backmerge/${RELEASE_TAG}-${GITHUB_RUN_ID}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git switch --detach "$base_dev_sha"
|
||||
|
||||
set +e
|
||||
git merge --no-ff -m "chore: back-merge ${RELEASE_TAG}" "$release_commit"
|
||||
merge_status=$?
|
||||
set -e
|
||||
if [ "$merge_status" -ne 0 ]; then
|
||||
conflicts="$(git diff --name-only --diff-filter=U | paste -sd ', ' -)"
|
||||
echo "outcome=conflict" >> "$GITHUB_OUTPUT"
|
||||
echo "::error::Automatic backmerge conflicts: ${conflicts:-unknown}. Open a reconciliation PR."
|
||||
echo "## Manual reconciliation PR required" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "\`$RELEASE_TAG\` conflicts with current \`dev\`: ${conflicts:-unknown}." >> "$GITHUB_STEP_SUMMARY"
|
||||
git merge --abort || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
candidate_sha="$(git rev-parse HEAD)"
|
||||
first_parent="$(git rev-parse HEAD^1)"
|
||||
second_parent="$(git rev-parse HEAD^2)"
|
||||
if [ "$first_parent" != "$base_dev_sha" ] || [ "$second_parent" != "$release_commit" ]; then
|
||||
echo "::error::Candidate parents do not match dev + release commit"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git push origin "$candidate_sha:refs/heads/$candidate_branch"
|
||||
echo "outcome=candidate" >> "$GITHUB_OUTPUT"
|
||||
echo "candidate_branch=$candidate_branch" >> "$GITHUB_OUTPUT"
|
||||
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "## Backmerge candidate prepared" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Release: \`$RELEASE_TAG\` (\`$release_commit\`)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Dev base: \`$base_dev_sha\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Candidate: \`$candidate_sha\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Temporary ref: \`$candidate_branch\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
gate:
|
||||
name: Run exact-tree required checks
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.outcome == 'candidate'
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
check_run_id: ${{ steps.gate.outputs.check_run_id }}
|
||||
steps:
|
||||
- name: Dispatch and await Required checks
|
||||
id: gate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_DEV_SHA: ${{ needs.prepare.outputs.base_dev_sha }}
|
||||
CANDIDATE_BRANCH: ${{ needs.prepare.outputs.candidate_branch }}
|
||||
CANDIDATE_SHA: ${{ needs.prepare.outputs.candidate_sha }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh workflow run ci-required.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref "$CANDIDATE_BRANCH" \
|
||||
-f base_sha="$BASE_DEV_SHA" \
|
||||
-f head_sha="$CANDIDATE_SHA"
|
||||
|
||||
check_run_id=""
|
||||
for _ in {1..20}; do
|
||||
check_run_id="$(
|
||||
gh run list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--workflow ci-required.yml \
|
||||
--branch "$CANDIDATE_BRANCH" \
|
||||
--event workflow_dispatch \
|
||||
--limit 20 \
|
||||
--json databaseId,headSha \
|
||||
--jq ".[] | select(.headSha == \"$CANDIDATE_SHA\") | .databaseId" \
|
||||
| head -n 1
|
||||
)"
|
||||
if [ -n "$check_run_id" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
if [ -z "$check_run_id" ]; then
|
||||
echo "::error::Required checks dispatch was not observed for $CANDIDATE_SHA"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "check_run_id=$check_run_id" >> "$GITHUB_OUTPUT"
|
||||
gh run watch "$check_run_id" --repo "$GITHUB_REPOSITORY" --exit-status
|
||||
|
||||
promote:
|
||||
name: Compare-and-swap dev
|
||||
needs: [prepare, gate]
|
||||
if: needs.prepare.outputs.outcome == 'candidate' && needs.gate.result == 'success'
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Fast-forward dev to the tested candidate
|
||||
env:
|
||||
BASE_DEV_SHA: ${{ needs.prepare.outputs.base_dev_sha }}
|
||||
CANDIDATE_BRANCH: ${{ needs.prepare.outputs.candidate_branch }}
|
||||
CANDIDATE_SHA: ${{ needs.prepare.outputs.candidate_sha }}
|
||||
RELEASE_COMMIT: ${{ needs.prepare.outputs.release_commit }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin --no-tags \
|
||||
"+refs/heads/dev:refs/remotes/origin/dev" \
|
||||
"+refs/heads/$CANDIDATE_BRANCH:refs/remotes/origin/$CANDIDATE_BRANCH"
|
||||
current_dev="$(git rev-parse origin/dev)"
|
||||
remote_candidate="$(git rev-parse "origin/$CANDIDATE_BRANCH")"
|
||||
|
||||
if [ "$current_dev" != "$BASE_DEV_SHA" ]; then
|
||||
echo "::error::dev moved from $BASE_DEV_SHA to $current_dev; rerun or open a reconciliation PR"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$remote_candidate" != "$CANDIDATE_SHA" ]; then
|
||||
echo "::error::Candidate ref moved from $CANDIDATE_SHA to $remote_candidate"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(git rev-parse "$CANDIDATE_SHA^1")" != "$BASE_DEV_SHA" ] || \
|
||||
[ "$(git rev-parse "$CANDIDATE_SHA^2")" != "$RELEASE_COMMIT" ]; then
|
||||
echo "::error::Candidate ancestry changed after verification"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The explicit lease is the atomic stale-base guard. The update is a
|
||||
# fast-forward from BASE_DEV_SHA; no unrelated history can be replaced.
|
||||
git push \
|
||||
--force-with-lease="refs/heads/dev:$BASE_DEV_SHA" \
|
||||
origin "$CANDIDATE_SHA:refs/heads/dev"
|
||||
|
||||
git push origin --delete "$CANDIDATE_BRANCH" || \
|
||||
echo "::warning::Could not remove temporary branch $CANDIDATE_BRANCH"
|
||||
|
||||
echo "## Release backmerge complete" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Fast-forwarded \`dev\` from \`$BASE_DEV_SHA\` to tested merge \`$CANDIDATE_SHA\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
fallback:
|
||||
name: Report PR fallback
|
||||
needs: [prepare, gate, promote]
|
||||
if: always() && needs.prepare.outputs.outcome == 'candidate' && needs.promote.result != 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Preserve safe fallback instructions
|
||||
env:
|
||||
CANDIDATE_BRANCH: ${{ needs.prepare.outputs.candidate_branch }}
|
||||
CANDIDATE_SHA: ${{ needs.prepare.outputs.candidate_sha }}
|
||||
CHECK_RUN_ID: ${{ needs.gate.outputs.check_run_id }}
|
||||
run: |
|
||||
echo "## Automatic backmerge stopped" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "\`dev\` was not updated. Open or refresh a reconciliation PR after addressing the failed/stale gate." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Candidate ref: \`${CANDIDATE_BRANCH:-not-created}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Candidate SHA: \`${CANDIDATE_SHA:-n/a}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Required-check run: \`${CHECK_RUN_ID:-n/a}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -539,3 +539,22 @@ jobs:
|
||||
release-assets/cli-binaries/hermes-relay-darwin-arm64
|
||||
release-assets/cli-windows-installer/hermes-relay-windows-x64-setup.exe
|
||||
release-assets/SHA256SUMS.txt
|
||||
|
||||
request-backmerge:
|
||||
name: Request stable release backmerge
|
||||
needs: [validate-release, publish-release]
|
||||
if: ${{ !contains(needs.validate-release.outputs.version, '-') }}
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch fail-closed release reconciliation
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: desktop-v${{ needs.validate-release.outputs.version }}
|
||||
run: |
|
||||
gh workflow run release-backmerge.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref main \
|
||||
-f release_tag="$RELEASE_TAG"
|
||||
|
||||
@@ -138,3 +138,22 @@ jobs:
|
||||
dist/*.whl
|
||||
dist/*.tar.gz
|
||||
dist/SHA256SUMS.txt
|
||||
|
||||
request-backmerge:
|
||||
name: Request stable release backmerge
|
||||
needs: [validate, package]
|
||||
if: ${{ !contains(needs.validate.outputs.version, '-') }}
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch fail-closed release reconciliation
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: server-v${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
gh workflow run release-backmerge.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref main \
|
||||
-f release_tag="$RELEASE_TAG"
|
||||
|
||||
@@ -28,7 +28,7 @@ not redefine the branch, release, or hotfix policy here and in `RELEASE.md`.
|
||||
| Staging source | An exact tested `dev` SHA or release-candidate tag; staging is an environment, never a branch |
|
||||
| Production source | Immutable `android-v*`, `server-v*`, or `desktop-v*` tags, selected by surface |
|
||||
| Hotfix base | The immutable production tag for the affected surface |
|
||||
| Back-merge target | `dev`; merge `main` back immediately after every hotfix |
|
||||
| Back-merge target | `dev`; stable hotfixes reconcile automatically when the exact tested merge is conflict-free, otherwise through a PR |
|
||||
|
||||
Feature completion means merged and verified on `dev`; it does not mean
|
||||
released. A release train is separate work owned by a Forge release
|
||||
@@ -37,6 +37,14 @@ open the `dev` → `main` release PR, tag the resulting `main` tip, publish the
|
||||
surface artifacts, deploy or roll out, and verify the live result. Never create
|
||||
a staging branch.
|
||||
|
||||
A normal `dev` → `main` release needs no back-merge: the released integration
|
||||
parent is already in `dev`. A production-tag hotfix is different. After its
|
||||
stable release succeeds, `Release Backmerge` prepares a `dev`-first merge
|
||||
commit, runs the same path-aware required checks on that exact SHA, verifies
|
||||
that `dev` has not moved, and fast-forwards `dev`. Conflicts, failed checks,
|
||||
stale refs, or denied branch updates fail closed and require a reconciliation
|
||||
PR; never resolve those cases by choosing a side automatically.
|
||||
|
||||
### Local integration discipline
|
||||
|
||||
- Fetch `origin/dev` before creating a task branch or worktree; do not base new
|
||||
|
||||
@@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [Android 1.13.2] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Android Supervised Mode presents a parent-controlled, profile-pinned chat surface.** Parents can limit attachments, Standard voice, generated media, conversation history, actions, and technical metadata while device authentication protects full settings. Hermes-Relay can identify and revoke a paired supervised client without becoming the policy enforcement boundary.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android session rows stay neutral when optional live activity is unavailable or still loading.** Directory refreshes no longer restore a persistent Checking state, and full-row activity borders are reserved for actual Starting or Working turns.
|
||||
- **Returning from parent settings keeps Supervised Chat rendered.** Parent access now relocks without rebuilding the active navigation graph, and full Settings keeps a prominent shortcut back to Supervised Mode controls.
|
||||
|
||||
## [Android 1.13.1] - 2026-08-25
|
||||
|
||||
### Fixed
|
||||
|
||||
+32
-10
@@ -200,6 +200,11 @@ never create a staging branch. Stable production tags are cut only from the new
|
||||
10. Build and publish that surface's artifacts, roll out or deploy from the
|
||||
immutable tag, and verify the release and live environment.
|
||||
|
||||
Do not back-merge a normal release. The `main` release merge already has the
|
||||
released `dev` tip as its integration parent, so merging it back only adds
|
||||
history noise. The release-backmerge workflow detects this topology and exits
|
||||
successfully without changing `dev`.
|
||||
|
||||
### Branch names
|
||||
|
||||
| Prefix | When | Example |
|
||||
@@ -258,7 +263,9 @@ The intended settings are:
|
||||
- **`main`** — PRs required; `Required checks` required and current; force push
|
||||
and deletion blocked. Normal work does not target this branch.
|
||||
- **`dev`** — PRs and `Required checks` required; force push and deletion
|
||||
blocked. This is the normal contribution target.
|
||||
blocked. This is the normal contribution target. The release-backmerge
|
||||
workflow is the sole exception: its automation identity may compare-and-swap
|
||||
`dev` to an exact checked merge commit after a stable hotfix release.
|
||||
- **Merge policy** — merge commits allowed; squash and rebase merges disabled so
|
||||
the no-ff contract cannot be bypassed in the GitHub UI.
|
||||
- **Default branch** — `main`, which remains the release-history branch and the
|
||||
@@ -922,8 +929,23 @@ When production has a bug, use the same invariant for every surface:
|
||||
4. Open the focused hotfix PR into `main` and merge with a merge commit/no-ff.
|
||||
5. Tag the new `main` tip with the affected surface's patch tag.
|
||||
6. Verify the artifacts and production rollout or deployment.
|
||||
7. Merge `main` back into `dev` immediately so integration inherits the fix and
|
||||
version history.
|
||||
7. Let the stable release workflow dispatch `Release Backmerge`. A
|
||||
conflict-free candidate runs the same path-aware `Required checks` against
|
||||
its exact SHA, then compare-and-swaps `dev` only if the base ref is unchanged.
|
||||
Conflicts, failed checks, stale refs, or a denied update require a normal
|
||||
reconciliation PR.
|
||||
|
||||
`Release Backmerge` accepts only published stable `android-v*`, `server-v*`, or
|
||||
`desktop-v*` SemVer tags contained in `main`. It exits without mutation for a
|
||||
normal release whose integration parent is already in `dev`. For a selective
|
||||
hotfix, it pushes a temporary merge ref, dispatches `Required checks` with full
|
||||
base/head SHAs, and updates `dev` with an explicit force-with-lease only after
|
||||
that exact candidate passes. The lease is a compare-and-swap guard, not
|
||||
permission to rewrite history: the candidate's first parent must be the
|
||||
unchanged `dev` tip and its second parent the released commit. The repository
|
||||
ruleset must allow this workflow's automation identity to perform that one
|
||||
checked branch update; if it does not, the workflow fails closed and the
|
||||
reconciliation uses a PR.
|
||||
|
||||
For an Android app hotfix:
|
||||
|
||||
@@ -938,21 +960,21 @@ For an Android app hotfix:
|
||||
6. `git tag android-v0.6.2` from the new `main` tip and `git push origin android-v0.6.2`
|
||||
so Android release CI builds and publishes.
|
||||
7. Verify the automated Play submission, GitHub artifacts, and rollout.
|
||||
8. Merge `main` back into `dev` (`git checkout dev && git merge --no-ff main`)
|
||||
so `dev` picks up the hotfix and the versionCode bump. Without this,
|
||||
`dev`'s `appVersionCode` lags behind `main` and the next app release
|
||||
bump collides.
|
||||
8. Verify the automated release backmerge completed. If it stopped, open a
|
||||
reconciliation PR so `dev` picks up the hotfix and versionCode bump. Without
|
||||
reconciliation, `dev`'s `appVersionCode` lags behind `main` and the next app
|
||||
release bump collides.
|
||||
|
||||
For a Plugin hotfix, branch from the affected `server-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-plugin-version.sh <next-version>`, merge to
|
||||
`main`, tag `server-v<next-version>`, verify the package/deployment, and merge
|
||||
`main` back to `dev`. Do not touch
|
||||
`main`, tag `server-v<next-version>`, verify the package/deployment, and verify
|
||||
the automated release backmerge. Do not touch
|
||||
`gradle/libs.versions.toml` unless an Android app release is also shipping.
|
||||
|
||||
For a CLI+UI hotfix, branch from the affected `desktop-v*` tag, update only
|
||||
`desktop/package.json` and its generated lock/runtime/tray metadata, merge to
|
||||
`main`, tag `desktop-v<next-version>`, verify all binaries and the installer,
|
||||
then merge `main` back to `dev`.
|
||||
then verify the automated release backmerge or use the PR fallback.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
+10
-7
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.13.1
|
||||
# Hermes-Relay Android v1.13.2
|
||||
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.13.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.13.2-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,17 +12,20 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch makes Android session activity follow live Hermes runtime state instead of a five-minute recency estimate. It keeps Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work accurate while preserving stale state until a complete, unambiguous snapshot can safely replace it.
|
||||
This release adds a parent-configured Supervised Mode and improves its return from full settings. It also keeps session rows neutral until live activity is confirmed.
|
||||
|
||||
## Added
|
||||
|
||||
- Use a profile-pinned Supervised Mode with parent-controlled attachments, Standard voice, generated media, history, actions, and technical details. Device authentication protects full settings; this remains a client-side restricted view rather than a server-enforced account boundary.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Derive session activity from the authoritative live runtime snapshot rather than Dashboard recency.
|
||||
- Preserve prior activity when a refresh is incomplete, unsupported, or ambiguously scoped.
|
||||
- Keep session drawer labels, timestamps, and active-turn ownership aligned with the exact profile and session.
|
||||
- Keep session rows neutral while optional live activity is unavailable or still loading, and reserve full-row activity borders for actual Starting or Working turns.
|
||||
- Keep Supervised Chat rendered when parent access relocks after visiting full settings.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.13.1** (versionCode **50**).
|
||||
- App version: **1.13.2** (versionCode **51**).
|
||||
- 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 remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
|
||||
Supervised Mode adds a parent-configured, profile-pinned chat view with device-authenticated settings. Parents can limit attachments, Standard voice, generated media, history, actions, and technical details. Session rows stay neutral while live activity is unavailable, and returning from parent settings no longer blanks Supervised Chat.
|
||||
|
||||
@@ -1 +1 @@
|
||||
会话活动现在依据 Hermes 的实时运行状态,而不是最近活动时间估算。工作中、启动中、需要输入、空闲、检查中、不可用和后台工作等状态会保持准确;只有完整且明确的更新才会清除旧状态。
|
||||
新增监督模式:家长可配置并固定到指定配置文件,设置受设备身份验证保护。家长可限制附件、标准语音、生成媒体、历史记录、操作和技术详情。实时活动不可用时会话行保持中性显示,从家长设置返回时监督聊天也不再空白。
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.13.2",
|
||||
"title": "Supervised Mode and clearer activity",
|
||||
"date": "2026-08-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Use a supervised chat",
|
||||
"bullets": [
|
||||
"Configure a profile-pinned restricted chat with parent-controlled attachments, voice, media, history, actions, and technical details.",
|
||||
"Protect full settings with device authentication and keep Supervised Chat visible when parent access relocks."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Show only confirmed activity",
|
||||
"bullets": [
|
||||
"Keep session rows neutral while optional live activity is unavailable or still loading.",
|
||||
"Show full-row activity borders only during actual Starting or Working turns."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.13.1",
|
||||
"title": "Accurate session activity",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
v1.13.1 - Accurate session activity
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
|
||||
* Follow live Hermes runtime state for Working, Starting, Needs input, and Idle.
|
||||
* Keep stale activity visible until a complete, unambiguous snapshot clears it.
|
||||
* Distinguish Checking, Unavailable, and Background work in the session drawer.
|
||||
* Configure a profile-pinned Supervised Mode with device-authenticated parent settings.
|
||||
* Keep Supervised Chat visible when parent access relocks after full settings.
|
||||
* Keep uncertain session activity neutral until live work is confirmed.
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.hermesandroid.relay.data.replaceHermesReachCredential
|
||||
import com.hermesandroid.relay.data.sameBrokerAuthority
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.isSafeProfileUiMeta
|
||||
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
@@ -18,6 +19,8 @@ import com.hermesandroid.relay.network.shared.InvalidCredentialException
|
||||
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
@@ -53,6 +56,39 @@ sealed class AuthState {
|
||||
data class Failed(val reason: String) : AuthState()
|
||||
}
|
||||
|
||||
internal fun relaySupervisedModePayload(policy: SupervisedModePolicy): JsonObject {
|
||||
if (!policy.isActive) return buildJsonObject { put("active", false) }
|
||||
val capabilities = buildList {
|
||||
add("text_chat")
|
||||
if (policy.capabilities.newChat) add("new_chat")
|
||||
if (policy.capabilities.cancelResponse) add("cancel")
|
||||
if (policy.capabilities.steerResponse) add("steer")
|
||||
if (policy.capabilities.attachments) add("attachments")
|
||||
if (policy.capabilities.voice) add("voice")
|
||||
if (policy.capabilities.generatedImages) add("generated_images")
|
||||
if (policy.capabilities.shareGeneratedImages) add("share_images")
|
||||
if (policy.capabilities.copyResponses) add("copy")
|
||||
if (policy.capabilities.retryResponse) add("retry")
|
||||
if (policy.capabilities.quoteReplies) add("quote_reply")
|
||||
if (policy.visibility.resolved().showTimestamps) add("timestamps")
|
||||
}.take(12)
|
||||
return buildJsonObject {
|
||||
put("active", true)
|
||||
put("profile_label", policy.pinnedProfileName.orEmpty().take(80))
|
||||
put("capabilities", JsonArray(capabilities.map(::JsonPrimitive)))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun relaySupervisedModeUpdateEnvelope(
|
||||
policy: SupervisedModePolicy,
|
||||
): Envelope = Envelope(
|
||||
channel = "system",
|
||||
type = "supervised.update",
|
||||
payload = buildJsonObject {
|
||||
put("supervised_mode", relaySupervisedModePayload(policy))
|
||||
},
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectionAuthSecrets(
|
||||
val sessionToken: String? = null,
|
||||
@@ -120,6 +156,60 @@ class AuthManager(
|
||||
private val eagerHydrate: Boolean = true,
|
||||
) : ChannelMultiplexer.ChannelHandler {
|
||||
|
||||
@Volatile
|
||||
private var supervisedMode: SupervisedModePolicy = SupervisedModePolicy()
|
||||
|
||||
@Volatile
|
||||
private var supervisedMetadataReconnectFallback: (() -> Unit)? = null
|
||||
private var pendingSupervisedUpdateId: String? = null
|
||||
private var supervisedUpdateFallbackJob: Job? = null
|
||||
|
||||
/**
|
||||
* Update the public client-mode tag sent on Relay auth. This does not grant
|
||||
* authority: Relay labels enforcement_owner=android_client and the Android
|
||||
* policy remains the enforcing surface.
|
||||
*/
|
||||
fun updateSupervisedMode(policy: SupervisedModePolicy) {
|
||||
if (supervisedMode == policy) return
|
||||
supervisedMode = policy
|
||||
if (_authState.value is AuthState.Paired) sendSupervisedModeUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the narrow compatibility path used when an older Relay ignores
|
||||
* `system/supervised.update`. Reopening the authenticated socket causes
|
||||
* the current policy to travel through the legacy `system/auth` payload.
|
||||
*/
|
||||
fun setSupervisedMetadataReconnectFallback(callback: () -> Unit) {
|
||||
supervisedMetadataReconnectFallback = callback
|
||||
}
|
||||
|
||||
private fun sendSupervisedModeUpdate() {
|
||||
val envelope = relaySupervisedModeUpdateEnvelope(supervisedMode)
|
||||
pendingSupervisedUpdateId = envelope.id
|
||||
supervisedUpdateFallbackJob?.cancel()
|
||||
multiplexer.send(envelope)
|
||||
supervisedUpdateFallbackJob = scope.launch {
|
||||
delay(SUPERVISED_UPDATE_ACK_TIMEOUT_MS)
|
||||
if (pendingSupervisedUpdateId == envelope.id) {
|
||||
pendingSupervisedUpdateId = null
|
||||
Log.i(TAG, "supervised.update unsupported or unacknowledged; refreshing Relay socket")
|
||||
supervisedMetadataReconnectFallback?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun settleSupervisedModeUpdate(envelope: Envelope, unsupported: Boolean) {
|
||||
if (envelope.id != pendingSupervisedUpdateId) return
|
||||
pendingSupervisedUpdateId = null
|
||||
supervisedUpdateFallbackJob?.cancel()
|
||||
supervisedUpdateFallbackJob = null
|
||||
if (unsupported) {
|
||||
Log.i(TAG, "supervised.update rejected; refreshing Relay socket for compatibility")
|
||||
supervisedMetadataReconnectFallback?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AuthManager"
|
||||
private const val KEY_SESSION_TOKEN = "session_token"
|
||||
@@ -134,6 +224,7 @@ class AuthManager(
|
||||
// migration has run, so we never rebuild the legacy keyset to re-check.
|
||||
private const val KEY_LEGACY_MIGRATED = "legacy_migrated"
|
||||
private const val PAIRING_CODE_LENGTH = 6
|
||||
private const val SUPERVISED_UPDATE_ACK_TIMEOUT_MS = 2_000L
|
||||
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
|
||||
|
||||
/**
|
||||
@@ -835,6 +926,10 @@ class AuthManager(
|
||||
put("device_form_factor", "phone")
|
||||
}
|
||||
|
||||
private fun JsonObjectBuilder.putSupervisedMode() {
|
||||
put("supervised_mode", relaySupervisedModePayload(supervisedMode))
|
||||
}
|
||||
|
||||
private fun relayDeviceName(): String {
|
||||
val configured = runCatching {
|
||||
Settings.Global.getString(context.contentResolver, "device_name")
|
||||
@@ -890,6 +985,7 @@ class AuthManager(
|
||||
put("device_id", deviceId)
|
||||
putRelayDeviceIdentity()
|
||||
putRelayClientSupports()
|
||||
putSupervisedMode()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@@ -906,6 +1002,7 @@ class AuthManager(
|
||||
put("device_id", deviceId)
|
||||
putRelayDeviceIdentity()
|
||||
putRelayClientSupports()
|
||||
putSupervisedMode()
|
||||
pendingTtlSeconds?.let { put("ttl_seconds", it) }
|
||||
pendingGrants?.let { grants ->
|
||||
val obj = buildJsonObject {
|
||||
@@ -985,6 +1082,8 @@ class AuthManager(
|
||||
when (envelope.type) {
|
||||
"auth.ok" -> handleAuthOk(envelope)
|
||||
"auth.fail" -> handleAuthFail(envelope)
|
||||
"supervised.updated" -> settleSupervisedModeUpdate(envelope, unsupported = false)
|
||||
"error" -> settleSupervisedModeUpdate(envelope, unsupported = true)
|
||||
// `profiles.updated` push — sent by the v0.7.1+ relay on
|
||||
// the "pairing" channel whenever its in-memory profile
|
||||
// snapshot changes (file-watcher, SIGHUP, or a manual
|
||||
@@ -1129,6 +1228,11 @@ class AuthManager(
|
||||
get() = _authState.value is AuthState.Paired
|
||||
|
||||
private fun handleAuthOk(envelope: Envelope) {
|
||||
// A successful auth always carries the latest client report, including
|
||||
// after the compatibility reconnect used for older Relay versions.
|
||||
pendingSupervisedUpdateId = null
|
||||
supervisedUpdateFallbackJob?.cancel()
|
||||
supervisedUpdateFallbackJob = null
|
||||
scope.launch {
|
||||
try {
|
||||
val payload = envelope.payload
|
||||
|
||||
@@ -90,10 +90,10 @@ data class SessionActivityRecord(
|
||||
}
|
||||
}
|
||||
|
||||
/** Presentation projection that never labels uncertain or background activity as Working. */
|
||||
/** Presentation projection that never labels missing optional runtime data as session state. */
|
||||
fun presentationState(nowMillis: Long = Long.MIN_VALUE): SessionActivityState? = when (freshness) {
|
||||
SessionActivityFreshness.Revalidating -> SessionActivityState.Checking
|
||||
SessionActivityFreshness.Unavailable -> SessionActivityState.Unavailable
|
||||
SessionActivityFreshness.Revalidating -> null
|
||||
SessionActivityFreshness.Unavailable -> null
|
||||
SessionActivityFreshness.Confirmed -> when (phase(nowMillis)) {
|
||||
SessionActivityPhase.Starting -> SessionActivityState.Starting
|
||||
SessionActivityPhase.Working -> SessionActivityState.Working
|
||||
@@ -294,7 +294,9 @@ data class SessionActivityRegistry(
|
||||
|
||||
private fun observeOwner(update: SessionActivityUpdate.ObserveOwner): SessionActivityRegistry {
|
||||
val existing = records[update.owner]
|
||||
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
|
||||
// Directory rows establish ownership only. They are not live evidence and must not
|
||||
// turn an unsupported/failed active-list probe back into a permanent Checking row.
|
||||
if (existing != null) return this
|
||||
val observed = SessionActivityRecord(
|
||||
owner = update.owner,
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import com.hermesandroid.relay.ui.theme.AppThemes
|
||||
|
||||
/**
|
||||
* Parent-configured restrictions for the official Android client.
|
||||
*
|
||||
* This policy deliberately describes a client presentation mode, not a server
|
||||
* authorization boundary. The pinned profile is expected to have already been
|
||||
* configured with the appropriate server-side tool and content restrictions.
|
||||
*/
|
||||
@Serializable
|
||||
data class SupervisedModePolicy(
|
||||
val enabled: Boolean = false,
|
||||
val pinnedProfileName: String? = null,
|
||||
val capabilities: SupervisedCapabilities = SupervisedCapabilities(),
|
||||
val appearance: SupervisedAppearance = SupervisedAppearance(),
|
||||
val visibility: SupervisedVisibility = SupervisedVisibility(),
|
||||
val parentAccess: SupervisedParentAccess = SupervisedParentAccess(),
|
||||
) {
|
||||
/** A saved policy is usable only when it names a concrete Hermes profile. */
|
||||
val isConfigured: Boolean
|
||||
get() = !pinnedProfileName.isNullOrBlank()
|
||||
|
||||
/** Consumers should use this instead of treating [enabled] alone as sufficient. */
|
||||
val isActive: Boolean
|
||||
get() = enabled && isConfigured
|
||||
|
||||
internal fun normalized(): SupervisedModePolicy = copy(
|
||||
pinnedProfileName = pinnedProfileName?.trim()?.takeIf { it.isNotEmpty() },
|
||||
capabilities = capabilities.normalized(),
|
||||
appearance = appearance.normalized(),
|
||||
parentAccess = parentAccess.normalized(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Actions and content types the supervised chat surface may expose. */
|
||||
@Serializable
|
||||
data class SupervisedCapabilities(
|
||||
val attachments: Boolean = false,
|
||||
val voice: Boolean = false,
|
||||
val generatedImages: Boolean = true,
|
||||
val conversationHistory: Boolean = false,
|
||||
val newChat: Boolean = true,
|
||||
val cancelResponse: Boolean = true,
|
||||
val steerResponse: Boolean = true,
|
||||
val retryResponse: Boolean = true,
|
||||
val copyResponses: Boolean = true,
|
||||
val quoteReplies: Boolean = true,
|
||||
val editAndResend: Boolean = false,
|
||||
val shareGeneratedImages: Boolean = false,
|
||||
val sessionActions: SupervisedSessionActions = SupervisedSessionActions(),
|
||||
val attachmentMaxCount: Int = DEFAULT_ATTACHMENT_MAX_COUNT,
|
||||
val attachmentMaxFileMb: Int = DEFAULT_ATTACHMENT_MAX_FILE_MB,
|
||||
val attachmentCategories: Set<SupervisedAttachmentCategory> = setOf(
|
||||
SupervisedAttachmentCategory.Images,
|
||||
),
|
||||
) {
|
||||
internal fun normalized(): SupervisedCapabilities = copy(
|
||||
attachmentMaxCount = attachmentMaxCount.coerceIn(1, MAX_ATTACHMENT_COUNT),
|
||||
attachmentMaxFileMb = attachmentMaxFileMb.coerceIn(1, MAX_ATTACHMENT_FILE_MB),
|
||||
attachmentCategories = attachmentCategories.ifEmpty {
|
||||
setOf(SupervisedAttachmentCategory.Images)
|
||||
},
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_ATTACHMENT_MAX_COUNT = 4
|
||||
const val DEFAULT_ATTACHMENT_MAX_FILE_MB = 10
|
||||
const val MAX_ATTACHMENT_COUNT = 10
|
||||
const val MAX_ATTACHMENT_FILE_MB = 100
|
||||
}
|
||||
}
|
||||
|
||||
/** Appearance applied only while the supervised root is locked. */
|
||||
@Serializable
|
||||
data class SupervisedAppearance(
|
||||
val appThemeId: String = AppThemes.DEFAULT_ID,
|
||||
val themePreference: String = "auto",
|
||||
val showPet: Boolean = false,
|
||||
val allowProfileIconChanges: Boolean = false,
|
||||
val allowBackgroundChanges: Boolean = false,
|
||||
) {
|
||||
internal fun normalized(): SupervisedAppearance = copy(
|
||||
appThemeId = AppThemes.byId(appThemeId).id,
|
||||
themePreference = themePreference.takeIf { it in VALID_THEME_PREFERENCES } ?: "auto",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val VALID_THEME_PREFERENCES = setOf("auto", "light", "dark")
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable operations available from a supervised conversation-history row. */
|
||||
@Serializable
|
||||
data class SupervisedSessionActions(
|
||||
val pin: Boolean = false,
|
||||
val rename: Boolean = false,
|
||||
val archive: Boolean = false,
|
||||
val delete: Boolean = false,
|
||||
val shareTranscript: Boolean = false,
|
||||
) {
|
||||
val enabledCount: Int
|
||||
get() = listOf(pin, rename, archive, delete, shareTranscript).count { it }
|
||||
|
||||
val allEnabled: Boolean
|
||||
get() = enabledCount == TOTAL
|
||||
|
||||
val noneEnabled: Boolean
|
||||
get() = enabledCount == 0
|
||||
|
||||
fun withAll(enabled: Boolean): SupervisedSessionActions = SupervisedSessionActions(
|
||||
pin = enabled,
|
||||
rename = enabled,
|
||||
archive = enabled,
|
||||
delete = enabled,
|
||||
shareTranscript = enabled,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val TOTAL = 5
|
||||
}
|
||||
}
|
||||
|
||||
enum class SupervisedSessionAction {
|
||||
Pin,
|
||||
Rename,
|
||||
Archive,
|
||||
Delete,
|
||||
ShareTranscript,
|
||||
}
|
||||
|
||||
fun SupervisedModePolicy.allowsSessionAction(action: SupervisedSessionAction): Boolean {
|
||||
if (!enabled) return true
|
||||
if (!capabilities.conversationHistory) return false
|
||||
return when (action) {
|
||||
SupervisedSessionAction.Pin -> capabilities.sessionActions.pin
|
||||
SupervisedSessionAction.Rename -> capabilities.sessionActions.rename
|
||||
SupervisedSessionAction.Archive -> capabilities.sessionActions.archive
|
||||
SupervisedSessionAction.Delete -> capabilities.sessionActions.delete
|
||||
SupervisedSessionAction.ShareTranscript -> capabilities.sessionActions.shareTranscript
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SupervisedAttachmentCategory {
|
||||
@SerialName("images")
|
||||
Images,
|
||||
|
||||
@SerialName("documents")
|
||||
Documents,
|
||||
|
||||
@SerialName("audio")
|
||||
Audio,
|
||||
|
||||
@SerialName("video")
|
||||
Video,
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls which metadata and conversation affordances are rendered.
|
||||
*
|
||||
* [Simple] is the quiet default. [Transparent] is a useful preset for older or
|
||||
* technical users, while [Custom] tells the UI to honor every stored toggle.
|
||||
*/
|
||||
@Serializable
|
||||
data class SupervisedVisibility(
|
||||
val preset: SupervisedVisibilityPreset = SupervisedVisibilityPreset.Simple,
|
||||
val showAgentIdentity: Boolean = true,
|
||||
val showModelName: Boolean = false,
|
||||
val showProfileName: Boolean = false,
|
||||
val showConnectionStatus: Boolean = true,
|
||||
val showTechnicalRoute: Boolean = false,
|
||||
val showTimestamps: Boolean = true,
|
||||
val showToolNames: Boolean = false,
|
||||
val showToolDetails: Boolean = false,
|
||||
val showWorkingStatus: Boolean = true,
|
||||
val showReasoning: Boolean = false,
|
||||
val showUsage: Boolean = false,
|
||||
) {
|
||||
/** Resolve presets to the concrete flags consumed by chat presentation. */
|
||||
fun resolved(): SupervisedVisibility = when (preset) {
|
||||
SupervisedVisibilityPreset.Simple -> SIMPLE
|
||||
SupervisedVisibilityPreset.Transparent -> TRANSPARENT
|
||||
SupervisedVisibilityPreset.Custom -> this
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SIMPLE = SupervisedVisibility(preset = SupervisedVisibilityPreset.Simple)
|
||||
|
||||
val TRANSPARENT = SupervisedVisibility(
|
||||
preset = SupervisedVisibilityPreset.Transparent,
|
||||
showModelName = true,
|
||||
showProfileName = true,
|
||||
showTechnicalRoute = true,
|
||||
showToolNames = true,
|
||||
showUsage = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SupervisedVisibilityPreset {
|
||||
@SerialName("simple")
|
||||
Simple,
|
||||
|
||||
@SerialName("transparent")
|
||||
Transparent,
|
||||
|
||||
@SerialName("custom")
|
||||
Custom,
|
||||
}
|
||||
|
||||
/** Device-authentication and automatic relock behavior for parent access. */
|
||||
@Serializable
|
||||
data class SupervisedParentAccess(
|
||||
/** Reserved for forward-compatible persistence; normalization never permits an auth bypass. */
|
||||
val requireDeviceAuthentication: Boolean = true,
|
||||
val relockOnBackground: Boolean = true,
|
||||
val timeoutMinutes: Int = DEFAULT_TIMEOUT_MINUTES,
|
||||
) {
|
||||
internal fun normalized(): SupervisedParentAccess = copy(
|
||||
requireDeviceAuthentication = true,
|
||||
timeoutMinutes = timeoutMinutes.coerceIn(MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_TIMEOUT_MINUTES = 5
|
||||
const val MIN_TIMEOUT_MINUTES = 1
|
||||
const val MAX_TIMEOUT_MINUTES = 60
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Persists one independent [SupervisedModePolicy] per Hermes connection. */
|
||||
class SupervisedModeStore private constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
private val serializer = MapSerializer(String.serializer(), SupervisedModePolicy.serializer())
|
||||
|
||||
fun policyFlow(connectionId: String): Flow<SupervisedModePolicy> =
|
||||
dataStore.data.map { preferences ->
|
||||
val decoded = decode(preferences[KEY_POLICIES])
|
||||
if (decoded.corrupt) {
|
||||
// A malformed persisted policy must never silently reopen the
|
||||
// unrestricted app. Enabled + unconfigured renders the
|
||||
// supervised recovery surface until an authenticated user
|
||||
// repairs or clears the policy.
|
||||
SupervisedModePolicy(enabled = true)
|
||||
} else {
|
||||
decoded.policies[connectionId]?.normalized() ?: SupervisedModePolicy()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPolicy(connectionId: String, policy: SupervisedModePolicy) {
|
||||
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
|
||||
dataStore.edit { preferences ->
|
||||
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
|
||||
policies[connectionId] = policy.normalized()
|
||||
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePolicy(
|
||||
connectionId: String,
|
||||
transform: (SupervisedModePolicy) -> SupervisedModePolicy,
|
||||
) {
|
||||
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
|
||||
dataStore.edit { preferences ->
|
||||
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
|
||||
val current = policies[connectionId]?.normalized() ?: SupervisedModePolicy()
|
||||
policies[connectionId] = transform(current).normalized()
|
||||
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setEnabled(connectionId: String, enabled: Boolean) {
|
||||
updatePolicy(connectionId) { it.copy(enabled = enabled) }
|
||||
}
|
||||
|
||||
suspend fun clear(connectionId: String) {
|
||||
dataStore.edit { preferences ->
|
||||
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
|
||||
policies.remove(connectionId)
|
||||
if (policies.isEmpty()) {
|
||||
preferences.remove(KEY_POLICIES)
|
||||
} else {
|
||||
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear supervised policies without disturbing unrelated app settings. */
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { preferences -> preferences.remove(KEY_POLICIES) }
|
||||
}
|
||||
|
||||
private fun decode(raw: String?): DecodeResult {
|
||||
if (raw.isNullOrBlank()) return DecodeResult(emptyMap(), corrupt = false)
|
||||
return try {
|
||||
DecodeResult(json.decodeFromString(serializer, raw), corrupt = false)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Unable to decode supervised-mode policies; failing closed", error)
|
||||
DecodeResult(emptyMap(), corrupt = true)
|
||||
}
|
||||
}
|
||||
|
||||
private data class DecodeResult(
|
||||
val policies: Map<String, SupervisedModePolicy>,
|
||||
val corrupt: Boolean,
|
||||
)
|
||||
|
||||
internal companion object {
|
||||
private const val TAG = "SupervisedModeStore"
|
||||
private val KEY_POLICIES = stringPreferencesKey("supervised_mode_policies_v1")
|
||||
|
||||
fun forTesting(dataStore: DataStore<Preferences>): SupervisedModeStore =
|
||||
SupervisedModeStore(dataStore)
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ class ChannelMultiplexer {
|
||||
)
|
||||
send(pong)
|
||||
}
|
||||
"auth.ok", "auth.fail" -> {
|
||||
"auth.ok", "auth.fail", "supervised.updated", "error" -> {
|
||||
// Delegate to system handler if registered
|
||||
handlers["system"]?.onMessage(envelope)
|
||||
}
|
||||
|
||||
@@ -442,6 +442,35 @@ class ConnectionManager(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen the current authenticated Relay socket without discarding pair
|
||||
* state. Used only as a compatibility fallback when an older Relay does
|
||||
* not acknowledge a post-auth metadata update; the replacement socket's
|
||||
* normal `system/auth` frame carries the latest metadata.
|
||||
*/
|
||||
fun reconnectForAuthenticatedMetadataUpdate(): Boolean {
|
||||
val targetUrl = serverUrl?.takeIf { it.isNotBlank() } ?: return false
|
||||
if (isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
) {
|
||||
Log.i(TAG, "metadata reconnect: preserving active rate-limit backoff")
|
||||
return false
|
||||
}
|
||||
val previousSocket = webSocket
|
||||
if (previousSocket == null) {
|
||||
connect(targetUrl)
|
||||
} else {
|
||||
doConnect(
|
||||
targetUrl,
|
||||
previousSocketToClose = previousSocket,
|
||||
replaceReason = "Relay metadata compatibility refresh",
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [connect] but bypasses the resolver — used by the network-
|
||||
* change callback when we've already picked a winner and just want to
|
||||
|
||||
@@ -48,6 +48,9 @@ interface VoiceAudioClient {
|
||||
val effectiveRoute: VoiceAudioRoute
|
||||
get() = route
|
||||
|
||||
/** Temporary client-policy override; the shared router honors it before user prefs. */
|
||||
fun setRouteOverride(route: VoiceAudioRoute?) = Unit
|
||||
|
||||
suspend fun transcribe(audioFile: File): Result<String>
|
||||
suspend fun synthesize(text: String): Result<File>
|
||||
|
||||
@@ -82,8 +85,15 @@ class AutoVoiceAudioClient(
|
||||
private val standardReadyProvider: () -> Boolean,
|
||||
private val relayReadyProvider: () -> Boolean,
|
||||
) : VoiceAudioClient {
|
||||
@Volatile
|
||||
private var routeOverride: VoiceAudioRoute? = null
|
||||
|
||||
override fun setRouteOverride(route: VoiceAudioRoute?) {
|
||||
routeOverride = route
|
||||
}
|
||||
|
||||
override val route: VoiceAudioRoute
|
||||
get() = routeProvider()
|
||||
get() = routeOverride ?: routeProvider()
|
||||
|
||||
/**
|
||||
* Resolve the configured preference to the backend a call would land on:
|
||||
@@ -92,7 +102,7 @@ class AutoVoiceAudioClient(
|
||||
* decide whether standard-only limitations (global TTS) currently apply.
|
||||
*/
|
||||
override val effectiveRoute: VoiceAudioRoute
|
||||
get() = when (routeProvider()) {
|
||||
get() = when (route) {
|
||||
VoiceAudioRoute.Standard -> VoiceAudioRoute.Standard
|
||||
VoiceAudioRoute.Relay -> VoiceAudioRoute.Relay
|
||||
VoiceAudioRoute.Auto ->
|
||||
@@ -114,7 +124,7 @@ class AutoVoiceAudioClient(
|
||||
private suspend fun <T> runWithSelectedRoute(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
return when (routeProvider()) {
|
||||
return when (route) {
|
||||
VoiceAudioRoute.Standard -> {
|
||||
if (!standardReadyProvider()) {
|
||||
Result.failure(
|
||||
|
||||
@@ -54,6 +54,7 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -133,6 +134,8 @@ import com.hermesandroid.relay.data.CandidateBuild
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.SupervisedModeStore
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
@@ -144,6 +147,7 @@ import com.hermesandroid.relay.util.HumanError
|
||||
import kotlinx.coroutines.delay
|
||||
import com.hermesandroid.relay.ui.onboarding.OnboardingScreen
|
||||
import com.hermesandroid.relay.ui.screens.AboutScreen
|
||||
import com.hermesandroid.relay.ui.screens.AdvancedSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.AnalyticsScreen
|
||||
import com.hermesandroid.relay.ui.screens.AppearanceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.CustomThemeScreen
|
||||
@@ -170,6 +174,8 @@ import com.hermesandroid.relay.ui.screens.PermissionsStatusScreen
|
||||
import com.hermesandroid.relay.ui.screens.ProfileInspectorScreen
|
||||
import com.hermesandroid.relay.ui.screens.RealtimeVoiceTestScreen
|
||||
import com.hermesandroid.relay.ui.screens.SettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.SupervisedControlsScreen
|
||||
import com.hermesandroid.relay.ui.screens.SupervisedAppearanceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.UsageLimitsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginPageScreen
|
||||
@@ -531,6 +537,17 @@ sealed class Screen(
|
||||
// the plural `ConnectionsSettings` subpage. See `ConnectionsSettings`
|
||||
// above for the surviving route.)
|
||||
data object ChatSettings : Screen("settings/chat", "Chat", Icons.Filled.Settings)
|
||||
data object AdvancedSettings : Screen("settings/advanced", "Advanced", Icons.Filled.Settings)
|
||||
data object SupervisedAppearanceSettings : Screen(
|
||||
"settings/supervised/appearance",
|
||||
"Appearance",
|
||||
Icons.Filled.Settings,
|
||||
)
|
||||
data object SupervisedControls : Screen(
|
||||
"settings/supervised",
|
||||
"Supervised mode",
|
||||
Icons.Filled.Settings,
|
||||
)
|
||||
data object ProviderUsage : Screen("settings/usage", "Usage & limits", Icons.Filled.Settings)
|
||||
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
|
||||
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
|
||||
@@ -589,6 +606,24 @@ sealed class Screen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupervisedStartupLoadingScreen() {
|
||||
HermesRelayTheme(themePreference = "dark") {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Loading protected settings…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayApp() {
|
||||
val applicationContext = LocalContext.current.applicationContext
|
||||
@@ -603,7 +638,10 @@ fun RelayApp() {
|
||||
LaunchedEffect(processRuntime) {
|
||||
processRuntime.ensureInitialized()
|
||||
}
|
||||
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) return
|
||||
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) {
|
||||
SupervisedStartupLoadingScreen()
|
||||
return
|
||||
}
|
||||
|
||||
val voiceClient: RelayVoiceClient = processRuntime.relayVoiceClient
|
||||
val voicePreferences = processRuntime.voicePreferences
|
||||
@@ -700,6 +738,72 @@ fun RelayApp() {
|
||||
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val connectionStoreHydrated by
|
||||
connectionViewModel.connectionStore.isHydrated.collectAsState()
|
||||
val supervisedModeStore = remember(applicationContext) {
|
||||
SupervisedModeStore(applicationContext)
|
||||
}
|
||||
val supervisedPolicyState = produceState<Pair<String?, SupervisedModePolicy>?>(
|
||||
initialValue = null,
|
||||
key1 = activeConnectionId,
|
||||
key2 = supervisedModeStore,
|
||||
) {
|
||||
val connectionId = activeConnectionId
|
||||
if (connectionId == null) {
|
||||
value = null to SupervisedModePolicy()
|
||||
} else {
|
||||
supervisedModeStore.policyFlow(connectionId).collect { policy ->
|
||||
value = connectionId to policy
|
||||
}
|
||||
}
|
||||
}
|
||||
val ownedSupervisedPolicyState = supervisedPolicyState.value
|
||||
?.takeIf { (ownerConnectionId, _) -> ownerConnectionId == activeConnectionId }
|
||||
// Fail closed across process restoration. activeConnectionId starts as
|
||||
// null while ConnectionStore reads DataStore, so null alone cannot prove
|
||||
// this is a fresh install with no supervised policy to restore.
|
||||
if (!isRelayNavigationHydrated(
|
||||
connectionStoreHydrated = connectionStoreHydrated,
|
||||
activeConnectionId = activeConnectionId,
|
||||
supervisedPolicyHydrated = ownedSupervisedPolicyState != null,
|
||||
)
|
||||
) {
|
||||
SupervisedStartupLoadingScreen()
|
||||
return
|
||||
}
|
||||
val supervisedPolicy = ownedSupervisedPolicyState?.second ?: SupervisedModePolicy()
|
||||
val supervisedPinnedProfile = supervisedPolicy.pinnedProfileName?.let { name ->
|
||||
agentProfiles.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
}
|
||||
val supervisedProfileConfirmed = !supervisedPolicy.enabled || (
|
||||
profileSelectionSettled &&
|
||||
supervisedPinnedProfile != null &&
|
||||
selectedProfile?.name.equals(supervisedPinnedProfile.name, ignoreCase = true)
|
||||
)
|
||||
val chatSupervisedPolicy = if (supervisedPolicy.enabled && !supervisedProfileConfirmed) {
|
||||
supervisedPolicy.copy(pinnedProfileName = null)
|
||||
} else supervisedPolicy
|
||||
var parentAccessUnlocked by remember(activeConnectionId) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(
|
||||
activeConnectionId,
|
||||
supervisedPolicy,
|
||||
agentProfiles,
|
||||
selectedProfile,
|
||||
profileSelectionSettled,
|
||||
) {
|
||||
chatViewModel.updateSupervisedModePolicy(chatSupervisedPolicy)
|
||||
connectionViewModel.authManager.updateSupervisedMode(chatSupervisedPolicy)
|
||||
if (!supervisedPolicy.enabled) {
|
||||
parentAccessUnlocked = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val pinned = supervisedPinnedProfile ?: return@LaunchedEffect
|
||||
if (!selectedProfile?.name.equals(pinned.name, ignoreCase = true)) {
|
||||
connectionViewModel.selectProfile(pinned)
|
||||
chatViewModel.activateGatewayProfile(pinned)
|
||||
}
|
||||
}
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
@@ -784,6 +888,22 @@ fun RelayApp() {
|
||||
val appearanceAccent by connectionViewModel.appearanceAccent.collectAsState()
|
||||
val appearanceShape by connectionViewModel.appearanceShape.collectAsState()
|
||||
val activeCustomTheme by connectionViewModel.activeCustomTheme.collectAsState()
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
val parentAccessForCurrentRoute = parentAccessUnlocked &&
|
||||
!shouldRelockParentAccess(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessUnlocked,
|
||||
route = currentRoute,
|
||||
)
|
||||
val resolvedTheme = resolveSupervisedTheme(
|
||||
policy = supervisedPolicy,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
globalAppThemeId = appThemeId,
|
||||
globalThemePreference = themePreference,
|
||||
)
|
||||
val supervisedAppearanceLocked = supervisedPolicy.enabled && !parentAccessForCurrentRoute
|
||||
|
||||
// Resolve the active sphere skin (built-in / adaptive / user-loaded) and
|
||||
// publish it + the full available set so every MorphingSphere picks it up
|
||||
@@ -800,10 +920,10 @@ fun RelayApp() {
|
||||
value = SphereRegistry.builtIns +
|
||||
withContext(Dispatchers.IO) { SphereSkinLoader.loadUserSkins(sphereContext) }
|
||||
}
|
||||
val activeSphereSkin = remember(sphereSkinId, appThemeId, availableSphereSkins) {
|
||||
val activeSphereSkin = remember(sphereSkinId, resolvedTheme.appThemeId, availableSphereSkins) {
|
||||
SphereRegistry.resolve(
|
||||
selectedId = sphereSkinId,
|
||||
themeDefaultSkinId = AppThemes.byId(appThemeId).defaultSphereSkinId,
|
||||
themeDefaultSkinId = AppThemes.byId(resolvedTheme.appThemeId).defaultSphereSkinId,
|
||||
available = availableSphereSkins,
|
||||
)
|
||||
}
|
||||
@@ -938,37 +1058,19 @@ fun RelayApp() {
|
||||
),
|
||||
)
|
||||
HermesRelayTheme(
|
||||
appThemeId = appThemeId,
|
||||
themePreference = themePreference,
|
||||
appThemeId = resolvedTheme.appThemeId,
|
||||
themePreference = resolvedTheme.themePreference,
|
||||
fontScale = fontScale,
|
||||
appFontId = appFontId,
|
||||
accentHex = appearanceAccent,
|
||||
accentHex = appearanceAccent.takeIf { resolvedTheme.useGlobalCustomTheme },
|
||||
shapeId = appearanceShape,
|
||||
customTheme = activeCustomTheme,
|
||||
customTheme = activeCustomTheme.takeIf { resolvedTheme.useGlobalCustomTheme },
|
||||
) {
|
||||
// Surface a crash report from a previous session, if any. Renders a
|
||||
// platform Dialog (own window) so tree position is z-order-agnostic;
|
||||
// it just needs to be inside the theme for Material colors.
|
||||
CrashReportGate()
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
|
||||
// Collect navigation requests posted by external launchers (e.g., the
|
||||
// BridgeForegroundService notification's "Settings" action). The
|
||||
// service sets EXTRA_NAV_ROUTE on its launch intent → MainActivity's
|
||||
// onCreate / onNewIntent reads it and pumps it onto NavRouteRequest →
|
||||
// we forward each emission to the NavController. Single observer at
|
||||
// the app root so every screen benefits.
|
||||
LaunchedEffect(navController) {
|
||||
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
|
||||
// Wire the proactive "session" surfacing once: a message with
|
||||
// surfacing="session" is injected into the active chat conversation.
|
||||
// ChatViewModel isn't available where ConnectionViewModel builds the
|
||||
@@ -1039,8 +1141,74 @@ fun RelayApp() {
|
||||
// restart cleanly lands back in setup.
|
||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
// The unlock remains useful while moving between parent-only settings,
|
||||
// but never follows an enrolled device user back into supervised chat.
|
||||
// Cross-layer requests (notifications, services, deep links) use the
|
||||
// route-scoped unlock. As soon as Chat is current, the parent grant is
|
||||
// ineffective even before the state-clearing effect runs.
|
||||
LaunchedEffect(
|
||||
navController,
|
||||
supervisedPolicy.enabled,
|
||||
parentAccessForCurrentRoute,
|
||||
) {
|
||||
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
|
||||
if (
|
||||
supervisedPolicy.enabled &&
|
||||
!isSupervisedRouteAllowed(route, parentAccessForCurrentRoute)
|
||||
) return@collect
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(
|
||||
supervisedPolicy.enabled,
|
||||
parentAccessForCurrentRoute,
|
||||
currentRoute,
|
||||
) {
|
||||
val redirect = shouldRedirectSupervisedRoute(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
currentRoute = currentRoute,
|
||||
)
|
||||
if (redirect) {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute) {
|
||||
if (shouldRelockParentAccess(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute)) {
|
||||
// Route-scoped authority is already false on Chat. Let Navigation
|
||||
// finish committing the new destination before clearing the raw
|
||||
// parent grant, otherwise the same-frame root recomposition can
|
||||
// leave a themed but contentless surface.
|
||||
withFrameNanos { }
|
||||
withFrameNanos { }
|
||||
parentAccessUnlocked = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(parentAccessUnlocked, supervisedPolicy.parentAccess.timeoutMinutes) {
|
||||
if (parentAccessUnlocked) {
|
||||
delay(supervisedPolicy.parentAccess.timeoutMinutes * 60_000L)
|
||||
parentAccessUnlocked = false
|
||||
}
|
||||
}
|
||||
DisposableEffect(lifecycleOwner, supervisedPolicy.enabled, parentAccessUnlocked) {
|
||||
val relockObserver = LifecycleEventObserver { _, event ->
|
||||
if (
|
||||
event == Lifecycle.Event.ON_PAUSE &&
|
||||
supervisedPolicy.enabled &&
|
||||
parentAccessUnlocked &&
|
||||
supervisedPolicy.parentAccess.relockOnBackground
|
||||
) {
|
||||
parentAccessUnlocked = false
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(relockObserver)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(relockObserver) }
|
||||
}
|
||||
val suppressGlobalChrome = shouldSuppressGlobalChrome(
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
isDemoMode = isDemoMode,
|
||||
@@ -1719,6 +1887,8 @@ fun RelayApp() {
|
||||
!suppressGlobalChrome &&
|
||||
!isKeyboardVisible &&
|
||||
!showStartupSphere &&
|
||||
(!supervisedPolicy.enabled ||
|
||||
supervisedPolicy.visibility.resolved().showTechnicalRoute) &&
|
||||
shouldShowConnectionFooter(voiceUiState.voiceMode, voicePresentationMode)
|
||||
) {
|
||||
val footerRoute = resolveFooterRouteCandidate(
|
||||
@@ -1793,12 +1963,16 @@ fun RelayApp() {
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
val routeContentAllowed = isSupervisedRouteContentAllowed(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
currentRoute = currentRoute,
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
composable(Screen.Onboarding.route) {
|
||||
// The wizard inside OnboardingScreen now owns credential
|
||||
@@ -1885,15 +2059,43 @@ fun RelayApp() {
|
||||
// sheet.
|
||||
val openAgentSheetArg = backStackEntry.arguments
|
||||
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
|
||||
val requestedSessionId = backStackEntry.arguments
|
||||
val rawRequestedSessionId = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_SESSION_ID)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val requestedProfileRoute = backStackEntry.arguments
|
||||
val rawRequestedProfileRoute = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_PROFILE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val requestedProactiveChatId = backStackEntry.arguments
|
||||
val rawRequestedProactiveChatId = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_PROACTIVE_CHAT_ID)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
// Nav/deep-link arguments are not ownership evidence. The
|
||||
// supervised drawer uses profile-scoped session rows
|
||||
// directly; external args stay discarded until an
|
||||
// owner-aware source can explicitly prove the binding.
|
||||
val sanitizedRouteArgs = sanitizeSupervisedChatRouteArgs(
|
||||
policy = supervisedPolicy,
|
||||
args = SupervisedChatRouteArgs(
|
||||
sessionId = rawRequestedSessionId,
|
||||
profile = rawRequestedProfileRoute,
|
||||
proactiveChatId = rawRequestedProactiveChatId,
|
||||
),
|
||||
pinnedProfileOwnershipProven = false,
|
||||
)
|
||||
val requestedSessionId = sanitizedRouteArgs.sessionId
|
||||
val requestedProfileRoute = sanitizedRouteArgs.profile
|
||||
val requestedProactiveChatId = sanitizedRouteArgs.proactiveChatId
|
||||
LaunchedEffect(
|
||||
supervisedPolicy.enabled,
|
||||
rawRequestedSessionId,
|
||||
rawRequestedProfileRoute,
|
||||
rawRequestedProactiveChatId,
|
||||
) {
|
||||
if (supervisedPolicy.enabled) {
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROACTIVE_CHAT_ID, null)
|
||||
}
|
||||
}
|
||||
val proactiveInboxEntries by connectionViewModel.inboxMessages.collectAsState()
|
||||
val phoneThreadChatIds by connectionViewModel.phoneThreadChatIds.collectAsState()
|
||||
LaunchedEffect(
|
||||
@@ -2056,6 +2258,7 @@ fun RelayApp() {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
supervisedPolicy = chatSupervisedPolicy,
|
||||
onNavigateToBotMode = {
|
||||
navController.navigate(Screen.BotMode.route) { launchSingleTop = true }
|
||||
},
|
||||
@@ -2376,6 +2579,25 @@ fun RelayApp() {
|
||||
SettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
supervisedPolicy = supervisedPolicy,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
onRequestParentAccess = { parentAccessUnlocked = true },
|
||||
onUpdateSupervisedPolicy = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
connectionSwitchScope.launch {
|
||||
supervisedModeStore.setPolicy(connectionId, policy)
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateToAdvancedSettings = {
|
||||
navController.navigate(Screen.AdvancedSettings.route)
|
||||
},
|
||||
onNavigateToSupervisedAppearance = {
|
||||
navController.navigate(Screen.SupervisedAppearanceSettings.route)
|
||||
},
|
||||
onNavigateToSupervisedControls = {
|
||||
navController.navigate(Screen.SupervisedControls.route)
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
// (The `onNavigateToChatWithAgentSheet` callback that
|
||||
// used to live here was removed 2026-04-21. Tapping
|
||||
@@ -2450,6 +2672,62 @@ fun RelayApp() {
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.AdvancedSettings.route) {
|
||||
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
AdvancedSettingsScreen(
|
||||
supervisedPolicy = supervisedPolicy,
|
||||
onNavigateToSupervisedControls = {
|
||||
navController.navigate(Screen.SupervisedControls.route)
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.SupervisedAppearanceSettings.route) {
|
||||
if (!supervisedPolicy.enabled && !parentAccessForCurrentRoute) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
SupervisedAppearanceSettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
policy = supervisedPolicy,
|
||||
onPolicyChange = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
connectionSwitchScope.launch {
|
||||
supervisedModeStore.setPolicy(connectionId, policy)
|
||||
}
|
||||
}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.SupervisedControls.route) {
|
||||
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
SupervisedControlsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
policy = supervisedPolicy,
|
||||
profiles = agentProfiles.filterNot { it.isDefault },
|
||||
onPolicyChange = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
connectionSwitchScope.launch {
|
||||
supervisedModeStore.setPolicy(connectionId, policy)
|
||||
}
|
||||
}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
onReturnToSupervisedView = {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(Screen.Chat.route) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.ProviderUsage.route) {
|
||||
UsageLimitsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
@@ -2927,7 +3205,8 @@ fun RelayApp() {
|
||||
composable(Screen.About.route) {
|
||||
AboutScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
onBack = { navController.popBackStack() },
|
||||
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessForCurrentRoute,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
@@ -3030,6 +3309,12 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!routeContentAllowed) {
|
||||
// Keep the graph mounted so the redirect can complete, but
|
||||
// cover restored parent-only content with an opaque fail-closed surface.
|
||||
SupervisedStartupLoadingScreen()
|
||||
}
|
||||
}
|
||||
} // end bridge-return wrapper column
|
||||
} // end CompositionLocalProvider
|
||||
}
|
||||
@@ -3042,6 +3327,7 @@ fun RelayApp() {
|
||||
val petSurfaceOwner = petSurfaceOwnerForRoute(currentRoute)
|
||||
val petActivity = petCompanionCoordinator.activityFor(petSurfaceOwner)
|
||||
val showFloatingPet = activeFloatingPet != null &&
|
||||
shouldShowPetInSupervisedMode(supervisedPolicy, parentAccessForCurrentRoute) &&
|
||||
floatingPetAllowedOnRoute(currentRoute) &&
|
||||
!petActivity.hidden &&
|
||||
!suppressGlobalChrome &&
|
||||
@@ -3072,6 +3358,7 @@ fun RelayApp() {
|
||||
),
|
||||
animationEnabled = animationEnabled,
|
||||
appForeground = appIsForeground,
|
||||
interactive = !supervisedAppearanceLocked,
|
||||
route = roamingRoute,
|
||||
visitRequest = petCompanionCoordinator.pendingVisitRequest,
|
||||
onVisitRequestConsumed = petCompanionCoordinator::clearVisitRequest,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
|
||||
internal data class ResolvedSupervisedTheme(
|
||||
val appThemeId: String,
|
||||
val themePreference: String,
|
||||
val useGlobalCustomTheme: Boolean,
|
||||
)
|
||||
|
||||
/** Keep the supervised palette isolated from the parent's ordinary app theme. */
|
||||
internal fun resolveSupervisedTheme(
|
||||
policy: SupervisedModePolicy,
|
||||
parentAccessUnlocked: Boolean,
|
||||
globalAppThemeId: String,
|
||||
globalThemePreference: String,
|
||||
): ResolvedSupervisedTheme = if (policy.enabled && !parentAccessUnlocked) {
|
||||
ResolvedSupervisedTheme(
|
||||
appThemeId = policy.appearance.appThemeId,
|
||||
themePreference = policy.appearance.themePreference,
|
||||
useGlobalCustomTheme = false,
|
||||
)
|
||||
} else {
|
||||
ResolvedSupervisedTheme(
|
||||
appThemeId = globalAppThemeId,
|
||||
themePreference = globalThemePreference,
|
||||
useGlobalCustomTheme = true,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun shouldShowPetInSupervisedMode(
|
||||
policy: SupervisedModePolicy,
|
||||
parentAccessUnlocked: Boolean,
|
||||
): Boolean = !policy.enabled || parentAccessUnlocked || policy.appearance.showPet
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.ConnectionStore
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
|
||||
/** Allowlist applied to external, deep-link, and programmatic navigation. */
|
||||
internal fun isSupervisedRouteAllowed(route: String?, parentAccessUnlocked: Boolean): Boolean {
|
||||
if (parentAccessUnlocked) return true
|
||||
val normalized = route?.substringBefore('?') ?: return false
|
||||
return normalized == "chat" ||
|
||||
normalized == Screen.Settings.route ||
|
||||
normalized == Screen.SupervisedAppearanceSettings.route
|
||||
}
|
||||
|
||||
/** Do not inspect or mutate a NavController until its first destination exists. */
|
||||
internal fun shouldRedirectSupervisedRoute(
|
||||
supervisedEnabled: Boolean,
|
||||
parentAccessUnlocked: Boolean,
|
||||
currentRoute: String?,
|
||||
): Boolean = currentRoute != null &&
|
||||
supervisedEnabled &&
|
||||
!isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
|
||||
|
||||
/** A null route is Navigation's pre-graph bootstrap state, not a forbidden destination. */
|
||||
internal fun isSupervisedRouteContentAllowed(
|
||||
supervisedEnabled: Boolean,
|
||||
parentAccessUnlocked: Boolean,
|
||||
currentRoute: String?,
|
||||
): Boolean = currentRoute == null ||
|
||||
!supervisedEnabled ||
|
||||
isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
|
||||
|
||||
/**
|
||||
* Cold-start gate for the app navigation graph.
|
||||
*
|
||||
* A null active connection is also the seed value used while [ConnectionStore]
|
||||
* is reading DataStore. Callers must therefore wait for the store's explicit
|
||||
* hydration signal before treating null as "no connection" and composing the
|
||||
* unrestricted onboarding/settings graph.
|
||||
*/
|
||||
internal fun isRelayNavigationHydrated(
|
||||
connectionStoreHydrated: Boolean,
|
||||
activeConnectionId: String?,
|
||||
supervisedPolicyHydrated: Boolean,
|
||||
): Boolean = connectionStoreHydrated &&
|
||||
(activeConnectionId == null || supervisedPolicyHydrated)
|
||||
|
||||
/** A parent unlock never follows the user back into the supervised chat root. */
|
||||
internal fun shouldRelockParentAccess(
|
||||
supervisedEnabled: Boolean,
|
||||
parentAccessUnlocked: Boolean,
|
||||
route: String?,
|
||||
): Boolean = supervisedEnabled &&
|
||||
parentAccessUnlocked &&
|
||||
route?.substringBefore('?') == "chat"
|
||||
|
||||
/**
|
||||
* External chat route arguments are untrusted. A session may be restored only
|
||||
* after an owner-aware source has proved that it belongs to the pinned profile.
|
||||
*/
|
||||
internal fun mayRestoreSupervisedSessionRoute(
|
||||
policy: SupervisedModePolicy,
|
||||
requestedSessionId: String?,
|
||||
requestedProfile: String?,
|
||||
pinnedProfileOwnershipProven: Boolean,
|
||||
): Boolean = policy.isActive &&
|
||||
policy.capabilities.conversationHistory &&
|
||||
pinnedProfileOwnershipProven &&
|
||||
!requestedSessionId.isNullOrBlank() &&
|
||||
!requestedProfile.isNullOrBlank() &&
|
||||
requestedProfile.equals(policy.pinnedProfileName, ignoreCase = true)
|
||||
|
||||
internal data class SupervisedChatRouteArgs(
|
||||
val sessionId: String? = null,
|
||||
val profile: String? = null,
|
||||
val proactiveChatId: String? = null,
|
||||
)
|
||||
|
||||
/** Strip external chat targeting before any destination effect can dispatch it. */
|
||||
internal fun sanitizeSupervisedChatRouteArgs(
|
||||
policy: SupervisedModePolicy,
|
||||
args: SupervisedChatRouteArgs,
|
||||
pinnedProfileOwnershipProven: Boolean,
|
||||
): SupervisedChatRouteArgs {
|
||||
if (!policy.enabled) return args
|
||||
val allowSession = mayRestoreSupervisedSessionRoute(
|
||||
policy = policy,
|
||||
requestedSessionId = args.sessionId,
|
||||
requestedProfile = args.profile,
|
||||
pinnedProfileOwnershipProven = pinnedProfileOwnershipProven,
|
||||
)
|
||||
return if (allowSession) {
|
||||
args.copy(proactiveChatId = null)
|
||||
} else {
|
||||
SupervisedChatRouteArgs()
|
||||
}
|
||||
}
|
||||
|
||||
/** A disabled policy may become active only after an enrolled credential succeeds. */
|
||||
internal fun mayEnableSupervisedMode(
|
||||
policy: SupervisedModePolicy,
|
||||
deviceSecure: Boolean,
|
||||
deviceCredentialConfirmed: Boolean,
|
||||
): Boolean = !policy.enabled &&
|
||||
policy.isConfigured &&
|
||||
deviceSecure &&
|
||||
deviceCredentialConfirmed
|
||||
@@ -125,6 +125,7 @@ fun AttachmentGallery(
|
||||
if (attachments.size < 2) return
|
||||
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
val revealed = remember { mutableStateMapOf<String, Boolean>() }
|
||||
@@ -189,7 +190,7 @@ fun AttachmentGallery(
|
||||
)
|
||||
}
|
||||
|
||||
if (!blurred) {
|
||||
if (!blurred && exportAllowed) {
|
||||
SaveOverlayButton(
|
||||
onClick = {
|
||||
scope.launch { saveAttachment(context, attachment) }
|
||||
@@ -201,7 +202,7 @@ fun AttachmentGallery(
|
||||
}
|
||||
|
||||
AttachmentActionsMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = menuExpanded && exportAllowed,
|
||||
onDismiss = { menuExpanded = false },
|
||||
context = context,
|
||||
scope = scope,
|
||||
|
||||
@@ -312,6 +312,8 @@ fun AttachmentViewer(
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current ||
|
||||
attachment.renderMode != AttachmentRenderMode.IMAGE
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
@@ -405,6 +407,7 @@ fun AttachmentViewer(
|
||||
title = title,
|
||||
busy = busy,
|
||||
actionsEnabled = !blurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
@@ -448,6 +451,7 @@ internal fun AttachmentGalleryViewer(
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
@@ -584,6 +588,7 @@ internal fun AttachmentGalleryViewer(
|
||||
title = toolbarTitle,
|
||||
busy = busy,
|
||||
actionsEnabled = !currentBlurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
@@ -613,6 +618,7 @@ private fun MediaViewerToolbar(
|
||||
title: String,
|
||||
busy: Boolean,
|
||||
actionsEnabled: Boolean = true,
|
||||
exportAllowed: Boolean = true,
|
||||
onShare: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onOpenExternal: () -> Unit,
|
||||
@@ -653,11 +659,13 @@ private fun MediaViewerToolbar(
|
||||
) {
|
||||
Icon(Icons.Filled.OpenInNew, contentDescription = stringResource(R.string.attachment_open_externally_a11y))
|
||||
}
|
||||
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
|
||||
}
|
||||
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
|
||||
if (exportAllowed) {
|
||||
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
|
||||
}
|
||||
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ fun ChatFailurePanel(
|
||||
onDetails: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
showDetails: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
@@ -72,8 +73,10 @@ fun ChatFailurePanel(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onDetails) {
|
||||
Text(stringResource(R.string.chat_failure_details))
|
||||
if (showDetails) {
|
||||
TextButton(onClick = onDetails) {
|
||||
Text(stringResource(R.string.chat_failure_details))
|
||||
}
|
||||
}
|
||||
if (failure.recoverable) {
|
||||
TextButton(onClick = onRetry) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -42,6 +43,9 @@ import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.util.MediaSaver
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Whether the current conversation policy permits copying image bytes out of the app. */
|
||||
val LocalImageExportAllowed = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* What the [ChatImageViewer] displays and how it obtains bytes for Save/Share.
|
||||
*
|
||||
@@ -104,6 +108,7 @@ fun ChatImageViewer(
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -156,60 +161,72 @@ fun ChatImageViewer(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
|
||||
val cdShare = stringResource(R.string.cd_share)
|
||||
val cdSave = stringResource(R.string.cd_save)
|
||||
val cdClose = stringResource(R.string.cd_close_viewer)
|
||||
val errorMsg = context.getString(R.string.image_viewer_error)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
busy = false
|
||||
if (bytes == null) {
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = cdShare)
|
||||
}
|
||||
val savedFmt = context.getString(R.string.image_viewer_saved)
|
||||
val failedFmt = context.getString(R.string.image_viewer_failed)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
if (bytes == null) {
|
||||
if (exportAllowed) {
|
||||
val cdShare = stringResource(R.string.cd_share)
|
||||
val cdSave = stringResource(R.string.cd_save)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
busy = false
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
if (bytes == null) {
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = cdShare)
|
||||
}
|
||||
val savedFmt = context.getString(R.string.image_viewer_saved)
|
||||
val failedFmt = context.getString(R.string.image_viewer_failed)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
toast(context, savedFmt.format(result.location))
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
toast(context, failedFmt.format(result.message))
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
busy = false
|
||||
toast(context, savedFmt.format(result.location))
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
toast(context, failedFmt.format(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = cdSave)
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = cdSave)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDismiss, colors = tint) {
|
||||
Icon(Icons.Filled.Close, contentDescription = cdClose)
|
||||
|
||||
@@ -483,6 +483,7 @@ fun FloatingPetCompanion(
|
||||
compact: Boolean,
|
||||
animationEnabled: Boolean,
|
||||
appForeground: Boolean,
|
||||
interactive: Boolean = true,
|
||||
route: String?,
|
||||
visitRequest: PetVisitRequest?,
|
||||
onVisitRequestConsumed: (String) -> Unit,
|
||||
@@ -2318,13 +2319,14 @@ fun FloatingPetCompanion(
|
||||
}
|
||||
.pointerInput(
|
||||
pet.id,
|
||||
interactive,
|
||||
safeBounds,
|
||||
roamingRails,
|
||||
settledHabitat,
|
||||
positioned,
|
||||
surfaceScrolling,
|
||||
) {
|
||||
if (!floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
|
||||
if (!interactive || !floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
|
||||
return@pointerInput
|
||||
}
|
||||
detectDragGesturesAfterLongPress(
|
||||
@@ -2399,16 +2401,16 @@ fun FloatingPetCompanion(
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
enabled = floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
|
||||
enabled = interactive && floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
|
||||
) {
|
||||
tapReactionNonce += 1
|
||||
setMenuExpanded(true)
|
||||
}
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = Role.Button
|
||||
if (interactive) role = Role.Button
|
||||
contentDescription = companionDescription
|
||||
stateDescription = stateLabel
|
||||
customActions = buildList {
|
||||
customActions = if (interactive) buildList {
|
||||
add(CustomAccessibilityAction(moveStartLabel) {
|
||||
onPlacementChanged(placement.copy(edge = PetLogicalEdge.Start)); true
|
||||
})
|
||||
@@ -2438,7 +2440,7 @@ fun FloatingPetCompanion(
|
||||
add(CustomAccessibilityAction(resetLabel) { onResetPlacement(); true })
|
||||
add(CustomAccessibilityAction(appearanceLabel) { onOpenAppearance(); true })
|
||||
add(CustomAccessibilityAction(hideLabel) { onHide(); true })
|
||||
}
|
||||
} else emptyList()
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -2482,7 +2484,7 @@ fun FloatingPetCompanion(
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = interactive && menuExpanded,
|
||||
onDismissRequest = { setMenuExpanded(false) },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
|
||||
+18
-13
@@ -270,6 +270,7 @@ private fun ImageRender(
|
||||
maxWidth: Dp
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// Decode OFF the main thread — a large inbound image would otherwise block
|
||||
// composition. Null while decoding (placeholder); decodeFailed → file card.
|
||||
@@ -356,14 +357,14 @@ private fun ImageRender(
|
||||
}
|
||||
// One-tap save overlay — hidden while the blur cover is up so it
|
||||
// doesn't sit over the "tap to reveal" prompt.
|
||||
if (!blurred) {
|
||||
if (!blurred && exportAllowed) {
|
||||
SaveOverlayButton(
|
||||
onClick = { scope.launch { saveAttachment(context, attachment) } },
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp),
|
||||
)
|
||||
}
|
||||
AttachmentActionsMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = menuExpanded && exportAllowed,
|
||||
onDismiss = { menuExpanded = false },
|
||||
context = context,
|
||||
scope = scope,
|
||||
@@ -380,6 +381,8 @@ private fun FileCardRender(
|
||||
maxWidth: Dp
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current ||
|
||||
attachment.renderMode != AttachmentRenderMode.IMAGE
|
||||
val scope = rememberCoroutineScope()
|
||||
val (emoji, typeLabel) = emojiAndLabelFor(attachment.renderMode, attachment.contentType)
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
@@ -463,21 +466,23 @@ private fun FileCardRender(
|
||||
}
|
||||
}
|
||||
// Visible one-tap save affordance (B2).
|
||||
IconButton(
|
||||
onClick = { scope.launch { saveAttachment(context, attachment) } },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = stringResource(R.string.inbound_attach_cd_save),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
if (exportAllowed) {
|
||||
IconButton(
|
||||
onClick = { scope.launch { saveAttachment(context, attachment) } },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = stringResource(R.string.inbound_attach_cd_save),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AttachmentActionsMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = menuExpanded && exportAllowed,
|
||||
onDismiss = { menuExpanded = false },
|
||||
context = context,
|
||||
scope = scope,
|
||||
|
||||
@@ -94,6 +94,14 @@ import java.util.Date
|
||||
internal const val CHAT_PET_IDENTITY_OBSTACLE_PREFIX = "chat-message-identity:"
|
||||
private val MESSAGE_REACTIONS = listOf("❤️", "👍", "👎", "😂", "‼️", "❓")
|
||||
|
||||
internal fun assistantImageContent(
|
||||
content: String,
|
||||
showImages: Boolean,
|
||||
): Pair<String, List<ChatInlineImage>> {
|
||||
val (body, images) = extractChatInlineImages(content)
|
||||
return body to if (showImages) images else emptyList()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
@@ -101,6 +109,13 @@ fun MessageBubble(
|
||||
modifier: Modifier = Modifier,
|
||||
maxBubbleWidth: Dp = 300.dp,
|
||||
showThinking: Boolean = true,
|
||||
showAgentIdentity: Boolean = true,
|
||||
showTimestamps: Boolean = true,
|
||||
showWorkingStatus: Boolean = true,
|
||||
showUsage: Boolean = true,
|
||||
showTechnicalBadges: Boolean = true,
|
||||
showAssistantImages: Boolean = true,
|
||||
allowAssistantImageExport: Boolean = true,
|
||||
isFirstInGroup: Boolean = true,
|
||||
isLastInGroup: Boolean = true,
|
||||
onCopyMessage: (String) -> Unit = {},
|
||||
@@ -238,18 +253,24 @@ fun MessageBubble(
|
||||
// content so they render as real images (remote URLs via Coil) or a
|
||||
// graceful inline notice — not the blank element the markdown renderer
|
||||
// emits for an image link. User/system bubbles keep their raw content.
|
||||
val (markdownBody, inlineImages) = remember(visibleMessageContent, isUser, isSystem) {
|
||||
val (markdownBody, inlineImages) = remember(
|
||||
visibleMessageContent,
|
||||
isUser,
|
||||
isSystem,
|
||||
showAssistantImages,
|
||||
) {
|
||||
if (isUser || isSystem) {
|
||||
visibleMessageContent to emptyList()
|
||||
} else {
|
||||
extractChatInlineImages(visibleMessageContent)
|
||||
assistantImageContent(visibleMessageContent, showAssistantImages)
|
||||
}
|
||||
}
|
||||
val showImageGeneration = shouldShowImageGenerationPlaceholder(
|
||||
val showImageGeneration = showAssistantImages && showWorkingStatus && shouldShowImageGenerationPlaceholder(
|
||||
toolCalls = message.toolCalls,
|
||||
isStreaming = message.isStreaming,
|
||||
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
|
||||
)
|
||||
val actionContent = if (!isUser && !isSystem) markdownBody else visibleMessageContent
|
||||
val streamingStatusLabel = if (
|
||||
!isUser &&
|
||||
!isSystem &&
|
||||
@@ -297,7 +318,10 @@ fun MessageBubble(
|
||||
val blurRepo = remember(context) { MediaSettingsRepository(context.applicationContext) }
|
||||
val blurMode by blurRepo.blurMode.collectAsState(initial = BlurMode.FLAGGED)
|
||||
|
||||
CompositionLocalProvider(LocalMediaBlurMode provides blurMode) {
|
||||
CompositionLocalProvider(
|
||||
LocalMediaBlurMode provides blurMode,
|
||||
LocalImageExportAllowed provides allowAssistantImageExport,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = alignment,
|
||||
@@ -305,7 +329,7 @@ fun MessageBubble(
|
||||
// Keep sender identity in the first-message label rather than a
|
||||
// persistent leading column. Long responses and every follow-up in the
|
||||
// group therefore retain the full bubble-width allowance.
|
||||
if (!isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
|
||||
if (showAgentIdentity && !isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
@@ -336,7 +360,7 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUser && !isSystem && message.badges.isNotEmpty()) {
|
||||
if (showTechnicalBadges && !isUser && !isSystem && message.badges.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
@@ -415,7 +439,7 @@ fun MessageBubble(
|
||||
// is rendered directly in the conversation
|
||||
// lane below, without an opaque bubble. Cards and attachments still own
|
||||
// a normal bubble even when response prose has not arrived yet.
|
||||
streamingStatusLabel?.let { streamingStatus ->
|
||||
streamingStatusLabel?.takeIf { showWorkingStatus }?.let { streamingStatus ->
|
||||
StandaloneStreamingStatus(
|
||||
status = streamingStatus,
|
||||
accessibilityDescription = a11yDescription,
|
||||
@@ -508,7 +532,7 @@ fun MessageBubble(
|
||||
text = { Text(stringResource(R.string.msg_bubble_copy)) },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onCopyMessage(visibleMessageContent)
|
||||
onCopyMessage(actionContent)
|
||||
},
|
||||
)
|
||||
if (onQuoteMessage != null) {
|
||||
@@ -516,7 +540,7 @@ fun MessageBubble(
|
||||
text = { Text(stringResource(R.string.msg_bubble_quote)) },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onQuoteMessage(message.copy(content = visibleMessageContent))
|
||||
onQuoteMessage(message.copy(content = actionContent))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -531,7 +555,7 @@ fun MessageBubble(
|
||||
},
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onSpeakMessage?.invoke(visibleMessageContent)
|
||||
onSpeakMessage?.invoke(actionContent)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -601,7 +625,7 @@ fun MessageBubble(
|
||||
) {
|
||||
showMessageActions = true
|
||||
} else {
|
||||
onCopyMessage(visibleMessageContent)
|
||||
onCopyMessage(actionContent)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -798,7 +822,7 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
val hasTokenUsage = !isUser &&
|
||||
val hasTokenUsage = showUsage && !isUser &&
|
||||
(message.inputTokens != null || message.outputTokens != null)
|
||||
|
||||
// Timestamp — only on the LAST bubble of a same-author run so a
|
||||
@@ -808,13 +832,13 @@ fun MessageBubble(
|
||||
// This row is reserved from the first streaming frame. Completion
|
||||
// can reveal both timestamp and token usage without adding a new
|
||||
// footer line or changing the bubble's measured height.
|
||||
if (isLastInGroup) {
|
||||
if (isLastInGroup && (showTimestamps || hasTokenUsage)) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
if (showTimestamps) Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
|
||||
@@ -883,15 +907,15 @@ fun MessageBubble(
|
||||
showEdit = showEditAction,
|
||||
onCopy = {
|
||||
showInlineActions = false
|
||||
onCopyMessage(visibleMessageContent)
|
||||
onCopyMessage(actionContent)
|
||||
},
|
||||
onQuote = {
|
||||
showInlineActions = false
|
||||
onQuoteMessage?.invoke(message.copy(content = visibleMessageContent))
|
||||
onQuoteMessage?.invoke(message.copy(content = actionContent))
|
||||
},
|
||||
onSpeak = {
|
||||
showInlineActions = false
|
||||
onSpeakMessage?.invoke(visibleMessageContent)
|
||||
onSpeakMessage?.invoke(actionContent)
|
||||
},
|
||||
onStopSpeaking = {
|
||||
showInlineActions = false
|
||||
|
||||
@@ -109,6 +109,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.SupervisedSessionActions
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
|
||||
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
|
||||
@@ -208,6 +209,8 @@ fun SessionDrawerContent(
|
||||
animationEnabled: Boolean = true,
|
||||
autoTitlesSupported: Boolean = true,
|
||||
archiveSupported: Boolean = true,
|
||||
supervisedSessionActions: SupervisedSessionActions? = null,
|
||||
newChatEnabled: Boolean = true,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
/** Opens the separate Bot Mode messenger workspace; never changes drawer filters. */
|
||||
onOpenBotMode: (() -> Unit)? = null,
|
||||
@@ -288,8 +291,10 @@ fun SessionDrawerContent(
|
||||
allowBareSessionIds = !showAllProfiles,
|
||||
)
|
||||
val sourceSessions = sourceRows.map { it.session }
|
||||
val showThreads = threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) }
|
||||
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, archiveSupported)
|
||||
val showThreads = supervisedSessionActions == null &&
|
||||
(threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) })
|
||||
val effectiveArchiveSupported = archiveSupported && supervisedSessionActions?.archive != false
|
||||
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, effectiveArchiveSupported)
|
||||
// External gateway sources present (discord/telegram/cron/…) for the source
|
||||
// filter dropdown. Own chats (tui/api_server) + phone Threads aren't listed.
|
||||
val presentSources = sourceSessions
|
||||
@@ -405,7 +410,7 @@ fun SessionDrawerContent(
|
||||
)
|
||||
// Source filter — show/hide gateway sources (default hides the
|
||||
// noisy cron+webhook). Only when external sources are present.
|
||||
if (onToggleSourceHidden != null && presentSources.isNotEmpty()) {
|
||||
if (supervisedSessionActions == null && onToggleSourceHidden != null && presentSources.isNotEmpty()) {
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { sourceFilterOpen = true },
|
||||
@@ -466,7 +471,7 @@ fun SessionDrawerContent(
|
||||
// Threads affordance — a clean thread-spool that toggles the Threads
|
||||
// filter. Shown only when the Threads capability is active (or a Thread is
|
||||
// already present), so an ordinary no-relay drawer is visually unchanged.
|
||||
if (showThreads) {
|
||||
if (supervisedSessionActions == null && showThreads) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
filter = if (filter == SessionDrawerFilter.Threads) {
|
||||
@@ -537,7 +542,8 @@ fun SessionDrawerContent(
|
||||
onNewChat()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = newChatEnabled,
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -602,8 +608,10 @@ fun SessionDrawerContent(
|
||||
}
|
||||
SessionDrawerFilter.entries
|
||||
.filter { item ->
|
||||
(item != SessionDrawerFilter.Threads || showThreads) &&
|
||||
(item != SessionDrawerFilter.Archive || archiveSupported)
|
||||
(item != SessionDrawerFilter.Threads ||
|
||||
(supervisedSessionActions == null && showThreads)) &&
|
||||
(item != SessionDrawerFilter.Archive ||
|
||||
effectiveArchiveSupported)
|
||||
}
|
||||
.forEach { item ->
|
||||
FilterChip(
|
||||
@@ -635,17 +643,19 @@ fun SessionDrawerContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = { customizeOpen = true },
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.drawer_customize_sessions))
|
||||
if (supervisedSessionActions == null) {
|
||||
TextButton(
|
||||
onClick = { customizeOpen = true },
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.drawer_customize_sessions))
|
||||
}
|
||||
}
|
||||
// "+ New Thread" — Discord-style user-created thread, shown when the
|
||||
// Threads filter is active. The first message opens the conversation.
|
||||
@@ -779,13 +789,20 @@ fun SessionDrawerContent(
|
||||
showTokens = viewOptions.showTokens,
|
||||
showCost = viewOptions.showCost,
|
||||
nowMillis = drawerNowMillis,
|
||||
actionsEnabled = !provisional,
|
||||
actionsEnabled = !provisional && (
|
||||
supervisedSessionActions == null ||
|
||||
supervisedSessionActions.pin ||
|
||||
supervisedSessionActions.rename ||
|
||||
supervisedSessionActions.delete ||
|
||||
(supervisedSessionActions.archive && archiveSupported)
|
||||
),
|
||||
isActive = !showAllProfiles && session.sessionId == currentSessionId,
|
||||
activityState = activityState,
|
||||
animationEnabled = animationEnabled && isOpen,
|
||||
pinned = session.pinned,
|
||||
archived = session.archived,
|
||||
archiveSupported = archiveSupported,
|
||||
supervisedSessionActions = supervisedSessionActions,
|
||||
onClick = {
|
||||
if (showAllProfiles) {
|
||||
onSelectProfileSession?.invoke(row.profile, session.sessionId)
|
||||
@@ -1347,6 +1364,7 @@ private fun SessionItem(
|
||||
pinned: Boolean,
|
||||
archived: Boolean,
|
||||
archiveSupported: Boolean,
|
||||
supervisedSessionActions: SupervisedSessionActions?,
|
||||
onClick: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onToggleArchived: () -> Unit,
|
||||
@@ -1510,7 +1528,7 @@ private fun SessionItem(
|
||||
expanded = menuOpen,
|
||||
onDismissRequest = { menuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions?.pin != false) DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
if (pinned) {
|
||||
@@ -1536,7 +1554,7 @@ private fun SessionItem(
|
||||
onTogglePinned()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions == null) DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_copy_session_id)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.ContentCopy, contentDescription = null)
|
||||
@@ -1546,7 +1564,7 @@ private fun SessionItem(
|
||||
onCopySessionId()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions?.rename != false) DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.drawer_rename)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Edit, contentDescription = null)
|
||||
@@ -1556,7 +1574,7 @@ private fun SessionItem(
|
||||
onRename()
|
||||
},
|
||||
)
|
||||
if (archiveSupported) {
|
||||
if (archiveSupported && supervisedSessionActions?.archive != false) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
|
||||
leadingIcon = {
|
||||
@@ -1576,7 +1594,7 @@ private fun SessionItem(
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions?.delete != false) DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.drawer_delete),
|
||||
@@ -1752,19 +1770,9 @@ private fun Modifier.sessionActivityBorder(
|
||||
state: SessionActivityState?,
|
||||
animated: Boolean,
|
||||
): Modifier {
|
||||
if (state == null) return this
|
||||
val color = when (state) {
|
||||
SessionActivityState.Starting,
|
||||
SessionActivityState.Working -> RelayRefresh.Relay
|
||||
SessionActivityState.NeedsInput -> RelayRefresh.Amber
|
||||
SessionActivityState.BackgroundWork,
|
||||
SessionActivityState.Checking,
|
||||
SessionActivityState.Unavailable,
|
||||
-> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val shouldRotate = animated && (
|
||||
state == SessionActivityState.Starting || state == SessionActivityState.Working
|
||||
)
|
||||
if (!sessionActivityShowsRowBorder(state)) return this
|
||||
val color = RelayRefresh.Relay
|
||||
val shouldRotate = animated
|
||||
val phase = if (shouldRotate) {
|
||||
val transition = rememberInfiniteTransition(label = "session-activity")
|
||||
transition.animateFloat(
|
||||
|
||||
@@ -84,6 +84,10 @@ internal fun sessionDrawerStatus(
|
||||
null -> SessionDrawerStatus.Idle
|
||||
}
|
||||
|
||||
/** Desktop-style row emphasis is reserved for an actual foreground turn. */
|
||||
internal fun sessionActivityShowsRowBorder(state: SessionActivityState?): Boolean =
|
||||
state == SessionActivityState.Starting || state == SessionActivityState.Working
|
||||
|
||||
/**
|
||||
* Normalizes live activity to the drawer's profile-scoped row identity.
|
||||
*
|
||||
|
||||
@@ -87,6 +87,8 @@ fun AboutScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onUnlockDeveloperOptions: () -> Unit = {},
|
||||
/** Supervised clients may read About without gaining a settings mutation backdoor. */
|
||||
allowDeveloperUnlock: Boolean = true,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -218,7 +220,7 @@ fun AboutScreen(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
.clickable(enabled = allowDeveloperUnlock) {
|
||||
if (devOptionsUnlocked) return@clickable
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastTapTime > 2000) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
|
||||
/** Optional and specialized features kept off the primary Settings surface. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdvancedSettingsScreen(
|
||||
supervisedPolicy: SupervisedModePolicy,
|
||||
onNavigateToSupervisedControls: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.settings_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
title = { Text(stringResource(R.string.settings_advanced)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_advanced_intro),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.settings_supervised_mode),
|
||||
subtitle = when {
|
||||
supervisedPolicy.isActive -> stringResource(
|
||||
R.string.settings_supervised_on_profile,
|
||||
supervisedPolicy.pinnedProfileName.orEmpty(),
|
||||
)
|
||||
supervisedPolicy.isConfigured -> stringResource(
|
||||
R.string.settings_supervised_ready_profile,
|
||||
supervisedPolicy.pinnedProfileName.orEmpty(),
|
||||
)
|
||||
else -> stringResource(R.string.settings_supervised_desc)
|
||||
},
|
||||
badge = supervisedPolicy.takeIf { it.isActive }?.let {
|
||||
SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_supervised_on),
|
||||
tone = SettingsStatusTone.Good,
|
||||
)
|
||||
},
|
||||
onClick = onNavigateToSupervisedControls,
|
||||
isDarkTheme = isDarkTheme,
|
||||
petPerchKey = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-18
@@ -1487,26 +1487,27 @@ private fun AppearanceSummaryRow(
|
||||
|
||||
/** Representative, theme-live chat sample so presets are judged in context. */
|
||||
@Composable
|
||||
private fun AppearanceLivePreview(
|
||||
internal fun AppearanceLivePreview(
|
||||
palette: BrandPalette,
|
||||
shapeScale: AppearanceShapeScale,
|
||||
restricted: Boolean = false,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalBrand provides palette,
|
||||
LocalAppearanceShapeScale provides shapeScale,
|
||||
) {
|
||||
MaterialTheme(colorScheme = palette.toColorScheme(), shapes = shapeScale.asMaterialShapes()) {
|
||||
AppearanceLivePreviewContent()
|
||||
AppearanceLivePreviewContent(restricted = restricted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppearanceLivePreviewContent() {
|
||||
private fun AppearanceLivePreviewContent(restricted: Boolean) {
|
||||
val backgroundEnabled = LocalBackgroundVisualizationEnabled.current
|
||||
val backgroundAvatar = LocalAgentAvatar.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().height(294.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(if (restricted) 258.dp else 294.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
@@ -1648,14 +1649,16 @@ private fun AppearanceLivePreviewContent() {
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_preview_tool_meta),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||
color = LocalBrand.current.green,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
if (!restricted) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_preview_tool_meta),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||
color = LocalBrand.current.green,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.padding(start = 6.dp).size(38.dp), contentAlignment = Alignment.Center) {
|
||||
@@ -1678,10 +1681,12 @@ private fun AppearanceLivePreviewContent() {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Add, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
if (!restricted) {
|
||||
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.appearance_preview_message_placeholder),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||
@@ -1691,7 +1696,7 @@ private fun AppearanceLivePreviewContent() {
|
||||
Icon(Icons.Filled.GraphicEq, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
if (!restricted) Surface(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
shape = appearanceRoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
@@ -1785,7 +1790,7 @@ private fun FontOptionRow(
|
||||
* are added.
|
||||
*/
|
||||
@Composable
|
||||
private fun ThemeSwatchChip(
|
||||
internal fun ThemeSwatchChip(
|
||||
appTheme: AppTheme,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
@@ -147,6 +148,7 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
@@ -179,6 +181,11 @@ import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.PhysicalKeyboardEnterBehavior
|
||||
import com.hermesandroid.relay.data.ProfilePresentationPolicy
|
||||
import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.SupervisedSessionAction
|
||||
import com.hermesandroid.relay.data.allowsSessionAction
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
@@ -710,8 +717,40 @@ fun ChatScreen(
|
||||
// existing test/preview call sites keep compiling.
|
||||
onNavigateToVoiceSettings: () -> Unit = {},
|
||||
onNavigateToProfileInspector: (String) -> Unit = {},
|
||||
supervisedPolicy: SupervisedModePolicy = SupervisedModePolicy(),
|
||||
onNavigateToBotMode: () -> Unit = {},
|
||||
) {
|
||||
val supervised = supervisedPolicy.enabled
|
||||
val supervisedVisibility = supervisedPolicy.visibility.resolved()
|
||||
LaunchedEffect(supervisedPolicy) {
|
||||
voiceViewModel.updateSupervisedModePolicy(supervisedPolicy)
|
||||
}
|
||||
if (supervised && !supervisedPolicy.isActive) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Supervised chat unavailable") },
|
||||
actions = {
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"The supervised profile is unavailable. Parent access is required to update this connection.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val responseSpeechActive by voiceViewModel.responseSpeechActive.collectAsState()
|
||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||
@@ -734,7 +773,6 @@ fun ChatScreen(
|
||||
LaunchedEffect(voiceUiState.voiceMode) {
|
||||
if (!voiceUiState.voiceMode) voicePresentationOverride = null
|
||||
}
|
||||
|
||||
// Route classified chat errors (media cache, streaming failures, …) to
|
||||
// the app-wide snackbar. Same pattern every VM-bound screen uses.
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
@@ -796,7 +834,22 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
|
||||
val messages by chatViewModel.messages.collectAsState()
|
||||
val rawMessages by chatViewModel.messages.collectAsState()
|
||||
val messages = remember(rawMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
|
||||
if (!supervised) rawMessages
|
||||
else rawMessages.map { message ->
|
||||
if (message.role == MessageRole.ASSISTANT) {
|
||||
message.copy(
|
||||
attachments = if (supervisedPolicy.capabilities.generatedImages) {
|
||||
message.attachments.filter { it.isImage }
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
cards = emptyList(),
|
||||
)
|
||||
} else message
|
||||
}
|
||||
}
|
||||
val messageReactionsSupported by chatViewModel.messageReactionsSupported.collectAsState()
|
||||
val newestReactableMessageKeys = remember(messages) {
|
||||
setOfNotNull(
|
||||
@@ -823,10 +876,17 @@ fun ChatScreen(
|
||||
// Stable voice can use the standard Hermes dashboard audio routes or the
|
||||
// optional Relay voice routes. Gate the mic on either route being usable;
|
||||
// availability picks the actionable toast when neither is.
|
||||
val voiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
val connectionVoiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val voiceReady = if (supervised) {
|
||||
supervisedPolicy.capabilities.voice &&
|
||||
standardVoiceAvailability ==
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Ready
|
||||
} else {
|
||||
connectionVoiceReady
|
||||
}
|
||||
val chatSpeakResponseActionsEnabled =
|
||||
shouldOfferChatSpeakAction(voiceReady, voiceUiState.state)
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val standardVoiceSignInRouteHint by
|
||||
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
|
||||
val dashboardRouteMovedHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
|
||||
@@ -990,8 +1050,15 @@ fun ChatScreen(
|
||||
?: sessionModelState.pickerModel?.let { model ->
|
||||
modelProviders.singleOrNull { model in it.models }?.slug
|
||||
}
|
||||
val showThinking by connectionViewModel.showThinking.collectAsState()
|
||||
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
||||
val configuredShowThinking by connectionViewModel.showThinking.collectAsState()
|
||||
val configuredToolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
||||
val showThinking = configuredShowThinking &&
|
||||
(!supervised || supervisedVisibility.showReasoning)
|
||||
val toolDisplay = if (!supervised) configuredToolDisplay else when {
|
||||
supervisedVisibility.showToolDetails -> "detailed"
|
||||
supervisedVisibility.showToolNames -> "compact"
|
||||
else -> "off"
|
||||
}
|
||||
val smoothAutoScroll by connectionViewModel.smoothAutoScroll.collectAsState()
|
||||
val closeDrawerOnSend by connectionViewModel.closeDrawerOnSend.collectAsState()
|
||||
val keepComposerFocusedOnSend by
|
||||
@@ -1010,7 +1077,10 @@ fun ChatScreen(
|
||||
// marker so the user knows approvals are off without opening the agent drawer.
|
||||
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
|
||||
val pendingAttachments by chatViewModel.pendingAttachments.collectAsState()
|
||||
val maxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
|
||||
val configuredMaxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
|
||||
val maxAttachmentMb = if (supervised) {
|
||||
minOf(configuredMaxAttachmentMb, supervisedPolicy.capabilities.attachmentMaxFileMb)
|
||||
} else configuredMaxAttachmentMb
|
||||
val charLimit by connectionViewModel.maxMessageLength.collectAsState()
|
||||
|
||||
// === Gateway desktop-parity state ===
|
||||
@@ -1019,6 +1089,9 @@ fun ChatScreen(
|
||||
val contextWindow by chatViewModel.contextWindow.collectAsState()
|
||||
// Injected-context audit sheet (opened by tapping the context meter).
|
||||
var showContextSheet by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(supervised) {
|
||||
if (supervised) showContextSheet = false
|
||||
}
|
||||
val steerableTurn by chatViewModel.steerableTurn.collectAsState()
|
||||
val steerNotice by chatViewModel.steerNotice.collectAsState()
|
||||
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
|
||||
@@ -2020,9 +2093,9 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val showAutocomplete by remember(filteredCommands, inputText) {
|
||||
val showAutocomplete by remember(filteredCommands, inputText, supervised) {
|
||||
derivedStateOf {
|
||||
inputText.startsWith("/") && filteredCommands.isNotEmpty()
|
||||
!supervised && inputText.startsWith("/") && filteredCommands.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2295,7 +2368,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
val selectedProfileKey = AgentDisplay.profileSessionKey(selectedProfile?.name)
|
||||
val profileShelfAvailable = ProfilePresentationPolicy.shouldShowShelf(
|
||||
val profileShelfAvailable = !supervised && ProfilePresentationPolicy.shouldShowShelf(
|
||||
profiles = agentProfiles,
|
||||
presentation = profilePresentation,
|
||||
selectedKey = selectedProfileKey,
|
||||
@@ -2322,7 +2395,7 @@ fun ChatScreen(
|
||||
// Material routes scrim taps through the drawer's gesture handler.
|
||||
// Keep it enabled so tapping outside always dismisses the drawer; the
|
||||
// voice overlay already owns input while voice mode is visible.
|
||||
gesturesEnabled = true,
|
||||
gesturesEnabled = !supervised || supervisedPolicy.capabilities.conversationHistory,
|
||||
drawerContent = {
|
||||
val drawerProfileName = explicitBindingProfileName ?: effectiveProfile?.name
|
||||
val drawerTitle = if (drawerProfileName != null) {
|
||||
@@ -2366,7 +2439,9 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
SessionDrawerContent(
|
||||
sessions = sessions,
|
||||
sessions = if (
|
||||
supervised && !supervisedPolicy.capabilities.conversationHistory
|
||||
) emptyList() else sessions,
|
||||
currentSessionId = currentSessionId,
|
||||
scopeTitle = drawerTitle,
|
||||
scopeSubtitle = drawerSubtitle,
|
||||
@@ -2377,14 +2452,19 @@ fun ChatScreen(
|
||||
animationEnabled = animationEnabled,
|
||||
autoTitlesSupported = serverAutoTitles,
|
||||
archiveSupported = sessionArchivingSupported,
|
||||
supervisedSessionActions = supervisedPolicy.capabilities.sessionActions
|
||||
.takeIf { supervised },
|
||||
newChatEnabled = !supervised || supervisedPolicy.capabilities.newChat,
|
||||
onRefresh = { chatViewModel.refreshSessions() },
|
||||
onOpenBotMode = {
|
||||
scope.launch { drawerState.close() }
|
||||
onNavigateToBotMode()
|
||||
},
|
||||
onNewChat = {
|
||||
chatViewModel.createNewChat()
|
||||
scope.launch { drawerState.close() }
|
||||
if (!supervised || supervisedPolicy.capabilities.newChat) {
|
||||
chatViewModel.createNewChat()
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
},
|
||||
onNewDefaultChat = {
|
||||
if (isProfileLocked) return@SessionDrawerContent
|
||||
@@ -2410,6 +2490,9 @@ fun ChatScreen(
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onDeleteSession = { sessionId ->
|
||||
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Delete)) {
|
||||
return@SessionDrawerContent
|
||||
}
|
||||
val connectionId = activeConnection?.id
|
||||
val profileId = explicitBindingProfileName ?: selectedProfile?.name
|
||||
chatViewModel.deleteSession(sessionId) {
|
||||
@@ -2423,10 +2506,21 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onRenameSession = { sessionId, title ->
|
||||
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Rename)) {
|
||||
return@SessionDrawerContent
|
||||
}
|
||||
chatViewModel.renameSession(sessionId, title)
|
||||
},
|
||||
onSetSessionPinned = chatViewModel::setSessionPinned,
|
||||
onSetSessionArchived = chatViewModel::setSessionArchived,
|
||||
onSetSessionPinned = { sessionId, pinned ->
|
||||
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Pin)) {
|
||||
chatViewModel.setSessionPinned(sessionId, pinned)
|
||||
}
|
||||
},
|
||||
onSetSessionArchived = { sessionId, archived ->
|
||||
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Archive)) {
|
||||
chatViewModel.setSessionArchived(sessionId, archived)
|
||||
}
|
||||
},
|
||||
onCopySessionId = { sessionId ->
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
@@ -2456,7 +2550,7 @@ fun ChatScreen(
|
||||
onToggleSourceHidden = { source, hidden ->
|
||||
connectionViewModel.setSourceHidden(source, hidden)
|
||||
},
|
||||
allProfilesSupported = !isProfileLocked &&
|
||||
allProfilesSupported = !supervised && !isProfileLocked &&
|
||||
!activeConnection?.resolvedDashboardUrl.isNullOrBlank(),
|
||||
allProfileSessions = allProfileSessions,
|
||||
allProfileSessionsLoading = allProfileSessionsLoading,
|
||||
@@ -2577,8 +2671,14 @@ fun ChatScreen(
|
||||
// Top bar — messaging app style with avatar, name, model subtitle
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
|
||||
if (!supervised || supervisedPolicy.capabilities.conversationHistory) {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
|
||||
}
|
||||
} else if (supervisedPolicy.capabilities.newChat) {
|
||||
IconButton(onClick = { chatViewModel.createNewChat() }) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = "New chat")
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
@@ -2610,8 +2710,10 @@ fun ChatScreen(
|
||||
// style subtitle status.
|
||||
var everConnected by remember { mutableStateOf(false) }
|
||||
if (headerChatReady) everConnected = true
|
||||
val showStreamingState = isStreaming &&
|
||||
(!supervised || supervisedVisibility.showWorkingStatus)
|
||||
val statusText = when {
|
||||
headerChatReady -> if (isStreaming) {
|
||||
headerChatReady -> if (showStreamingState) {
|
||||
stringResource(R.string.chat_streaming)
|
||||
} else {
|
||||
stringResource(R.string.chat_connected_label)
|
||||
@@ -2661,6 +2763,14 @@ fun ChatScreen(
|
||||
// personality label.
|
||||
val subtitleText = if (!headerChatReady) {
|
||||
statusText
|
||||
} else if (supervised) {
|
||||
buildList {
|
||||
if (supervisedVisibility.showProfileName) {
|
||||
conversationProfile?.name?.takeIf { it.isNotBlank() }?.let(::add)
|
||||
}
|
||||
if (supervisedVisibility.showModelName && !modelName.isNullOrBlank()) add(modelName)
|
||||
if (isEmpty() && supervisedVisibility.showConnectionStatus) add(statusText)
|
||||
}.joinToString(" · ")
|
||||
} else {
|
||||
resolveChatHeaderSubtitle(
|
||||
isStreaming = isStreaming,
|
||||
@@ -2679,7 +2789,7 @@ fun ChatScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
.clickable(enabled = !supervised) {
|
||||
if (profileShelfAvailable) {
|
||||
showProfileShelf = !showProfileShelf
|
||||
} else {
|
||||
@@ -2703,7 +2813,7 @@ fun ChatScreen(
|
||||
// Avatar — a plain 40dp circle whose letter swaps to the
|
||||
// active agent (profile or personality). No overlay ring:
|
||||
// the letter itself is the indicator.
|
||||
Box(modifier = Modifier.size(40.dp)) {
|
||||
if (!supervised || supervisedVisibility.showAgentIdentity) Box(modifier = Modifier.size(40.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
shape = CircleShape,
|
||||
@@ -2753,14 +2863,16 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
ConnectionStatusBadge(
|
||||
isConnected = headerChatReady,
|
||||
isConnecting = isConnecting,
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
size = 10.dp
|
||||
)
|
||||
if (!supervised || supervisedVisibility.showConnectionStatus) {
|
||||
ConnectionStatusBadge(
|
||||
isConnected = headerChatReady,
|
||||
isConnecting = isConnecting,
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
size = 10.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Name + single-line subtitle.
|
||||
@@ -2801,7 +2913,13 @@ fun ChatScreen(
|
||||
} else {
|
||||
Column {
|
||||
Text(
|
||||
text = if (agentDisplayName.isNotBlank()) agentDisplayName else stringResource(R.string.chat_agent_default),
|
||||
text = if (supervised && !supervisedVisibility.showAgentIdentity) {
|
||||
stringResource(R.string.screen_chat_label)
|
||||
} else if (agentDisplayName.isNotBlank()) {
|
||||
agentDisplayName
|
||||
} else {
|
||||
stringResource(R.string.chat_agent_default)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
@@ -2838,7 +2956,7 @@ fun ChatScreen(
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
if (isStreaming && animationEnabled) {
|
||||
if (showStreamingState && animationEnabled) {
|
||||
StreamingDots(
|
||||
color = subtitleColor,
|
||||
modifier = Modifier.clearAndSetSemantics { },
|
||||
@@ -2859,7 +2977,7 @@ fun ChatScreen(
|
||||
// full explanation (global mode / --yolo / per-session)
|
||||
// lives. Keeps the risk visible without eating subtitle
|
||||
// width on every turn.
|
||||
if (yoloEnabled == true) {
|
||||
if (!supervised && yoloEnabled == true) {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Bolt,
|
||||
contentDescription = stringResource(R.string.cd_approvals_off),
|
||||
@@ -2877,12 +2995,14 @@ fun ChatScreen(
|
||||
// tappable → Connections, so the affordance moved with the
|
||||
// info. Dropping it here declutters the actions row and frees
|
||||
// width for the title subtitle.)
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = stringResource(R.string.cd_terminal),
|
||||
onClick = onNavigateToTerminal,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
if (!supervised) {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = stringResource(R.string.cd_terminal),
|
||||
onClick = onNavigateToTerminal,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Tune,
|
||||
contentDescription = stringResource(R.string.cd_settings),
|
||||
@@ -2895,7 +3015,11 @@ fun ChatScreen(
|
||||
// Settings — which is what was squeezing the title subtitle.
|
||||
// Session identity is useful before the first message; sharing only appears
|
||||
// once the conversation has content.
|
||||
if (messages.isNotEmpty() || !currentSessionId.isNullOrBlank()) {
|
||||
if (
|
||||
(!supervised && (messages.isNotEmpty() || !currentSessionId.isNullOrBlank())) ||
|
||||
(supervised && messages.isNotEmpty() &&
|
||||
supervisedPolicy.allowsSessionAction(SupervisedSessionAction.ShareTranscript))
|
||||
) {
|
||||
var showOverflowMenu by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
RelayChromeIconButton(
|
||||
@@ -2908,7 +3032,7 @@ fun ChatScreen(
|
||||
expanded = showOverflowMenu,
|
||||
onDismissRequest = { showOverflowMenu = false },
|
||||
) {
|
||||
currentSessionId?.takeIf { it.isNotBlank() }?.let { sessionId ->
|
||||
currentSessionId?.takeIf { !supervised && it.isNotBlank() }?.let { sessionId ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(copySessionIdLabel) },
|
||||
leadingIcon = {
|
||||
@@ -2933,7 +3057,7 @@ fun ChatScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (messages.isNotEmpty()) {
|
||||
if (!supervised && messages.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_search_conversation)) },
|
||||
leadingIcon = {
|
||||
@@ -2957,6 +3081,22 @@ fun ChatScreen(
|
||||
shareConversation(context, messages)
|
||||
},
|
||||
)
|
||||
} else if (
|
||||
messages.isNotEmpty() &&
|
||||
supervisedPolicy.allowsSessionAction(
|
||||
SupervisedSessionAction.ShareTranscript,
|
||||
)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_share_conversation)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Share, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
showOverflowMenu = false
|
||||
shareConversation(context, messages)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2998,13 +3138,15 @@ fun ChatScreen(
|
||||
// and the mode strip — slim bar + `NN% · used/max` token readout,
|
||||
// color-graded by fullness. Composes to nothing until the server
|
||||
// reports a context_max for the session.
|
||||
ContextMeterBar(
|
||||
usedFraction = contextUsage,
|
||||
usedTokens = contextWindow?.usedTokens,
|
||||
maxTokens = contextWindow?.maxTokens,
|
||||
onClick = { showContextSheet = true },
|
||||
)
|
||||
if (showContextSheet) {
|
||||
if (!supervised || supervisedVisibility.showUsage) {
|
||||
ContextMeterBar(
|
||||
usedFraction = contextUsage,
|
||||
usedTokens = contextWindow?.usedTokens,
|
||||
maxTokens = contextWindow?.maxTokens,
|
||||
onClick = if (supervised) null else ({ showContextSheet = true }),
|
||||
)
|
||||
}
|
||||
if (!supervised && showContextSheet) {
|
||||
// Live audit of the exact extra context the agent will be
|
||||
// injected with on the next turn (transparency / auditability).
|
||||
InjectedContextSheet(
|
||||
@@ -3065,7 +3207,31 @@ fun ChatScreen(
|
||||
},
|
||||
label = "chatEmptyStatePhaseTransition",
|
||||
) { targetConnectState ->
|
||||
if (targetConnectState == ChatConnectState.Connecting) {
|
||||
if (supervised && targetConnectState != ChatConnectState.Ready) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (supervisedVisibility.showConnectionStatus) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (targetConnectState == ChatConnectState.Connecting) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
Text(
|
||||
text = if (targetConnectState == ChatConnectState.Connecting) {
|
||||
stringResource(R.string.chat_connecting_dots)
|
||||
} else {
|
||||
stringResource(R.string.chat_disconnected_label)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (targetConnectState == ChatConnectState.Connecting) {
|
||||
ChatColdStartLoadingState(
|
||||
animationEnabled = animationEnabled,
|
||||
streamingIntensity = streamingIntensity,
|
||||
@@ -3105,7 +3271,10 @@ fun ChatScreen(
|
||||
Spacer(modifier = Modifier.weight(0.15f))
|
||||
|
||||
// ASCII sphere (constrained to square aspect)
|
||||
if (LocalBackgroundVisualizationEnabled.current) {
|
||||
if (
|
||||
LocalBackgroundVisualizationEnabled.current &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -3133,7 +3302,10 @@ fun ChatScreen(
|
||||
// thread itself (not just the header) -
|
||||
// the desktop's intro.
|
||||
ChatConnectState.Ready ->
|
||||
if (effectiveProfile != null) {
|
||||
if (
|
||||
effectiveProfile != null &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
|
||||
} else {
|
||||
stringResource(R.string.chat_start_conversation)
|
||||
@@ -3151,7 +3323,11 @@ fun ChatScreen(
|
||||
val profileBlurb = effectiveProfile?.description
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals(agentDisplayName, ignoreCase = true) }
|
||||
if (targetConnectState == ChatConnectState.Ready && profileBlurb != null) {
|
||||
if (
|
||||
targetConnectState == ChatConnectState.Ready &&
|
||||
profileBlurb != null &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = profileBlurb,
|
||||
@@ -3268,6 +3444,7 @@ fun ChatScreen(
|
||||
// Ambient avatar behind messages
|
||||
if (
|
||||
LocalBackgroundVisualizationEnabled.current &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity) &&
|
||||
animationBehindChat &&
|
||||
!ambientMode
|
||||
) {
|
||||
@@ -3289,8 +3466,13 @@ fun ChatScreen(
|
||||
// /media/by-path route when a relay session is paired,
|
||||
// instead of degrading to the "image is on the server"
|
||||
// notice. Null when no relay (standard no-plugin) → notice.
|
||||
val relayServerImageResolver = remember(chatViewModel) {
|
||||
RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
|
||||
val relayServerImageResolver = remember(
|
||||
chatViewModel,
|
||||
supervised,
|
||||
supervisedPolicy.capabilities.generatedImages,
|
||||
) {
|
||||
if (supervised && !supervisedPolicy.capabilities.generatedImages) null
|
||||
else RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
|
||||
}
|
||||
val thinkingIndicatorConfig = remember(
|
||||
thinkingIndicatorStyle,
|
||||
@@ -3345,6 +3527,7 @@ fun ChatScreen(
|
||||
items(messages.size, key = { messages[it].uiKey }) { index ->
|
||||
val message = messages[index]
|
||||
val processNotification = message.hermesProcessNotificationOrNull()
|
||||
?.takeIf { !supervised || supervisedVisibility.showToolNames }
|
||||
|
||||
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
|
||||
// no attachments). Attachments keep the bubble alive for inbound media;
|
||||
@@ -3369,7 +3552,10 @@ fun ChatScreen(
|
||||
messages[index + 1].timestamp - message.timestamp > GROUP_GAP_MS
|
||||
|
||||
// Date separator
|
||||
if (index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp)) {
|
||||
if (
|
||||
(!supervised || supervisedVisibility.showTimestamps) &&
|
||||
(index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp))
|
||||
) {
|
||||
DateSeparator(timestamp = message.timestamp)
|
||||
}
|
||||
|
||||
@@ -3381,7 +3567,9 @@ fun ChatScreen(
|
||||
message.attachments.isNotEmpty() ||
|
||||
message.cards.isNotEmpty()
|
||||
|
||||
message.backgroundTask?.let { task ->
|
||||
message.backgroundTask
|
||||
?.takeIf { !supervised || supervisedVisibility.showWorkingStatus }
|
||||
?.let { task ->
|
||||
val taskModifier = Modifier.padding(
|
||||
top = if (isFirstInGroup) 6.dp else 2.dp,
|
||||
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
|
||||
@@ -3439,6 +3627,14 @@ fun ChatScreen(
|
||||
},
|
||||
maxBubbleWidth = maxBubbleWidth,
|
||||
showThinking = showThinking,
|
||||
showAgentIdentity = !supervised || supervisedVisibility.showAgentIdentity,
|
||||
showTimestamps = !supervised || supervisedVisibility.showTimestamps,
|
||||
showWorkingStatus = !supervised || supervisedVisibility.showWorkingStatus,
|
||||
showUsage = !supervised || supervisedVisibility.showUsage,
|
||||
showTechnicalBadges = !supervised || supervisedVisibility.showTechnicalRoute,
|
||||
showAssistantImages = !supervised || supervisedPolicy.capabilities.generatedImages,
|
||||
allowAssistantImageExport = !supervised ||
|
||||
supervisedPolicy.capabilities.shareGeneratedImages,
|
||||
isFirstInGroup = isFirstInGroup,
|
||||
isLastInGroup = isLastInGroup,
|
||||
recoveringAnswer = recoveringAnswer,
|
||||
@@ -3451,9 +3647,9 @@ fun ChatScreen(
|
||||
onAttachmentManualFetch = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onCardAction = handleCardAction,
|
||||
onCardInput = handleCardInput,
|
||||
onSessionReference = { reference ->
|
||||
onCardAction = if (supervised) ({ _, _, _ -> }) else handleCardAction,
|
||||
onCardInput = if (supervised) ({ _, _, _ -> }) else handleCardInput,
|
||||
onSessionReference = if (supervised) null else { reference ->
|
||||
val target = agentProfiles.firstOrNull {
|
||||
it.name.equals(reference.profile, ignoreCase = true)
|
||||
}
|
||||
@@ -3471,6 +3667,7 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onReact = if (
|
||||
!supervised &&
|
||||
isGatewayTransport &&
|
||||
messageReactionsSupported &&
|
||||
!message.isStreaming &&
|
||||
@@ -3484,6 +3681,7 @@ fun ChatScreen(
|
||||
null
|
||||
},
|
||||
onEditMessage = if (
|
||||
(!supervised || supervisedPolicy.capabilities.editAndResend) &&
|
||||
isGatewayTransport &&
|
||||
!isStreaming &&
|
||||
message.role == MessageRole.USER &&
|
||||
@@ -3502,10 +3700,14 @@ fun ChatScreen(
|
||||
null
|
||||
},
|
||||
animationEnabled = animationEnabled,
|
||||
onQuoteMessage = { quoted ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
quotedMessage = quoted
|
||||
},
|
||||
onQuoteMessage = if (
|
||||
!supervised || supervisedPolicy.capabilities.quoteReplies
|
||||
) {
|
||||
{ quoted ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
quotedMessage = quoted
|
||||
}
|
||||
} else null,
|
||||
onNavigateToMessage = { messageId ->
|
||||
val targetIndex = messages.indexOfFirst { it.id == messageId }
|
||||
if (targetIndex >= 0) {
|
||||
@@ -3516,7 +3718,10 @@ fun ChatScreen(
|
||||
scope.launch { listState.animateScrollToItem(targetIndex + 1) }
|
||||
}
|
||||
},
|
||||
onSpeakMessage = if (chatSpeakResponseActionsEnabled) {
|
||||
onSpeakMessage = if (
|
||||
chatSpeakResponseActionsEnabled &&
|
||||
(!supervised || supervisedPolicy.capabilities.voice)
|
||||
) {
|
||||
{ text -> voiceViewModel.speakResponse(text) }
|
||||
} else {
|
||||
null
|
||||
@@ -3527,6 +3732,9 @@ fun ChatScreen(
|
||||
null
|
||||
},
|
||||
onCopyMessage = { text ->
|
||||
if (supervised && !supervisedPolicy.capabilities.copyResponses) {
|
||||
return@MessageBubble
|
||||
}
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
// The new Clipboard API is suspend-based, so the
|
||||
// setClipEntry call has to live inside a coroutine.
|
||||
@@ -3995,7 +4203,8 @@ fun ChatScreen(
|
||||
// Gateway redirect is text-only. Attachment-bearing follow-ups must
|
||||
// retain their files in the session-owned queue instead of showing
|
||||
// a correction action that cannot carry them.
|
||||
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty()
|
||||
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty() &&
|
||||
(!supervised || supervisedPolicy.capabilities.steerResponse)
|
||||
val trailing = when {
|
||||
!isStreaming && hasContent -> ChatInputTrailing.SEND
|
||||
!isStreaming -> ChatInputTrailing.VOICE
|
||||
@@ -4128,7 +4337,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val modelControl = modelPickerOptions.takeIf { it.isNotEmpty() }?.let {
|
||||
val modelControl = modelPickerOptions.takeIf { !supervised && it.isNotEmpty() }?.let {
|
||||
ChatInputPickerControl(
|
||||
value = compactModelChipLabel(currentModelForInput, modelDefaultLabel),
|
||||
contentDescription = stringResource(R.string.cd_select_model),
|
||||
@@ -4179,6 +4388,7 @@ fun ChatScreen(
|
||||
// is definitively unreachable (SSE-only) — the agent sheet carries the
|
||||
// disabled-with-reason version there.
|
||||
val effortControl = if (
|
||||
!supervised &&
|
||||
chatGatewayAvailability != GatewayAvailability.Unreachable &&
|
||||
effortAvailability.supported != false &&
|
||||
effortPickerOptions.isNotEmpty()
|
||||
@@ -4195,7 +4405,13 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
visibleChatFailure?.let { failure ->
|
||||
val failureRouteLabel = when (failure.route) {
|
||||
val displayFailure = if (!supervised) failure else failure.copy(
|
||||
model = failure.model.takeIf { supervisedVisibility.showModelName },
|
||||
provider = failure.provider.takeIf { supervisedVisibility.showTechnicalRoute },
|
||||
)
|
||||
val failureRouteLabel = if (
|
||||
supervised && !supervisedVisibility.showTechnicalRoute
|
||||
) "" else when (failure.route) {
|
||||
ChatFailureRoute.GATEWAY ->
|
||||
stringResource(R.string.chat_failure_route_gateway)
|
||||
ChatFailureRoute.API_FALLBACK ->
|
||||
@@ -4203,23 +4419,28 @@ fun ChatScreen(
|
||||
null -> ""
|
||||
}
|
||||
ChatFailurePanel(
|
||||
failure = failure,
|
||||
failure = displayFailure,
|
||||
routeLabel = failureRouteLabel,
|
||||
onDetails = { showChatFailureDetails = true },
|
||||
onRetry = { chatViewModel.retryLastMessage() },
|
||||
onRetry = {
|
||||
if (!supervised || supervisedPolicy.capabilities.retryResponse) {
|
||||
chatViewModel.retryLastMessage()
|
||||
}
|
||||
},
|
||||
onDismiss = chatViewModel::dismissChatFailure,
|
||||
showDetails = !supervised || supervisedVisibility.showTechnicalRoute,
|
||||
)
|
||||
if (showChatFailureDetails) {
|
||||
ChatFailureDetailsDialog(
|
||||
failure = failure,
|
||||
failure = displayFailure,
|
||||
routeLabel = failureRouteLabel,
|
||||
onCopy = {
|
||||
val details = buildString {
|
||||
append(failureRouteLabel)
|
||||
failure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
failure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
displayFailure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
displayFailure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
append("\n\n")
|
||||
append(failure.rawError)
|
||||
append(displayFailure.rawError)
|
||||
}
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
@@ -4301,6 +4522,9 @@ fun ChatScreen(
|
||||
)
|
||||
},
|
||||
onStop = {
|
||||
if (supervised && !supervisedPolicy.capabilities.cancelResponse) {
|
||||
return@ChatInputBar
|
||||
}
|
||||
chatViewModel.cancelStream()
|
||||
// Firm haptic (LongPress — TextHandleMove was near-
|
||||
// imperceptible) plus a "Stopped" badge stamped on the turn
|
||||
@@ -4316,14 +4540,44 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onAttachPhotos = {
|
||||
photoPickerLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
val allowed = !supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Images in
|
||||
supervisedPolicy.capabilities.attachmentCategories &&
|
||||
pendingAttachments.size < supervisedPolicy.capabilities.attachmentMaxCount
|
||||
)
|
||||
if (allowed) {
|
||||
photoPickerLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
}
|
||||
},
|
||||
onAttachFiles = { filePickerLauncher.launch(arrayOf("*/*")) },
|
||||
onAttachCamera = requestCameraCapture,
|
||||
onPasteImage = pasteImageFromClipboard,
|
||||
onLongPressAttach = { showCommandPalette = true },
|
||||
onAttachFiles = {
|
||||
if (!supervised || supervisedPolicy.capabilities.attachments) {
|
||||
val mimeTypes = if (!supervised) arrayOf("*/*") else buildList {
|
||||
val categories = supervisedPolicy.capabilities.attachmentCategories
|
||||
if (SupervisedAttachmentCategory.Images in categories) add("image/*")
|
||||
if (SupervisedAttachmentCategory.Audio in categories) add("audio/*")
|
||||
if (SupervisedAttachmentCategory.Video in categories) add("video/*")
|
||||
if (SupervisedAttachmentCategory.Documents in categories) {
|
||||
add("text/*")
|
||||
add("application/pdf")
|
||||
}
|
||||
}.toTypedArray()
|
||||
if (mimeTypes.isNotEmpty()) filePickerLauncher.launch(mimeTypes)
|
||||
}
|
||||
},
|
||||
onAttachCamera = if (!supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Images in
|
||||
supervisedPolicy.capabilities.attachmentCategories
|
||||
)) requestCameraCapture else ({ }),
|
||||
onPasteImage = if (!supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Images in
|
||||
supervisedPolicy.capabilities.attachmentCategories
|
||||
)) pasteImageFromClipboard else ({ }),
|
||||
onLongPressAttach = { if (!supervised) showCommandPalette = true },
|
||||
charLimit = charLimit,
|
||||
caption = turnStatus ?: inputCaption,
|
||||
voiceReady = voiceReady,
|
||||
@@ -4335,8 +4589,13 @@ fun ChatScreen(
|
||||
submitEnabled = pendingAttachments.none {
|
||||
it.state == com.hermesandroid.relay.data.AttachmentState.LOADING
|
||||
},
|
||||
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS
|
||||
.takeIf { convertLargePastesToAttachments },
|
||||
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS.takeIf {
|
||||
convertLargePastesToAttachments && (!supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Documents in
|
||||
supervisedPolicy.capabilities.attachmentCategories
|
||||
))
|
||||
},
|
||||
onLargePaste = { pastedText ->
|
||||
val owner = activeComposerDraftKey ?: composerDraftKey
|
||||
val sizeBytes = pastedText.toByteArray(Charsets.UTF_8).size.toLong()
|
||||
@@ -4654,7 +4913,7 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
// Command palette bottom sheet
|
||||
if (showCommandPalette) {
|
||||
if (showCommandPalette && !supervised) {
|
||||
CommandPalette(
|
||||
commands = allCommands,
|
||||
onSelect = { cmd ->
|
||||
@@ -4682,7 +4941,7 @@ fun ChatScreen(
|
||||
// personality, connection summary). Replaces the old AlertDialog and the
|
||||
// two top-bar chips (ProfilePicker + PersonalityPicker). Tap target is
|
||||
// the title Row in the TopAppBar above.
|
||||
if (showAgentInfo) {
|
||||
if (showAgentInfo && !supervised) {
|
||||
AgentInfoSheet(
|
||||
connectionViewModel = connectionViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
|
||||
@@ -101,6 +101,7 @@ import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.ProviderUsageLandingMode
|
||||
import com.hermesandroid.relay.data.ProviderUsagePreferences
|
||||
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
@@ -157,6 +158,14 @@ fun SettingsScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
/** Header back affordance — Settings is a pushed destination, not a tab. */
|
||||
onBack: (() -> Unit)? = null,
|
||||
supervisedPolicy: SupervisedModePolicy? = null,
|
||||
parentAccessUnlocked: Boolean = false,
|
||||
/** Called only after the restricted surface completes device authentication. */
|
||||
onRequestParentAccess: () -> Unit = {},
|
||||
onUpdateSupervisedPolicy: (SupervisedModePolicy) -> Unit = {},
|
||||
onNavigateToAdvancedSettings: () -> Unit = {},
|
||||
onNavigateToSupervisedAppearance: () -> Unit = {},
|
||||
onNavigateToSupervisedControls: () -> Unit = {},
|
||||
// Needed by the Active Agent summary card at the top of the screen — it
|
||||
// reads the current personality pick so the subtitle can render
|
||||
// `connection · model · personality` without re-reading ChatViewModel
|
||||
@@ -206,6 +215,21 @@ fun SettingsScreen(
|
||||
// discoverable before a pair-and-pick happens.
|
||||
onNavigateToProfileInspector: (profileName: String) -> Unit,
|
||||
) {
|
||||
// Keep the restricted root when an enabled policy becomes temporarily
|
||||
// unusable (for example, its profile was renamed). Parent authentication,
|
||||
// not a configuration error, is what unlocks the full settings surface.
|
||||
if (supervisedPolicy?.enabled == true && !parentAccessUnlocked) {
|
||||
SupervisedSettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
policy = supervisedPolicy,
|
||||
onPolicyChange = onUpdateSupervisedPolicy,
|
||||
onBack = onBack,
|
||||
onNavigateToAppearance = onNavigateToSupervisedAppearance,
|
||||
onParentAccessGranted = onRequestParentAccess,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
@@ -432,6 +456,20 @@ fun SettingsScreen(
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (supervisedPolicy?.enabled == true && parentAccessUnlocked) {
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = "Supervised mode",
|
||||
subtitle = "On · ${supervisedPolicy.pinnedProfileName.orEmpty()}",
|
||||
badge = SettingsStatusPillModel(
|
||||
label = "On",
|
||||
tone = SettingsStatusTone.Good,
|
||||
),
|
||||
onClick = onNavigateToSupervisedControls,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Active Agent summary ───────────────────────────────────
|
||||
// Mirrors the ChatScreen TopAppBar title block (avatar + name
|
||||
// + one-line `connection · model · personality` subtitle).
|
||||
@@ -497,6 +535,7 @@ fun SettingsScreen(
|
||||
modifier = Modifier.settingsPetSurface("settings-card:profile-lock"),
|
||||
)
|
||||
|
||||
|
||||
// ── Quick Controls ─────────────────────────────────────────
|
||||
// The switches flipped most often, pinned to the top-level Settings
|
||||
// landing instead of buried in a sub-screen. Persistent connection is
|
||||
@@ -666,6 +705,24 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.settings_advanced),
|
||||
subtitle = when {
|
||||
supervisedPolicy?.isActive == true -> "On · ${supervisedPolicy.pinnedProfileName}"
|
||||
supervisedPolicy?.isConfigured == true -> "Ready · ${supervisedPolicy.pinnedProfileName}"
|
||||
else -> stringResource(R.string.settings_advanced_desc)
|
||||
},
|
||||
badge = supervisedPolicy?.takeIf { it.isActive }?.let {
|
||||
SettingsStatusPillModel(
|
||||
label = "On",
|
||||
tone = SettingsStatusTone.Good,
|
||||
)
|
||||
},
|
||||
onClick = onNavigateToAdvancedSettings,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Analytics,
|
||||
title = stringResource(R.string.settings_analytics),
|
||||
@@ -1280,12 +1337,12 @@ private fun ProfileLockOptionRow(
|
||||
}
|
||||
}
|
||||
|
||||
private data class SettingsStatusPillModel(
|
||||
internal data class SettingsStatusPillModel(
|
||||
val label: String,
|
||||
val tone: SettingsStatusTone = SettingsStatusTone.Neutral,
|
||||
)
|
||||
|
||||
private enum class SettingsStatusTone {
|
||||
internal enum class SettingsStatusTone {
|
||||
Neutral,
|
||||
Good,
|
||||
Info,
|
||||
@@ -1483,18 +1540,22 @@ private fun SettingsSectionHeader(
|
||||
* mega-SettingsScreen.
|
||||
*/
|
||||
@Composable
|
||||
private fun SettingsCategoryRow(
|
||||
internal fun SettingsCategoryRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
badge: SettingsStatusPillModel? = null,
|
||||
petPerchKey: String = title,
|
||||
petPerchKey: String? = title,
|
||||
) {
|
||||
val surfaceModifier = if (petPerchKey != null) {
|
||||
Modifier.settingsPetSurface("settings-category:$petPerchKey")
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.settingsPetSurface("settings-category:$petPerchKey")
|
||||
modifier = surfaceModifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(
|
||||
shape = appearanceRoundedCornerShape(12.dp),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,10 @@ import com.hermesandroid.relay.data.SessionActivityScope
|
||||
import com.hermesandroid.relay.data.SessionActivityUpdate
|
||||
import com.hermesandroid.relay.data.SessionLiveRuntime
|
||||
import com.hermesandroid.relay.data.SessionLiveStatus
|
||||
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.SupervisedSessionAction
|
||||
import com.hermesandroid.relay.data.allowsSessionAction
|
||||
import com.hermesandroid.relay.data.ToolCallEvent
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
@@ -336,6 +340,24 @@ internal fun voiceTurnTransportRejection(
|
||||
}
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
/**
|
||||
* Active Android-only supervision policy. RelayApp replaces this snapshot
|
||||
* whenever the active connection changes. Enforcement belongs here as well
|
||||
* as in Compose so alternate UI entry points cannot bypass the restrictions.
|
||||
*/
|
||||
@Volatile
|
||||
private var supervisedModePolicy: SupervisedModePolicy = SupervisedModePolicy()
|
||||
|
||||
fun updateSupervisedModePolicy(policy: SupervisedModePolicy) {
|
||||
supervisedModePolicy = policy
|
||||
if (policy.enabled) {
|
||||
_pendingAttachments.update { attachments ->
|
||||
attachments.filterIndexed { index, attachment ->
|
||||
isAttachmentAllowedBySupervision(attachment, index)
|
||||
}.take(policy.capabilities.attachmentMaxCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var apiClient: HermesApiClient? = null
|
||||
private var chatHandler: ChatHandler? = null
|
||||
@@ -828,7 +850,10 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun addAttachment(attachment: Attachment) {
|
||||
_pendingAttachments.update { it + attachment }
|
||||
_pendingAttachments.update { current ->
|
||||
if (!isAttachmentAllowedBySupervision(attachment, current.size)) current
|
||||
else current + attachment
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAttachment(index: Int) {
|
||||
@@ -838,11 +863,20 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun replacePendingAttachments(attachments: List<Attachment>) {
|
||||
_pendingAttachments.value = attachments.toList()
|
||||
val policy = supervisedModePolicy
|
||||
_pendingAttachments.value = if (!policy.enabled) {
|
||||
attachments.toList()
|
||||
} else {
|
||||
attachments.filter { isAttachmentAllowedBySupervision(it, 0) }
|
||||
.take(policy.capabilities.attachmentMaxCount)
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceAttachment(composerId: String, attachment: Attachment) {
|
||||
_pendingAttachments.update { attachments ->
|
||||
if (!isAttachmentAllowedBySupervision(attachment, (attachments.size - 1).coerceAtLeast(0))) {
|
||||
return@update attachments.filterNot { it.composerId == composerId }
|
||||
}
|
||||
var replaced = false
|
||||
val updated = attachments.map { current ->
|
||||
if (current.composerId == composerId) {
|
||||
@@ -872,6 +906,23 @@ class ChatViewModel : ViewModel() {
|
||||
_pendingAttachments.value = emptyList()
|
||||
}
|
||||
|
||||
private fun isAttachmentAllowedBySupervision(attachment: Attachment, existingCount: Int): Boolean {
|
||||
val policy = supervisedModePolicy
|
||||
if (!policy.enabled) return true
|
||||
val capabilities = policy.capabilities
|
||||
if (!policy.isActive || !capabilities.attachments) return false
|
||||
if (existingCount >= capabilities.attachmentMaxCount) return false
|
||||
val maxBytes = capabilities.attachmentMaxFileMb.toLong() * 1024L * 1024L
|
||||
if ((attachment.fileSize ?: 0L) > maxBytes) return false
|
||||
val category = when {
|
||||
attachment.contentType.startsWith("image/") -> SupervisedAttachmentCategory.Images
|
||||
attachment.contentType.startsWith("audio/") -> SupervisedAttachmentCategory.Audio
|
||||
attachment.contentType.startsWith("video/") -> SupervisedAttachmentCategory.Video
|
||||
else -> SupervisedAttachmentCategory.Documents
|
||||
}
|
||||
return category in capabilities.attachmentCategories
|
||||
}
|
||||
|
||||
// Server-side personality selection
|
||||
private val _selectedPersonality = MutableStateFlow("default")
|
||||
val selectedPersonality: StateFlow<String> = _selectedPersonality.asStateFlow()
|
||||
@@ -4624,6 +4675,7 @@ class ChatViewModel : ViewModel() {
|
||||
onReady: ((String?) -> Unit)? = null,
|
||||
onFailure: (() -> Unit)? = null,
|
||||
) {
|
||||
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.newChat) return
|
||||
val handler = chatHandler ?: return
|
||||
recordPreResetEvidence(handler, "new_chat")
|
||||
clearOpenedSessionOwner()
|
||||
@@ -4956,6 +5008,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun deleteSession(sessionId: String, onDeleted: () -> Unit = {}) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Delete)) return
|
||||
val handler = chatHandler ?: return
|
||||
val client = apiClient
|
||||
if (streamingEndpoint != "gateway" && client == null) return
|
||||
@@ -5020,6 +5073,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun renameSession(sessionId: String, newTitle: String) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Rename)) return
|
||||
val handler = chatHandler ?: return
|
||||
val client = apiClient
|
||||
if (streamingEndpoint != "gateway" && client == null) return
|
||||
@@ -5064,6 +5118,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun setSessionPinned(sessionId: String, pinned: Boolean) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Pin)) return
|
||||
val expectedContextKey = activeProfileContextKey
|
||||
val profileName = currentSessionProfileName()
|
||||
mutateSessionFlag(
|
||||
@@ -5082,6 +5137,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun setSessionArchived(sessionId: String, archived: Boolean) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Archive)) return
|
||||
if (!_sessionArchivingSupported.value) {
|
||||
emitError(
|
||||
UnsupportedOperationException("Archive and restore require Dashboard sessions"),
|
||||
@@ -5150,6 +5206,22 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
if (text.isBlank()) return
|
||||
supervisedMessageBlockReason(supervisedModePolicy, text)?.let { reason ->
|
||||
chatHandler?.addSystemNotice(reason)
|
||||
return
|
||||
}
|
||||
if (supervisedModePolicy.enabled) {
|
||||
val attachments = _pendingAttachments.value
|
||||
if (attachments.any { attachment ->
|
||||
!isAttachmentAllowedBySupervision(attachment, attachments.indexOf(attachment))
|
||||
}
|
||||
) {
|
||||
chatHandler?.addSystemNotice(
|
||||
"One or more attachments are unavailable under the supervised policy.",
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
recordRecentPrompt(text)
|
||||
|
||||
// Demo / Explore mode: there is no server, but a silently dead Send
|
||||
@@ -5418,6 +5490,10 @@ class ChatViewModel : ViewModel() {
|
||||
action: com.hermesandroid.relay.data.HermesCardAction,
|
||||
) {
|
||||
val handler = chatHandler ?: return
|
||||
if (supervisedModePolicy.enabled) {
|
||||
handler.addSystemNotice("This action is unavailable in supervised mode.")
|
||||
return
|
||||
}
|
||||
// Ask answers route straight to the gateway respond RPCs —
|
||||
// answerAsk records its own (sanitized) dispatch stamp, so don't
|
||||
// double-stamp here.
|
||||
@@ -5456,6 +5532,10 @@ class ChatViewModel : ViewModel() {
|
||||
ask: GatewayAsk,
|
||||
restored: ChatTurnAskCheckpoint? = null,
|
||||
) {
|
||||
if (supervisedModePolicy.enabled) {
|
||||
denySupervisedInteraction(handler, ask)
|
||||
return
|
||||
}
|
||||
val sessionId = handler.currentSessionId.value
|
||||
val contextKey = activeProfileContextKey
|
||||
val existing = _pendingAsk.value
|
||||
@@ -5584,6 +5664,33 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId?.let { maybeNotifyInteraction(it, ask) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervised Chat never exposes approval, clarification, sudo, or secret
|
||||
* inputs. Settle the upstream interaction immediately with its safest
|
||||
* negative/empty response; if that cannot be confirmed, interrupt the turn
|
||||
* so a hidden card cannot leave the session waiting indefinitely.
|
||||
*/
|
||||
private fun denySupervisedInteraction(handler: ChatHandler, ask: GatewayAsk) {
|
||||
val gateway = gatewayClient
|
||||
if (gateway == null) {
|
||||
handler.addSystemNotice("An interactive request was blocked by supervised mode.")
|
||||
cancelStream()
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val response: Result<GatewayAskResponse>? = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> gateway.respondApproval(choice = "deny")
|
||||
GatewayAsk.Kind.CLARIFY -> ask.requestId?.let {
|
||||
gateway.respondClarify(it, "This supervised client cannot answer interactive requests.")
|
||||
}
|
||||
GatewayAsk.Kind.SUDO -> ask.requestId?.let { gateway.respondSudo(it, "") }
|
||||
GatewayAsk.Kind.SECRET -> ask.requestId?.let { gateway.respondSecret(it, "") }
|
||||
}
|
||||
handler.addSystemNotice("An interactive request was denied by supervised mode.")
|
||||
if (response == null || response.isFailure) cancelStream()
|
||||
}
|
||||
}
|
||||
|
||||
/** Render only upstream-supported approval values; old servers retain Approve/Deny. */
|
||||
private fun approvalActions(ask: GatewayAsk): List<HermesCardAction> {
|
||||
val advertised = ask.choices.orEmpty()
|
||||
@@ -9404,6 +9511,9 @@ class ChatViewModel : ViewModel() {
|
||||
* so we shouldn't see duplicate calls here.
|
||||
*/
|
||||
fun onMediaAttachmentRequested(messageId: String, token: String) {
|
||||
if (supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return
|
||||
val handler = chatHandler ?: return
|
||||
val relay = relayHttpClient
|
||||
val repo = mediaSettingsRepo
|
||||
@@ -9461,6 +9571,9 @@ class ChatViewModel : ViewModel() {
|
||||
* token and uses [RelayHttpClient.fetchMedia].
|
||||
*/
|
||||
fun manualFetchAttachment(messageId: String, attachmentIndex: Int) {
|
||||
if (supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return
|
||||
val handler = chatHandler ?: return
|
||||
val relay = relayHttpClient ?: return
|
||||
val repo = mediaSettingsRepo ?: return
|
||||
@@ -9534,6 +9647,9 @@ class ChatViewModel : ViewModel() {
|
||||
* it into the markdown-image renderer, which previously ignored the relay.
|
||||
*/
|
||||
suspend fun resolveServerImage(serverPath: String): ServerImageResult {
|
||||
if (supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return ServerImageResult.Failure("Generated images are disabled in supervised mode")
|
||||
val relay = relayHttpClient
|
||||
?: return ServerImageResult.Failure("Relay not configured on this connection")
|
||||
// fetchMediaByPath returns Result<MediaBytes>; fold it ONCE, right here,
|
||||
@@ -9576,6 +9692,11 @@ class ChatViewModel : ViewModel() {
|
||||
expectedRole: MessageRole,
|
||||
unavailableMessage: String,
|
||||
) {
|
||||
if (
|
||||
expectedRole == MessageRole.ASSISTANT &&
|
||||
supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return
|
||||
val handler = chatHandler ?: return
|
||||
val relay = relayHttpClient
|
||||
val repo = mediaSettingsRepo
|
||||
|
||||
@@ -2749,6 +2749,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
private fun installAuthManager(am: AuthManager) {
|
||||
am.setActiveEndpointProvider { connectionManager.activeRelayEndpoint.value }
|
||||
am.setSupervisedMetadataReconnectFallback {
|
||||
connectionManager.reconnectForAuthenticatedMetadataUpdate()
|
||||
}
|
||||
authManager = am
|
||||
// Push into the flow so the flatMapLatest chains on authState /
|
||||
// pairingCode / currentPairedSession repoint to the new manager.
|
||||
@@ -3591,6 +3594,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// ConnectionStore's EncryptedSharedPrefs.
|
||||
profileController.profileSelectionStore.clear(connectionId)
|
||||
profileController.profileLockStore.clear(connectionId)
|
||||
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
|
||||
.clear(connectionId)
|
||||
profileController.profilePresentationStore.clear(connectionId)
|
||||
profileController.profileSessionStore.clearConnection(connectionId)
|
||||
profileController.profileDisplayAliasStore.clearConnection(connectionId)
|
||||
@@ -3630,6 +3635,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
|
||||
init {
|
||||
authManager.setSupervisedMetadataReconnectFallback {
|
||||
connectionManager.reconnectForAuthenticatedMetadataUpdate()
|
||||
}
|
||||
// Wire multiplexer to connection manager (for relay/bridge/terminal)
|
||||
multiplexer.setSendCallback { envelope ->
|
||||
connectionManager.send(envelope)
|
||||
@@ -4096,6 +4104,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
connectionStore.removeConnection(duplicate.id)
|
||||
profileController.profileSelectionStore.clear(duplicate.id)
|
||||
profileController.profileLockStore.clear(duplicate.id)
|
||||
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
|
||||
.clear(duplicate.id)
|
||||
profileController.profilePresentationStore.clear(duplicate.id)
|
||||
profileController.profileSessionStore.clearConnection(duplicate.id)
|
||||
}
|
||||
@@ -7190,6 +7200,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
check(dataManager.resetAppData()) { "App data store reset failed" }
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
|
||||
.clearAll()
|
||||
profileController.profilePresentationStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
|
||||
/**
|
||||
* Fail-closed dispatch policy for Android Supervised Mode.
|
||||
*
|
||||
* This intentionally runs before demo handling, route selection, slash.exec,
|
||||
* command.dispatch, steering, and queueing. Kotlin's default trim recognizes
|
||||
* Unicode whitespace, preventing an indented slash command from bypassing the
|
||||
* client restriction.
|
||||
*/
|
||||
internal fun supervisedMessageBlockReason(
|
||||
policy: SupervisedModePolicy,
|
||||
text: String,
|
||||
): String? {
|
||||
if (!policy.enabled) return null
|
||||
if (!policy.isConfigured) {
|
||||
return "Supervised mode is unavailable until the parent selects a profile."
|
||||
}
|
||||
if (text.trimStart().startsWith('/')) {
|
||||
return "Slash commands are unavailable in supervised mode."
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import com.hermesandroid.relay.data.DEFAULT_VOICE_STOP_PHRASES
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
@@ -279,6 +281,23 @@ internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
|
||||
*/
|
||||
enum class InteractionMode { TapToTalk, HoldToTalk, Continuous }
|
||||
|
||||
internal fun isVoiceCommandAllowed(
|
||||
action: VoiceCommandAction,
|
||||
policy: SupervisedModePolicy,
|
||||
): Boolean {
|
||||
if (!policy.enabled) return true
|
||||
val capabilities: SupervisedCapabilities = policy.capabilities
|
||||
return when (action) {
|
||||
VoiceCommandAction.StartNewChat -> capabilities.newChat
|
||||
VoiceCommandAction.StopResponse,
|
||||
VoiceCommandAction.CancelBackgroundTask -> capabilities.cancelResponse
|
||||
VoiceCommandAction.EndVoiceChat,
|
||||
VoiceCommandAction.PauseContinuousListening,
|
||||
VoiceCommandAction.ResumeContinuousListening,
|
||||
VoiceCommandAction.RepeatBackgroundAnswer -> capabilities.voice
|
||||
}
|
||||
}
|
||||
|
||||
internal fun InteractionMode.storageValue(): String = when (this) {
|
||||
InteractionMode.TapToTalk -> "tap"
|
||||
InteractionMode.HoldToTalk -> "hold"
|
||||
@@ -723,6 +742,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var voicePreferences: VoicePreferencesRepository? = null
|
||||
private var voicePreferencesJob: Job? = null
|
||||
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
|
||||
private var supervisedModePolicy: SupervisedModePolicy = SupervisedModePolicy()
|
||||
private var voiceStopPhrases: List<String> = DEFAULT_VOICE_STOP_PHRASES
|
||||
private var finalAnswerOnly: Boolean = false
|
||||
private var realtimeTraceDetails: Boolean = false
|
||||
@@ -1380,6 +1400,28 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the active Android client policy at the voice coordinator boundary. */
|
||||
fun updateSupervisedModePolicy(policy: SupervisedModePolicy) {
|
||||
supervisedModePolicy = policy
|
||||
val supervised = policy.enabled
|
||||
voiceAudioClient?.setRouteOverride(if (supervised) VoiceAudioRoute.Standard else null)
|
||||
if (supervised) {
|
||||
if (voiceEngineMode == VoiceEngineMode.RealtimeAgent) closeRealtimeSession()
|
||||
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput
|
||||
_voiceStats.update {
|
||||
it.copy(
|
||||
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput.storageValue,
|
||||
)
|
||||
}
|
||||
if (!policy.capabilities.voice && _uiState.value.voiceMode) exitVoiceMode()
|
||||
} else {
|
||||
val prefs = voicePreferences ?: return
|
||||
viewModelScope.launch {
|
||||
prefs.settings.firstOrNull()?.let { applyVoiceSettingsSnapshot(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistInteractionMode(mode: InteractionMode) {
|
||||
val prefs = voicePreferences ?: return
|
||||
viewModelScope.launch {
|
||||
@@ -1485,7 +1527,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
) {
|
||||
closeRealtimeSession()
|
||||
}
|
||||
voiceEngineMode = nextEngineMode
|
||||
voiceEngineMode = if (supervisedModePolicy.enabled) {
|
||||
VoiceEngineMode.HermesVoiceOutput
|
||||
} else {
|
||||
nextEngineMode
|
||||
}
|
||||
voiceStopPhrases = settings.stopPhrases
|
||||
finalAnswerOnly = settings.finalAnswerOnly
|
||||
realtimeTraceDetails = settings.realtimeTraceDetails
|
||||
@@ -1506,7 +1552,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
it.copy(
|
||||
vadThresholdMs = settings.silenceThresholdMs,
|
||||
interactionMode = settings.interactionMode,
|
||||
voiceEngineMode = settings.engineMode,
|
||||
voiceEngineMode = voiceEngineMode.storageValue,
|
||||
realtimeModel = settings.realtimeModel,
|
||||
realtimeVoice = settings.realtimeVoice,
|
||||
)
|
||||
@@ -1540,6 +1586,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
activationId: String? = null,
|
||||
expectScreenContext: Boolean = false,
|
||||
) {
|
||||
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.voice) return
|
||||
val freshEntry = !_uiState.value.voiceMode
|
||||
val orphanedRun = _uiState.value
|
||||
.takeIf { freshEntry }
|
||||
@@ -2430,6 +2477,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (!canSpeakSettledResponse(state, providerRealtimeAgentTurnActive.get())) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
supervisedModePolicy.enabled &&
|
||||
voiceAudioClient?.effectiveRoute != VoiceAudioRoute.Standard
|
||||
) return false
|
||||
|
||||
val spoken = sanitizeForTts(text)
|
||||
if (spoken.isBlank()) return false
|
||||
@@ -3007,6 +3058,17 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isVoiceCommandAllowed(action, supervisedModePolicy)) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Idle,
|
||||
outputAudioActive = false,
|
||||
responseText = "That voice action is disabled by Parent controls.",
|
||||
)
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
Log.i(TAG, "Hands-free voice command action=$action source=${if (fromRealtime) "realtime" else "stt"}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
@@ -3076,12 +3138,23 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
setError("Voice pipeline not initialized")
|
||||
return
|
||||
}
|
||||
if (
|
||||
supervisedModePolicy.enabled &&
|
||||
audioClient.effectiveRoute != VoiceAudioRoute.Standard
|
||||
) {
|
||||
setError("Supervised voice requires the Standard Hermes voice route")
|
||||
return
|
||||
}
|
||||
currentTurnPcm = inputPcm
|
||||
currentTurnPcmSampleRate = inputSampleRate
|
||||
resetBrokeredToolSpeechState()
|
||||
resetRealtimeSpeechCoalescer()
|
||||
resetTtsTurnStats()
|
||||
val engineModeForTurn = voiceEngineMode
|
||||
val engineModeForTurn = if (supervisedModePolicy.enabled) {
|
||||
VoiceEngineMode.HermesVoiceOutput
|
||||
} else {
|
||||
voiceEngineMode
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"Processing voice input engine=${engineModeForTurn.storageValue} " +
|
||||
@@ -3230,7 +3303,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// ever mis-edited. If/when a `BuildFlavor.bridgeTier3` compile-
|
||||
// time constant exists we should still short-circuit here for
|
||||
// clarity, but today the factory already does the right thing.
|
||||
val bridgeHandler = voiceBridgeIntentHandler
|
||||
val bridgeHandler = voiceBridgeIntentHandler.takeUnless { supervisedModePolicy.enabled }
|
||||
|
||||
// === PHASE3-voice-cancel-midcountdown ===
|
||||
// Voice-in-voice cancel: if a destructive action is currently
|
||||
@@ -4906,7 +4979,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
private fun shouldPreferRealtimeVoice(): Boolean =
|
||||
voiceOutputAvailable != false &&
|
||||
!supervisedModePolicy.enabled &&
|
||||
voiceOutputAvailable != false &&
|
||||
realtimePcmPlayer != null &&
|
||||
voiceClient != null &&
|
||||
// Use the RESOLVED route: AutoVoiceAudioClient.effectiveRoute maps
|
||||
|
||||
@@ -657,6 +657,14 @@
|
||||
<string name="settings_analytics_desc">Estatísticas de uso, TTFT, tokens e integridade</string>
|
||||
<string name="settings_diagnostics">Diagnóstico</string>
|
||||
<string name="settings_diagnostics_desc">Verificações de status e atividade recente da API, do relay, da sessão e da voz</string>
|
||||
<string name="settings_advanced">Avançado</string>
|
||||
<string name="settings_advanced_desc">Modo supervisionado e outros recursos opcionais</string>
|
||||
<string name="settings_advanced_intro">Recursos opcionais e especializados ficam aqui para manter a tela principal de Configurações organizada.</string>
|
||||
<string name="settings_supervised_mode">Modo supervisionado</string>
|
||||
<string name="settings_supervised_desc">Escolha um perfil e os recursos de chat permitidos</string>
|
||||
<string name="settings_supervised_on">Ativado</string>
|
||||
<string name="settings_supervised_on_profile">Ativado · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Pronto · %1$s</string>
|
||||
<string name="settings_developer_options">Opções do desenvolvedor</string>
|
||||
<string name="settings_developer_options_desc">Flags de recursos, gerenciamento de dados e opções experimentais</string>
|
||||
<string name="settings_whats_new">Novidades</string>
|
||||
|
||||
@@ -698,6 +698,14 @@
|
||||
<string name="settings_analytics_desc">使用统计、TTFT、token、健康状态</string>
|
||||
<string name="settings_diagnostics">诊断</string>
|
||||
<string name="settings_diagnostics_desc">状态检查,以及最近的 API、Relay、会话和语音活动</string>
|
||||
<string name="settings_advanced">高级</string>
|
||||
<string name="settings_advanced_desc">受监督模式和其他可选功能</string>
|
||||
<string name="settings_advanced_intro">可选和专用功能集中在此,以保持主设置界面简洁。</string>
|
||||
<string name="settings_supervised_mode">受监督模式</string>
|
||||
<string name="settings_supervised_desc">选择配置文件和允许的聊天功能</string>
|
||||
<string name="settings_supervised_on">已开启</string>
|
||||
<string name="settings_supervised_on_profile">已开启 · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">已就绪 · %1$s</string>
|
||||
<string name="settings_developer_options">开发者选项</string>
|
||||
<string name="settings_developer_options_desc">功能标志、数据管理、实验性</string>
|
||||
<string name="settings_whats_new">新功能</string>
|
||||
|
||||
@@ -698,6 +698,14 @@
|
||||
<string name="settings_analytics_desc">Nutzungsstatistiken, TTFT, Token, Status</string>
|
||||
<string name="settings_diagnostics">Diagnose</string>
|
||||
<string name="settings_diagnostics_desc">Statusprüfungen sowie letzte API-, Relay-, Sitzungs- und Sprachaktivitäten</string>
|
||||
<string name="settings_advanced">Erweitert</string>
|
||||
<string name="settings_advanced_desc">Beaufsichtigter Modus und weitere optionale Funktionen</string>
|
||||
<string name="settings_advanced_intro">Optionale und spezielle Funktionen befinden sich hier, damit die Haupteinstellungen übersichtlich bleiben.</string>
|
||||
<string name="settings_supervised_mode">Beaufsichtigter Modus</string>
|
||||
<string name="settings_supervised_desc">Profil und erlaubte Chatfunktionen auswählen</string>
|
||||
<string name="settings_supervised_on">Ein</string>
|
||||
<string name="settings_supervised_on_profile">Ein · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Bereit · %1$s</string>
|
||||
<string name="settings_developer_options">Entwickleroptionen</string>
|
||||
<string name="settings_developer_options_desc">Funktionsschalter, Datenverwaltung, Experimente</string>
|
||||
<string name="settings_whats_new">Neuigkeiten</string>
|
||||
|
||||
@@ -625,6 +625,14 @@
|
||||
<string name="settings_analytics_desc">Estadísticas de uso, TTFT, tokens, salud</string>
|
||||
<string name="settings_diagnostics">Diagnóstico</string>
|
||||
<string name="settings_diagnostics_desc">Verificaciones de estado, además de actividad reciente de API, relay, sesión y voz</string>
|
||||
<string name="settings_advanced">Avanzado</string>
|
||||
<string name="settings_advanced_desc">Modo supervisado y otras funciones opcionales</string>
|
||||
<string name="settings_advanced_intro">Las funciones opcionales y especializadas están aquí para mantener despejada la pantalla principal de Ajustes.</string>
|
||||
<string name="settings_supervised_mode">Modo supervisado</string>
|
||||
<string name="settings_supervised_desc">Elige un perfil y las funciones de chat permitidas</string>
|
||||
<string name="settings_supervised_on">Activado</string>
|
||||
<string name="settings_supervised_on_profile">Activado · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Listo · %1$s</string>
|
||||
<string name="settings_developer_options">Opciones de desarrollador</string>
|
||||
<string name="settings_developer_options_desc">Indicadores de funciones, gestión de datos, experimental.</string>
|
||||
<string name="settings_whats_new">Novedades</string>
|
||||
|
||||
@@ -698,6 +698,14 @@
|
||||
<string name="settings_analytics_desc">使用状況統計、TTFT、トークン、ヘルス</string>
|
||||
<string name="settings_diagnostics">診断</string>
|
||||
<string name="settings_diagnostics_desc">ステータス チェック、および最近の API、Relay、セッション、および音声アクティビティ</string>
|
||||
<string name="settings_advanced">詳細設定</string>
|
||||
<string name="settings_advanced_desc">監督モードとその他のオプション機能</string>
|
||||
<string name="settings_advanced_intro">メインの設定画面をシンプルに保つため、オプション機能と専門機能はここにまとめられています。</string>
|
||||
<string name="settings_supervised_mode">監督モード</string>
|
||||
<string name="settings_supervised_desc">プロファイルと許可するチャット機能を選択</string>
|
||||
<string name="settings_supervised_on">オン</string>
|
||||
<string name="settings_supervised_on_profile">オン · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">準備完了 · %1$s</string>
|
||||
<string name="settings_developer_options">開発者向けオプション</string>
|
||||
<string name="settings_developer_options_desc">機能フラグ、データ管理、実験的</string>
|
||||
<string name="settings_whats_new">新着情報</string>
|
||||
|
||||
@@ -668,6 +668,14 @@
|
||||
<string name="settings_analytics_desc">Статистика использования, TTFT, токены, состояние</string>
|
||||
<string name="settings_diagnostics">Диагностика</string>
|
||||
<string name="settings_diagnostics_desc">Проверка состояния, а также недавняя активность API, Relay, сессий и голосовых данных</string>
|
||||
<string name="settings_advanced">Дополнительно</string>
|
||||
<string name="settings_advanced_desc">Режим с контролем и другие дополнительные функции</string>
|
||||
<string name="settings_advanced_intro">Дополнительные и специальные функции собраны здесь, чтобы не перегружать главный экран настроек.</string>
|
||||
<string name="settings_supervised_mode">Режим с контролем</string>
|
||||
<string name="settings_supervised_desc">Выберите профиль и разрешённые функции чата</string>
|
||||
<string name="settings_supervised_on">Вкл.</string>
|
||||
<string name="settings_supervised_on_profile">Вкл. · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Готово · %1$s</string>
|
||||
<string name="settings_developer_options">Настройки разработчика</string>
|
||||
<string name="settings_developer_options_desc">Флаги функций, управление данными, экспериментальные</string>
|
||||
<string name="settings_whats_new">Что нового</string>
|
||||
|
||||
@@ -736,6 +736,14 @@
|
||||
<string name="settings_analytics_desc">Usage stats, TTFT, tokens, health</string>
|
||||
<string name="settings_diagnostics">Diagnostics</string>
|
||||
<string name="settings_diagnostics_desc">Status checks, plus recent API, relay, session, and voice activity</string>
|
||||
<string name="settings_advanced">Advanced</string>
|
||||
<string name="settings_advanced_desc">Supervised mode and other optional features</string>
|
||||
<string name="settings_advanced_intro">Optional and specialized features live here to keep the main Settings screen focused.</string>
|
||||
<string name="settings_supervised_mode">Supervised mode</string>
|
||||
<string name="settings_supervised_desc">Choose a profile and approved chat features</string>
|
||||
<string name="settings_supervised_on">On</string>
|
||||
<string name="settings_supervised_on_profile">On · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Ready · %1$s</string>
|
||||
<string name="settings_developer_options">Developer options</string>
|
||||
<string name="settings_developer_options_desc">Feature flags, data management, experimental</string>
|
||||
<string name="settings_whats_new">What\'s New</string>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.hermesandroid.relay.auth
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedModeAuthPayloadTest {
|
||||
@Test fun `active policy reports only public capability ids`() {
|
||||
val payload = relaySupervisedModePayload(
|
||||
SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(attachments = true, voice = true),
|
||||
),
|
||||
)
|
||||
assertTrue(payload.getValue("active").jsonPrimitive.boolean)
|
||||
assertEquals("willow", payload.getValue("profile_label").jsonPrimitive.content)
|
||||
val capabilities = payload.getValue("capabilities").jsonArray.map { it.jsonPrimitive.content }
|
||||
assertTrue("text_chat" in capabilities)
|
||||
assertTrue("attachments" in capabilities)
|
||||
assertTrue("voice" in capabilities)
|
||||
assertFalse(capabilities.any { it.contains("model") || it.contains("tool") })
|
||||
}
|
||||
|
||||
@Test fun `inactive update explicitly clears Relay tag`() {
|
||||
val payload = relaySupervisedModePayload(SupervisedModePolicy())
|
||||
assertFalse(payload.getValue("active").jsonPrimitive.boolean)
|
||||
assertEquals(setOf("active"), payload.keys)
|
||||
}
|
||||
|
||||
@Test fun `live update uses typed correlated system envelope`() {
|
||||
val envelope = relaySupervisedModeUpdateEnvelope(
|
||||
SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(voice = true),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("system", envelope.channel)
|
||||
assertEquals("supervised.update", envelope.type)
|
||||
assertTrue(envelope.id.isNotBlank())
|
||||
val mode = envelope.payload.getValue("supervised_mode")
|
||||
.jsonObject
|
||||
assertTrue(mode.getValue("active").jsonPrimitive.boolean)
|
||||
assertEquals("willow", mode.getValue("profile_label").jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,19 @@ class SessionActivityRegistryTest {
|
||||
private val scope = SessionActivityScope.of("connection-a", "default")
|
||||
|
||||
@Test
|
||||
fun `directory owner is checking until status is unavailable or confirms idle`() {
|
||||
fun `directory owner stays neutral until status confirms live activity`() {
|
||||
val checking = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1),
|
||||
)
|
||||
assertEquals(SessionActivityPhase.Idle, checking.record(owner)?.phase())
|
||||
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
|
||||
assertEquals(SessionActivityFreshness.Revalidating, checking.record(owner)?.freshness)
|
||||
assertNull(checking.record(owner)?.presentationState())
|
||||
|
||||
val unavailable = checking.reduce(
|
||||
SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2),
|
||||
)
|
||||
assertEquals(SessionActivityState.Unavailable, unavailable.record(owner)?.presentationState())
|
||||
assertEquals(SessionActivityFreshness.Unavailable, unavailable.record(owner)?.freshness)
|
||||
assertNull(unavailable.record(owner)?.presentationState())
|
||||
|
||||
val confirmedIdle = checking.reduce(activeList(scope, generation = 1))
|
||||
assertEquals(SessionActivityPhase.Idle, confirmedIdle.record(owner)?.phase())
|
||||
@@ -28,6 +30,17 @@ class SessionActivityRegistryTest {
|
||||
assertNull(confirmedIdle.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `directory refresh cannot restore checking after active status is unavailable`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1))
|
||||
.reduce(SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2))
|
||||
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 3))
|
||||
|
||||
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `directory observation cannot downgrade confirmed live evidence`() {
|
||||
val state = SessionActivityRegistry()
|
||||
@@ -184,7 +197,7 @@ class SessionActivityRegistryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed or unsupported status refresh is unavailable rather than idle`() {
|
||||
fun `failed or unsupported status refresh preserves evidence but presents a neutral row`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(
|
||||
SessionActivityUpdate.LiveState(
|
||||
@@ -206,7 +219,7 @@ class SessionActivityRegistryTest {
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
|
||||
assertEquals(SessionActivityState.Unavailable, state.record(owner)?.presentationState())
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -226,7 +239,7 @@ class SessionActivityRegistryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `presentation keeps starting background and revalidation distinct from working`() {
|
||||
fun `presentation keeps starting and background distinct while revalidation stays neutral`() {
|
||||
val starting = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.LocalSend(owner, generation = 1, observedAtMillis = 1),
|
||||
)
|
||||
@@ -240,7 +253,7 @@ class SessionActivityRegistryTest {
|
||||
val checking = starting.reduce(
|
||||
SessionActivityUpdate.BeginGeneration(scope, generation = 2, observedAtMillis = 2),
|
||||
)
|
||||
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
|
||||
assertNull(checking.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -287,7 +300,7 @@ class SessionActivityRegistryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restored needs-input checkpoint stays checking until live confirmation`() {
|
||||
fun `restored needs-input checkpoint stays neutral until live confirmation`() {
|
||||
val state = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
@@ -298,7 +311,7 @@ class SessionActivityRegistryTest {
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -323,7 +336,7 @@ class SessionActivityRegistryTest {
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
|
||||
state = state.reduce(
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.mutablePreferencesOf
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedModeStoreTest {
|
||||
|
||||
@Test
|
||||
fun freshConnectionUsesRestrictiveDefaults() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
|
||||
val policy = store.policyFlow("connection-a").first()
|
||||
|
||||
assertFalse(policy.enabled)
|
||||
assertFalse(policy.isConfigured)
|
||||
assertFalse(policy.isActive)
|
||||
assertFalse(policy.capabilities.attachments)
|
||||
assertFalse(policy.capabilities.voice)
|
||||
assertFalse(policy.visibility.resolved().showModelName)
|
||||
assertFalse(policy.visibility.resolved().showTechnicalRoute)
|
||||
assertTrue(policy.parentAccess.requireDeviceAuthentication)
|
||||
assertEquals(5, policy.parentAccess.timeoutMinutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun policyRoundTripsWithCapabilitiesLimitsAndVisibility() = runTest {
|
||||
val dataStore = InMemorySupervisedPreferencesDataStore()
|
||||
val store = SupervisedModeStore.forTesting(dataStore)
|
||||
val saved = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = " willow ",
|
||||
capabilities = SupervisedCapabilities(
|
||||
attachments = true,
|
||||
voice = true,
|
||||
attachmentMaxCount = 6,
|
||||
attachmentMaxFileMb = 20,
|
||||
attachmentCategories = setOf(
|
||||
SupervisedAttachmentCategory.Images,
|
||||
SupervisedAttachmentCategory.Documents,
|
||||
),
|
||||
sessionActions = SupervisedSessionActions(
|
||||
pin = true,
|
||||
rename = true,
|
||||
shareTranscript = true,
|
||||
),
|
||||
),
|
||||
appearance = SupervisedAppearance(
|
||||
appThemeId = "rose",
|
||||
themePreference = "dark",
|
||||
showPet = true,
|
||||
allowProfileIconChanges = true,
|
||||
allowBackgroundChanges = true,
|
||||
),
|
||||
visibility = SupervisedVisibility(
|
||||
preset = SupervisedVisibilityPreset.Custom,
|
||||
showAgentIdentity = true,
|
||||
showModelName = true,
|
||||
showToolNames = true,
|
||||
),
|
||||
)
|
||||
|
||||
store.setPolicy("connection-a", saved)
|
||||
val restored = SupervisedModeStore.forTesting(dataStore).policyFlow("connection-a").first()
|
||||
|
||||
assertTrue(restored.isActive)
|
||||
assertEquals("willow", restored.pinnedProfileName)
|
||||
assertEquals(6, restored.capabilities.attachmentMaxCount)
|
||||
assertEquals(20, restored.capabilities.attachmentMaxFileMb)
|
||||
assertEquals(saved.capabilities.attachmentCategories, restored.capabilities.attachmentCategories)
|
||||
assertEquals(saved.capabilities.sessionActions, restored.capabilities.sessionActions)
|
||||
assertEquals("rose", restored.appearance.appThemeId)
|
||||
assertEquals("dark", restored.appearance.themePreference)
|
||||
assertTrue(restored.appearance.showPet)
|
||||
assertTrue(restored.appearance.allowProfileIconChanges)
|
||||
assertTrue(restored.appearance.allowBackgroundChanges)
|
||||
assertEquals(SupervisedVisibilityPreset.Custom, restored.visibility.preset)
|
||||
assertTrue(restored.visibility.showModelName)
|
||||
assertTrue(restored.visibility.showToolNames)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionsAreIsolatedAndClearRemovesOnlyTarget() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
|
||||
store.setPolicy("connection-b", SupervisedModePolicy(true, "juniper"))
|
||||
|
||||
store.clear("connection-a")
|
||||
|
||||
assertFalse(store.policyFlow("connection-a").first().enabled)
|
||||
assertEquals("juniper", store.policyFlow("connection-b").first().pinnedProfileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateAndSetEnabledPreserveOtherPolicyFields() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
store.setPolicy(
|
||||
"connection-a",
|
||||
SupervisedModePolicy(
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(voice = true),
|
||||
),
|
||||
)
|
||||
|
||||
store.setEnabled("connection-a", true)
|
||||
store.updatePolicy("connection-a") {
|
||||
it.copy(visibility = it.visibility.copy(preset = SupervisedVisibilityPreset.Transparent))
|
||||
}
|
||||
|
||||
val policy = store.policyFlow("connection-a").first()
|
||||
assertTrue(policy.isActive)
|
||||
assertTrue(policy.capabilities.voice)
|
||||
assertEquals(SupervisedVisibilityPreset.Transparent, policy.visibility.preset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidLimitsAreNormalizedAndEmptyCategoriesFallBackToImages() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
store.setPolicy(
|
||||
"connection-a",
|
||||
SupervisedModePolicy(
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(
|
||||
attachmentMaxCount = Int.MAX_VALUE,
|
||||
attachmentMaxFileMb = -1,
|
||||
attachmentCategories = emptySet(),
|
||||
),
|
||||
parentAccess = SupervisedParentAccess(
|
||||
requireDeviceAuthentication = false,
|
||||
timeoutMinutes = 0,
|
||||
),
|
||||
appearance = SupervisedAppearance(
|
||||
appThemeId = "missing-theme",
|
||||
themePreference = "sepia",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val policy = store.policyFlow("connection-a").first()
|
||||
assertEquals(SupervisedCapabilities.MAX_ATTACHMENT_COUNT, policy.capabilities.attachmentMaxCount)
|
||||
assertEquals(1, policy.capabilities.attachmentMaxFileMb)
|
||||
assertEquals(setOf(SupervisedAttachmentCategory.Images), policy.capabilities.attachmentCategories)
|
||||
assertTrue(policy.parentAccess.requireDeviceAuthentication)
|
||||
assertEquals(SupervisedParentAccess.MIN_TIMEOUT_MINUTES, policy.parentAccess.timeoutMinutes)
|
||||
assertEquals("hermes-relay", policy.appearance.appThemeId)
|
||||
assertEquals("auto", policy.appearance.themePreference)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun simplePresetResolvesToSafeValuesEvenIfStoredFlagsDiffer() {
|
||||
val visibility = SupervisedVisibility(
|
||||
preset = SupervisedVisibilityPreset.Simple,
|
||||
showModelName = true,
|
||||
showTechnicalRoute = true,
|
||||
showReasoning = true,
|
||||
).resolved()
|
||||
|
||||
assertFalse(visibility.showModelName)
|
||||
assertFalse(visibility.showTechnicalRoute)
|
||||
assertFalse(visibility.showReasoning)
|
||||
assertTrue(visibility.showAgentIdentity)
|
||||
assertTrue(visibility.showConnectionStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedPersistedPolicyFailsClosed() = runTest {
|
||||
val policyKey = androidx.datastore.preferences.core.stringPreferencesKey(
|
||||
"supervised_mode_policies_v1",
|
||||
)
|
||||
val dataStore = InMemorySupervisedPreferencesDataStore(
|
||||
mutablePreferencesOf(policyKey to "{not-valid-json"),
|
||||
)
|
||||
|
||||
val policy = SupervisedModeStore.forTesting(dataStore)
|
||||
.policyFlow("connection-a")
|
||||
.first()
|
||||
|
||||
assertTrue(policy.enabled)
|
||||
assertFalse(policy.isConfigured)
|
||||
assertFalse(policy.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearAllDoesNotClearUnrelatedPreferences() = runTest {
|
||||
val unrelatedKey = androidx.datastore.preferences.core.stringPreferencesKey("unrelated")
|
||||
val dataStore = InMemorySupervisedPreferencesDataStore(
|
||||
mutablePreferencesOf(unrelatedKey to "kept"),
|
||||
)
|
||||
val store = SupervisedModeStore.forTesting(dataStore)
|
||||
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
|
||||
|
||||
store.clearAll()
|
||||
|
||||
assertFalse(store.policyFlow("connection-a").first().enabled)
|
||||
assertEquals("kept", dataStore.data.first()[unrelatedKey])
|
||||
}
|
||||
}
|
||||
|
||||
private class InMemorySupervisedPreferencesDataStore(
|
||||
initial: Preferences = emptyPreferences(),
|
||||
) : DataStore<Preferences> {
|
||||
private val state = MutableStateFlow(initial)
|
||||
override val data: Flow<Preferences> = state
|
||||
|
||||
override suspend fun updateData(
|
||||
transform: suspend (t: Preferences) -> Preferences,
|
||||
): Preferences {
|
||||
val updated = transform(state.value)
|
||||
state.value = updated
|
||||
return updated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedSessionPolicyTest {
|
||||
@Test fun `session action summary derives none mixed and all`() {
|
||||
val none = SupervisedSessionActions()
|
||||
val mixed = none.copy(rename = true, delete = true)
|
||||
val all = none.withAll(true)
|
||||
|
||||
assertTrue(none.noneEnabled)
|
||||
assertEquals(2, mixed.enabledCount)
|
||||
assertFalse(mixed.noneEnabled)
|
||||
assertFalse(mixed.allEnabled)
|
||||
assertTrue(all.allEnabled)
|
||||
assertEquals(SupervisedSessionActions.TOTAL, all.enabledCount)
|
||||
}
|
||||
|
||||
@Test fun `supervised history and granular flag are both required`() {
|
||||
val base = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(
|
||||
conversationHistory = true,
|
||||
sessionActions = SupervisedSessionActions(rename = true),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(base.allowsSessionAction(SupervisedSessionAction.Rename))
|
||||
assertFalse(base.allowsSessionAction(SupervisedSessionAction.Delete))
|
||||
assertFalse(
|
||||
base.copy(
|
||||
capabilities = base.capabilities.copy(conversationHistory = false),
|
||||
).allowsSessionAction(SupervisedSessionAction.Rename),
|
||||
)
|
||||
assertTrue(SupervisedModePolicy().allowsSessionAction(SupervisedSessionAction.Delete))
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.hermesandroid.relay.network.relay
|
||||
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChannelMultiplexerSupervisedUpdateTest {
|
||||
@Test fun `supervised update acknowledgement reaches system auth handler`() {
|
||||
val multiplexer = ChannelMultiplexer()
|
||||
val received = mutableListOf<Envelope>()
|
||||
multiplexer.registerHandler("system") { received += it }
|
||||
|
||||
val acknowledgement = Envelope(
|
||||
channel = "system",
|
||||
type = "supervised.updated",
|
||||
id = "update-1",
|
||||
)
|
||||
multiplexer.route(acknowledgement)
|
||||
|
||||
assertEquals(listOf(acknowledgement), received)
|
||||
}
|
||||
|
||||
@Test fun `correlated system error reaches system auth handler`() {
|
||||
val multiplexer = ChannelMultiplexer()
|
||||
val received = mutableListOf<Envelope>()
|
||||
multiplexer.registerHandler("system") { received += it }
|
||||
|
||||
val error = Envelope(channel = "system", type = "error", id = "update-2")
|
||||
multiplexer.route(error)
|
||||
|
||||
assertEquals(listOf(error), received)
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AutoVoiceAudioClientSupervisionTest {
|
||||
@Test
|
||||
fun `route override forces standard even when auto prefers ready relay`() = runTest {
|
||||
val standard = FakeVoiceClient(VoiceAudioRoute.Standard, "standard")
|
||||
val relay = FakeVoiceClient(VoiceAudioRoute.Relay, "relay")
|
||||
val router = AutoVoiceAudioClient(
|
||||
standardClient = standard,
|
||||
relayClient = relay,
|
||||
routeProvider = { VoiceAudioRoute.Auto },
|
||||
standardReadyProvider = { true },
|
||||
relayReadyProvider = { true },
|
||||
)
|
||||
|
||||
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
|
||||
router.setRouteOverride(VoiceAudioRoute.Standard)
|
||||
assertEquals(VoiceAudioRoute.Standard, router.effectiveRoute)
|
||||
assertEquals("standard", router.transcribe(File("voice.wav")).getOrThrow())
|
||||
router.setRouteOverride(null)
|
||||
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
|
||||
}
|
||||
|
||||
private class FakeVoiceClient(
|
||||
override val route: VoiceAudioRoute,
|
||||
private val transcript: String,
|
||||
) : VoiceAudioClient {
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = Result.success(transcript)
|
||||
override suspend fun synthesize(text: String): Result<File> = Result.success(File("voice.mp3"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedAppearance
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedAppearancePolicyTest {
|
||||
private val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
appearance = SupervisedAppearance(
|
||||
appThemeId = "rose",
|
||||
themePreference = "dark",
|
||||
showPet = false,
|
||||
),
|
||||
)
|
||||
|
||||
@Test fun `locked supervised root uses only its own theme`() {
|
||||
val resolved = resolveSupervisedTheme(policy, false, "midnight", "light")
|
||||
|
||||
assertEquals("rose", resolved.appThemeId)
|
||||
assertEquals("dark", resolved.themePreference)
|
||||
assertFalse(resolved.useGlobalCustomTheme)
|
||||
}
|
||||
|
||||
@Test fun `parent access restores ordinary app theme`() {
|
||||
val resolved = resolveSupervisedTheme(policy, true, "midnight", "light")
|
||||
|
||||
assertEquals("midnight", resolved.appThemeId)
|
||||
assertEquals("light", resolved.themePreference)
|
||||
assertTrue(resolved.useGlobalCustomTheme)
|
||||
}
|
||||
|
||||
@Test fun `pet visibility follows supervised policy only while locked`() {
|
||||
assertFalse(shouldShowPetInSupervisedMode(policy, false))
|
||||
assertTrue(shouldShowPetInSupervisedMode(policy, true))
|
||||
assertTrue(
|
||||
shouldShowPetInSupervisedMode(
|
||||
policy.copy(appearance = policy.appearance.copy(showPet = true)),
|
||||
false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `enabled recovery policy stays on restricted appearance defaults`() {
|
||||
val recovery = SupervisedModePolicy(enabled = true)
|
||||
val resolved = resolveSupervisedTheme(recovery, false, "rose", "dark")
|
||||
|
||||
assertEquals("hermes-relay", resolved.appThemeId)
|
||||
assertEquals("auto", resolved.themePreference)
|
||||
assertFalse(resolved.useGlobalCustomTheme)
|
||||
assertFalse(shouldShowPetInSupervisedMode(recovery, false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedNavigationPolicyTest {
|
||||
@Test fun `locked surface permits only approved destinations`() {
|
||||
assertTrue(isSupervisedRouteAllowed("chat?sessionId=owned", false))
|
||||
assertTrue(isSupervisedRouteAllowed("settings", false))
|
||||
assertTrue(isSupervisedRouteAllowed(Screen.SupervisedAppearanceSettings.route, false))
|
||||
// The full Appearance destination includes profile/avatar/pet controls.
|
||||
// The supervised Settings root owns its own allowlisted theme controls.
|
||||
assertFalse(isSupervisedRouteAllowed("settings/appearance", false))
|
||||
assertFalse(isSupervisedRouteAllowed("settings/about", false))
|
||||
assertFalse(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, false))
|
||||
assertFalse(isSupervisedRouteAllowed("manage", false))
|
||||
assertFalse(isSupervisedRouteAllowed("settings/developer", false))
|
||||
assertFalse(isSupervisedRouteAllowed("settings/supervised", false))
|
||||
assertFalse(isSupervisedRouteAllowed(null, false))
|
||||
}
|
||||
|
||||
@Test fun `parent unlock permits full navigation`() {
|
||||
assertTrue(isSupervisedRouteAllowed("manage", true))
|
||||
assertTrue(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, true))
|
||||
}
|
||||
|
||||
@Test fun `supervised redirect waits until the navigation graph has a route`() {
|
||||
assertFalse(shouldRedirectSupervisedRoute(true, false, null))
|
||||
assertTrue(isSupervisedRouteContentAllowed(true, false, null))
|
||||
assertFalse(shouldRedirectSupervisedRoute(true, false, Screen.Chat.route))
|
||||
assertTrue(isSupervisedRouteContentAllowed(true, false, Screen.Chat.route))
|
||||
assertTrue(shouldRedirectSupervisedRoute(true, false, Screen.AdvancedSettings.route))
|
||||
assertFalse(isSupervisedRouteContentAllowed(true, false, Screen.AdvancedSettings.route))
|
||||
assertFalse(shouldRedirectSupervisedRoute(true, true, Screen.AdvancedSettings.route))
|
||||
assertTrue(isSupervisedRouteContentAllowed(true, true, Screen.AdvancedSettings.route))
|
||||
}
|
||||
|
||||
@Test fun `navigation waits for connection store before trusting null active id`() {
|
||||
assertFalse(isRelayNavigationHydrated(false, null, false))
|
||||
assertTrue(isRelayNavigationHydrated(true, null, false))
|
||||
assertFalse(isRelayNavigationHydrated(true, "home", false))
|
||||
assertTrue(isRelayNavigationHydrated(true, "home", true))
|
||||
}
|
||||
|
||||
@Test fun `parent access relocks as soon as chat becomes current`() {
|
||||
assertTrue(shouldRelockParentAccess(true, true, "chat?sessionId=ignored"))
|
||||
assertFalse(shouldRelockParentAccess(true, true, "settings/supervised"))
|
||||
assertFalse(shouldRelockParentAccess(false, true, "chat"))
|
||||
assertFalse(shouldRelockParentAccess(true, false, "chat"))
|
||||
}
|
||||
|
||||
@Test fun `supervised route session requires history pinned profile and trusted ownership proof`() {
|
||||
val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(conversationHistory = true),
|
||||
)
|
||||
|
||||
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "willow", false))
|
||||
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "parent", true))
|
||||
assertTrue(mayRestoreSupervisedSessionRoute(policy, "session-1", "WILLOW", true))
|
||||
assertFalse(
|
||||
mayRestoreSupervisedSessionRoute(
|
||||
policy.copy(capabilities = policy.capabilities.copy(conversationHistory = false)),
|
||||
"session-1",
|
||||
"willow",
|
||||
true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `supervised external route discards session profile and proactive targets`() {
|
||||
val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(conversationHistory = true),
|
||||
)
|
||||
val external = SupervisedChatRouteArgs(
|
||||
sessionId = "parent-session",
|
||||
profile = "willow",
|
||||
proactiveChatId = "phone",
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
sanitizeSupervisedChatRouteArgs(policy, external, false) ==
|
||||
SupervisedChatRouteArgs(),
|
||||
)
|
||||
assertTrue(
|
||||
sanitizeSupervisedChatRouteArgs(policy, external, true) ==
|
||||
external.copy(proactiveChatId = null),
|
||||
)
|
||||
assertTrue(
|
||||
sanitizeSupervisedChatRouteArgs(SupervisedModePolicy(), external, false) == external,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `first enable requires configured policy secure screen and successful device credential`() {
|
||||
val configured = SupervisedModePolicy(pinnedProfileName = "willow")
|
||||
|
||||
assertFalse(
|
||||
mayEnableSupervisedMode(
|
||||
configured,
|
||||
deviceSecure = false,
|
||||
deviceCredentialConfirmed = true,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
mayEnableSupervisedMode(
|
||||
configured,
|
||||
deviceSecure = true,
|
||||
deviceCredentialConfirmed = false,
|
||||
),
|
||||
)
|
||||
assertFalse(mayEnableSupervisedMode(SupervisedModePolicy(), true, true))
|
||||
assertTrue(mayEnableSupervisedMode(configured, true, true))
|
||||
assertFalse(mayEnableSupervisedMode(configured.copy(enabled = true), true, true))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionDrawerPolicyTest {
|
||||
@@ -144,6 +145,17 @@ class SessionDrawerPolicyTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full row border is limited to foreground live work`() {
|
||||
assertTrue(sessionActivityShowsRowBorder(SessionActivityState.Starting))
|
||||
assertTrue(sessionActivityShowsRowBorder(SessionActivityState.Working))
|
||||
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.NeedsInput))
|
||||
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.BackgroundWork))
|
||||
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.Checking))
|
||||
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.Unavailable))
|
||||
assertFalse(sessionActivityShowsRowBorder(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile project status and pull request filters compose`() {
|
||||
val wanted = row(
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedImagePresentationTest {
|
||||
@Test
|
||||
fun `disabled assistant images strip markdown without exposing a fetchable source`() {
|
||||
val content = "Here it is  and "
|
||||
|
||||
val (body, images) = assistantImageContent(content, showImages = false)
|
||||
|
||||
assertEquals("Here it is and", body)
|
||||
assertTrue(images.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled assistant images preserve all supported sources`() {
|
||||
val content = " "
|
||||
|
||||
val (_, images) = assistantImageContent(content, showImages = true)
|
||||
|
||||
assertEquals(listOf("https://example.com/a.png", "/tmp/b.png"), images.map { it.src })
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -333,7 +333,7 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsupportedActiveListProjectsUnavailableInsteadOfRestWorking() {
|
||||
fun unsupportedActiveListLeavesRowsNeutralAcrossDirectoryRefresh() {
|
||||
bindActivityTestDirectory()
|
||||
handler.updateSessions(
|
||||
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
|
||||
@@ -344,9 +344,14 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
gatewayHarness.awaitRpc("session.active_list")
|
||||
|
||||
awaitCondition {
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
|
||||
SessionActivityState.Unavailable
|
||||
"default:$STORED_SESSION_ID" !in viewModel.backgroundSessionActivityStates.value
|
||||
}
|
||||
|
||||
viewModel.updateSessionActivityDirectory(
|
||||
rows = listOf("default" to STORED_SESSION_ID),
|
||||
)
|
||||
|
||||
assertFalse("default:$STORED_SESSION_ID" in viewModel.backgroundSessionActivityStates.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedChatPolicyTest {
|
||||
@Test
|
||||
fun `normal mode preserves slash commands`() {
|
||||
assertNull(supervisedMessageBlockReason(SupervisedModePolicy(), " /model"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled policy fails closed without pinned profile`() {
|
||||
assertEquals(
|
||||
"Supervised mode is unavailable until the parent selects a profile.",
|
||||
supervisedMessageBlockReason(SupervisedModePolicy(enabled = true), "hello"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active policy blocks slash commands after unicode whitespace`() {
|
||||
val policy = SupervisedModePolicy(enabled = true, pinnedProfileName = "willow")
|
||||
assertEquals(
|
||||
"Slash commands are unavailable in supervised mode.",
|
||||
supervisedMessageBlockReason(policy, "\u2003\t /model hidden"),
|
||||
)
|
||||
assertNull(supervisedMessageBlockReason(policy, "please explain /model"))
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.voice.VoiceCommandAction
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedVoiceCommandPolicyTest {
|
||||
@Test
|
||||
fun `normal mode preserves every voice command`() {
|
||||
VoiceCommandAction.entries.forEach { action ->
|
||||
assertTrue(isVoiceCommandAllowed(action, SupervisedModePolicy()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `supervised mode gates new chat and cancellation independently`() {
|
||||
val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(
|
||||
voice = true,
|
||||
newChat = false,
|
||||
cancelResponse = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.StartNewChat, policy))
|
||||
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.StopResponse, policy))
|
||||
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.CancelBackgroundTask, policy))
|
||||
assertTrue(isVoiceCommandAllowed(VoiceCommandAction.EndVoiceChat, policy))
|
||||
}
|
||||
}
|
||||
+119
-2
@@ -1,6 +1,6 @@
|
||||
# Hermes-Relay — Decisions & Implementation Guide
|
||||
|
||||
> Updated: 2026-04-06
|
||||
> Updated: 2026-08-24
|
||||
>
|
||||
> Read this before SPEC.md — it tells you what to build, what was deferred, and why.
|
||||
|
||||
@@ -3692,7 +3692,124 @@ be considered later without being silently introduced now.
|
||||
|
||||
---
|
||||
|
||||
## ADR 66 — Android Bot Mode is a separate upstream-owned messaging workspace
|
||||
## ADR 66 — Android Supervised Mode is a parent-controlled client policy
|
||||
|
||||
**Status:** Implemented in code; physical managed-device certification pending (2026-08-24).
|
||||
|
||||
**Context.** Some operators prepare a deliberately restricted Hermes profile
|
||||
for use through a parent-supervised Android client. The profile remains the
|
||||
authority for its model, prompt, tools, provider credentials, content behavior,
|
||||
and server-side data. Hermes-Relay should help a parent present a smaller,
|
||||
proctored phone interface without representing that interface as end-to-end
|
||||
child security or as a server-enforced account type.
|
||||
|
||||
**Decision.** Android will treat Supervised Mode as an opt-in, locally enforced
|
||||
policy pinned to one existing Connection and one existing Hermes profile. The
|
||||
parent is responsible for preparing and reviewing that profile before enabling
|
||||
the mode. Entering, changing, or leaving the parent policy requires Android
|
||||
device authentication. That prompt authenticates an enrolled device user, not
|
||||
a distinct server-side parent identity. While the policy is active, the app restores directly
|
||||
into a restricted root and never renders the ordinary app behind an
|
||||
authentication prompt. A missing Connection, missing profile, malformed policy,
|
||||
failed authentication, process restart, or restored route that cannot prove its
|
||||
owner fails closed to the restricted surface.
|
||||
|
||||
The ordinary Chat screen stays visually quiet. It does not carry a persistent
|
||||
"supervised" banner. Its existing Settings action opens only approved
|
||||
preferences; a separate **Parent access** row authenticates before showing the
|
||||
policy editor or full application settings. Backgrounding, inactivity, process
|
||||
recreation, and leaving parent settings relock parent access according to the
|
||||
policy. Deep links, notification actions, restored navigation, shortcuts, and
|
||||
programmatic routes pass the same gate.
|
||||
|
||||
The parent policy controls capabilities rather than imposing a special
|
||||
attachment count. Initial capabilities are text chat, new chat, cancel, steer,
|
||||
attachments, standard voice, generated-media viewing, save/share media, copy,
|
||||
retry, quote/reply, and edit/resend. Attachments and voice are independently
|
||||
enabled. When attachments are enabled, Android retains the normal supported
|
||||
attachment flow and its existing size/type limits unless the parent selects a
|
||||
stricter limit; disabling attachments removes every picker, paste-to-file,
|
||||
camera/share-to-chat, and restored-draft entry point. Disabling voice removes
|
||||
capture, voice intents, and voice settings from the restricted surface. Provider
|
||||
credentials remain on the configured Hermes host under the existing standard
|
||||
voice contract.
|
||||
|
||||
The restricted composer does not expose the command palette, slash
|
||||
autocomplete, server command catalog, or command-generated action cards. A
|
||||
leading slash is rejected locally rather than dispatched; approved outcomes
|
||||
such as New chat and Cancel remain explicit typed UI actions. Approval,
|
||||
clarification, secret, and elevated-access requests are denied or skipped
|
||||
immediately with a bounded notice. The supervised user cannot authorize them;
|
||||
a parent may retry from the authenticated full client.
|
||||
|
||||
Restricted Settings contains only parent-approved, non-authoritative choices,
|
||||
such as a supervised-only theme, text size, language, haptics, accessibility,
|
||||
message presentation, sensitive-media blur, and permitted voice playback
|
||||
preferences. Connections, Manage, profiles, models, personalities, reasoning,
|
||||
approvals, tools, plugins, Terminal, TUI, Bridge, Device Control, notification
|
||||
companion, diagnostics, logs, files, credentials, developer controls, Relay
|
||||
management, and other sessions are absent rather than shown disabled.
|
||||
|
||||
The parent may allow the configured floating pet and may independently let the
|
||||
supervised user change the phone-local profile icon or an already-installed chat
|
||||
background. The parent retains those appearance controls when supervised-user
|
||||
changes are disabled. Conversation history and its mutations are separate
|
||||
permissions: pin, rename, archive/restore, transcript sharing, and delete are
|
||||
individually allowlisted, while technical session identifiers and cross-profile
|
||||
administration remain hidden. Delete retains its confirmation step.
|
||||
|
||||
The parent also chooses what Chat discloses. **Simple** is the default: agent
|
||||
name/avatar plus generic Connected, Working, and Reconnecting states; it hides
|
||||
model, profile, provider/route, context, token/usage, reasoning, and tool detail.
|
||||
**Transparent** may add timestamps, bounded usage/context information, and
|
||||
approved activity labels without exposing arguments, results, paths, or
|
||||
credentials. **Custom** exposes the individual visibility switches. Model name
|
||||
and profile name default off in every new policy. Required errors, safety
|
||||
notices, parent-action states, and connection failures cannot be hidden by a
|
||||
cosmetic visibility choice.
|
||||
|
||||
Session selection is limited to the pinned profile. New chat creates a new
|
||||
conversation for that profile; history visibility, transcript retention, and
|
||||
conversation actions follow the parent policy. Ending Supervised Mode may clear
|
||||
local drafts, pending media, and restricted caches, but does not imply deletion
|
||||
of server-owned session history. Server history remains available through the
|
||||
parent's ordinary authenticated Hermes surfaces.
|
||||
|
||||
When the optional Relay plugin is paired, Android reports a bounded
|
||||
client-declared `supervised` tag and a non-sensitive policy summary with its
|
||||
ordinary device identity. Relay and its UI may display that tag and allow the
|
||||
operator to revoke the paired Relay session through the existing revocation
|
||||
model. The tag is informational: Relay does not interpret or enforce the Android
|
||||
policy, pin a profile, filter Gateway traffic, or certify the client. Revoking
|
||||
the Relay session removes Relay-backed capabilities but cannot revoke a direct
|
||||
Dashboard/Gateway session or remotely disable an Android-only policy. Without
|
||||
Relay pairing, Supervised Mode remains usable and locally enforced.
|
||||
|
||||
**Security and product boundary.** This mode restricts the official Android UI,
|
||||
not the Hermes agent or server. It cannot secure another client, a modified APK,
|
||||
direct server access, server-side tools, provider output, or a parent account
|
||||
whose credentials are available elsewhere. It is not a substitute for profile
|
||||
hardening, provider safety controls, parental review, operating-system controls,
|
||||
or applicable legal obligations. Public language uses **Supervised Mode** or
|
||||
**parent-controlled client**, not "child account," "safe for children," or
|
||||
"server enforced."
|
||||
|
||||
**Verification gate.** Implementation requires policy, authentication,
|
||||
navigation, process-death, deep-link, notification, capability, attachment,
|
||||
voice, session-ownership, Relay-tag, and revocation tests. Physical testing must
|
||||
cover the exact Android build on a managed/restricted device, including relock,
|
||||
restart, offline recovery, and attempts to escape the restricted root. Until
|
||||
that evidence exists, documentation and release notes must call the feature
|
||||
planned or experimental and must not call it child-ready.
|
||||
|
||||
**Consequences.** The project gains a generalized, low-noise supervised client
|
||||
without creating a new Hermes account type or making Relay a chat authorization
|
||||
proxy. Parents receive clear local controls and optional paired-device
|
||||
visibility, while server ownership and the limits of client-side enforcement
|
||||
remain explicit.
|
||||
---
|
||||
|
||||
## ADR 67 — Android Bot Mode is a separate upstream-owned messaging workspace
|
||||
|
||||
**Status:** Accepted (2026-08-24).
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
@@ -91,9 +91,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.13.1 - Accurate session activity
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
|
||||
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
|
||||
Supervised Mode adds a parent-configured, profile-pinned chat view with device-authenticated settings. Parents can limit attachments, Standard voice, generated media, history, actions, and technical details. Session rows stay neutral while live activity is unavailable, and returning from parent settings no longer blanks Supervised Chat.
|
||||
```
|
||||
## Category
|
||||
|
||||
|
||||
+5
-1
@@ -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-22
|
||||
**Updated:** 2026-08-24
|
||||
|
||||
---
|
||||
|
||||
@@ -46,6 +46,10 @@ token, terminal/bridge grants, and optional network candidates.
|
||||
4. **Clean UX** — Material 3, minimal setup, and clear route identity for Vanilla Hermes vs Relay.
|
||||
5. **Offline-aware** — graceful degradation when connection drops. Auto-reconnect with exponential backoff.
|
||||
6. **Server-side state** — the app is a thin client. Sessions, history, memory, profiles, and dashboard state live on the Hermes server.
|
||||
7. **Supervision is a client policy** — Android may offer a parent-controlled,
|
||||
profile-pinned restricted interface, but it does not claim to make the
|
||||
selected Hermes profile, server, or agent child-safe. See ADR 66 and the
|
||||
[Supervised Mode guide](../user-docs/guide/supervised-mode.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.13.1"
|
||||
appVersionCode = "50"
|
||||
appVersionName = "1.13.2"
|
||||
appVersionCode = "51"
|
||||
agp = "9.3.2"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.08.00"
|
||||
|
||||
Vendored
+7
-7
File diff suppressed because one or more lines are too long
@@ -0,0 +1,73 @@
|
||||
const MAX_PROFILE_LABEL_LENGTH = 80;
|
||||
const MAX_CAPABILITY_LENGTH = 32;
|
||||
const MAX_CAPABILITIES = 12;
|
||||
const MAX_VISIBLE_CAPABILITIES = 4;
|
||||
|
||||
const CAPABILITY_LABELS = {
|
||||
text_chat: "Text chat",
|
||||
attachments: "Attachments",
|
||||
voice: "Voice",
|
||||
generated_images: "Generated images",
|
||||
new_chat: "New chat",
|
||||
cancel: "Cancel",
|
||||
steer: "Steer",
|
||||
share_images: "Share images",
|
||||
copy: "Copy",
|
||||
retry: "Retry",
|
||||
quote_reply: "Quote & reply",
|
||||
timestamps: "Timestamps",
|
||||
};
|
||||
|
||||
function boundedText(value, maxLength) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return null;
|
||||
return normalized.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function capabilityLabel(value) {
|
||||
const normalized = boundedText(value, MAX_CAPABILITY_LENGTH);
|
||||
if (!normalized) return null;
|
||||
const key = normalized.toLowerCase();
|
||||
return CAPABILITY_LABELS[key] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize optional, client-reported supervised-mode metadata for display.
|
||||
* This deliberately requires active === true and never treats the report as a
|
||||
* Relay authorization policy.
|
||||
*/
|
||||
export function supervisedSessionDisplay(session) {
|
||||
const raw = session && session.supervised_mode;
|
||||
if (
|
||||
!raw ||
|
||||
typeof raw !== "object" ||
|
||||
Array.isArray(raw) ||
|
||||
raw.active !== true ||
|
||||
raw.enforcement_owner !== "android_client"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profileLabel = boundedText(raw.profile_label, MAX_PROFILE_LABEL_LENGTH);
|
||||
const source = Array.isArray(raw.capabilities) ? raw.capabilities : [];
|
||||
const capabilities = [];
|
||||
const seen = new Set();
|
||||
for (const entry of source.slice(0, MAX_CAPABILITIES)) {
|
||||
const label = capabilityLabel(entry);
|
||||
if (!label) continue;
|
||||
const key = label.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
capabilities.push(label);
|
||||
}
|
||||
|
||||
const visibleCapabilities = capabilities.slice(0, MAX_VISIBLE_CAPABILITIES);
|
||||
const remainingCapabilityCount = Math.max(0, capabilities.length - visibleCapabilities.length);
|
||||
|
||||
return {
|
||||
profileLabel,
|
||||
visibleCapabilities,
|
||||
remainingCapabilityCount,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../lib/api.js";
|
||||
import { relativeTime, ttlCountdown, uptime, shortToken } from "../lib/formatters.js";
|
||||
import { formatSessionExpiry } from "../lib/session-expiry.mjs";
|
||||
import { supervisedSessionDisplay } from "../lib/supervised-session.mjs";
|
||||
import PairDialog from "../components/PairDialog.jsx";
|
||||
import {
|
||||
Alert,
|
||||
@@ -596,6 +597,7 @@ export default function RelayManagement({ autoRefresh }) {
|
||||
|
||||
const ov = overview || {};
|
||||
const list = sessions || [];
|
||||
const hasSupervisedSession = list.some((session) => supervisedSessionDisplay(session));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -643,6 +645,13 @@ export default function RelayManagement({ autoRefresh }) {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasSupervisedSession ? (
|
||||
<div className="mb-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
Supervised mode is reported and enforced by the Android client, not by Relay. Relay
|
||||
shows the client's reported settings here so a paired device can be identified and
|
||||
revoked.
|
||||
</div>
|
||||
) : null}
|
||||
{!autoRefresh ? (
|
||||
<div className="mb-3">
|
||||
<Button size="sm" variant="outline" onClick={load}>
|
||||
@@ -685,13 +694,21 @@ export default function RelayManagement({ autoRefresh }) {
|
||||
const grants = extractGrants(s);
|
||||
const type = classifySession(s, grants);
|
||||
const transport = sessionTransport(s);
|
||||
const supervised = supervisedSessionDisplay(s);
|
||||
const deviceDetail = [s.device_model, s.device_platform]
|
||||
.filter((value) => value && value !== "unknown")
|
||||
.join(" · ");
|
||||
return (
|
||||
<TableRow key={tokenPrefix || idx}>
|
||||
<TableCell className="font-medium">
|
||||
<div>{label}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{label}</span>
|
||||
{supervised ? (
|
||||
<Badge variant="secondary" className="w-fit text-xs">
|
||||
Supervised
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="font-mono text-xs font-normal text-muted-foreground">
|
||||
{tokenPrefix ? shortToken(tokenPrefix, 12) : "no token prefix"}
|
||||
</div>
|
||||
@@ -700,6 +717,22 @@ export default function RelayManagement({ autoRefresh }) {
|
||||
{deviceDetail}
|
||||
</div>
|
||||
) : null}
|
||||
{supervised && supervised.profileLabel ? (
|
||||
<div className="text-xs font-normal text-muted-foreground">
|
||||
Pinned profile: {supervised.profileLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{supervised && supervised.visibleCapabilities.length > 0 ? (
|
||||
<div
|
||||
className="max-w-xs text-xs font-normal text-muted-foreground"
|
||||
title="Capabilities reported by the Android client"
|
||||
>
|
||||
Client allows: {supervised.visibleCapabilities.join(" · ")}
|
||||
{supervised.remainingCapabilityCount > 0
|
||||
? ` · +${supervised.remainingCapabilityCount} more`
|
||||
: ""}
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { supervisedSessionDisplay } from "../src/lib/supervised-session.mjs";
|
||||
|
||||
test("leaves legacy and inactive sessions unchanged", () => {
|
||||
assert.equal(supervisedSessionDisplay({ device_name: "Pixel" }), null);
|
||||
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: false } }), null);
|
||||
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: "true" } }), null);
|
||||
assert.equal(
|
||||
supervisedSessionDisplay({ supervised_mode: { active: true, enforcement_owner: "relay" } }),
|
||||
null,
|
||||
);
|
||||
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: true } }), null);
|
||||
assert.equal(supervisedSessionDisplay({ supervised_mode: [] }), null);
|
||||
});
|
||||
|
||||
test("formats active client-reported metadata", () => {
|
||||
assert.deepEqual(
|
||||
supervisedSessionDisplay({
|
||||
supervised_mode: {
|
||||
active: true,
|
||||
profile_label: " Willow ",
|
||||
capabilities: ["attachments", "voice", "generated_images", "new_chat"],
|
||||
enforcement_owner: "android_client",
|
||||
},
|
||||
}),
|
||||
{
|
||||
profileLabel: "Willow",
|
||||
visibleCapabilities: ["Attachments", "Voice", "Generated images", "New chat"],
|
||||
remainingCapabilityCount: 0,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("bounds, sanitizes, and deduplicates untrusted display values", () => {
|
||||
const result = supervisedSessionDisplay({
|
||||
supervised_mode: {
|
||||
active: true,
|
||||
enforcement_owner: "android_client",
|
||||
profile_label: `Willow\u0000 ${"x".repeat(100)}`,
|
||||
capabilities: [
|
||||
"voice",
|
||||
"VOICE",
|
||||
"unknown_capability",
|
||||
"text_chat",
|
||||
"generated_images",
|
||||
"cancel",
|
||||
"steer",
|
||||
"attachments",
|
||||
"new_chat",
|
||||
"share_images",
|
||||
"copy",
|
||||
"retry",
|
||||
"quote_reply",
|
||||
"timestamps",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.profileLabel.length, 80);
|
||||
assert.deepEqual(
|
||||
result.visibleCapabilities,
|
||||
["Voice", "Text chat", "Generated images", "Cancel"],
|
||||
);
|
||||
assert.equal(result.remainingCapabilityCount, 6);
|
||||
});
|
||||
|
||||
test("tolerates malformed optional members", () => {
|
||||
assert.deepEqual(
|
||||
supervisedSessionDisplay({
|
||||
supervised_mode: {
|
||||
active: true,
|
||||
enforcement_owner: "android_client",
|
||||
profile_label: 42,
|
||||
capabilities: "voice",
|
||||
},
|
||||
}),
|
||||
{ profileLabel: null, visibleCapabilities: [], remainingCapabilityCount: 0 },
|
||||
);
|
||||
});
|
||||
+107
-1
@@ -97,6 +97,29 @@ _PAIRING_CODE_TTL = 600.0
|
||||
DEFAULT_REFRESH_TTL_SECONDS: float = 180 * 24 * 3600 # 180 days
|
||||
_REFRESH_TOKEN_BYTES = 32
|
||||
|
||||
# Client-reported supervised-mode metadata is intentionally small and
|
||||
# non-authoritative. Relay stores it only so paired-device surfaces can show
|
||||
# the operator which Android client is presenting a restricted UI. The
|
||||
# Android client remains the enforcement owner.
|
||||
SUPERVISED_PROFILE_LABEL_MAX_LENGTH = 80
|
||||
SUPERVISED_CAPABILITY_MAX_COUNT = 12
|
||||
SUPERVISED_CAPABILITIES: frozenset[str] = frozenset(
|
||||
{
|
||||
"attachments",
|
||||
"cancel",
|
||||
"copy",
|
||||
"generated_images",
|
||||
"new_chat",
|
||||
"quote_reply",
|
||||
"retry",
|
||||
"share_images",
|
||||
"steer",
|
||||
"text_chat",
|
||||
"timestamps",
|
||||
"voice",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── Data models ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -114,6 +137,58 @@ def _refresh_token_hash(token: str) -> str:
|
||||
return sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupervisedMode:
|
||||
"""Bounded, client-reported metadata for paired-device display only."""
|
||||
|
||||
active: bool = False
|
||||
profile_label: str = ""
|
||||
capabilities: tuple[str, ...] = ()
|
||||
|
||||
def to_public_dict(self) -> dict[str, Any]:
|
||||
"""Return the stable public wire shape for an active report."""
|
||||
return {
|
||||
"active": True,
|
||||
"profile_label": self.profile_label,
|
||||
"capabilities": list(self.capabilities),
|
||||
"enforcement_owner": "android_client",
|
||||
}
|
||||
|
||||
|
||||
def parse_supervised_mode(value: Any) -> SupervisedMode:
|
||||
"""Validate untrusted supervised-mode metadata.
|
||||
|
||||
Missing, inactive, malformed, oversized, or unknown values all normalize
|
||||
to ordinary mode. Capability values are allowlisted so this public summary
|
||||
cannot become a side channel for model, tool, path, or arbitrary client
|
||||
data.
|
||||
"""
|
||||
ordinary = SupervisedMode()
|
||||
if not isinstance(value, dict) or value.get("active") is not True:
|
||||
return ordinary
|
||||
|
||||
raw_label = value.get("profile_label")
|
||||
raw_capabilities = value.get("capabilities", [])
|
||||
if not isinstance(raw_label, str) or not isinstance(raw_capabilities, list):
|
||||
return ordinary
|
||||
|
||||
if not raw_label.isprintable():
|
||||
return ordinary
|
||||
label = raw_label.strip()
|
||||
if not label or len(label) > SUPERVISED_PROFILE_LABEL_MAX_LENGTH:
|
||||
return ordinary
|
||||
if len(raw_capabilities) > SUPERVISED_CAPABILITY_MAX_COUNT:
|
||||
return ordinary
|
||||
|
||||
capabilities: list[str] = []
|
||||
for candidate in raw_capabilities:
|
||||
if not isinstance(candidate, str) or candidate not in SUPERVISED_CAPABILITIES:
|
||||
return ordinary
|
||||
if candidate not in capabilities:
|
||||
capabilities.append(candidate)
|
||||
return SupervisedMode(True, label, tuple(capabilities))
|
||||
|
||||
|
||||
def _default_grants(ttl_seconds: float, now: float) -> dict[str, float]:
|
||||
"""Compute default per-channel grants given an overall session TTL.
|
||||
|
||||
@@ -229,6 +304,7 @@ class Session:
|
||||
refresh_token: str | None = field(default=None, repr=False, compare=False)
|
||||
device_model: str = "unknown"
|
||||
device_platform: str = "unknown"
|
||||
supervised_mode: SupervisedMode = field(default_factory=SupervisedMode)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.expires_at == 0.0:
|
||||
@@ -290,6 +366,7 @@ class TrustedDevice:
|
||||
device_form_factor: str = "unknown"
|
||||
device_model: str = "unknown"
|
||||
device_platform: str = "unknown"
|
||||
supervised_mode: SupervisedMode = field(default_factory=SupervisedMode)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.expires_at == 0.0:
|
||||
@@ -458,7 +535,7 @@ def _session_to_json(session: Session) -> dict[str, Any]:
|
||||
return "never"
|
||||
return v
|
||||
|
||||
return {
|
||||
payload = {
|
||||
"token": session.token,
|
||||
"device_name": session.device_name,
|
||||
"device_id": session.device_id,
|
||||
@@ -473,6 +550,9 @@ def _session_to_json(session: Session) -> dict[str, Any]:
|
||||
"device_platform": session.device_platform,
|
||||
"first_seen": session.first_seen,
|
||||
}
|
||||
if session.supervised_mode.active:
|
||||
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
|
||||
return payload
|
||||
|
||||
|
||||
def _session_from_json(payload: dict[str, Any]) -> Session | None:
|
||||
@@ -508,6 +588,7 @@ def _session_from_json(payload: dict[str, Any]) -> Session | None:
|
||||
device_model = str(payload.get("device_model", "unknown"))
|
||||
device_platform = str(payload.get("device_platform", "unknown"))
|
||||
first_seen = float(payload.get("first_seen", created_at))
|
||||
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -525,6 +606,7 @@ def _session_from_json(payload: dict[str, Any]) -> Session | None:
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
first_seen=first_seen,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -550,6 +632,8 @@ def _trusted_device_to_json(device: TrustedDevice) -> dict[str, Any]:
|
||||
}
|
||||
if device.grants is not None:
|
||||
payload["grants"] = dict(device.grants)
|
||||
if device.supervised_mode.active:
|
||||
payload["supervised_mode"] = device.supervised_mode.to_public_dict()
|
||||
return payload
|
||||
|
||||
|
||||
@@ -586,6 +670,7 @@ def _trusted_device_from_json(payload: dict[str, Any]) -> TrustedDevice | None:
|
||||
device_form_factor = str(payload.get("device_form_factor", "unknown"))
|
||||
device_model = str(payload.get("device_model", "unknown"))
|
||||
device_platform = str(payload.get("device_platform", "unknown"))
|
||||
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -606,6 +691,7 @@ def _trusted_device_from_json(payload: dict[str, Any]) -> TrustedDevice | None:
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -883,6 +969,7 @@ class SessionManager:
|
||||
device_form_factor: str = "unknown",
|
||||
device_model: str = "unknown",
|
||||
device_platform: str = "unknown",
|
||||
supervised_mode: SupervisedMode | None = None,
|
||||
issue_refresh_token: bool = False,
|
||||
) -> Session:
|
||||
"""Create a new session for an authenticated device.
|
||||
@@ -913,6 +1000,9 @@ class SessionManager:
|
||||
device_platform:
|
||||
Optional operating-system/platform metadata retained for device
|
||||
details. Neither field participates in authorization.
|
||||
supervised_mode:
|
||||
Optional bounded report of Android's supervised client state.
|
||||
Informational only; Relay does not enforce the reported policy.
|
||||
issue_refresh_token:
|
||||
When True, also create a persisted trusted-device credential and
|
||||
attach the raw one-time refresh token to the returned
|
||||
@@ -922,6 +1012,7 @@ class SessionManager:
|
||||
"""
|
||||
if ttl_seconds is None:
|
||||
ttl_seconds = DEFAULT_TTL_SECONDS
|
||||
supervised_mode = supervised_mode or SupervisedMode()
|
||||
|
||||
now = time.time()
|
||||
if ttl_seconds == 0:
|
||||
@@ -981,6 +1072,7 @@ class SessionManager:
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
|
||||
token = str(uuid.uuid4())
|
||||
@@ -997,6 +1089,7 @@ class SessionManager:
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
first_seen=now,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
@@ -1051,6 +1144,7 @@ class SessionManager:
|
||||
device_form_factor=session.device_form_factor,
|
||||
device_model=session.device_model,
|
||||
device_platform=session.device_platform,
|
||||
supervised_mode=session.supervised_mode,
|
||||
)
|
||||
session.refresh_token = refresh_token
|
||||
self._save_to_disk()
|
||||
@@ -1067,6 +1161,7 @@ class SessionManager:
|
||||
device_form_factor: str = "unknown",
|
||||
device_model: str = "unknown",
|
||||
device_platform: str = "unknown",
|
||||
supervised_mode: SupervisedMode | None = None,
|
||||
) -> Session | None:
|
||||
"""Mint a replacement session from a trusted-device refresh token.
|
||||
|
||||
@@ -1121,6 +1216,8 @@ class SessionManager:
|
||||
trusted.device_model = device_model
|
||||
if device_platform and device_platform != "unknown":
|
||||
trusted.device_platform = device_platform
|
||||
if supervised_mode is not None:
|
||||
trusted.supervised_mode = supervised_mode
|
||||
self._trusted_devices[new_refresh_hash] = trusted
|
||||
|
||||
session = self.create_session(
|
||||
@@ -1133,6 +1230,7 @@ class SessionManager:
|
||||
device_form_factor=trusted.device_form_factor,
|
||||
device_model=trusted.device_model,
|
||||
device_platform=trusted.device_platform,
|
||||
supervised_mode=trusted.supervised_mode,
|
||||
issue_refresh_token=False,
|
||||
)
|
||||
session.refresh_token = new_refresh_token
|
||||
@@ -1153,6 +1251,7 @@ class SessionManager:
|
||||
device_platform: str | None = None,
|
||||
client_surface: str | None = None,
|
||||
device_form_factor: str | None = None,
|
||||
supervised_mode: SupervisedMode | None = None,
|
||||
) -> None:
|
||||
"""Adopt identity metadata from a valid reconnecting client.
|
||||
|
||||
@@ -1180,6 +1279,10 @@ class SessionManager:
|
||||
setattr(session, field_name, value)
|
||||
changed = True
|
||||
|
||||
if supervised_mode is not None and session.supervised_mode != supervised_mode:
|
||||
session.supervised_mode = supervised_mode
|
||||
changed = True
|
||||
|
||||
for trusted in self._trusted_devices.values():
|
||||
if trusted.device_id != session.device_id:
|
||||
continue
|
||||
@@ -1192,6 +1295,9 @@ class SessionManager:
|
||||
if getattr(trusted, field_name) != value:
|
||||
setattr(trusted, field_name, value)
|
||||
changed = True
|
||||
if supervised_mode is not None and trusted.supervised_mode != supervised_mode:
|
||||
trusted.supervised_mode = supervised_mode
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
self._save_to_disk()
|
||||
|
||||
+61
-1
@@ -53,6 +53,7 @@ from .auth import (
|
||||
RateLimiter,
|
||||
Session,
|
||||
SessionManager,
|
||||
parse_supervised_mode,
|
||||
)
|
||||
from .channels.bridge import BridgeError, BridgeHandler
|
||||
from .channels.chat import ChatHandler
|
||||
@@ -887,7 +888,7 @@ def _session_to_dict(session: Session, current_token: str | None) -> dict[str, A
|
||||
return None if math.isinf(ts) else ts
|
||||
|
||||
grants_out = {k: _norm(v) for k, v in session.grants.items()}
|
||||
return {
|
||||
payload = {
|
||||
"token_prefix": session.token[:8],
|
||||
"device_name": session.device_name,
|
||||
"device_id": session.device_id,
|
||||
@@ -903,6 +904,9 @@ def _session_to_dict(session: Session, current_token: str | None) -> dict[str, A
|
||||
"device_platform": session.device_platform,
|
||||
"is_current": current_token is not None and session.token == current_token,
|
||||
}
|
||||
if session.supervised_mode.active:
|
||||
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
|
||||
return payload
|
||||
|
||||
|
||||
def _require_bearer_session(
|
||||
@@ -1012,6 +1016,18 @@ async def handle_sessions_revoke(request: web.Request) -> web.Response:
|
||||
target = matches[0]
|
||||
revoked_self = current_token is not None and target.token == current_token
|
||||
server.sessions.revoke_session(target.token)
|
||||
# Revocation ends already-connected Relay sockets as well as preventing
|
||||
# future authentication. This does not enforce the Android supervised
|
||||
# policy; it revokes the ordinary paired Relay session that reported it.
|
||||
revoked_sockets = [
|
||||
ws for ws, token in server._clients.items() if token == target.token
|
||||
]
|
||||
for ws in revoked_sockets:
|
||||
if not ws.closed:
|
||||
await ws.close(
|
||||
code=aiohttp.WSCloseCode.POLICY_VIOLATION,
|
||||
message=b"Relay session revoked",
|
||||
)
|
||||
route_credential_id = credential_id_for(target.token)
|
||||
server.secure_link_route_credentials.pop(route_credential_id, None)
|
||||
if server.secure_link_connector is not None:
|
||||
@@ -4209,6 +4225,8 @@ def _build_auth_ok_payload(
|
||||
}
|
||||
if session.refresh_token:
|
||||
payload["refresh_token"] = session.refresh_token
|
||||
if session.supervised_mode.active:
|
||||
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
|
||||
if route_credential is not None:
|
||||
payload["route_credential"] = route_credential
|
||||
return payload
|
||||
@@ -4310,6 +4328,11 @@ async def _authenticate(
|
||||
device_platform = str(
|
||||
payload.get("device_platform", "unknown") or "unknown"
|
||||
).strip()
|
||||
# Every authentication is a fresh client report. Missing or invalid
|
||||
# metadata explicitly returns the paired session to ordinary mode rather
|
||||
# than leaving a stale supervised badge behind after the client disables
|
||||
# the mode or downgrades.
|
||||
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
|
||||
|
||||
# Pairing policy is attached by a loopback-only operator flow. Clients
|
||||
# may still send ttl_seconds / grants for wire compatibility, but those
|
||||
@@ -4338,6 +4361,7 @@ async def _authenticate(
|
||||
device_form_factor=(
|
||||
device_form_factor if "device_form_factor" in payload else None
|
||||
),
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
if (
|
||||
not refresh_token_attempt
|
||||
@@ -4370,6 +4394,7 @@ async def _authenticate(
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
if session is not None:
|
||||
server.rate_limiter.record_success(remote_ip)
|
||||
@@ -4403,6 +4428,7 @@ async def _authenticate(
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
server.rate_limiter.record_success(remote_ip)
|
||||
@@ -4584,6 +4610,40 @@ async def _handle_system(
|
||||
elif msg_type == "pong":
|
||||
# Client responding to our ping — nothing to do
|
||||
pass
|
||||
elif msg_type == "supervised.update":
|
||||
# Informational update from an already-authenticated Android client.
|
||||
# The socket's paired session is the only ownership input: payload
|
||||
# fields cannot select or modify another session. Relay deliberately
|
||||
# does not enforce the reported client policy.
|
||||
token = server._clients.get(ws)
|
||||
session = server.sessions.get_session(token) if token else None
|
||||
if session is None:
|
||||
await _send_system(
|
||||
ws,
|
||||
"error",
|
||||
{"message": "Authenticated Relay session is no longer valid"},
|
||||
msg_id,
|
||||
)
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
|
||||
server.sessions.update_session_device_metadata(
|
||||
session,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
applied: dict[str, Any] = {
|
||||
"active": False,
|
||||
"enforcement_owner": "android_client",
|
||||
}
|
||||
if supervised_mode.active:
|
||||
applied = supervised_mode.to_public_dict()
|
||||
await _send_system(
|
||||
ws,
|
||||
"supervised.updated",
|
||||
{"supervised_mode": applied},
|
||||
msg_id,
|
||||
)
|
||||
else:
|
||||
logger.debug("Unhandled system message type: %s", msg_type)
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Client-reported supervised-mode metadata is bounded and informational."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from plugin.relay.auth import (
|
||||
SUPERVISED_CAPABILITY_MAX_COUNT,
|
||||
SUPERVISED_PROFILE_LABEL_MAX_LENGTH,
|
||||
SessionManager,
|
||||
SupervisedMode,
|
||||
parse_supervised_mode,
|
||||
)
|
||||
from plugin.relay.config import RelayConfig
|
||||
from plugin.relay.server import RelayServer, _build_auth_ok_payload, create_app
|
||||
|
||||
|
||||
class SupervisedModeParsingTests(unittest.TestCase):
|
||||
def test_valid_report_is_normalized_and_deduplicated(self) -> None:
|
||||
parsed = parse_supervised_mode(
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": " Learning ",
|
||||
"capabilities": ["text_chat", "voice", "voice"],
|
||||
"enforcement_owner": "server", # client cannot override it
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
parsed,
|
||||
SupervisedMode(True, "Learning", ("text_chat", "voice")),
|
||||
)
|
||||
self.assertEqual(parsed.to_public_dict()["enforcement_owner"], "android_client")
|
||||
|
||||
def test_missing_inactive_and_malformed_reports_are_ordinary(self) -> None:
|
||||
invalid = (
|
||||
None,
|
||||
[],
|
||||
{"active": False, "profile_label": "Learning"},
|
||||
{"active": "true", "profile_label": "Learning"},
|
||||
{"active": True, "profile_label": 7},
|
||||
{"active": True, "profile_label": "Learning", "capabilities": {}},
|
||||
{"active": True, "profile_label": "Learning\n", "capabilities": []},
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["model:gpt-private"],
|
||||
},
|
||||
)
|
||||
for value in invalid:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(parse_supervised_mode(value), SupervisedMode())
|
||||
|
||||
def test_oversized_report_is_ordinary(self) -> None:
|
||||
self.assertEqual(
|
||||
parse_supervised_mode(
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "x" * (SUPERVISED_PROFILE_LABEL_MAX_LENGTH + 1),
|
||||
"capabilities": [],
|
||||
}
|
||||
),
|
||||
SupervisedMode(),
|
||||
)
|
||||
self.assertEqual(
|
||||
parse_supervised_mode(
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["voice"] * (SUPERVISED_CAPABILITY_MAX_COUNT + 1),
|
||||
}
|
||||
),
|
||||
SupervisedMode(),
|
||||
)
|
||||
|
||||
|
||||
class SupervisedModePersistenceTests(unittest.TestCase):
|
||||
def test_active_report_and_trusted_device_survive_restart_and_refresh(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "sessions.json"
|
||||
mode = SupervisedMode(
|
||||
True,
|
||||
"Learning",
|
||||
("text_chat", "attachments", "voice"),
|
||||
)
|
||||
manager = SessionManager(persistence_path=path)
|
||||
session = manager.create_session(
|
||||
"Managed phone",
|
||||
"managed-phone-id",
|
||||
supervised_mode=mode,
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
refresh_token = session.refresh_token
|
||||
assert refresh_token is not None
|
||||
|
||||
reloaded = SessionManager(persistence_path=path)
|
||||
restored = reloaded.get_session(session.token)
|
||||
self.assertIsNotNone(restored)
|
||||
assert restored is not None
|
||||
self.assertEqual(restored.supervised_mode, mode)
|
||||
|
||||
reloaded._sessions.clear()
|
||||
replacement = reloaded.refresh_session(
|
||||
refresh_token,
|
||||
device_name="Managed phone",
|
||||
device_id="managed-phone-id",
|
||||
)
|
||||
self.assertIsNotNone(replacement)
|
||||
assert replacement is not None
|
||||
self.assertEqual(replacement.supervised_mode, mode)
|
||||
|
||||
def test_legacy_and_invalid_disk_rows_load_as_ordinary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "sessions.json"
|
||||
manager = SessionManager(persistence_path=path)
|
||||
session = manager.create_session("Legacy phone", "legacy-id")
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
payload["sessions"][0]["supervised_mode"] = {
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["unknown_future_value"],
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
reloaded = SessionManager(persistence_path=path)
|
||||
restored = reloaded.get_session(session.token)
|
||||
self.assertIsNotNone(restored)
|
||||
assert restored is not None
|
||||
self.assertEqual(restored.supervised_mode, SupervisedMode())
|
||||
|
||||
payload["sessions"][0].pop("supervised_mode")
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
legacy = SessionManager(persistence_path=path).get_session(session.token)
|
||||
self.assertIsNotNone(legacy)
|
||||
assert legacy is not None
|
||||
self.assertEqual(legacy.supervised_mode, SupervisedMode())
|
||||
|
||||
def test_auth_ok_only_emits_active_client_report(self) -> None:
|
||||
server = RelayServer(RelayConfig())
|
||||
ordinary = server.sessions.create_session("Phone", "ordinary-id")
|
||||
self.assertNotIn("supervised_mode", _build_auth_ok_payload(ordinary, server))
|
||||
|
||||
managed = server.sessions.create_session(
|
||||
"Managed phone",
|
||||
"managed-id",
|
||||
supervised_mode=SupervisedMode(True, "Learning", ("voice",)),
|
||||
)
|
||||
report = _build_auth_ok_payload(managed, server)["supervised_mode"]
|
||||
self.assertEqual(report["profile_label"], "Learning")
|
||||
self.assertEqual(report["capabilities"], ["voice"])
|
||||
self.assertEqual(report["enforcement_owner"], "android_client")
|
||||
|
||||
|
||||
class SupervisedModeSessionRoutesTests(AioHTTPTestCase):
|
||||
async def get_application(self) -> web.Application:
|
||||
return create_app(RelayConfig())
|
||||
|
||||
async def test_list_exposes_active_report_and_omits_ordinary_report(self) -> None:
|
||||
ordinary = self.app["server"].sessions.create_session("Phone", "ordinary-id")
|
||||
self.app["server"].sessions.create_session(
|
||||
"Managed phone",
|
||||
"managed-id",
|
||||
supervised_mode=SupervisedMode(
|
||||
True, "Learning", ("text_chat", "attachments")
|
||||
),
|
||||
)
|
||||
|
||||
response = await self.client.get(
|
||||
"/sessions", headers={"Authorization": f"Bearer {ordinary.token}"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
rows = {row["device_name"]: row for row in (await response.json())["sessions"]}
|
||||
self.assertNotIn("supervised_mode", rows["Phone"])
|
||||
self.assertEqual(
|
||||
rows["Managed phone"]["supervised_mode"],
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["text_chat", "attachments"],
|
||||
"enforcement_owner": "android_client",
|
||||
},
|
||||
)
|
||||
|
||||
async def test_pairing_auth_records_and_returns_client_report(self) -> None:
|
||||
response = await self.client.post(
|
||||
"/pairing/register", json={"code": "MODE01"}
|
||||
)
|
||||
self.assertEqual(response.status, 200, await response.text())
|
||||
|
||||
socket = await self.client.ws_connect("/ws")
|
||||
await socket.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "auth",
|
||||
"payload": {
|
||||
"pairing_code": "MODE01",
|
||||
"device_name": "Managed phone",
|
||||
"device_id": "managed-auth-id",
|
||||
"client_surface": "android",
|
||||
"supervised_mode": {
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["text_chat", "voice"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
envelope = await socket.receive_json()
|
||||
await socket.close()
|
||||
|
||||
self.assertEqual(envelope["type"], "auth.ok")
|
||||
report = envelope["payload"]["supervised_mode"]
|
||||
self.assertEqual(report["profile_label"], "Learning")
|
||||
self.assertEqual(report["capabilities"], ["text_chat", "voice"])
|
||||
self.assertEqual(report["enforcement_owner"], "android_client")
|
||||
stored = self.app["server"].sessions.get_session(
|
||||
envelope["payload"]["session_token"]
|
||||
)
|
||||
self.assertIsNotNone(stored)
|
||||
assert stored is not None
|
||||
self.assertTrue(stored.supervised_mode.active)
|
||||
|
||||
reconnect = await self.client.ws_connect("/ws")
|
||||
await reconnect.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "auth",
|
||||
"payload": {
|
||||
"session_token": stored.token,
|
||||
"device_id": "managed-auth-id",
|
||||
},
|
||||
}
|
||||
)
|
||||
ordinary_envelope = await reconnect.receive_json()
|
||||
await reconnect.close()
|
||||
self.assertEqual(ordinary_envelope["type"], "auth.ok")
|
||||
self.assertNotIn("supervised_mode", ordinary_envelope["payload"])
|
||||
refreshed = self.app["server"].sessions.get_session(stored.token)
|
||||
self.assertIsNotNone(refreshed)
|
||||
assert refreshed is not None
|
||||
self.assertFalse(refreshed.supervised_mode.active)
|
||||
|
||||
async def test_authenticated_live_update_is_owned_acked_and_persisted(self) -> None:
|
||||
manager = self.app["server"].sessions
|
||||
original = SupervisedMode(True, "Learning", ("text_chat", "voice"))
|
||||
session = manager.create_session(
|
||||
"Managed phone",
|
||||
"managed-live-id",
|
||||
supervised_mode=original,
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
other = manager.create_session(
|
||||
"Other phone",
|
||||
"other-id",
|
||||
supervised_mode=SupervisedMode(True, "Other", ("text_chat",)),
|
||||
)
|
||||
|
||||
socket = await self.client.ws_connect("/ws")
|
||||
await socket.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "auth",
|
||||
"payload": {
|
||||
"session_token": session.token,
|
||||
"device_id": session.device_id,
|
||||
"supervised_mode": original.to_public_dict(),
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertEqual((await socket.receive_json())["type"], "auth.ok")
|
||||
|
||||
await socket.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "supervised.update",
|
||||
"id": "update-active",
|
||||
"payload": {
|
||||
# Must be ignored: ownership comes from the authenticated
|
||||
# socket, not any selector supplied in the update body.
|
||||
"session_token": other.token,
|
||||
"supervised_mode": {
|
||||
"active": True,
|
||||
"profile_label": "School",
|
||||
"capabilities": ["text_chat", "attachments"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
ack = await socket.receive_json()
|
||||
self.assertEqual(ack["type"], "supervised.updated")
|
||||
self.assertEqual(ack["id"], "update-active")
|
||||
self.assertEqual(
|
||||
ack["payload"]["supervised_mode"],
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "School",
|
||||
"capabilities": ["text_chat", "attachments"],
|
||||
"enforcement_owner": "android_client",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
manager.get_session(session.token).supervised_mode,
|
||||
SupervisedMode(True, "School", ("text_chat", "attachments")),
|
||||
)
|
||||
self.assertEqual(manager.get_session(other.token).supervised_mode.profile_label, "Other")
|
||||
self.assertTrue(
|
||||
any(
|
||||
device.device_id == session.device_id
|
||||
and device.supervised_mode.profile_label == "School"
|
||||
for device in manager._trusted_devices.values()
|
||||
)
|
||||
)
|
||||
|
||||
await socket.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "supervised.update",
|
||||
"id": "update-inactive",
|
||||
"payload": {"supervised_mode": {"active": False}},
|
||||
}
|
||||
)
|
||||
cleared = await socket.receive_json()
|
||||
await socket.close()
|
||||
self.assertEqual(cleared["type"], "supervised.updated")
|
||||
self.assertEqual(cleared["id"], "update-inactive")
|
||||
self.assertEqual(
|
||||
cleared["payload"]["supervised_mode"],
|
||||
{"active": False, "enforcement_owner": "android_client"},
|
||||
)
|
||||
self.assertFalse(manager.get_session(session.token).supervised_mode.active)
|
||||
self.assertTrue(
|
||||
all(
|
||||
not device.supervised_mode.active
|
||||
for device in manager._trusted_devices.values()
|
||||
if device.device_id == session.device_id
|
||||
)
|
||||
)
|
||||
|
||||
async def test_revoke_closes_connected_relay_socket(self) -> None:
|
||||
caller = self.app["server"].sessions.create_session("Caller", "caller-id")
|
||||
target = self.app["server"].sessions.create_session(
|
||||
"Managed phone",
|
||||
"managed-id",
|
||||
supervised_mode=SupervisedMode(True, "Learning", ("text_chat",)),
|
||||
)
|
||||
socket = AsyncMock()
|
||||
socket.closed = False
|
||||
self.app["server"]._clients[socket] = target.token
|
||||
|
||||
response = await self.client.delete(
|
||||
f"/sessions/{target.token[:8]}",
|
||||
headers={"Authorization": f"Bearer {caller.token}"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIsNone(self.app["server"].sessions.get_session(target.token))
|
||||
socket.close.assert_awaited_once()
|
||||
self.assertEqual(
|
||||
socket.close.await_args.kwargs["message"], b"Relay session revoked"
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify whether a stable release commit needs reconciliation into dev."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
|
||||
def classify_release(
|
||||
release_commit: str,
|
||||
dev_commit: str,
|
||||
parents: Sequence[str],
|
||||
is_ancestor: Callable[[str, str], bool],
|
||||
) -> str:
|
||||
"""Return already-contained, normal-release, or hotfix."""
|
||||
if is_ancestor(release_commit, dev_commit):
|
||||
return "already-contained"
|
||||
if len(parents) < 2:
|
||||
raise ValueError("stable release commit is not a release/hotfix merge commit")
|
||||
if is_ancestor(parents[1], dev_commit):
|
||||
return "normal-release"
|
||||
return "hotfix"
|
||||
|
||||
|
||||
def git(*args: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def git_is_ancestor(older: str, newer: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "merge-base", "--is-ancestor", older, newer],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode not in {0, 1}:
|
||||
raise RuntimeError(result.stderr.strip() or "git merge-base failed")
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--release-commit", required=True)
|
||||
parser.add_argument("--dev-commit", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
release_commit = git("rev-parse", f"{args.release_commit}^{{commit}}")
|
||||
dev_commit = git("rev-parse", f"{args.dev_commit}^{{commit}}")
|
||||
parents = git("show", "-s", "--format=%P", release_commit).split()
|
||||
print(
|
||||
classify_release(
|
||||
release_commit,
|
||||
dev_commit,
|
||||
parents,
|
||||
git_is_ancestor,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from scripts.plan_release_backmerge import classify_release
|
||||
|
||||
|
||||
class ReleaseBackmergePlanTest(unittest.TestCase):
|
||||
def test_already_contained_release_is_a_noop(self) -> None:
|
||||
ancestry = {("release", "dev")}
|
||||
self.assertEqual(
|
||||
classify_release(
|
||||
"release",
|
||||
"dev",
|
||||
["main", "topic"],
|
||||
lambda older, newer: (older, newer) in ancestry,
|
||||
),
|
||||
"already-contained",
|
||||
)
|
||||
|
||||
def test_normal_release_with_dev_parent_is_a_noop(self) -> None:
|
||||
ancestry = {("released-dev", "dev")}
|
||||
self.assertEqual(
|
||||
classify_release(
|
||||
"release",
|
||||
"dev",
|
||||
["previous-main", "released-dev"],
|
||||
lambda older, newer: (older, newer) in ancestry,
|
||||
),
|
||||
"normal-release",
|
||||
)
|
||||
|
||||
def test_selective_hotfix_requires_backmerge(self) -> None:
|
||||
self.assertEqual(
|
||||
classify_release(
|
||||
"release",
|
||||
"dev",
|
||||
["previous-main", "hotfix-topic"],
|
||||
lambda _older, _newer: False,
|
||||
),
|
||||
"hotfix",
|
||||
)
|
||||
|
||||
def test_non_merge_release_commit_fails_closed(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "not a release/hotfix merge commit"):
|
||||
classify_release("release", "dev", ["parent"], lambda _older, _newer: False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -220,6 +220,7 @@ export default defineConfig({
|
||||
{ text: 'Remote access', link: '/guide/remote-access' },
|
||||
{ text: 'Release tracks', link: '/guide/release-tracks' },
|
||||
{ text: 'Chat', link: '/guide/chat' },
|
||||
{ text: 'Supervised Mode', link: '/guide/supervised-mode' },
|
||||
{ text: 'Sessions', link: '/guide/sessions' },
|
||||
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
|
||||
],
|
||||
|
||||
@@ -98,6 +98,12 @@ Google Play builds do not include AccessibilityService-backed screen reading or
|
||||
| Message history | Loads from server on session switch |
|
||||
| Persistence | Last session resumes on app restart |
|
||||
|
||||
## Supervised access
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| Android Supervised Mode | Planned | Parent-controlled, profile-pinned restricted client interface; not a server-enforced child account. See the [Supervised Mode guide](/guide/supervised-mode). |
|
||||
|
||||
## Analytics
|
||||
|
||||
| Feature | Description |
|
||||
|
||||
@@ -53,6 +53,7 @@ voice routes. Sideload builds additionally expose Android Device Control routes.
|
||||
- [Quick Start](/guide/quick-start) — Recommended Android + Relay setup
|
||||
- [Installation & Setup](/guide/getting-started) — Builds, manual setup, and fallbacks
|
||||
- [Chat Guide](/guide/chat) — Using the chat interface
|
||||
- [Supervised Mode](/guide/supervised-mode) — Planned parent-controlled, profile-pinned Android interface
|
||||
- [Sessions](/guide/sessions) — Managing conversations
|
||||
- [Features](/features/) — All features at a glance
|
||||
- [Architecture](/architecture/) — How it works under the hood
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
title: Supervised Mode
|
||||
description: Configure a parent-controlled, profile-pinned Hermes-Relay Android experience
|
||||
---
|
||||
|
||||
# Supervised Mode
|
||||
|
||||
::: warning Physical certification pending
|
||||
Supervised Mode is implemented in the Android client, but it has not completed
|
||||
physical managed-device certification. Treat it as experimental and do not call
|
||||
an installation child-ready until the managed-device checks below pass.
|
||||
:::
|
||||
|
||||
Supervised Mode is a parent-controlled, restricted view of Hermes-Relay for
|
||||
Android. It is intended for a parent or guardian who has already created and
|
||||
reviewed a suitably restricted Hermes profile and wants the phone app to expose
|
||||
only an approved set of chat features.
|
||||
|
||||
It is a client-interface control, not a child account or a server security
|
||||
boundary. The selected Hermes profile still controls the agent's prompt, model,
|
||||
tools, provider credentials, content behavior, and server-side data.
|
||||
|
||||
## Before enabling it
|
||||
|
||||
Prepare the Hermes profile first. At minimum, review its:
|
||||
|
||||
- identity and system instructions;
|
||||
- model and provider safety settings;
|
||||
- enabled skills, tools, and external services;
|
||||
- memory, files, schedules, and existing sessions;
|
||||
- voice and image-generation providers;
|
||||
- retention and parental-review expectations.
|
||||
|
||||
Supervised Mode cannot make an unrestricted profile safe by hiding controls on
|
||||
the phone. Ordinary prose can still cause the configured agent to use whatever
|
||||
server-side capabilities that profile has.
|
||||
|
||||
## Set it up
|
||||
|
||||
From full Android Settings, the parent:
|
||||
|
||||
1. Open **Settings → Advanced → Supervised Mode** for the active Hermes
|
||||
Connection.
|
||||
2. Choose one existing named profile. Android requires a secure device screen
|
||||
lock before the mode can be enabled.
|
||||
3. Select the allowed features and any stricter attachment or history limits.
|
||||
4. Choose a visibility preset or customize what appears in Chat.
|
||||
5. Review the summary, then enable the mode.
|
||||
|
||||
The app returns to the pinned profile's Chat screen. If the Connection or
|
||||
profile is unavailable, the restricted client shows a recovery state
|
||||
without falling back to another profile or exposing full Settings.
|
||||
|
||||
## The everyday experience
|
||||
|
||||
Chat should look like ordinary Hermes-Relay Chat. There is no persistent
|
||||
Supervised Mode banner consuming conversation space. The agent name and avatar
|
||||
remain the primary identity, with a small connection state when permitted.
|
||||
|
||||
The existing Settings button opens **Restricted Settings**, which contains only
|
||||
approved preferences. A clearly labelled **Parent access** row starts device
|
||||
authentication before any parent controls or full application settings appear.
|
||||
|
||||
Restricted Settings may include:
|
||||
|
||||
- a supervised-only theme, text size, language, and haptics;
|
||||
- parent-approved pet display and, when allowed, a phone-local profile icon and
|
||||
chat background;
|
||||
- accessibility preferences;
|
||||
- message presentation and sensitive-media blur;
|
||||
- harmless playback or interaction preferences when voice is allowed;
|
||||
- Help and About;
|
||||
- the locked Parent access row.
|
||||
|
||||
Connections, profiles, Manage, model controls, personalities, reasoning,
|
||||
approvals, tools, plugins, Terminal, TUI, Bridge, Device Control, notification
|
||||
companion, diagnostics, logs, files, credentials, developer options, Relay
|
||||
management, and other sessions are not shown.
|
||||
|
||||
The command palette, slash autocomplete, server command catalog, and command
|
||||
action cards are also absent. Messages whose first non-whitespace character is
|
||||
`/` are rejected by the restricted client. Approved outcomes such as New chat
|
||||
and Cancel remain normal, explicit buttons. If the agent requests approval, a
|
||||
secret, clarification, or elevated access, the restricted client denies or
|
||||
skips that request and shows a short notice. A parent can retry the task later
|
||||
from the full client after authentication.
|
||||
|
||||
## Allowed features
|
||||
|
||||
The parent chooses capabilities independently. The proposed controls are:
|
||||
|
||||
| Capability | Suggested default | Effect when disabled |
|
||||
|---|---:|---|
|
||||
| Text chat | On | Required for the restricted chat experience |
|
||||
| New chat | On | Removes the new-conversation action |
|
||||
| Cancel reply | On | Removes Stop while a reply is running |
|
||||
| Steer reply | On | Queues or disables mid-reply input instead |
|
||||
| Attachments | Parent choice | Removes pickers, camera/share intake, paste-to-file, and restored attachment drafts |
|
||||
| Standard voice | Parent choice | Removes recording, voice intents, and voice preferences |
|
||||
| Generated media | On | Hides generated-image viewing and related actions |
|
||||
| Save/share media | Off | Keeps permitted media view-only inside the app |
|
||||
| Copy replies | On | Removes copy actions |
|
||||
| Retry | On | Removes retry/regenerate actions |
|
||||
| Quote/reply | On | Removes quote/reply actions |
|
||||
| Edit and resend | Off | Prevents rewriting earlier prompts from the client |
|
||||
| Session history | Parent choice | Limits the pinned profile to the current or approved conversations |
|
||||
| Session actions | Off | Individually allows pin, rename, archive, share, and delete for visible history |
|
||||
|
||||
Attachments are a general capability, not a one-image rule. When enabled, the
|
||||
normal supported attachment flow and app limits apply unless the parent chooses
|
||||
a stricter maximum size or permitted-type policy. When disabled, every Android
|
||||
entry point must be removed or rejected consistently, including share intents
|
||||
and a draft restored after process death.
|
||||
|
||||
Standard voice uses the existing host-side voice configuration. Provider
|
||||
credentials stay on the Hermes host and are not exposed in Restricted Settings.
|
||||
|
||||
The supervised theme is stored separately from the parent app theme and applies
|
||||
only while the restricted root is locked. Pet display is parent-controlled.
|
||||
Profile-icon and background changes can be enabled independently for the
|
||||
supervised user; the authenticated parent retains those controls either way.
|
||||
|
||||
Session actions use a separate allowlist. The parent can allow all, allow none,
|
||||
or choose individual actions. Copying technical session identifiers, browsing
|
||||
other profiles, Relay Threads, and drawer customization remain unavailable.
|
||||
Delete continues to require confirmation.
|
||||
|
||||
Generated media is limited by display policy, not by a claim that Android can
|
||||
prove how the server created it. Parents may allow viewing while disabling save
|
||||
and share. Ordinary remote links, files, and unsupported media retain the app's
|
||||
normal safety behavior.
|
||||
|
||||
## What appears in Chat
|
||||
|
||||
Visibility controls affect presentation only. They never suppress an error,
|
||||
safety notice, parent-action state, or connection failure that requires
|
||||
attention.
|
||||
|
||||
### Simple (recommended)
|
||||
|
||||
- Shows the agent name and avatar.
|
||||
- Shows generic **Connected**, **Working**, and **Reconnecting** states.
|
||||
- Hides model, profile, provider, route, context, token usage, reasoning, and
|
||||
tool details.
|
||||
- Keeps the header and composer visually quiet.
|
||||
|
||||
### Transparent
|
||||
|
||||
Adds parent-approved timestamps, bounded usage or context information, and
|
||||
safe activity labels. It still does not reveal tool arguments, tool results,
|
||||
host paths, credentials, or administration surfaces.
|
||||
|
||||
### Custom
|
||||
|
||||
Lets the parent control individual surfaces, including:
|
||||
|
||||
- model name and profile name;
|
||||
- connection state and route identity;
|
||||
- timestamps, context, and token usage;
|
||||
- generic work status, tool names, and tool detail;
|
||||
- reasoning visibility;
|
||||
- message and media actions.
|
||||
|
||||
Model and profile names default off for a new policy. The agent's friendly name
|
||||
and avatar provide the normal identity in the Simple preset.
|
||||
|
||||
## Parent access and relocking
|
||||
|
||||
Enabling, changing, or ending Supervised Mode requires Android device
|
||||
authentication. Parent access should relock when its authenticated task closes,
|
||||
after the configured inactivity period, when the app backgrounds, or after
|
||||
process recreation.
|
||||
|
||||
Android's device-credential prompt authenticates any user enrolled for that
|
||||
device; it does not establish a separate parent identity. Use a device lock the
|
||||
supervised user does not know, or keep the device under direct supervision.
|
||||
|
||||
The restricted root is restored before the first interactive screen. Deep
|
||||
links, notification actions, shortcuts, saved back stacks, and share intents
|
||||
must not provide a route around it. A missing or unreadable policy fails closed
|
||||
to restricted recovery instead of opening full Settings.
|
||||
|
||||
Ending the mode may clear local drafts, pending attachments, and supervised
|
||||
media caches according to the parent's choice. It does not automatically delete
|
||||
Hermes sessions or history stored on the server. Parents review or delete that
|
||||
history through their normal authenticated Hermes interface.
|
||||
|
||||
## Optional Relay visibility
|
||||
|
||||
If the Android client is paired with the optional Relay plugin, it may identify
|
||||
itself with a client-reported **Supervised** tag and a short, non-sensitive
|
||||
capability summary. The Relay UI can then make the device easy to recognize and
|
||||
can revoke its paired Relay session through the normal paired-device controls.
|
||||
|
||||
The tag is informational. Relay does not enforce the Android policy, pin the
|
||||
Hermes profile, filter direct Dashboard/Gateway chat, or certify that the client
|
||||
is unmodified. Revoking the Relay session disables Relay-backed access for that
|
||||
pairing; it does not remotely end an Android-only mode or revoke an independent
|
||||
Dashboard sign-in. Supervised Mode does not require Relay.
|
||||
|
||||
## Limits of protection
|
||||
|
||||
Supervised Mode cannot control:
|
||||
|
||||
- another Hermes client or a modified Android build;
|
||||
- someone with direct access to the Hermes server or parent credentials;
|
||||
- tools, files, services, and provider behavior enabled in the selected profile;
|
||||
- server-side session retention or provider data handling;
|
||||
- the developmental suitability or factual accuracy of model output;
|
||||
- Android behavior outside the Hermes-Relay app.
|
||||
|
||||
Use it alongside a restrictive Hermes profile, parental supervision, Android
|
||||
parental or enterprise controls where appropriate, and regular review of the
|
||||
profile and its conversations.
|
||||
|
||||
## Certification requirement
|
||||
|
||||
The feature should not be described as child-ready until the exact Android
|
||||
build passes automated policy and navigation tests plus physical testing on a
|
||||
managed/restricted Android device. Certification must cover authentication,
|
||||
relocking, restart and offline recovery, process death, deep links,
|
||||
notifications, share intents, attachments, voice, session ownership, Relay
|
||||
tagging/revocation, and attempts to escape the restricted interface.
|
||||
|
||||
See [Profiles](/features/profiles) for the server-owned identity model and
|
||||
[Chat](/guide/chat) for the full, unrestricted interface.
|
||||
Reference in New Issue
Block a user