Compare commits

...
Author SHA1 Message Date
Bailey Dixon e55dd99f62 Merge pull request #184 from Codename-11/dev
release(android): android-v1.4.2
2026-07-11 21:27:39 -04:00
Bailey Dixon 7793934edf Merge pull request #183 from Codename-11/feature/android-i18n-zh-salvage
feat(android): add scalable Simplified Chinese localization
2026-07-11 21:02:01 -04:00
Bailey Dixon f49c6c4203 release(android): prepare android-v1.4.2 2026-07-11 20:52:27 -04:00
Bailey DixonandEasongChung 52a7d67cc4 feat(android): add scalable Simplified Chinese localization
Salvages and integrates the user-facing localization work from PR #180 onto current dev, with complete catalogs, CI parity checks, translated entry points, and contributor guidance.

Co-authored-by: EasongChung <easong@users.noreply.github.com>
2026-07-11 20:52:07 -04:00
126 changed files with 10921 additions and 3253 deletions
+13
View File
@@ -12,10 +12,22 @@
-
## Lineage / contributor credit
<!--
If this PR salvages or supersedes earlier work, link every source PR and name
the original contributor(s). Preserve original commit authors where practical;
otherwise use verified Co-authored-by trailers. Write "N/A" for original work.
-->
- Source PR(s): N/A
- Attribution preserved by: N/A
## Checklist
- [ ] Target branch is `dev` unless this is a release PR
- [ ] Android changes: lint and focused unit tests ran, or rationale is listed above
- [ ] Translation changes: `python scripts/check-android-locales.py` ran and the locale was reviewed on a device/emulator, or N/A
- [ ] Server changes: focused `python -m unittest ...` checks ran, or rationale is listed above
- [ ] Desktop changes: `npm run build` or a narrower documented check ran, or rationale is listed above
- [ ] Docs/site changes: docs build or link check ran, or rationale is listed above
@@ -23,3 +35,4 @@
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
- [ ] CHANGELOG.md updated (if user-facing)
- [ ] Public writing hygiene checked: no secrets, private infrastructure, personal names, or AI/process narration
- [ ] Salvaged work links the source PR and preserves contributor authorship, or N/A
+5
View File
@@ -25,6 +25,7 @@ on:
- "gradle.properties"
- "gradlew"
- "gradlew.bat"
- "scripts/check-android-locales.py"
- ".github/workflows/ci-android.yml"
pull_request:
branches: [main, dev]
@@ -36,6 +37,7 @@ on:
- "gradle.properties"
- "gradlew"
- "gradlew.bat"
- "scripts/check-android-locales.py"
- ".github/workflows/ci-android.yml"
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
@@ -66,6 +68,9 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
- name: Validate translation catalogs
run: python3 scripts/check-android-locales.py
- name: Run Android lint
run: ./gradlew lint --console=plain
+14 -1
View File
@@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [1.4.2] - 2026-07-11
### Added
- **Android now supports Simplified Chinese.** Chat, Manage, Voice, connection setup, settings, diagnostics, notifications, accessibility labels, and both product flavors follow the device language, with Android per-app language discovery on supported versions.
- **Localization is contributor-ready.** CI enforces resource, plural, and format-argument parity; translated README and VitePress entry points establish a repeatable path for adding languages without duplicating fast-moving technical references.
### Fixed
- **Connection scan and queued-message counts use proper plurals.** Count formatting no longer depends on English-only suffix arguments and cannot fail when a locale needs a different plural structure.
## [1.4.1] - 2026-07-11
### Added
@@ -1523,7 +1534,9 @@ MVP release — native Android companion app for Hermes agent with direct API ch
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
- **ProGuard rules** — okhttp-sse, markdown renderer, intellij-markdown parser
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.0...HEAD
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.2...HEAD
[1.4.2]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.1...android-v1.4.2
[1.4.1]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.0...android-v1.4.1
[1.4.0]: https://github.com/Codename-11/hermes-relay/compare/android-v1.3.0...android-v1.4.0
[1.0.0]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v1.0.0
[0.8.1]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v0.8.1
+46
View File
@@ -96,6 +96,52 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`,
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `server-vX.Y.Z`, or `desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
## Stale PR salvage and contributor credit
A valuable pull request can become unsafe to merge when `dev` has materially
changed around it. Maintainers may create a replacement **salvage PR** from the
current `dev` instead of resolving a stale branch by choosing whole conflict
sides.
A salvage PR must:
- Link the original PR and contributor in its title or opening summary.
- Recover only the intended feature; unrelated fork, release, signing, and
generated migration changes stay out.
- Preserve the original commit author when a substantive commit can be safely
cherry-picked.
- Use a verified `Co-authored-by: Name <email>` trailer when the implementation
must be reconstructed or substantially rewritten.
- Include a `Lineage` section listing source and superseded PRs, plus a concise
explanation of integration changes made for current `dev`.
- Run current verification rather than relying on checks from the stale branch.
- Leave a comment linking the replacement before the source PR is closed.
The maintainer remains the committer for integration commits. The original
contributor remains the author or co-author of the recovered work. Do not guess
an email address: use the source commit's verified address or ask the
contributor.
## Localization contributions
English resources are canonical and Android locale catalogs must retain exact
resource and format-argument parity. Read [docs/localization.md](docs/localization.md)
before changing user-facing strings or adding a language.
Translation PRs should cover one locale or one clear catalog refresh. They must
not include custom APK publishing, signing configuration, version bumps, or
fork-specific branding. Run:
```bash
python scripts/check-android-locales.py
./gradlew lint
```
Also identify a fluent reviewer or explain the device and language review used.
Translated READMEs use separate `README.<locale>.md` files; `README.md` remains
the canonical project description. User docs may be added incrementally under
`user-docs/<locale>/`, with links back to canonical English reference material.
## Changelog & writing conventions
This is a **public repo** — `CHANGELOG.md`, `DEVLOG.md`, the README, and everything under `docs/` ship publicly. Keep them clean:
+25
View File
@@ -1,5 +1,30 @@
# Hermes-Relay — Dev Log
## 2026-07-11 — Android localization foundation and Simplified Chinese
The Android UI now resolves its broad static copy through canonical resources,
with a complete Simplified Chinese catalog under the script-qualified
`values-b+zh+Hans` directory and a matching sideload-flavor catalog. Android's
locale configuration advertises English and Simplified Chinese to system per-app
language settings. Current notification, voice-overlay, background-process,
diagnostic, attachment, card, crash, timeline, and session-TTL surfaces were
reconciled after the original localization branch diverged from `dev`.
`scripts/check-android-locales.py` discovers locale catalogs in every Android
source set and rejects malformed XML, duplicate resources, missing or extra
keys, mismatched resource types, incompatible format arguments, and locale
configuration drift. Android CI runs the checker before lint. Scan summaries and
queued-message counts were converted from English suffix formatting to Android
plurals after validation exposed a missing-format-argument path in the current
connection wizard.
The public contribution workflow now documents stale-PR salvage, preserved
authorship, scoped translation PRs, locale qualifiers, device review, and the
English canonical boundary. The root README links a maintained Chinese summary,
and VitePress exposes a Simplified Chinese locale with localized landing, quick
start, and feature pages while linking fast-moving reference material back to
canonical English.
## 2026-07-10 — In-flight Chat turns recover across app recreation
Current upstream Hermes can keep a running Dashboard/TUI Gateway session alive
+11
View File
@@ -21,6 +21,7 @@
</p>
<p align="center">
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a><br>
<a href="https://codename-11.github.io/hermes-relay/">Documentation</a> ·
<a href="https://github.com/Codename-11/hermes-relay/releases">Releases</a> ·
<a href="CHANGELOG.md">Changelog</a> ·
@@ -151,6 +152,16 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
</tr>
</table>
### Simplified Chinese
<table>
<tr>
<td align="center" width="33%"><img src="assets/screenshots/Zh01.jpg" alt="中文设置界面" width="100%"><br><sub><b>设置 — 全面汉化</b></sub></td>
<td align="center" width="33%"><img src="assets/screenshots/Zh02.jpg" alt="中文管理界面" width="100%"><br><sub><b>管理 — 仪表盘汉化</b></sub></td>
<td align="center" width="33%"><img src="assets/screenshots/Zh03.jpg" alt="中文导航界面" width="100%"><br><sub><b>导航菜单 — 简体中文</b></sub></td>
</tr>
</table>
<p align="center"><sub>▶ <a href="https://codename-11.github.io/hermes-relay/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
## Features
+104
View File
@@ -0,0 +1,104 @@
<p align="center">
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — 随身携带您的 Hermes 代理" width="800">
</p>
<p align="center">
<strong>运行在您的电脑上,连接到您的设备。</strong><br>
Hermes-Relay 是 <a href="https://github.com/NousResearch/hermes-agent">Hermes Agent</a> 的原生 Android 客户端,提供流式聊天、免手动语音和代理管理;另有单文件 CLI,让代理在已配对的电脑上安全使用终端、文件和截图工具。
</p>
<p align="center">
<strong>简体中文</strong> · <a href="README.md">English</a><br>
<a href="https://codename-11.github.io/hermes-relay/zh-CN/">中文文档</a> ·
<a href="https://github.com/Codename-11/hermes-relay/releases">版本下载</a> ·
<a href="CHANGELOG.md">更新日志</a>
</p>
> 英文 [README.md](README.md) 是最新、完整的项目说明。本页维护中文安装入口和核心功能摘要;协议、架构和维护者文档以英文版本为准。
## 功能简介
- **Android 应用**:流式聊天、会话历史、文件附件、Hermes 管理、语音模式、多连接和配置文件。
- **无需插件的标准路径**:聊天、管理和标准语音可直接连接未修改的上游 Hermes Agent。
- **可选 Relay 插件**:增加终端、手机控制、媒体传输、通知助手、Relay 语音和电脑工具。
- **安全连接**:二维码配对、Android Keystore、证书固定、按通道授权和可配置会话有效期。
- **远程使用**:可配置 Tailscale 或 HTTPS 地址,在家庭局域网和远程路由之间自动切换。
- **两种 Android 发行渠道**:Google Play 版本适合日常使用;sideload 版本包含完整手机控制能力。
## 快速开始
### 1. 安装 Android 应用
- [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay):自动更新,包含聊天、语音、管理、终端、媒体和通知功能。
- [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases):下载最新 `android-v*` 版本中以 `-sideload-release.apk` 结尾的文件,获得完整手机控制功能。
### 2. 启动 Hermes API 服务
手机需要能够访问 Hermes API 服务,并使用 API 密钥进行身份验证:
```bash
hermes setup --portal
mkdir -p ~/.hermes
API_SERVER_KEY="$(openssl rand -hex 32)"
cat >> ~/.hermes/.env <<EOF
API_SERVER_ENABLED=true
API_SERVER_HOST=0.0.0.0
API_SERVER_PORT=8642
API_SERVER_KEY=$API_SERVER_KEY
EOF
chmod 600 ~/.hermes/.env
echo "Android API URL: http://<电脑IP>:8642 key: $API_SERVER_KEY"
hermes gateway
```
`0.0.0.0` 会让同一网络中的设备访问 API。请保留强密钥;离开可信局域网时,应使用 Tailscale 或 HTTPS 反向代理,不要直接把端口暴露到互联网。
### 3. 在手机上连接
打开应用后,可以:
- 扫描局域网中的 Hermes;
- 手动输入 `http://<主机>:8642` 和 API 密钥;
- 扫描包含 API、Dashboard 和可选 Relay 地址的设置二维码。
如需在手机上管理模型、密钥、技能和配置文件,请运行 Hermes Dashboard,并在应用的 **管理** 页面登录一次。同一登录会话也会启用标准语音。
### 4. 可选:安装 Relay
仅在需要终端、手机控制、媒体路由、Relay 会话、实时语音或电脑工具时安装:
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
hermes relay doctor
hermes relay start --no-ssl
hermes pair
```
完整说明请阅读[中文快速开始](https://codename-11.github.io/hermes-relay/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
## 中文界面
<table>
<tr>
<td align="center" width="33%"><img src="assets/screenshots/Zh01.jpg" alt="中文设置界面" width="100%"><br><sub><b>设置</b></sub></td>
<td align="center" width="33%"><img src="assets/screenshots/Zh02.jpg" alt="中文管理界面" width="100%"><br><sub><b>管理</b></sub></td>
<td align="center" width="33%"><img src="assets/screenshots/Zh03.jpg" alt="中文导航界面" width="100%"><br><sub><b>导航</b></sub></td>
</tr>
</table>
## 参与翻译
Android 英文资源是规范来源。新增语言必须保持资源名称、类型和格式参数一致,并通过:
```bash
python scripts/check-android-locales.py
./gradlew lint
```
翻译规范、目录命名、复数和占位符规则见 [docs/localization.md](docs/localization.md)。
## 许可证
[MIT](LICENSE) — Copyright (c) 2026 [Axiom-Labs](https://codename-11.dev)
+15 -20
View File
@@ -1,18 +1,18 @@
# Hermes-Relay-Android v1.4.1
# Hermes-Relay-Android v1.4.2
**Release Date:** July 11, 2026
**Since v1.4.0:** Chat now keeps durable work visible and recoverable. Follow background terminal work from the conversation, receive its completion automatically, and reopen the app into the same in-flight answer with its visible progress intact. Voice adds practical spoken controls and mode presets, while streaming chat gets smoother Markdown, table, and image handling.
**Since v1.4.1:** Hermes-Relay now supports Simplified Chinese throughout the Android app. Android's per-app language settings can switch between English and Simplified Chinese, and the localization structure, validation, and contributor documentation make future languages easier to add safely.
v1.4.1 is recommended for everyone. Realtime Agent delivery hardening and voice presets pair with relay plugin v1.4.1; Standard chat and Vanilla Hermes voice remain compatible with unmodified upstream Hermes.
v1.4.2 is recommended for Simplified Chinese users and anyone contributing a translation. This is an Android and documentation release; it does not require a relay plugin update. Standard chat and Vanilla Hermes voice remain compatible with unmodified upstream Hermes.
---
## Download
**Installing on your phone?** Download hermes-relay-1.4.1-sideload-release.apk and tap it — that's the direct-install build with the full feature set (installs as com.axiomlabs.hermesrelay.sideload). Prefer the conservative build (no Device Control surface)? Get it from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
**Installing on your phone?** Download hermes-relay-1.4.2-sideload-release.apk and tap it — that's the direct-install build with the full feature set (installs as com.axiomlabs.hermesrelay.sideload). Prefer the conservative build (no Device Control surface)? Get it from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The other file, hermes-relay-1.4.1-googlePlay-release.aab, is an Android App Bundle for uploading to Play Console — it **cannot** be installed by tapping it on a phone.
The other file, hermes-relay-1.4.2-googlePlay-release.aab, is an Android App Bundle for uploading to Play Console — it **cannot** be installed by tapping it on a phone.
Verify integrity with SHA256SUMS.txt from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
@@ -20,27 +20,22 @@ Verify integrity with SHA256SUMS.txt from the same release. See the [Sideload gu
## Highlights
### Chat that keeps up
### Simplified Chinese throughout the app
- **See background work where it belongs.** Standard Chat surfaces active and recent background processes in a compact strip and expandable sheet with elapsed time, output, a targeted Stop action, and local Dismiss.
- **Get the completion without asking again.** When Hermes finishes detached work, its follow-up answer appears in the originating conversation automatically. The server's internal completion marker stays in history but is shown as a compact process notice.
- **Come back to the same answer.** Closing and reopening the app restores the partial reply, live reasoning, lifecycle status, tool and subagent states, background-task state, and any pending approval or clarification. The app reattaches when the server still has a live turn, otherwise it reconciles the finished transcript without repeating your prompt.
- **Use the complete Android experience in Simplified Chinese.** Onboarding, connection setup, Chat, Manage, Voice, settings, diagnostics, notifications, accessibility labels, and both product flavors are localized.
- **Switch languages with Android.** Supported Android versions expose English and Simplified Chinese through the system's per-app language settings; other versions follow the device language.
- **Get locale-correct counts.** Connection scan results and queued-message counts use Android plurals instead of English-only suffix formatting.
### Voice you can direct
### Translation support that can grow
- **Use natural spoken controls.** Pause or resume listening, stop speech, cancel background work, repeat a settled result, or start a new Standard voice chat.
- **Choose an interaction preset.** Hands-free, Low latency, Careful tools, and Quiet presets adjust existing voice and long-task behavior without changing your voice identity or routing.
- **Keep delivered answers authoritative.** Realtime delivery is generation-safe and uses one relay-TTS fallback if the provider cannot deliver a completed Hermes result.
### Clearer conversations
- **Browse images together.** Adjacent images form a compact gallery that opens at the image you selected.
- **Read while the reply streams.** Markdown settles into its final styling as text arrives, wide tables stay usable, and motion-sensitive indicators respect system accessibility settings.
- **Prevent incomplete catalogs.** CI checks resource names, plural structure, and format-argument parity against canonical English resources.
- **Start from documented conventions.** A localization guide, translated README, and Simplified Chinese user-doc entry points define how to add and maintain another language.
- **Preserve contributor credit.** The contribution workflow documents how maintainers salvage valuable stale translation PRs onto current `dev` while retaining original authorship.
---
## Upgrade notes
- App version: **1.4.1** (versionCode **23**).
- Realtime Agent improvements pair with relay plugin **1.4.1**.
- App version: **1.4.2** (versionCode **24**).
- No relay plugin update is required for localization support.
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
@@ -1,12 +1,8 @@
v1.4.1 - Chat that keeps up
v1.4.2 - Simplified Chinese
Chat
* Follow background work from a live process strip; its result appears automatically in the same conversation.
* Reopen while an answer runs: partial text, thinking, tool progress, and approvals return.
Language
* Use onboarding, Chat, Manage, Voice, settings, diagnostics, and notifications in Simplified Chinese.
* Switch between English and Simplified Chinese through Android's app-language settings.
Voice
* Speak commands to pause, resume, cancel, repeat a result, or start Standard voice chat.
* Pick Hands-free, Low latency, Careful tools, or Quiet presets.
Polish
* Multi-image galleries plus smoother streaming Markdown and long tables.
Contributing
* Locale validation and new contributor docs make future translations safer to add.
@@ -0,0 +1,8 @@
v1.4.2 - 简体中文支持
语言
* 入门引导、聊天、管理、语音、设置、诊断和通知现已支持简体中文。
* 可通过 Android 的应用语言设置在英文和简体中文之间切换。
贡献
* 新增语言目录校验和贡献文档,让后续翻译更易于安全添加。
+1
View File
@@ -29,6 +29,7 @@
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.HermesRelay">
+21
View File
@@ -1,5 +1,26 @@
{
"versions": [
{
"version": "1.4.2",
"title": "Simplified Chinese and scalable localization",
"date": "2026-07-11",
"sections": [
{
"header": "Simplified Chinese throughout the app",
"bullets": [
"Use onboarding, connection setup, Chat, Manage, Voice, settings, diagnostics, notifications, and accessibility labels in Simplified Chinese across both product flavors.",
"Switch between English and Simplified Chinese through Android's per-app language settings on supported versions, or follow the device language elsewhere."
]
},
{
"header": "Localization built to grow",
"bullets": [
"Automated catalog checks protect resource, plural, and format-argument parity, while contributor docs and translated entry points make another language easier to add safely.",
"Connection scan and queued-message counts now use locale-aware Android plurals."
]
}
]
},
{
"version": "1.4.1",
"title": "Chat that keeps up",
+6 -10
View File
@@ -1,12 +1,8 @@
v1.4.1 - Chat that keeps up
v1.4.2 - Simplified Chinese
Chat
* Follow background work from a live process strip; its result appears automatically in the same conversation.
* Reopen while an answer runs: partial text, thinking, tool progress, and approvals return.
Language
* Use onboarding, Chat, Manage, Voice, settings, diagnostics, and notifications in Simplified Chinese.
* Switch between English and Simplified Chinese through Android's app-language settings.
Voice
* Speak commands to pause, resume, cancel, repeat a result, or start Standard voice chat.
* Pick Hands-free, Low latency, Careful tools, or Quiet presets.
Polish
* Multi-image galleries plus smoother streaming Markdown and long tables.
Contributing
* Locale validation and new contributor docs make future translations safer to add.
@@ -9,6 +9,7 @@ import android.media.AudioTrack
import android.os.Build
import android.os.SystemClock
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
@@ -25,7 +26,7 @@ import kotlin.math.sqrt
* writes them directly to an AudioTrack so the Android Studio dev build can
* hear provider output without waiting for an encoded file.
*/
class RealtimePcmPlayer(context: Context? = null) {
class RealtimePcmPlayer(private val context: Context? = null) {
private val trackLock = Any()
private val writeLock = Any()
private val audioManager =
@@ -449,7 +450,7 @@ class RealtimePcmPlayer(context: Context? = null) {
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Info,
title = "Realtime audio started",
title = context?.getString(R.string.audio_diag_started) ?: "Realtime audio started",
detail = "First sample reached the speaker after ${ttfaMs}ms.",
)
}
@@ -489,7 +490,7 @@ class RealtimePcmPlayer(context: Context? = null) {
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Warning,
title = "Realtime audio not starting",
title = context?.getString(R.string.audio_diag_not_starting) ?: "Realtime audio not starting",
detail = "Playback running ${stuckMs}ms but no audio reached the speaker " +
"(${mediaVolumeSummaryLocked()}).",
)
@@ -587,7 +588,7 @@ class RealtimePcmPlayer(context: Context? = null) {
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Warning,
title = "Realtime audio stream gap",
title = context?.getString(R.string.audio_diag_stream_gap) ?: "Realtime audio stream gap",
detail = reason,
)
}
@@ -603,7 +604,7 @@ class RealtimePcmPlayer(context: Context? = null) {
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Warning,
title = "Realtime voice volume muted",
title = context?.getString(R.string.audio_diag_volume_muted) ?: "Realtime voice volume muted",
detail = "Media volume is 0/${maxVolume ?: "?"}.",
)
}
@@ -89,8 +89,8 @@ class AutoDisableWorker(private val context: Context) {
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Bridge auto-disabled")
.setContentText("Paused after idle — tap to re-enable in the Bridge tab.")
.setContentTitle(context.getString(R.string.bridge_notification_auto_disabled_title))
.setContentText(context.getString(R.string.bridge_notification_auto_disabled_body))
.setStyle(NotificationCompat.BigTextStyle().bigText(
"Hermes bridge was idle for too long, so device control has been turned off " +
"automatically. Open the Bridge tab to turn it back on if you still need it."
@@ -373,8 +373,8 @@ class BridgeForegroundService : Service() {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Hermes agent has device control")
.setContentText("Bridge is active — tap Disable to stop at any time.")
.setContentTitle(getString(R.string.bridge_notification_control_title))
.setContentText(getString(R.string.bridge_notification_control_body))
.setStyle(NotificationCompat.BigTextStyle().bigText(
"The Hermes agent can currently read the screen and perform " +
"actions on your behalf through the accessibility service. " +
@@ -6,6 +6,7 @@ import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.CertPinStore
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.PairingPreferences
@@ -306,7 +307,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Insecure relay mode enabled",
title = context?.getString(R.string.conn_diag_insecure_mode) ?: "Insecure relay mode enabled",
detail = "ws:// connections are allowed",
)
}
@@ -332,7 +333,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay route selected",
title = context?.getString(R.string.conn_diag_route_selected) ?: "Relay route selected",
endpointRole = resolved.role,
url = resolved.relay.url,
)
@@ -342,7 +343,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Using configured relay URL",
title = context?.getString(R.string.conn_diag_using_configured_url) ?: "Using configured relay URL",
detail = "No resolver winner",
url = url,
)
@@ -368,7 +369,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay socket blocked",
title = context?.getString(R.string.conn_diag_socket_blocked) ?: "Relay socket blocked",
detail = "ws:// is disabled",
url = url,
)
@@ -379,7 +380,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay socket URL invalid",
title = context?.getString(R.string.conn_diag_url_invalid) ?: "Relay socket URL invalid",
detail = "URL must start with ws:// or wss://",
url = url,
)
@@ -408,14 +409,14 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Opening insecure relay socket",
title = context?.getString(R.string.conn_diag_opening_insecure) ?: "Opening insecure relay socket",
url = normalized,
)
} else {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Opening relay socket",
title = context?.getString(R.string.conn_diag_opening_socket) ?: "Opening relay socket",
url = normalized,
)
}
@@ -757,7 +758,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay socket disconnect requested",
title = context?.getString(R.string.conn_diag_disconnect_requested) ?: "Relay socket disconnect requested",
url = serverUrl,
)
webSocket?.close(1000, "Client disconnect")
@@ -884,7 +885,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay socket connected",
title = context?.getString(R.string.conn_diag_connected) ?: "Relay socket connected",
url = url,
)
@@ -944,7 +945,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay socket closed",
title = context?.getString(R.string.conn_diag_closed) ?: "Relay socket closed",
detail = "code=$code reason=$reason",
url = url,
)
@@ -963,7 +964,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay socket failed",
title = context?.getString(R.string.conn_diag_failed) ?: "Relay socket failed",
detail = listOfNotNull(
t.javaClass.simpleName,
t.message,
@@ -1012,7 +1013,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Session,
severity = DiagnosticSeverity.Warning,
title = "Relay reconnect skipped",
title = context?.getString(R.string.conn_diag_reconnect_skipped) ?: "Relay reconnect skipped",
detail = "No paired session or pending pair code",
url = serverUrl,
)
@@ -1036,7 +1037,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay reconnect delayed",
title = context?.getString(R.string.conn_diag_reconnect_delayed) ?: "Relay reconnect delayed",
detail = "Rate limited; retrying in ${RATE_LIMIT_BACKOFF_MS / 1000}s",
url = url,
)
@@ -1049,7 +1050,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay reconnect slow-polling",
title = context?.getString(R.string.conn_diag_reconnect_slow_poll) ?: "Relay reconnect slow-polling",
detail = "Server unreachable for a while; retrying every ${SLOW_POLL_BACKOFF_MS / 1000}s until it recovers (a network change reconnects immediately)",
url = url,
)
@@ -1061,7 +1062,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay reconnect scheduled",
title = context?.getString(R.string.conn_diag_reconnect_scheduled) ?: "Relay reconnect scheduled",
detail = "Retrying in ${ms / 1000}s",
url = url,
)
@@ -1,6 +1,8 @@
package com.hermesandroid.relay.network.relay
import android.content.Context
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.PairedDeviceInfo
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
@@ -48,6 +50,9 @@ class RelayHttpClient(
* paired). Lets [mediaUrlConfigured] check fetch-readiness without
* suspending; mirrors what [sessionTokenProvider] resolves. */
private val pairedTokenSnapshot: () -> String? = { null },
/** Application context for localized string resources. Nullable for
* backwards-compat with call sites that don't need localization. */
private val context: Context? = null,
) {
companion object {
@@ -904,7 +909,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay URL invalid",
title = context?.getString(R.string.http_diag_url_invalid) ?: "Relay URL invalid",
detail = e.message,
url = relayUrl,
)
@@ -934,7 +939,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "HTTP ${response.code}",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -948,7 +953,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Empty response",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -965,7 +970,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Non-JSON response",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -979,7 +984,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "status=${status ?: "missing"}",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -993,7 +998,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Missing version field",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -1010,7 +1015,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay health ok",
title = context?.getString(R.string.http_diag_health_ok) ?: "Relay health ok",
detail = "version=$version clients=$clients sessions=$sessions",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -1023,7 +1028,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health timeout",
title = context?.getString(R.string.http_diag_health_timeout) ?: "Relay health timeout",
detail = "No HTTP response in 3s",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -1034,7 +1039,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay connection refused",
title = context?.getString(R.string.http_diag_conn_refused) ?: "Relay connection refused",
detail = e.message,
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -1045,7 +1050,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = e.message ?: "Network error",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -1056,7 +1061,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = e.message ?: e.javaClass.simpleName,
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.network.relay
import android.content.Context
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
@@ -542,7 +543,7 @@ class RelayVoiceClient(
getRealtimeProviderOptionsAt(
providerId = providerId,
pathPrefix = "/voice/realtime-agent/providers",
label = "Realtime agent provider options",
label = context.getString(R.string.voice_diag_agent_provider_options),
)
suspend fun validateRealtimeAgentProvider(
@@ -557,7 +558,7 @@ class RelayVoiceClient(
voice = voice,
sampleRate = sampleRate,
pathPrefix = "/voice/realtime-agent/providers",
label = "Realtime agent provider validation",
label = context.getString(R.string.voice_diag_agent_provider_validation),
)
suspend fun updateRealtimeAgentConfig(
@@ -574,7 +575,7 @@ class RelayVoiceClient(
voice = voice,
sampleRate = sampleRate,
path = "/voice/realtime-agent/config",
label = "Realtime agent config update",
label = context.getString(R.string.voice_diag_agent_config_update),
)
/**
@@ -879,7 +880,7 @@ class RelayVoiceClient(
if (resumeAttempted.get()) {
onHandoff(
VoiceHandoffEvent(
label = "Voice handoff failed",
label = context.getString(R.string.voice_diag_handoff_failed),
detail = message,
active = false,
)
@@ -904,7 +905,7 @@ class RelayVoiceClient(
if (resume) {
onHandoff(
VoiceHandoffEvent(
label = "Trying voice route",
label = context.getString(R.string.voice_diag_trying_route),
route = routeLabel(currentWsBase),
active = true,
)
@@ -930,7 +931,7 @@ class RelayVoiceClient(
)
onHandoff(
VoiceHandoffEvent(
label = "Resume sent",
label = context.getString(R.string.voice_diag_resume_sent),
route = routeLabel(webSocket.request().url.toString()),
active = true,
)
@@ -1016,7 +1017,7 @@ class RelayVoiceClient(
requestRouteProbeOnce("Voice output", t.message, routeProbeRequested)
onHandoff(
VoiceHandoffEvent(
label = "Waiting for route",
label = context.getString(R.string.voice_diag_waiting_for_route),
detail = t.message,
route = routeLabel(webSocket.request().url.toString()),
active = true,
@@ -1030,7 +1031,7 @@ class RelayVoiceClient(
requestRouteProbeOnce("Voice output", t.message, routeProbeRequested)
onHandoff(
VoiceHandoffEvent(
label = "Connection changed",
label = context.getString(R.string.voice_diag_connection_changed),
detail = t.message,
route = routeLabel(webSocket.request().url.toString()),
active = true,
@@ -1058,7 +1059,7 @@ class RelayVoiceClient(
requestRouteProbeOnce("Voice output", "Closed $code $reason", routeProbeRequested)
onHandoff(
VoiceHandoffEvent(
label = "Waiting for route",
label = context.getString(R.string.voice_diag_waiting_for_route),
detail = "Closed $code $reason",
route = routeLabel(webSocket.request().url.toString()),
active = true,
@@ -1076,7 +1077,7 @@ class RelayVoiceClient(
requestRouteProbeOnce("Voice output", "Closed $code $reason", routeProbeRequested)
onHandoff(
VoiceHandoffEvent(
label = "Connection changed",
label = context.getString(R.string.voice_diag_connection_changed),
detail = "Closed $code $reason",
route = routeLabel(webSocket.request().url.toString()),
active = true,
@@ -2365,7 +2366,7 @@ class RelayVoiceClient(
)
onHandoff(
VoiceHandoffEvent(
label = "Route changed",
label = context.getString(R.string.voice_diag_route_changed),
previousRoute = routeLabel(previousWsBase),
nextRoute = routeLabel(nextWsBase),
route = routeLabel(nextWsBase),
@@ -2830,7 +2831,7 @@ class RelayVoiceClient(
when (event.type) {
"voice.session.resumed" -> onHandoff(
VoiceHandoffEvent(
label = "Voice reconnected",
label = context.getString(R.string.voice_diag_reconnected),
detail = surface,
route = route,
active = false,
@@ -2840,7 +2841,7 @@ class RelayVoiceClient(
)
"voice.replay.started" -> onHandoff(
VoiceHandoffEvent(
label = "Replaying missed audio",
label = context.getString(R.string.voice_diag_replaying_audio),
route = route,
active = true,
transitionRevision = transitionRevision,
@@ -2848,7 +2849,7 @@ class RelayVoiceClient(
)
"voice.replay.done" -> onHandoff(
VoiceHandoffEvent(
label = "Caught up",
label = context.getString(R.string.voice_diag_caught_up),
detail = surface,
route = route,
active = false,
@@ -2858,7 +2859,7 @@ class RelayVoiceClient(
)
"voice.session.resume_failed" -> onHandoff(
VoiceHandoffEvent(
label = "Resume rejected",
label = context.getString(R.string.voice_diag_resume_rejected),
detail = event.message,
route = route,
active = false,
@@ -1,6 +1,8 @@
package com.hermesandroid.relay.network.shared
import android.content.Context
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
@@ -85,6 +87,12 @@ class EndpointResolver(
* tests feed a mutable clock to exercise the 30-second TTL.
*/
private val clock: () -> Long = { System.currentTimeMillis() },
/**
* Application context for localized string resources. When null the
* resolver falls back to hardcoded English strings — this is the
* expected path for plain JVM tests.
*/
private val context: Context? = null,
) {
/**
@@ -190,7 +198,7 @@ class EndpointResolver(
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Info,
title = "Endpoint selected",
title = context?.getString(R.string.endpoint_diag_selected) ?: "Endpoint selected",
detail = "priority=$priority",
endpointRole = winner.role,
url = winner.relay.url,
@@ -203,7 +211,7 @@ class EndpointResolver(
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = "No reachable endpoint",
title = context?.getString(R.string.endpoint_diag_no_reachable) ?: "No reachable endpoint",
detail = "${candidates.size} configured route(s) failed health probes",
)
return null
@@ -288,7 +296,7 @@ class EndpointResolver(
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Error,
title = "Endpoint probe invalid",
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
detail = "Invalid API URL",
endpointRole = candidate.role,
url = candidate.api.url,
@@ -312,10 +320,15 @@ class EndpointResolver(
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
fastClient.newCall(request).execute().use { resp ->
val ok = resp.isSuccessful
val probeTitle = if (ok) {
context?.getString(R.string.endpoint_diag_probe_ok) ?: "Endpoint probe ok"
} else {
context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed"
}
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = if (ok) DiagnosticSeverity.Info else DiagnosticSeverity.Warning,
title = if (ok) "Endpoint probe ok" else "Endpoint probe failed",
title = probeTitle,
detail = if (ok) null else "HTTP ${resp.code}",
endpointRole = candidate.role,
url = candidate.api.url,
@@ -332,7 +345,7 @@ class EndpointResolver(
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = "Endpoint probe timeout",
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
endpointRole = candidate.role,
url = candidate.api.url,
@@ -345,7 +358,7 @@ class EndpointResolver(
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = "Endpoint probe timeout",
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
endpointRole = candidate.role,
url = candidate.api.url,
@@ -359,7 +372,7 @@ class EndpointResolver(
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = "Endpoint probe failed",
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
detail = e.javaClass.simpleName,
endpointRole = candidate.role,
url = candidate.api.url,
@@ -150,8 +150,8 @@ class GatewayKeepAliveService : Service() {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Hermes connection active")
.setContentText("Keeping your connection to Hermes open in the background.")
.setContentTitle(getString(R.string.gateway_keepalive_title))
.setContentText(getString(R.string.gateway_keepalive_body))
.setContentIntent(tapPending)
.setOngoing(true)
.setOnlyAlertOnce(true)
@@ -235,7 +235,7 @@ object NotificationTriggerPromptNotifier {
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val tapPending = PendingIntent.getActivity(context, notificationId(entry), tapIntent, pendingFlags)
val title = "Ask Hermes about this?"
val title = context.getString(R.string.notification_trigger_prompt_title)
val source = entry.title?.takeIf { it.isNotBlank() } ?: entry.packageName
val body = entry.text?.takeIf { it.isNotBlank() }
?: "Rule matched: ${rule.summary()}"
@@ -56,6 +56,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.lifecycle.createSavedStateHandle
@@ -67,6 +68,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.CrashReportGate
import com.hermesandroid.relay.ui.components.DemoModeBanner
import com.hermesandroid.relay.ui.components.DemoUnavailableContent
@@ -1066,11 +1068,19 @@ fun RelayApp() {
bridgePrimaryReturnLabel = null
}
val bridgeReturnTitle = bridgePrimaryReturnLabel?.let { "Return to $it" }
val bridgeReturnLabelResId = when (bridgePrimaryReturnLabel) {
"Chat" -> R.string.bridge_return_chat_label
"Manage" -> R.string.bridge_return_manage_label
else -> R.string.bridge_return_default_label
}
val bridgeReturnDisplayLabel = stringResource(bridgeReturnLabelResId)
val bridgeReturnTitle = bridgePrimaryReturnLabel?.let {
stringResource(R.string.bridge_return_title_format, bridgeReturnDisplayLabel)
}
val bridgeReturnSubtitle = when (bridgePrimaryReturnLabel) {
"Chat" -> "Back to conversation"
"Manage" -> "Back to management"
else -> "Back to previous tab"
"Chat" -> stringResource(R.string.bridge_return_chat_subtitle)
"Manage" -> stringResource(R.string.bridge_return_manage_subtitle)
else -> stringResource(R.string.bridge_return_default_subtitle)
}
val bridgeReturnAction: (() -> Unit)? = bridgePrimaryReturnRoute?.let { route ->
{
@@ -1343,6 +1353,7 @@ fun RelayApp() {
connectionId = connection.id,
dashboardUrl = effectiveDashboardUrl,
cacheDir = hydrateContext.cacheDir,
context = hydrateContext,
)
}
@@ -1350,13 +1361,17 @@ fun RelayApp() {
// so voice/chat/settings screens can call showHumanError from their
// error-collector LaunchedEffects without threading state downwards.
val snackbarHostState = remember { SnackbarHostState() }
val profilesUpdatedLabel = stringResource(R.string.relay_app_profiles_updated)
val reconnectingRelayLabel = stringResource(R.string.relay_app_reconnecting)
val renameFailedLabel = stringResource(R.string.relay_app_rename_failed)
val revokeOnlyActiveLabel = stringResource(R.string.relay_app_revoke_only_active)
// Relay-pushed `profiles.updated` announcements. AuthManager
// filters out idempotent pushes (same names + same count), so
// this only fires when the profile list actually changed.
LaunchedEffect(connectionViewModel) {
connectionViewModel.profilesUpdatedEvents.collect {
UiMessageBus.success("Profiles updated")
UiMessageBus.success(profilesUpdatedLabel)
}
}
@@ -1576,7 +1591,7 @@ fun RelayApp() {
if (!suppressGlobalChrome && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) {
val routeLabel = activeEndpoint?.displayLabel()
?: activeConnection?.label
?: "no route"
?: stringResource(R.string.status_no_route)
val transportStatus = resolveChatTransportStatus(
streamingEndpoint = streamingEndpoint,
gatewayAvailability = gatewayAvailability,
@@ -1587,7 +1602,8 @@ fun RelayApp() {
} else {
routeLabel
}
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() } ?: "default"
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() }
?: stringResource(R.string.status_profile_default)
val displayProfile = AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
@@ -1595,11 +1611,12 @@ fun RelayApp() {
val modelLabel = AgentDisplay.displayModelName(gatewayCurrentModel)
?: AgentDisplay.displayModelName(displayProfile?.model)
?: AgentDisplay.displayModelName(serverModelName)
?: "model pending"
?: stringResource(R.string.status_model_pending)
val safetyLabel = if (BuildFlavor.isSideload && masterEnabled) {
"safety: ${if (unattendedEnabled) "unattended" else "on"}"
if (unattendedEnabled) stringResource(R.string.status_safety_unattended)
else stringResource(R.string.status_safety_on)
} else {
"profile: $profileLabel"
stringResource(R.string.status_profile_format, profileLabel)
}
val openConnections = {
navController.navigate(Screen.ConnectionsSettings.route) {
@@ -1714,6 +1731,8 @@ fun RelayApp() {
val openAgentSheetArg = backStackEntry.arguments
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
val screenChatLabel = stringResource(R.string.screen_chat_label)
ChatScreen(
chatViewModel = chatViewModel,
connectionViewModel = connectionViewModel,
@@ -1754,7 +1773,7 @@ fun RelayApp() {
onNavigateToBridge = {
rememberBridgeReturn(
route = Screen.Chat.route(openAgentSheet = false),
label = "Chat",
label = screenChatLabel,
)
navController.navigate(Screen.Bridge.route) {
popUpTo(navController.graph.findStartDestination().id) {
@@ -1792,10 +1811,11 @@ fun RelayApp() {
// so show a friendly demo empty state instead of
// attempting a sign-in / fetch.
DemoUnavailableContent(
feature = "Manage",
feature = stringResource(R.string.demo_feature_manage),
onConnect = exitDemoToConnect,
)
} else {
val screenManageLabel = stringResource(R.string.screen_manage_label)
DashboardManagementScreen(
connectionViewModel = connectionViewModel,
onNavigateToConnections = {
@@ -1811,7 +1831,7 @@ fun RelayApp() {
onNavigateToBridge = {
rememberBridgeReturn(
route = Screen.Manage.route,
label = "Manage",
label = screenManageLabel,
)
navController.navigate(Screen.Bridge.route) {
popUpTo(navController.graph.findStartDestination().id) {
@@ -1843,8 +1863,8 @@ fun RelayApp() {
)
} else {
PowerFeatureGateScreen(
title = "Terminal",
summary = "Open a server shell through your paired relay session.",
title = stringResource(R.string.power_gate_terminal_title),
summary = stringResource(R.string.power_gate_terminal_summary),
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
onPrimaryAction = {
navController.navigate(Screen.Pair.route())
@@ -1856,8 +1876,8 @@ fun RelayApp() {
composable(Screen.Bridge.route) {
if (coldStartAuthState !is AuthState.Paired) {
PowerFeatureGateScreen(
title = "Bridge",
summary = "Let Hermes send approved bridge commands to this phone.",
title = stringResource(R.string.power_gate_bridge_title),
summary = stringResource(R.string.power_gate_bridge_summary),
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
onPrimaryAction = {
navController.navigate(Screen.Pair.route())
@@ -1870,7 +1890,9 @@ fun RelayApp() {
connectionViewModel = connectionViewModel,
returnTitle = bridgeReturnTitle,
returnSubtitle = bridgeReturnSubtitle,
returnLabel = bridgePrimaryReturnLabel ?: "Back",
returnLabel = bridgePrimaryReturnLabel?.let {
stringResource(bridgeReturnLabelResId)
} ?: stringResource(R.string.bridge_return_default_label),
onReturn = bridgeReturnAction,
onNavigateToBridgeSafety = {
navController.navigate(Screen.BridgeSafetySettings.route)
@@ -1904,7 +1926,9 @@ fun RelayApp() {
connectionViewModel = connectionViewModel,
returnTitle = bridgeReturnTitle,
returnSubtitle = bridgeReturnSubtitle,
returnLabel = bridgePrimaryReturnLabel ?: "Back",
returnLabel = bridgePrimaryReturnLabel?.let {
stringResource(bridgeReturnLabelResId)
} ?: stringResource(R.string.bridge_return_default_label),
onReturn = bridgeReturnAction,
onNavigateToConnections = {
navController.navigate(Screen.ConnectionsSettings.route)
@@ -2034,7 +2058,7 @@ fun RelayApp() {
// Voice runs through the live server (transcribe /
// synthesize) — show the demo empty state offline.
DemoUnavailableContent(
feature = "Voice",
feature = stringResource(R.string.screen_voice_label),
onConnect = exitDemoToConnect,
)
} else {
@@ -2157,8 +2181,8 @@ fun RelayApp() {
)
} else {
PowerFeatureGateScreen(
title = "Relay sessions",
summary = "Review and revoke devices paired with this relay.",
title = stringResource(R.string.screen_relay_sessions_label),
summary = stringResource(R.string.power_gate_relay_sessions_summary),
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
onPrimaryAction = {
navController.navigate(Screen.Pair.route())
@@ -2233,14 +2257,14 @@ fun RelayApp() {
onBack = { navController.popBackStack() },
onReconnect = {
connectionViewModel.connectRelay()
UiMessageBus.status("Reconnecting to relay…")
UiMessageBus.status(reconnectingRelayLabel)
},
onRename = { id, newLabel ->
connectionSwitchScope.launch {
connectionViewModel.renameConnection(id, newLabel)
.onFailure { err ->
snackbarHostState.showSnackbar(
err.message ?: "Rename failed",
err.message ?: renameFailedLabel,
)
}
}
@@ -2255,9 +2279,7 @@ fun RelayApp() {
connectionSwitchScope.launch {
val result = connectionViewModel.revokeConnection(id)
if (result.isFailure) {
snackbarHostState.showSnackbar(
"Only the active connection can be revoked right now",
)
snackbarHostState.showSnackbar(revokeOnlyActiveLabel)
}
}
},
@@ -2429,8 +2451,8 @@ fun RelayApp() {
?: Screen.ProfileInspector.SECTION_CONFIG
if (coldStartAuthState !is AuthState.Paired) {
PowerFeatureGateScreen(
title = "Profile Inspector",
summary = "Inspect relay-backed profile config, SOUL, memory files, and skills.",
title = stringResource(R.string.screen_profile_inspector_label),
summary = stringResource(R.string.power_gate_profile_inspector_summary),
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
onPrimaryAction = {
navController.navigate(Screen.Pair.route())
@@ -2559,13 +2581,13 @@ fun RelayApp() {
.padding(bottom = 120.dp)
) {
Text(
text = "Hermes-Relay",
text = stringResource(R.string.app_title),
style = MaterialTheme.typography.headlineMedium,
color = RelayRefresh.Paper.copy(alpha = 0.92f)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "agent interface",
text = stringResource(R.string.agent_interface),
style = MaterialTheme.typography.bodyMedium,
color = RelayRefresh.Muted.copy(alpha = 0.72f),
letterSpacing = 2.sp
@@ -55,12 +55,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
@@ -130,29 +132,29 @@ fun ActiveCardStandardStatusSection(
)
ConnectionStatusRow(
label = "API Server",
label = stringResource(R.string.active_section_api_server),
isConnected = apiReachable,
isProbing = apiHealth == ConnectionViewModel.HealthStatus.Probing,
statusText = when {
apiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking…"
apiReachable -> "Reachable"
else -> "Unreachable"
apiHealth == ConnectionViewModel.HealthStatus.Probing -> stringResource(R.string.active_section_checking)
apiReachable -> stringResource(R.string.active_section_reachable)
else -> stringResource(R.string.active_section_unreachable)
},
onClick = onOpenApiInfo,
modifier = Modifier.fillMaxWidth(),
)
ConnectionStatusRow(
label = "Dashboard",
label = stringResource(R.string.active_section_dashboard),
isConnected = dashboardStatus?.reachable == true && !dashboardSignInRequired,
statusText = when {
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> "Not configured"
dashboardStatus == null -> "Not checked"
!dashboardStatus.reachable -> "Unreachable"
dashboardSignInRequired -> "Sign-in required"
dashboardStatus.authenticated == true -> "Signed in"
dashboardStatus.authRequired == false -> "Available"
else -> "Available"
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> stringResource(R.string.active_section_not_configured)
dashboardStatus == null -> stringResource(R.string.active_section_not_checked)
!dashboardStatus.reachable -> stringResource(R.string.active_section_unreachable)
dashboardSignInRequired -> stringResource(R.string.active_section_sign_in_required)
dashboardStatus.authenticated == true -> stringResource(R.string.active_section_signed_in)
dashboardStatus.authRequired == false -> stringResource(R.string.active_section_available)
else -> stringResource(R.string.active_section_available)
},
onClick = onOpenDashboard,
modifier = Modifier.fillMaxWidth(),
@@ -165,13 +167,13 @@ fun ActiveCardStandardStatusSection(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Dashboard controls need a sign-in for this route.",
text = stringResource(R.string.active_section_dashboard_sign_in_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onOpenDashboard) {
Text("Sign in")
Text(stringResource(R.string.active_section_sign_in))
}
}
}
@@ -194,18 +196,22 @@ fun ActiveCardRelayStatusSection(
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRowState by connectionViewModel.relayRowState.collectAsState()
// Pre-resolve strings for Toast (non-composable context)
val reconnectingRelayToast = stringResource(R.string.active_section_reconnecting_relay)
val connectedLabel = stringResource(R.string.conn_info_connected)
// ADR 24: relayRowState carries both the phase and the active endpoint
// role. statusText appends " · <Role>" when the resolver has picked one.
ConnectionStatusRow(
label = "Relay",
label = stringResource(R.string.active_section_relay),
state = relayRowState.asBadgeState(),
statusText = relayRowState.statusText(connectedLabel = "Connected"),
statusText = relayRowState.statusText(connectedLabel = connectedLabel),
onClick = {
if (relayUiState == RelayUiState.Stale) {
connectionViewModel.connectRelay()
Toast.makeText(
context,
"Reconnecting to relay…",
reconnectingRelayToast,
Toast.LENGTH_SHORT,
).show()
} else {
@@ -215,15 +221,21 @@ fun ActiveCardRelayStatusSection(
modifier = Modifier.fillMaxWidth(),
)
// Pre-resolve strings for Session status
val pairedLabel = stringResource(R.string.conn_info_paired)
val pairingLabel = stringResource(R.string.conn_info_pairing)
val unpairedLabel = stringResource(R.string.conn_info_unpaired)
val failedReasonLabel = stringResource(R.string.active_section_failed_reason)
ConnectionStatusRow(
label = "Session",
label = stringResource(R.string.active_section_session),
isConnected = authState is AuthState.Paired,
isConnecting = authState is AuthState.Pairing,
statusText = when (authState) {
is AuthState.Paired -> "Paired"
is AuthState.Pairing -> "Pairing..."
is AuthState.Unpaired -> "Unpaired"
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
is AuthState.Paired -> pairedLabel
is AuthState.Pairing -> pairingLabel
is AuthState.Unpaired -> unpairedLabel
is AuthState.Failed -> failedReasonLabel.format((authState as AuthState.Failed).reason)
},
onClick = onOpenSessionInfo,
modifier = Modifier.fillMaxWidth(),
@@ -261,38 +273,39 @@ fun ActiveCardFeaturesSection(
activeConnection?.routeCandidates.orEmpty().any { it.hasSecureProxy() }
val apiValue = when {
apiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking"
apiReachable -> "Ready"
activeConnection?.apiServerUrl.isNullOrBlank() -> "Missing"
else -> "Offline"
apiHealth == ConnectionViewModel.HealthStatus.Probing -> stringResource(R.string.active_section_checking)
apiReachable -> stringResource(R.string.active_section_ready)
activeConnection?.apiServerUrl.isNullOrBlank() -> stringResource(R.string.active_section_missing)
else -> stringResource(R.string.active_section_offline)
}
val apiTone = when (apiValue) {
"Ready" -> CapabilityTone.Good
"Offline", "Missing" -> CapabilityTone.Warning
else -> CapabilityTone.Neutral
val apiTone = when {
apiReachable -> CapabilityTone.Good
apiHealth == ConnectionViewModel.HealthStatus.Probing -> CapabilityTone.Neutral
else -> CapabilityTone.Warning
}
val dashboardValue = when {
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> "Missing"
dashboardStatus == null -> "Unchecked"
!dashboardStatus.reachable -> "Offline"
dashboardSignInRequired -> "Sign in"
dashboardStatus.authenticated == true -> "Signed in"
else -> "Available"
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> stringResource(R.string.active_section_missing)
dashboardStatus == null -> stringResource(R.string.active_section_unchecked)
!dashboardStatus.reachable -> stringResource(R.string.active_section_offline)
dashboardSignInRequired -> stringResource(R.string.active_section_sign_in)
dashboardStatus.authenticated == true -> stringResource(R.string.active_section_signed_in)
else -> stringResource(R.string.active_section_available)
}
val dashboardTone = when (dashboardValue) {
"Signed in", "Available" -> CapabilityTone.Good
"Sign in" -> CapabilityTone.Info
"Offline", "Missing" -> CapabilityTone.Warning
val dashboardTone = when {
dashboardStatus?.authenticated == true -> CapabilityTone.Good
dashboardStatus?.reachable == true && !dashboardSignInRequired -> CapabilityTone.Good
dashboardSignInRequired -> CapabilityTone.Info
activeConnection?.resolvedDashboardUrl.isNullOrBlank() || dashboardStatus?.reachable != true -> CapabilityTone.Warning
else -> CapabilityTone.Neutral
}
val voiceValue = when (standardVoiceAvailability) {
StandardVoiceAvailability.Ready -> "Ready"
StandardVoiceAvailability.SignInRequired -> "Sign in"
StandardVoiceAvailability.Unsupported -> "Unsupported"
StandardVoiceAvailability.Unreachable -> "Offline"
StandardVoiceAvailability.Unknown -> "Checking"
StandardVoiceAvailability.Ready -> stringResource(R.string.active_section_ready)
StandardVoiceAvailability.SignInRequired -> stringResource(R.string.active_section_sign_in)
StandardVoiceAvailability.Unsupported -> stringResource(R.string.active_section_unsupported)
StandardVoiceAvailability.Unreachable -> stringResource(R.string.active_section_offline)
StandardVoiceAvailability.Unknown -> stringResource(R.string.active_section_checking)
}
val voiceTone = when (standardVoiceAvailability) {
StandardVoiceAvailability.Ready -> CapabilityTone.Good
@@ -303,11 +316,11 @@ fun ActiveCardFeaturesSection(
}
val relayValue = when {
!relayEnabled -> "Disabled"
!relayConfigured -> "Optional"
relayReady -> "Ready"
relayUiState == RelayUiState.Stale -> "Reconnect"
else -> "Configured"
!relayEnabled -> stringResource(R.string.active_section_disabled)
!relayConfigured -> stringResource(R.string.active_section_optional)
relayReady -> stringResource(R.string.active_section_ready)
relayUiState == RelayUiState.Stale -> stringResource(R.string.active_section_reconnect)
else -> stringResource(R.string.active_section_configured)
}
val relayTone = when {
!relayEnabled || !relayConfigured -> CapabilityTone.Neutral
@@ -317,20 +330,28 @@ fun ActiveCardFeaturesSection(
}
val terminalValue = when {
!relayEnabled -> "Disabled"
authState is AuthState.Paired -> "Ready"
relayConfigured -> "Pair Relay"
else -> "Optional"
!relayEnabled -> stringResource(R.string.active_section_disabled)
authState is AuthState.Paired -> stringResource(R.string.active_section_ready)
relayConfigured -> stringResource(R.string.active_section_pair_relay)
else -> stringResource(R.string.active_section_optional)
}
val terminalTone = when (terminalValue) {
"Ready" -> CapabilityTone.Good
"Pair Relay" -> CapabilityTone.Info
val terminalTone = when {
authState is AuthState.Paired -> CapabilityTone.Good
relayConfigured && authState !is AuthState.Paired -> CapabilityTone.Info
else -> CapabilityTone.Neutral
}
val proxyValue = if (secureProxyAdvertised) "Available" else "Not advertised"
val proxyValue = if (secureProxyAdvertised) stringResource(R.string.active_section_available) else stringResource(R.string.active_section_not_advertised)
val proxyTone = if (secureProxyAdvertised) CapabilityTone.Good else CapabilityTone.Neutral
// Pre-resolve labels for CapabilityRow
val hermesApiLabel = stringResource(R.string.active_section_hermes_api)
val dashboardLabel = stringResource(R.string.active_section_dashboard)
val hermesVoiceLabel = stringResource(R.string.active_section_hermes_voice)
val relayToolsLabel = stringResource(R.string.active_section_relay_tools)
val terminalLabel = stringResource(R.string.active_section_terminal)
val secureProxyLabel = stringResource(R.string.active_section_secure_proxy)
// Lighter than the old six-filled-tile grid: one subtle grouped surface
// with a status dot + value per capability, dividers between rows. The
// header/glance pills used to duplicate API/Dashboard/Voice/Relay state;
@@ -342,21 +363,21 @@ fun ActiveCardFeaturesSection(
) {
Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
CapabilityRow(
label = "Hermes API",
label = hermesApiLabel,
value = apiValue,
tone = apiTone,
onClick = onOpenApiInfo,
)
CapabilityDivider()
CapabilityRow(
label = "Dashboard",
label = dashboardLabel,
value = dashboardValue,
tone = dashboardTone,
onClick = onOpenDashboard,
)
CapabilityDivider()
CapabilityRow(
label = "Hermes voice",
label = hermesVoiceLabel,
value = voiceValue,
tone = voiceTone,
onClick = if (standardVoiceAvailability ==
@@ -369,21 +390,21 @@ fun ActiveCardFeaturesSection(
)
CapabilityDivider()
CapabilityRow(
label = "Relay tools",
label = relayToolsLabel,
value = relayValue,
tone = relayTone,
onClick = onOpenRelayInfo,
)
CapabilityDivider()
CapabilityRow(
label = "Terminal",
label = terminalLabel,
value = terminalValue,
tone = terminalTone,
onClick = onOpenSessionInfo,
)
CapabilityDivider()
CapabilityRow(
label = "Secure proxy",
label = secureProxyLabel,
value = proxyValue,
tone = proxyTone,
)
@@ -499,7 +520,7 @@ fun ActiveCardAdvancedSection(
var expanded by rememberSaveable { mutableStateOf(false) }
SettingsExpandableCard(
title = "Advanced",
title = stringResource(R.string.active_section_advanced),
expanded = expanded,
onToggle = { expanded = !expanded },
isDarkTheme = isDarkTheme,
@@ -557,6 +578,41 @@ private fun ManualUrlSubsection(
}
val autoRelayUrl = RelayUrlDeriver.deriveFromApiUrl(apiUrlInput)
// Pre-resolve strings for Toast (non-composable context)
val apiHermesVoiceReachableToast = stringResource(R.string.active_section_api_hermes_voice_reachable)
val apiRelayVoiceReachableToast = stringResource(R.string.active_section_api_relay_voice_reachable)
val apiReachableVoiceReviewToast = stringResource(R.string.active_section_api_reachable_voice_review)
val cannotReachApiToast = stringResource(R.string.active_section_cannot_reach_api)
// Pre-resolve other strings
val apiServerUrlLabel = stringResource(R.string.active_section_api_server_url_label)
val apiServerUrlPlaceholder = stringResource(R.string.active_section_api_server_url_placeholder)
val apiKeyOptionalLabel = stringResource(R.string.active_section_api_key_optional)
val apiKeyAlreadySetPlaceholder = stringResource(R.string.active_section_api_key_already_set)
val apiKeyNotConfiguredPlaceholder = stringResource(R.string.active_section_api_key_not_configured)
val apiKeyStoredHint = stringResource(R.string.active_section_api_key_stored_hint)
val apiKeyNeededHint = stringResource(R.string.active_section_api_key_needed_hint)
val hideDesc = stringResource(R.string.active_section_hide)
val showDesc = stringResource(R.string.active_section_show)
val saveAndTestText = stringResource(R.string.active_section_save_and_test)
val relayUrlText = stringResource(R.string.active_section_relay_url)
val autoRelayUrlText = stringResource(R.string.active_section_auto_relay_url)
val manualOverrideText = stringResource(R.string.active_section_manual_override)
val relayOptionalForVoiceText = stringResource(R.string.active_section_relay_optional_for_voice)
val useAutoRelayUrlText = stringResource(R.string.active_section_use_auto_relay_url)
val useCustomRelayUrlText = stringResource(R.string.active_section_use_custom_relay_url)
val relayUrlOverrideLabel = stringResource(R.string.active_section_relay_url_override)
val relayUrlOverridePlaceholder = stringResource(R.string.active_section_relay_url_override_placeholder)
val relayUrlOverrideHint = stringResource(R.string.active_section_relay_url_override_hint)
val voiceReadyViaHermesApiText = stringResource(R.string.active_section_voice_ready_via_hermes_api)
val voiceReadyViaRelayText = stringResource(R.string.active_section_voice_ready_via_relay)
val voiceRouteNeedsReviewText = stringResource(R.string.active_section_voice_route_needs_review)
val testRelayText = stringResource(R.string.active_section_test_relay)
val disconnectText = stringResource(R.string.active_section_disconnect)
val probingHealthText = stringResource(R.string.active_section_probing_health)
val reachableRelayVersionText = stringResource(R.string.active_section_reachable_relay_version)
val unreachableRelayText = stringResource(R.string.active_section_unreachable_relay)
LaunchedEffect(apiUrlInput, relayOverrideVisible, autoRelayUrl) {
if (!relayOverrideVisible && autoRelayUrl != null && relayUrlInput != autoRelayUrl) {
relayUrlInput = autoRelayUrl
@@ -567,8 +623,8 @@ private fun ManualUrlSubsection(
OutlinedTextField(
value = apiUrlInput,
onValueChange = { apiUrlInput = it },
label = { Text("API Server URL") },
placeholder = { Text("http://your-server:8642") },
label = { Text(apiServerUrlLabel) },
placeholder = { Text(apiServerUrlPlaceholder) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -576,19 +632,19 @@ private fun ManualUrlSubsection(
OutlinedTextField(
value = apiKeyInput,
onValueChange = { apiKeyInput = it },
label = { Text("API Key (optional)") },
label = { Text(apiKeyOptionalLabel) },
placeholder = {
Text(
if (apiKeyPresent) "•••• already set (leave blank to keep)"
else "Leave empty if not configured",
if (apiKeyPresent) apiKeyAlreadySetPlaceholder
else apiKeyNotConfiguredPlaceholder,
)
},
supportingText = {
Text(
if (apiKeyPresent && apiKeyInput.isBlank()) {
"A key is already stored — leave blank to keep it, or type to replace"
apiKeyStoredHint
} else {
"Only needed if Hermes is configured with API_SERVER_KEY"
apiKeyNeededHint
},
)
},
@@ -603,7 +659,7 @@ private fun ManualUrlSubsection(
Icon(
imageVector = if (apiKeyVisible) Icons.Filled.VisibilityOff
else Icons.Filled.Visibility,
contentDescription = if (apiKeyVisible) "Hide" else "Show",
contentDescription = if (apiKeyVisible) hideDesc else showDesc,
)
}
},
@@ -635,13 +691,13 @@ private fun ManualUrlSubsection(
when {
result.apiReachable && result.voiceConfigReachable ->
if (result.voiceRoute == "standard") {
"API and Hermes voice reachable"
apiHermesVoiceReachableToast
} else {
"API and relay voice reachable"
apiRelayVoiceReachableToast
}
result.apiReachable ->
"API reachable; voice route needs review"
else -> "Cannot reach API server"
apiReachableVoiceReviewToast
else -> cannotReachApiToast
},
Toast.LENGTH_SHORT,
).show()
@@ -649,7 +705,7 @@ private fun ManualUrlSubsection(
},
enabled = apiUrlInput.isNotBlank() && !isTestingApi,
) {
Text("Save & Test")
Text(saveAndTestText)
}
if (isTestingApi) {
CircularProgressIndicator(
@@ -664,20 +720,20 @@ private fun ManualUrlSubsection(
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = "Relay URL",
text = relayUrlText,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = if (autoRelayUrl != null && !relayOverrideVisible) {
"Auto: $autoRelayUrl"
autoRelayUrlText.format(autoRelayUrl)
} else {
"Manual override"
manualOverrideText
},
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Relay is optional for voice. Hermes voice uses the Hermes API; Relay voice uses this route when selected or needed.",
text = relayOptionalForVoiceText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -691,7 +747,7 @@ private fun ManualUrlSubsection(
},
contentPadding = PaddingValues(horizontal = 0.dp),
) {
Text(if (relayOverrideVisible) "Use auto relay URL" else "Use custom relay URL")
Text(if (relayOverrideVisible) useAutoRelayUrlText else useCustomRelayUrlText)
}
}
@@ -703,11 +759,11 @@ private fun ManualUrlSubsection(
// Stale reachability results belong to the prior URL.
connectionViewModel.clearRelayReachableResult()
},
label = { Text("Relay URL override") },
placeholder = { Text("wss://your-server:8767") },
label = { Text(relayUrlOverrideLabel) },
placeholder = { Text(relayUrlOverridePlaceholder) },
singleLine = true,
supportingText = {
Text("Only needed when the optional Relay route cannot be auto-derived")
Text(relayUrlOverrideHint)
},
modifier = Modifier.fillMaxWidth(),
)
@@ -722,12 +778,12 @@ private fun ManualUrlSubsection(
Text(
text = if (result.voiceConfigReachable) {
if (result.voiceRoute == "standard") {
"Voice ready via Hermes API"
voiceReadyViaHermesApiText
} else {
"Voice ready via ${result.relayUrl ?: "relay"}"
voiceReadyViaRelayText.format(result.relayUrl ?: "relay")
}
} else {
"Voice route needs review: ${result.voiceConfigError ?: "voice config probe failed"}"
voiceRouteNeedsReviewText.format(result.voiceConfigError ?: stringResource(R.string.active_section_voice_config_probe_failed))
},
style = MaterialTheme.typography.bodySmall,
color = color,
@@ -751,13 +807,13 @@ private fun ManualUrlSubsection(
enabled = (if (relayOverrideVisible) relayUrlInput else autoRelayUrl.orEmpty()).isNotBlank() &&
relayReachable !is ConnectionViewModel.RelayReachable.Probing,
) {
Text("Test Relay")
Text(testRelayText)
}
OutlinedButton(
onClick = { connectionViewModel.disconnectRelay() },
enabled = relayConnectionState != ConnectionState.Disconnected,
) {
Text("Disconnect")
Text(disconnectText)
}
}
@@ -772,15 +828,16 @@ private fun ManualUrlSubsection(
strokeWidth = 2.dp,
)
Text(
text = "Probing /health…",
text = probingHealthText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is ConnectionViewModel.RelayReachable.Ok -> {
val sessionsSuffix = if (r.sessions == 1) "" else stringResource(R.string.active_section_sessions_suffix)
Text(
text = "✓ Reachable — hermes-relay v${r.version} (${r.clients} client, ${r.sessions} session${if (r.sessions == 1) "" else "s"})",
text = reachableRelayVersionText.format(r.version, r.clients, r.sessions, sessionsSuffix),
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF4CAF50),
)
@@ -792,7 +849,7 @@ private fun ManualUrlSubsection(
)
Column {
Text(
text = "✗ ${humanErr.title}",
text = unreachableRelayText.format(humanErr.title),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -824,6 +881,11 @@ private fun InsecureToggleSubsection(
val isInsecureConnection by connectionViewModel.isInsecureConnection.collectAsState()
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
// Pre-resolve strings
val plainConnectionNotEncrypted = stringResource(R.string.active_section_plain_connection_not_encrypted)
val allowPlainConnections = stringResource(R.string.active_section_allow_plain_connections)
val enableWsHttpForDev = stringResource(R.string.active_section_enable_ws_http_for_dev)
if (isInsecureConnection && relayConnectionState == ConnectionState.Connected) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -839,7 +901,7 @@ private fun InsecureToggleSubsection(
modifier = Modifier.size(16.dp),
)
Text(
text = "Plain connection — traffic is not encrypted",
text = plainConnectionNotEncrypted,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -853,11 +915,11 @@ private fun InsecureToggleSubsection(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Allow plain (unencrypted) connections",
text = allowPlainConnections,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Enable ws:// and http:// for local dev/testing only",
text = enableWsHttpForDev,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -910,6 +972,23 @@ private fun ManualPairingCodeSubsection(
var connectAttempt by remember { mutableStateOf(0) }
var explainerExpanded by rememberSaveable { mutableStateOf(false) }
// Pre-resolve strings for non-composable contexts (LaunchedEffect, UiMessageBus)
val pairedSuccessfullyToast = stringResource(R.string.active_section_paired_successfully)
val noResponseFromRelayToast = stringResource(R.string.active_section_no_response_from_relay)
val pairingCodeCopiedToast = stringResource(R.string.active_section_pairing_code_copied)
val commandCopiedToast = stringResource(R.string.active_section_command_copied)
val hermesPairCommandLabel = stringResource(R.string.active_section_hermes_pair_command)
val copyPairingCodeDesc = stringResource(R.string.conn_info_copy_pairing_code)
val generateNewCodeDesc = stringResource(R.string.active_section_generate_new_code)
val copyHermesPairCommandDesc = stringResource(R.string.active_section_copy_hermes_pair_command)
val connectingText = stringResource(R.string.active_section_connecting)
val connectText = stringResource(R.string.active_section_connect)
val relayUrlNotSetText = stringResource(R.string.active_section_relay_url_not_set)
val hideExplanationText = stringResource(R.string.active_section_hide_explanation)
val howDoesThisWorkText = stringResource(R.string.active_section_how_does_this_work)
val manualPairingExplanation = stringResource(R.string.active_section_manual_pairing_explanation)
val pairingCodeLabel = stringResource(R.string.conn_info_pairing_code)
LaunchedEffect(connectAttempt) {
if (connectAttempt == 0) return@LaunchedEffect
try {
@@ -919,7 +998,7 @@ private fun ManualPairingCodeSubsection(
}
connectInProgress = false
when (terminal) {
is AuthState.Paired -> UiMessageBus.success("Paired successfully")
is AuthState.Paired -> UiMessageBus.success(pairedSuccessfullyToast)
is AuthState.Failed -> {
val human = classifyError(
IllegalStateException(terminal.reason),
@@ -932,7 +1011,7 @@ private fun ManualPairingCodeSubsection(
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
connectInProgress = false
val human = classifyError(
java.io.IOException("No response from relay"),
java.io.IOException(noResponseFromRelayToast),
context = "pair",
)
snackbarHost.showHumanError(human)
@@ -942,20 +1021,25 @@ private fun ManualPairingCodeSubsection(
}
}
// Pre-resolve other UI strings
val manualPairingCodeTitle = stringResource(R.string.active_section_manual_pairing_code_title)
val manualPairingCodeDesc = stringResource(R.string.active_section_manual_pairing_code_desc)
val step1CopyCode = stringResource(R.string.active_section_step_1_copy_code)
val step2RunCommand = stringResource(R.string.active_section_step_2_run_command)
val step3TapConnect = stringResource(R.string.active_section_step_3_tap_connect)
Text(
text = "Manual pairing code (fallback)",
text = manualPairingCodeTitle,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = "Use this when you can't scan the pairing QR. " +
"Follow the three steps — they're meant to be done in order on " +
"whatever machine you have shell access to.",
text = manualPairingCodeDesc,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Step 1 — display + copy + regenerate
ManualPairStep(number = 1, title = "Copy the code below") {
ManualPairStep(number = 1, title = step1CopyCode) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -973,27 +1057,27 @@ private fun ManualPairingCodeSubsection(
IconButton(onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Pairing code", pairingCode)),
ClipEntry(ClipData.newPlainText(pairingCodeLabel, pairingCode)),
)
UiMessageBus.info("Pairing code copied")
UiMessageBus.info(pairingCodeCopiedToast)
}
}) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy pairing code",
contentDescription = copyPairingCodeDesc,
)
}
IconButton(onClick = { connectionViewModel.regeneratePairingCode() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Generate new code",
contentDescription = generateNewCodeDesc,
)
}
}
}
// Step 2 — host command
ManualPairStep(number = 2, title = "On the host running Hermes-Relay, run:") {
ManualPairStep(number = 2, title = step2RunCommand) {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(6.dp),
@@ -1016,16 +1100,16 @@ private fun ManualPairingCodeSubsection(
val cmd = "hermes pair --register-code $pairingCode"
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("hermes pair command", cmd)),
ClipEntry(ClipData.newPlainText(hermesPairCommandLabel, cmd)),
)
UiMessageBus.info("Command copied")
UiMessageBus.info(commandCopiedToast)
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy hermes pair command",
contentDescription = copyHermesPairCommandDesc,
modifier = Modifier.size(16.dp),
)
}
@@ -1034,7 +1118,7 @@ private fun ManualPairingCodeSubsection(
}
// Step 3 — Connect button + relay-URL prerequisite check
ManualPairStep(number = 3, title = "Come back here and tap Connect") {
ManualPairStep(number = 3, title = step3TapConnect) {
val canConnect = !connectInProgress &&
relayUrl.isNotBlank() &&
pairingCode.isNotBlank()
@@ -1061,14 +1145,14 @@ private fun ManualPairingCodeSubsection(
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(modifier = Modifier.size(8.dp))
Text("Connecting…")
Text(connectingText)
} else {
Text("Connect")
Text(connectText)
}
}
if (relayUrl.isBlank()) {
Text(
text = "Relay URL not set — open the Manual URL section above to set it first.",
text = relayUrlNotSetText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -1082,24 +1166,13 @@ private fun ManualPairingCodeSubsection(
contentPadding = PaddingValues(horizontal = 0.dp),
) {
Text(
text = if (explainerExpanded) "Hide explanation" else "How does this work?",
text = if (explainerExpanded) hideExplanationText else howDoesThisWorkText,
style = MaterialTheme.typography.bodySmall,
)
}
if (explainerExpanded) {
Text(
text = "This is a fallback for when you can't scan the pairing QR " +
"— for example, no camera, the host can't render a QR, or you " +
"only have SSH access from a single device. The canonical flow " +
"is the QR scan from `/hermes-relay-pair` or `hermes pair`.\n\n" +
"How it works: the phone generates a 6-character code locally. " +
"You paste that code into the host's `hermes pair --register-code` " +
"command, which pre-registers it with the relay. When you tap " +
"Connect here, the phone presents the same code to the relay " +
"and gets a long-lived session token in return.\n\n" +
"Bridge / device-control is gated by the master toggle on the " +
"Bridge tab, NOT by this pairing code. Pairing only authorizes " +
"the relay session.",
text = manualPairingExplanation,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -1136,6 +1209,13 @@ fun ActiveCardSecurityPosture(
modifier = Modifier.fillMaxWidth(),
)
// Pre-resolve strings
val tailscaleDetectedText = stringResource(R.string.active_section_tailscale_detected)
val sessionTokenHardwareKeystoreText = stringResource(R.string.active_section_session_token_hardware_keystore)
val relaySessionsLabel = stringResource(R.string.active_section_relay_sessions)
val activeSessionsOnServerText = stringResource(R.string.active_section_active_sessions_on_server)
val managePhonesConnectText = stringResource(R.string.active_section_manage_phones_connect)
if (isTailscaleDetected) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1148,7 +1228,7 @@ fun ActiveCardSecurityPosture(
modifier = Modifier.size(16.dp),
)
Text(
text = "Tailscale detected",
text = tailscaleDetectedText,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF2E7D32),
)
@@ -1167,7 +1247,7 @@ fun ActiveCardSecurityPosture(
modifier = Modifier.size(16.dp),
)
Text(
text = "Session token stored in hardware keystore",
text = sessionTokenHardwareKeystoreText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
@@ -1184,14 +1264,14 @@ fun ActiveCardSecurityPosture(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Relay sessions",
text = relaySessionsLabel,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = if (pairedDevices.isNotEmpty()) {
"${pairedDevices.size} active sessions on this server"
activeSessionsOnServerText.format(pairedDevices.size)
} else {
"Manage which phones can connect"
managePhonesConnectText
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -1253,22 +1333,42 @@ fun ActiveCardRoutesSection(
val probeCameUpEmpty = activeEndpoint == null &&
routeProbeStatus is ConnectionViewModel.RouteProbeStatus.Done &&
routeProbeStatus.winner == null
// Pre-resolve strings for route status labels
val checkingRoutesText = stringResource(R.string.active_section_checking_routes)
val noRouteReachableText = stringResource(R.string.active_section_no_route_reachable)
val resolvingText = stringResource(R.string.active_section_resolving)
val usingSavedUrlText = stringResource(R.string.active_section_using_saved_url)
val activeRouteLabel = when {
activeEndpoint != null -> activeEndpoint!!.displayLabel()
isRouteProbing -> "Checking routes…"
probeCameUpEmpty -> "No route reachable"
else -> "Resolving"
isRouteProbing -> checkingRoutesText
probeCameUpEmpty -> noRouteReachableText
else -> resolvingText
}
val activeRouteHost = activeEndpoint?.api?.url
?: "Using saved URL: ${connection.apiServerUrl.ifBlank { connection.relayUrl }}"
?: usingSavedUrlText.format(connection.apiServerUrl.ifBlank { connection.relayUrl })
// Pre-resolve other UI strings
val chooseHowPhoneReachesText = stringResource(R.string.active_section_choose_how_phone_reaches)
val currentRouteText = stringResource(R.string.active_section_current_route)
val noRoutesAnsweredProbeText = stringResource(R.string.active_section_no_routes_answered_probe)
val tailscaleRouteNotActiveText = stringResource(R.string.active_section_tailscale_route_not_active)
val connectPhoneInTailscaleText = stringResource(R.string.active_section_connect_phone_in_tailscale)
val openTailscaleText = stringResource(R.string.active_section_open_tailscale)
val checkingText = stringResource(R.string.active_section_checking)
val recheckText = stringResource(R.string.active_section_recheck)
val phoneOnTailscaleNoRouteText = stringResource(R.string.active_section_phone_on_tailscale_no_route)
val addServerTailscaleUrlText = stringResource(R.string.active_section_add_server_tailscale_url)
val addTailscaleRouteText = stringResource(R.string.active_section_add_tailscale_route)
val autoText = stringResource(R.string.active_section_auto)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Choose how this phone reaches Hermes. Features stay separate " +
"from the selected route.",
text = chooseHowPhoneReachesText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -1287,7 +1387,7 @@ fun ActiveCardRoutesSection(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Current: $activeRouteLabel",
text = currentRouteText.format(activeRouteLabel),
style = MaterialTheme.typography.bodyMedium,
color = if (probeCameUpEmpty) {
MaterialTheme.colorScheme.error
@@ -1311,8 +1411,7 @@ fun ActiveCardRoutesSection(
)
if (probeCameUpEmpty) {
Text(
text = "None of the saved routes answered a health probe. " +
"Expand the routes below for per-route reasons.",
text = noRoutesAnsweredProbeText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -1331,12 +1430,12 @@ fun ActiveCardRoutesSection(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "Tailscale route is not active on this phone",
text = tailscaleRouteNotActiveText,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text = "Connect this phone in Tailscale, then re-check routes.",
text = connectPhoneInTailscaleText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
@@ -1351,7 +1450,7 @@ fun ActiveCardRoutesSection(
},
contentPadding = PaddingValues(horizontal = 0.dp),
) {
Text("Open Tailscale")
Text(openTailscaleText)
}
}
TextButton(
@@ -1359,7 +1458,7 @@ fun ActiveCardRoutesSection(
enabled = !isRouteProbing,
contentPadding = PaddingValues(horizontal = 0.dp),
) {
Text(if (isRouteProbing) "Checking…" else "Re-check")
Text(if (isRouteProbing) checkingText else recheckText)
}
}
}
@@ -1380,13 +1479,12 @@ fun ActiveCardRoutesSection(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "Phone is on Tailscale — no Tailscale route yet",
text = phoneOnTailscaleNoRouteText,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text = "Add your server's Tailscale URL so Hermes keeps " +
"working when this phone leaves the server's network.",
text = addServerTailscaleUrlText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
@@ -1397,7 +1495,7 @@ fun ActiveCardRoutesSection(
},
contentPadding = PaddingValues(horizontal = 0.dp),
) {
Text("Add Tailscale route")
Text(addTailscaleRouteText)
}
}
}
@@ -1411,7 +1509,7 @@ fun ActiveCardRoutesSection(
onClick = { connectionViewModel.probeNow() },
enabled = !isRouteProbing,
) {
Text(if (isRouteProbing) "Checking…" else "Re-check")
Text(if (isRouteProbing) checkingText else recheckText)
}
if (preferredRole != null || manualSwitchActive) {
TextButton(
@@ -1420,7 +1518,7 @@ fun ActiveCardRoutesSection(
preferredRole = null
},
) {
Text("Auto")
Text(autoText)
}
}
}
@@ -24,6 +24,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import java.io.File
@@ -42,7 +44,7 @@ fun AgentIconRow(connectionViewModel: ConnectionViewModel) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = "Agent icon",
text = stringResource(R.string.agent_icon_title),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -61,23 +63,23 @@ fun AgentIconRow(connectionViewModel: ConnectionViewModel) {
if (!path.isNullOrBlank()) {
AsyncImage(
model = File(path),
contentDescription = "Agent icon",
contentDescription = stringResource(R.string.agent_icon_title),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
OutlinedButton(onClick = { launcher.launch(arrayOf("image/*")) }) {
Text(if (iconPath.isNullOrBlank()) "Set image" else "Change")
Text(if (iconPath.isNullOrBlank()) stringResource(R.string.agent_icon_set) else stringResource(R.string.agent_icon_change))
}
if (!iconPath.isNullOrBlank()) {
TextButton(onClick = { connectionViewModel.clearProfileIcon() }) {
Text("Clear")
Text(stringResource(R.string.agent_icon_clear))
}
}
}
Text(
text = "Shown beside this profile's name in chat. Stays on this device — never sent to Hermes.",
text = stringResource(R.string.agent_icon_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -72,12 +72,14 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
import com.hermesandroid.relay.ui.theme.RelayRefresh
import kotlinx.coroutines.delay
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.RelayRefresh
// --- Text-flow tuning constants -------------------------------------------
//
@@ -453,7 +455,7 @@ private fun CleanModeComposer(
Box(contentAlignment = Alignment.CenterStart) {
if (text.isEmpty()) {
Text(
text = "Message",
text = stringResource(R.string.agent_text_placeholder),
style = MaterialTheme.typography.bodyLarge,
color = RelayRefresh.Dim,
)
@@ -469,7 +471,7 @@ private fun CleanModeComposer(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
contentDescription = stringResource(R.string.agent_text_send_cd),
tint = if (canSend) {
MaterialTheme.colorScheme.primary
} else {
@@ -550,8 +552,9 @@ fun CleanChatMode(
BackHandler(enabled = true) { onExit() }
val sphereDescLabel = stringResource(R.string.agent_text_sphere_desc)
val sphereDescription = remember(sphereState) {
"Agent ${sphereState.name.lowercase()}"
"$sphereDescLabel ${sphereState.name.lowercase()}"
}
Box(
@@ -589,7 +592,7 @@ fun CleanChatMode(
IconButton(onClick = onExit) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Exit clean mode",
contentDescription = stringResource(R.string.agent_text_exit_clean),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -40,6 +40,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.platform.testTag
@@ -48,6 +49,7 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.AttachmentRenderMode
import com.hermesandroid.relay.data.AttachmentState
@@ -308,7 +310,7 @@ private fun GalleryImageFailure(description: String, modifier: Modifier) {
) {
Icon(
imageVector = Icons.Filled.BrokenImage,
contentDescription = "Couldn't load $description",
contentDescription = stringResource(R.string.attachment_load_failed_a11y, description),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(28.dp),
)
@@ -1,3 +1,5 @@
@file:Suppress("LocalContextGetResourceValueCall")
package com.hermesandroid.relay.ui.components
import android.content.Context
@@ -97,6 +99,8 @@ import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.AttachmentRenderMode
import com.hermesandroid.relay.data.BlurMode
import com.hermesandroid.relay.util.MediaSaver
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -167,18 +171,18 @@ fun BlurredMedia(
) {
Icon(
imageVector = Icons.Filled.VisibilityOff,
contentDescription = "Sensitive content",
contentDescription = stringResource(R.string.attach_viewer_cd_sensitive),
tint = Color.White,
modifier = Modifier.size(28.dp),
)
Text(
text = "Sensitive",
text = stringResource(R.string.attachment_sensitive),
style = MaterialTheme.typography.labelLarge,
color = Color.White,
)
if (revealOnTap) {
Text(
text = "Tap to reveal",
text = stringResource(R.string.attachment_tap_reveal),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.8f),
)
@@ -322,7 +326,7 @@ fun AttachmentViewer(
shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { "Attachment" }
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
// --- One shared Share / Save / Open-externally action set ----------
fun runWithBytes(action: suspend (ByteArray) -> Unit) {
@@ -331,7 +335,7 @@ fun AttachmentViewer(
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
busy = false
viewerToast(context, "Couldn't read this file")
viewerToast(context, context.getString(R.string.inbound_attach_share_failed))
return@launch
}
action(bytes)
@@ -354,13 +358,13 @@ fun AttachmentViewer(
}
when (result) {
is MediaSaver.SaveResult.Saved ->
viewerToast(context, "Saved to ${result.location}")
viewerToast(context, context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
is MediaSaver.SaveResult.Failed ->
viewerToast(context, "Save failed: ${result.message}")
viewerToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
}
}
}
@@ -626,7 +630,7 @@ private fun MediaViewerToolbar(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onClose, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = "Close")
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.attach_viewer_cd_close))
}
Text(
text = title,
@@ -648,13 +652,13 @@ private fun MediaViewerToolbar(
enabled = actionsEnabled && !busy,
colors = tint,
) {
Icon(Icons.Filled.OpenInNew, contentDescription = "Open externally")
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 = "Share")
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
}
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Download, contentDescription = "Save")
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
}
}
}
@@ -706,7 +710,7 @@ private fun ImageBody(
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().zoomable(),
)
failed -> CenteredNotice("Couldn't load this image")
failed -> CenteredNotice(stringResource(R.string.attach_viewer_load_failed))
else -> CircularProgressIndicator(color = Color.White)
}
}
@@ -722,7 +726,7 @@ private fun VideoBody(attachment: Attachment) {
val uri = rememberPlayableUri(attachment)
if (uri == null) {
CenteredNotice("Preparing video…", spinner = true)
CenteredNotice(stringResource(R.string.attach_viewer_preparing_video), spinner = true)
return
}
@@ -780,7 +784,7 @@ private fun VideoBody(attachment: Attachment) {
) {
Icon(
imageVector = if (muted) Icons.Filled.VolumeOff else Icons.Filled.VolumeUp,
contentDescription = if (muted) "Unmute" else "Mute",
contentDescription = if (muted) stringResource(R.string.attach_viewer_cd_unmute) else stringResource(R.string.attach_viewer_cd_mute),
)
}
},
@@ -797,7 +801,7 @@ private fun AudioBody(attachment: Attachment) {
val uri = rememberPlayableUri(attachment)
if (uri == null) {
CenteredNotice("Preparing audio…", spinner = true)
CenteredNotice(stringResource(R.string.attach_viewer_preparing_audio), spinner = true)
return
}
@@ -859,7 +863,7 @@ private fun AudioBody(attachment: Attachment) {
AmplitudeMeter(amplitude = amplitude, active = isPlaying)
Spacer(Modifier.height(24.dp))
Text(
text = attachment.fileName ?: "Audio",
text = attachment.fileName ?: stringResource(R.string.attachment_audio_label),
style = MaterialTheme.typography.titleMedium,
color = Color.White,
maxLines = 2,
@@ -938,6 +942,8 @@ private fun PdfBody(attachment: Attachment) {
var pdfError by remember(attachment.cachedUri, attachment.content) { mutableStateOf<String?>(null) }
var widthPx by remember { mutableStateOf(0) }
val pdfFailedText = stringResource(R.string.attach_viewer_pdf_failed)
LaunchedEffect(attachment.cachedUri, attachment.content) {
val opened = withContext(Dispatchers.IO) {
runCatching {
@@ -946,14 +952,14 @@ private fun PdfBody(attachment: Attachment) {
PdfDoc(PdfRenderer(pfd), pfd, Mutex())
}.getOrNull()
}
if (opened == null) pdfError = "Couldn't open this PDF" else doc = opened
if (opened == null) pdfError = pdfFailedText else doc = opened
}
DisposableEffect(doc) { onDispose { doc?.close() } }
val current = doc
when {
pdfError != null -> CenteredNotice(pdfError!!)
current == null -> CenteredNotice("Rendering PDF…", spinner = true)
current == null -> CenteredNotice(stringResource(R.string.attach_viewer_rendering_pdf), spinner = true)
else -> LazyColumn(
modifier = Modifier
.fillMaxSize()
@@ -1001,7 +1007,7 @@ private fun PdfPage(doc: PdfDoc, index: Int, widthPx: Int) {
if (bmp != null) {
Image(
bitmap = bmp,
contentDescription = "Page ${index + 1}",
contentDescription = stringResource(R.string.attach_viewer_page_label, index + 1),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(2.dp)),
)
@@ -1037,7 +1043,7 @@ private fun TextBody(attachment: Attachment) {
val body = text
when {
body == null -> CenteredNotice("Loading…", spinner = true)
body == null -> CenteredNotice(stringResource(R.string.attach_viewer_loading), spinner = true)
else -> SelectionContainer(
modifier = Modifier
.fillMaxSize()
@@ -1066,19 +1072,19 @@ private fun GenericBody(attachment: Attachment, onOpenExternal: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = attachment.fileName ?: "File",
text = attachment.fileName ?: stringResource(R.string.attach_viewer_file_label),
style = MaterialTheme.typography.titleMedium,
color = Color.White,
)
Spacer(Modifier.height(8.dp))
Text(
text = "No in-app preview for this type.",
text = stringResource(R.string.attachment_no_preview),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.7f),
)
Spacer(Modifier.height(16.dp))
androidx.compose.material3.OutlinedButton(onClick = onOpenExternal) {
Text("Open externally")
Text(stringResource(R.string.attachment_open_ext))
}
}
}
@@ -1116,7 +1122,7 @@ private fun PlaybackControls(
IconButton(onClick = onPlayPause, colors = tint) {
Icon(
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
contentDescription = if (isPlaying) stringResource(R.string.attach_viewer_cd_pause) else stringResource(R.string.attach_viewer_cd_play),
)
}
Text(
@@ -36,8 +36,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import com.hermesandroid.relay.R
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.BridgeActivityEntry
@@ -89,14 +91,14 @@ fun BridgeActivityLog(
)
Spacer(modifier = Modifier.size(6.dp))
Text(
text = "Activity Log",
text = stringResource(R.string.bal_activity_log),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
if (entries.isNotEmpty()) {
TextButton(onClick = onClear) { Text("Clear") }
TextButton(onClick = onClear) { Text(stringResource(R.string.bal_clear)) }
}
}
@@ -104,8 +106,7 @@ fun BridgeActivityLog(
if (entries.isEmpty()) {
Text(
text = "No bridge commands yet. Every tap, type, and " +
"screenshot the agent performs will show up here.",
text = stringResource(R.string.bal_no_activity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -162,7 +163,7 @@ private fun ActivityRow(entry: BridgeActivityEntry) {
if (entry.thumbnailToken != null) {
Icon(
imageVector = Icons.Filled.InsertPhoto,
contentDescription = "Has screenshot",
contentDescription = stringResource(R.string.bal_has_screenshot),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp)
)
@@ -175,13 +176,13 @@ private fun ActivityRow(entry: BridgeActivityEntry) {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "Full timestamp: ${formatFullTime(entry.timestampMs)}",
text = stringResource(R.string.bal_full_timestamp, formatFullTime(entry.timestampMs)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace
)
Text(
text = "Status: ${entry.status.name}",
text = stringResource(R.string.bal_status_label, entry.status.name),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -199,7 +200,7 @@ private fun ActivityRow(entry: BridgeActivityEntry) {
// label so the expand affordance still communicates the
// shape of the future feature.
Text(
text = "Screenshot token: ${entry.thumbnailToken}",
text = stringResource(R.string.bal_screenshot_token, entry.thumbnailToken),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace
@@ -33,9 +33,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.BridgeStatus
/**
@@ -61,6 +63,7 @@ fun BridgeMasterToggle(
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier,
label: String = "Agent Control",
// Note: label default is intentionally a literal — it's resolved from the caller, not from stringResource
// Called when the user taps the switch to enable but accessibility
// hasn't been granted yet. The default no-op keeps the v0.4 behaviour
// for callers that don't wire this up; BridgeScreen hooks a snackbar
@@ -81,17 +84,15 @@ fun BridgeMasterToggle(
val isSideloadLabel = label.contains("Agent", ignoreCase = true)
val subtitle = if (isSideloadLabel) {
if (enabled) {
"Master switch — agent can read screen and act via the " +
"sub-features below."
stringResource(R.string.bmt_master_switch_on)
} else {
"Master switch — off. All bridge features (unattended, " +
"commands, voice intents) are inactive."
stringResource(R.string.bmt_master_switch_off)
}
} else {
if (enabled) {
"Master switch — bridge is providing screen content to chat."
stringResource(R.string.bmt_master_switch_on_googleplay)
} else {
"Master switch — off. Bridge is not reading screen content."
stringResource(R.string.bmt_master_switch_off_googleplay)
}
}
@@ -125,7 +126,7 @@ fun BridgeMasterToggle(
IconButton(onClick = { showExplain = true }) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = "What does this do?",
contentDescription = stringResource(R.string.bmt_what_does_this_do),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -149,7 +150,7 @@ fun BridgeMasterToggle(
if (!accessibilityGranted) {
Text(
text = "Grant the Accessibility Service permission below to enable.",
text = stringResource(R.string.bmt_grant_accessibility),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
@@ -159,22 +160,22 @@ fun BridgeMasterToggle(
Spacer(modifier = Modifier.height(2.dp))
StatusInlineRow(
icon = Icons.Filled.PhoneAndroid,
label = "Device",
label = stringResource(R.string.bmt_device),
value = status.deviceName
)
StatusInlineRow(
icon = Icons.Filled.BatteryFull,
label = "Battery",
label = stringResource(R.string.bmt_battery),
value = status.batteryPercent?.let { "$it%" } ?: "—"
)
StatusInlineRow(
icon = Icons.Filled.ScreenLockPortrait,
label = "Screen",
value = if (status.screenOn) "ON" else "OFF"
label = stringResource(R.string.bmt_screen),
value = if (status.screenOn) stringResource(R.string.bmt_on) else stringResource(R.string.bmt_off)
)
StatusInlineRow(
icon = Icons.Filled.Smartphone,
label = "Current app",
label = stringResource(R.string.bmt_current_app),
value = status.currentApp ?: "—"
)
}
@@ -184,40 +185,29 @@ fun BridgeMasterToggle(
if (showExplain) {
AlertDialog(
onDismissRequest = { showExplain = false },
title = { Text("About Agent Control") },
title = { Text(stringResource(R.string.bmt_about_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Agent Control lets your Hermes agent read what's on " +
"your screen and interact with apps on your behalf " +
"(tap, type, scroll, screenshot).",
stringResource(R.string.bmt_about_body1),
style = MaterialTheme.typography.bodyMedium
)
Text(
"This uses Android's Accessibility Service API, which " +
"is the same permission screen readers use. You must " +
"enable it in Android Settings before this switch works.",
stringResource(R.string.bmt_about_body2),
style = MaterialTheme.typography.bodyMedium
)
Text(
"While this is on, a 'Hermes has device control' " +
"notification stays in your notification shade — " +
"that's tied to this master switch, not to any " +
"sub-feature (like Unattended Access), and goes " +
"away the moment you turn this off.",
stringResource(R.string.bmt_about_body3),
style = MaterialTheme.typography.bodyMedium
)
Text(
"You can turn Agent Control off at any time from this " +
"screen or by disabling the service in Android " +
"Settings. All bridge commands are logged in the " +
"Activity Log below.",
stringResource(R.string.bmt_about_body4),
style = MaterialTheme.typography.bodyMedium
)
}
},
confirmButton = {
TextButton(onClick = { showExplain = false }) { Text("Got it") }
TextButton(onClick = { showExplain = false }) { Text(stringResource(R.string.bmt_got_it)) }
}
)
}
@@ -254,7 +244,7 @@ private fun MasterPill() {
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Text(
text = "MASTER",
text = stringResource(R.string.bmt_master),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
@@ -44,9 +44,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.BridgePermissionStatus
/**
@@ -117,13 +119,13 @@ fun BridgePermissionChecklist(
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "Permissions",
text = stringResource(R.string.bpc_permissions),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Tap a row to grant or open Android Settings · Tap Test to verify.",
text = stringResource(R.string.bpc_permissions_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -132,16 +134,16 @@ fun BridgePermissionChecklist(
// ── Core bridge (required, both flavors) ──────────────────────
TierHeader(
label = "Core bridge",
subtitle = "Required for the agent to read and act on screen content.",
label = stringResource(R.string.bpc_core_bridge),
subtitle = stringResource(R.string.bpc_core_bridge_desc),
)
PermissionRow(
icon = Icons.Filled.Accessibility,
title = "Accessibility Service",
title = stringResource(R.string.bpc_accessibility),
subtitle = if (BuildFlavor.isSideload)
"Read screen content, dispatch taps/types"
stringResource(R.string.bpc_accessibility_desc_sideload)
else
"Read screen content for chat context",
stringResource(R.string.bpc_accessibility_desc_googleplay),
granted = status.accessibilityServiceEnabled,
onClick = { openAccessibilitySettings(context) },
onTest = onTestAccessibility,
@@ -153,11 +155,11 @@ fun BridgePermissionChecklist(
if (BuildFlavor.isSideload) {
PermissionRow(
icon = Icons.Filled.ScreenShare,
title = "Screen Capture",
title = stringResource(R.string.bpc_screen_capture),
subtitle = if (status.screenCapturePermitted)
"Granted for this session — agent can take screenshots"
stringResource(R.string.bpc_screen_capture_granted)
else
"Tap to grant — agent needs this for /screenshot",
stringResource(R.string.bpc_screen_capture_not_granted),
granted = status.screenCapturePermitted,
onClick = onRequestScreenCapture,
onTest = onTestScreenCapture,
@@ -170,8 +172,8 @@ fun BridgePermissionChecklist(
if (BuildFlavor.isSideload) {
PermissionRow(
icon = Icons.Filled.PictureInPicture,
title = "Display over other apps",
subtitle = "Status overlay while bridge is active",
title = stringResource(R.string.bpc_overlay),
subtitle = stringResource(R.string.bpc_overlay_desc),
granted = status.overlayPermitted,
onClick = { openOverlaySettings(context) },
onTest = onTestOverlay,
@@ -180,11 +182,11 @@ fun BridgePermissionChecklist(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
PermissionRow(
icon = Icons.Filled.Notifications,
title = "Notifications",
title = stringResource(R.string.bpc_notifications),
subtitle = if (status.notificationsPermitted)
"Bridge service notification can display"
stringResource(R.string.bpc_notifications_granted)
else
"Required for the bridge foreground service indicator",
stringResource(R.string.bpc_notifications_not_granted),
granted = status.notificationsPermitted,
onClick = onRequestNotifications,
)
@@ -193,13 +195,13 @@ fun BridgePermissionChecklist(
// ── Notification companion (optional, both flavors) ─────────────
TierSpacer()
TierHeader(
label = "Notification companion",
subtitle = "Optional. Lets the agent see incoming notifications for summaries and replies.",
label = stringResource(R.string.bpc_notification_companion),
subtitle = stringResource(R.string.bpc_notification_companion_desc),
)
PermissionRow(
icon = Icons.Filled.Notifications,
title = "Notification Listener",
subtitle = "Read notifications for agent summaries",
title = stringResource(R.string.bpc_notification_listener),
subtitle = stringResource(R.string.bpc_notification_listener_desc),
granted = status.notificationListenerPermitted,
onClick = { openNotificationListenerSettings(context) },
onTest = onTestNotificationListener,
@@ -209,21 +211,21 @@ fun BridgePermissionChecklist(
// ── Voice & camera (optional, both flavors) ────────────────────
TierSpacer()
TierHeader(
label = "Voice & camera",
subtitle = "Required when you use voice mode or attach camera media.",
label = stringResource(R.string.bpc_voice_camera),
subtitle = stringResource(R.string.bpc_voice_camera_desc),
)
PermissionRow(
icon = Icons.Filled.Mic,
title = "Microphone",
subtitle = "Required for voice mode (record + transcribe).",
title = stringResource(R.string.bpc_microphone),
subtitle = stringResource(R.string.bpc_microphone_desc),
granted = status.microphonePermitted,
onClick = onRequestMicrophone,
optional = true,
)
PermissionRow(
icon = Icons.Filled.CameraAlt,
title = "Camera",
subtitle = "Required to attach photos taken in-app.",
title = stringResource(R.string.bpc_camera),
subtitle = stringResource(R.string.bpc_camera_desc),
granted = status.cameraPermitted,
onClick = onRequestCamera,
optional = true,
@@ -233,37 +235,37 @@ fun BridgePermissionChecklist(
if (BuildFlavor.isSideload) {
TierSpacer()
TierHeader(
label = "Sideload features",
subtitle = "Optional. Powers contact lookup, SMS, dialer, and location tools.",
label = stringResource(R.string.bpc_sideload_features),
subtitle = stringResource(R.string.bpc_sideload_features_desc),
)
PermissionRow(
icon = Icons.Filled.Contacts,
title = "Contacts",
subtitle = "Resolve names to phone numbers (android_search_contacts).",
title = stringResource(R.string.bpc_contacts),
subtitle = stringResource(R.string.bpc_contacts_desc),
granted = status.contactsPermitted,
onClick = onRequestContacts,
optional = true,
)
PermissionRow(
icon = Icons.Filled.Sms,
title = "SMS",
subtitle = "Send text messages directly (android_send_sms).",
title = stringResource(R.string.bpc_sms),
subtitle = stringResource(R.string.bpc_sms_desc),
granted = status.smsPermitted,
onClick = onRequestSms,
optional = true,
)
PermissionRow(
icon = Icons.Filled.Call,
title = "Phone",
subtitle = "Place calls directly without opening the dialer (android_call).",
title = stringResource(R.string.bpc_phone),
subtitle = stringResource(R.string.bpc_phone_desc),
granted = status.phonePermitted,
onClick = onRequestPhone,
optional = true,
)
PermissionRow(
icon = Icons.Filled.LocationOn,
title = "Location",
subtitle = "Last-known GPS fix for context-aware queries (android_location).",
title = stringResource(R.string.bpc_location),
subtitle = stringResource(R.string.bpc_location_desc),
granted = status.locationPermitted,
onClick = onRequestLocation,
optional = true,
@@ -329,7 +331,7 @@ private fun OptionalBadge() {
// badge drops to the next line cleanly when space is tight, instead
// of compressing awkwardly in-line.
Text(
text = "Optional",
text = stringResource(R.string.bpc_optional),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
softWrap = false,
@@ -403,7 +405,7 @@ private fun PermissionRow(
),
) {
Text(
text = "Test",
text = stringResource(R.string.bpc_test),
style = MaterialTheme.typography.labelSmall,
)
}
@@ -412,9 +414,9 @@ private fun PermissionRow(
// Optional rows that are *not* granted use a neutral tint instead of
// error red so users don't perceive them as urgent action items.
val (statusTint, statusDescription) = when {
granted -> Color(0xFF4CAF50) to "Granted"
optional -> MaterialTheme.colorScheme.onSurfaceVariant to "Not granted (optional)"
else -> MaterialTheme.colorScheme.error to "Not granted"
granted -> Color(0xFF4CAF50) to stringResource(R.string.bpc_granted)
optional -> MaterialTheme.colorScheme.onSurfaceVariant to stringResource(R.string.bpc_not_granted_optional)
else -> MaterialTheme.colorScheme.error to stringResource(R.string.bpc_not_granted)
}
Icon(
imageVector = if (granted) Icons.Filled.CheckCircle
@@ -23,7 +23,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import com.hermesandroid.relay.R
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.BridgeSafetySettings
@@ -90,7 +92,7 @@ fun BridgeSafetySummaryCard(
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = "Safety",
text = stringResource(R.string.bssc_safety),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
@@ -98,21 +100,21 @@ fun BridgeSafetySummaryCard(
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "Manage",
contentDescription = stringResource(R.string.bssc_manage),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
SafetySummaryRow(
label = "Apps the agent can't touch",
label = stringResource(R.string.bssc_blocked_apps),
value = "${settings.blocklist.size}",
)
SafetySummaryRow(
label = "Words that always ask first",
label = stringResource(R.string.bssc_destructive_verbs),
value = "${settings.destructiveVerbs.size}",
)
SafetySummaryRow(
label = "Turns itself off when idle",
label = stringResource(R.string.bssc_auto_disable),
value = if (autoDisableAtMs != null) {
val remainMs = (autoDisableAtMs - nowMs).coerceAtLeast(0L)
val remainMin = (remainMs / 60_000L).toInt()
@@ -124,7 +126,7 @@ fun BridgeSafetySummaryCard(
)
Text(
text = "These guardrails keep Hermes in bounds. Tap to adjust.",
text = stringResource(R.string.bssc_guardrails_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -14,9 +14,11 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.BridgeStatus
/**
@@ -53,7 +55,7 @@ fun BridgeStatusCard(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Status",
text = stringResource(R.string.bsc_status),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
@@ -64,7 +66,7 @@ fun BridgeStatusCard(
isConnecting = false,
)
Text(
text = if (isConnected) "Connected" else "Disconnected",
text = if (isConnected) stringResource(R.string.bsc_connected) else stringResource(R.string.bsc_disconnected),
style = MaterialTheme.typography.labelMedium,
color = if (isConnected) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -75,22 +77,21 @@ fun BridgeStatusCard(
if (status == null) {
Text(
text = "Bridge runtime not yet reporting status. Enable " +
"Agent Control above to begin receiving device telemetry.",
text = stringResource(R.string.bsc_no_status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
StatusKeyValue("Device", status.deviceName)
StatusKeyValue(stringResource(R.string.bmt_device), status.deviceName)
StatusKeyValue(
"Battery",
status.batteryPercent?.let { "$it%" } ?: "Unknown"
stringResource(R.string.bmt_battery),
status.batteryPercent?.let { "$it%" } ?: stringResource(R.string.bsc_unknown)
)
StatusKeyValue("Screen", if (status.screenOn) "ON" else "OFF")
StatusKeyValue("Current app", status.currentApp ?: "—")
StatusKeyValue(stringResource(R.string.bmt_screen), if (status.screenOn) stringResource(R.string.bmt_on) else stringResource(R.string.bmt_off))
StatusKeyValue(stringResource(R.string.bmt_current_app), status.currentApp ?: "—")
StatusKeyValue(
"Accessibility service",
if (status.accessibilityEnabled) "Enabled" else "Disabled"
stringResource(R.string.bsc_accessibility_service),
if (status.accessibilityEnabled) stringResource(R.string.bsc_enabled) else stringResource(R.string.bsc_disabled)
)
}
}
@@ -1,3 +1,5 @@
@file:Suppress("LocalContextGetResourceValueCall")
package com.hermesandroid.relay.ui.components
import android.widget.Toast
@@ -31,10 +33,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil3.compose.AsyncImage
import com.hermesandroid.relay.R
import com.hermesandroid.relay.util.MediaSaver
import kotlinx.coroutines.launch
@@ -152,6 +156,10 @@ 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 {
@@ -159,7 +167,7 @@ fun ChatImageViewer(
val bytes = runCatching { source.bytesProvider() }.getOrNull()
busy = false
if (bytes == null) {
toast(context, "Couldn't load this image")
toast(context, errorMsg)
return@launch
}
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
@@ -168,8 +176,10 @@ fun ChatImageViewer(
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = "Share")
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 {
@@ -177,13 +187,13 @@ fun ChatImageViewer(
val bytes = runCatching { source.bytesProvider() }.getOrNull()
if (bytes == null) {
busy = false
toast(context, "Couldn't load this image")
toast(context, errorMsg)
return@launch
}
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
is MediaSaver.SaveResult.Saved -> {
busy = false
toast(context, "Saved to ${result.location}")
toast(context, savedFmt.format(result.location))
}
MediaSaver.SaveResult.UseShareInstead -> {
busy = false
@@ -192,17 +202,17 @@ fun ChatImageViewer(
}
is MediaSaver.SaveResult.Failed -> {
busy = false
toast(context, "Save failed: ${result.message}")
toast(context, failedFmt.format(result.message))
}
}
}
},
colors = tint,
) {
Icon(Icons.Filled.Download, contentDescription = "Save")
Icon(Icons.Filled.Download, contentDescription = cdSave)
}
IconButton(onClick = onDismiss, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = "Close")
Icon(Icons.Filled.Close, contentDescription = cdClose)
}
}
}
@@ -57,8 +57,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.purpleGlow
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
@@ -213,7 +215,7 @@ fun ChatInputBar(
exit = fadeOut(),
) {
Text(
text = "Live voice conversation",
text = stringResource(R.string.chat_input_live_voice_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
@@ -291,15 +293,15 @@ fun ChatInputBar(
.clip(CircleShape)
.combinedClickable(
onClick = { attachMenuExpanded = true },
onClickLabel = "Add attachment",
onClickLabel = stringResource(R.string.chat_input_add_attachment),
onLongClick = onLongPressAttach,
onLongClickLabel = "Browse commands",
onLongClickLabel = stringResource(R.string.chat_input_browse_commands),
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Add attachment; hold for commands",
contentDescription = stringResource(R.string.chat_input_add_attachment_hold),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -308,7 +310,7 @@ fun ChatInputBar(
onDismissRequest = { attachMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text("Photos") },
text = { Text(stringResource(R.string.chat_input_photos)) },
leadingIcon = {
Icon(Icons.Filled.PhotoLibrary, contentDescription = null)
},
@@ -318,7 +320,7 @@ fun ChatInputBar(
},
)
DropdownMenuItem(
text = { Text("Files") },
text = { Text(stringResource(R.string.chat_input_files)) },
leadingIcon = {
Icon(Icons.Filled.InsertDriveFile, contentDescription = null)
},
@@ -328,7 +330,7 @@ fun ChatInputBar(
},
)
DropdownMenuItem(
text = { Text("Camera") },
text = { Text(stringResource(R.string.chat_input_camera)) },
leadingIcon = {
Icon(Icons.Filled.PhotoCamera, contentDescription = null)
},
@@ -338,7 +340,7 @@ fun ChatInputBar(
},
)
DropdownMenuItem(
text = { Text("Paste image") },
text = { Text(stringResource(R.string.chat_input_paste_image)) },
leadingIcon = {
Icon(Icons.Filled.ContentPaste, contentDescription = null)
},
@@ -393,7 +395,7 @@ fun ChatInputBar(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send message",
contentDescription = stringResource(R.string.chat_input_send_message),
tint = if (enabled) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -403,8 +405,8 @@ fun ChatInputBar(
IconButton(onClick = onVoice) {
Icon(
imageVector = Icons.Filled.GraphicEq,
contentDescription = if (voiceReady) "Start voice conversation"
else "Voice conversation — setup needed",
contentDescription = if (voiceReady) stringResource(R.string.chat_input_start_voice)
else stringResource(R.string.chat_input_voice_setup_needed),
tint = MaterialTheme.colorScheme.primary,
)
}
@@ -431,7 +433,7 @@ fun ChatInputBar(
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = "Stop streaming",
contentDescription = stringResource(R.string.chat_input_stop_streaming),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
@@ -444,7 +446,7 @@ fun ChatInputBar(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Steer the response",
contentDescription = stringResource(R.string.chat_input_steer_response),
tint = MaterialTheme.colorScheme.tertiary,
)
}
@@ -456,7 +458,7 @@ fun ChatInputBar(
Box {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Queue message",
contentDescription = stringResource(R.string.chat_input_queue_message),
tint = MaterialTheme.colorScheme.tertiary,
)
Icon(
@@ -43,9 +43,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
/**
* A slash command entry — built-in, personality, server skill, or (on the
* gateway transport) a server-catalog command from `commands.catalog`.
@@ -140,11 +141,11 @@ fun CommandPalette(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Commands",
text = stringResource(R.string.command_palette_header),
style = MaterialTheme.typography.titleMedium
)
Text(
text = "${filtered.size} available",
text = stringResource(R.string.command_palette_count, filtered.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -159,7 +160,7 @@ fun CommandPalette(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
placeholder = { Text("Search commands...") },
placeholder = { Text(stringResource(R.string.command_palette_search)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
@@ -172,7 +173,7 @@ fun CommandPalette(
IconButton(onClick = { searchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Clear search",
contentDescription = stringResource(R.string.command_palette_cd_clear),
modifier = Modifier.size(20.dp)
)
}
@@ -202,7 +203,7 @@ fun CommandPalette(
FilterChip(
selected = selectedCategory == null,
onClick = { selectedCategory = null },
label = { Text("All") },
label = { Text(stringResource(R.string.command_palette_filter_all)) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
)
@@ -233,8 +234,8 @@ fun CommandPalette(
modifier = Modifier.padding(horizontal = 8.dp)
) {
Text(
text = if (categoriesExpanded) "Show less"
else "Show all (${categories.size})",
text = if (categoriesExpanded) stringResource(R.string.command_palette_show_less)
else stringResource(R.string.command_palette_show_all, categories.size),
style = MaterialTheme.typography.labelMedium
)
}
@@ -283,7 +284,7 @@ fun CommandPalette(
contentAlignment = Alignment.Center
) {
Text(
text = "No commands match your search",
text = stringResource(R.string.command_palette_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -24,9 +24,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ToolCall
@Composable
@@ -36,16 +38,18 @@ fun CompactToolCall(
) {
val isPreparing = toolCall.isGenerating && !toolCall.isComplete
val statusText = when {
toolCall.isComplete && toolCall.success == true -> "completed"
toolCall.isComplete && toolCall.success == false -> "failed"
isPreparing -> "preparing"
else -> "running"
toolCall.isComplete && toolCall.success == true -> stringResource(R.string.tool_progress_status_completed)
toolCall.isComplete && toolCall.success == false -> stringResource(R.string.tool_progress_status_failed)
isPreparing -> stringResource(R.string.tool_preparing_a11y)
else -> stringResource(R.string.tool_progress_status_running)
}
val duration = if (toolCall.completedAt != null && toolCall.completedAt >= toolCall.startedAt) {
val seconds = (toolCall.completedAt - toolCall.startedAt) / 1000.0
String.format("%.1fs", seconds)
} else null
val durationDescription = duration?.let { stringResource(R.string.tool_duration_a11y, it) }.orEmpty()
val toolDescription = stringResource(R.string.tool_a11y, toolCall.name, statusText, durationDescription)
Row(
modifier = modifier
@@ -53,7 +57,7 @@ fun CompactToolCall(
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 4.dp)
.semantics {
contentDescription = "Tool ${toolCall.name} $statusText${duration?.let { " in $it" } ?: ""}"
contentDescription = toolDescription
},
verticalAlignment = Alignment.CenterVertically
) {
@@ -91,7 +95,7 @@ fun CompactToolCall(
).value
Icon(
imageVector = Icons.Filled.MoreHoriz,
contentDescription = "Preparing",
contentDescription = stringResource(R.string.tool_preparing_a11y),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)
)
@@ -106,7 +110,7 @@ fun CompactToolCall(
toolCall.success == true -> {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Completed",
contentDescription = stringResource(R.string.tool_completed_a11y),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.primary
)
@@ -114,7 +118,7 @@ fun CompactToolCall(
else -> {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Failed",
contentDescription = stringResource(R.string.tool_failed_a11y),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.error
)
@@ -12,8 +12,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
/**
* Compact top-bar chip showing the currently-active Hermes connection.
@@ -40,7 +42,7 @@ fun ConnectionChip(
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = "Switch connection",
contentDescription = stringResource(R.string.cd_switch_connection),
modifier = Modifier,
)
}
File diff suppressed because it is too large Load Diff
@@ -25,6 +25,8 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.data.ConnectionSecurity
import com.hermesandroid.relay.data.ConnectionSecurityLevel
import com.hermesandroid.relay.data.SurfaceSecurity
@@ -79,7 +81,7 @@ fun ConnectionSecuritySheet(
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = "Connection security",
text = stringResource(R.string.security_sheet_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
@@ -93,8 +95,7 @@ fun ConnectionSecuritySheet(
if (security.surfaces.isEmpty()) {
Text(
text = "No active route yet. Connect to a server to see how each " +
"part of the connection is protected.",
text = stringResource(R.string.security_sheet_no_route),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -104,14 +105,17 @@ fun ConnectionSecuritySheet(
HorizontalDivider()
Text(
text = explainer(security.level),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val explainerTextRes = explainerResOrNull(security.level)
if (explainerTextRes != null) {
Text(
text = stringResource(explainerTextRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = { uriHandler.openUri(LEARN_MORE_URL) }) {
Text("Learn about connection security →")
Text(stringResource(R.string.security_sheet_learn_more))
}
}
}
@@ -145,17 +149,10 @@ private fun SurfaceSecurityRow(surface: SurfaceSecurity) {
}
}
private fun explainer(level: ConnectionSecurityLevel): String = when (level) {
ConnectionSecurityLevel.Tls ->
"Encrypted with TLS. The server's certificate is pinned on first connect."
ConnectionSecurityLevel.Overlay ->
"Encrypted by your overlay network (e.g. Tailscale/WireGuard), not TLS. " +
"Cert pinning applies only to TLS routes."
ConnectionSecurityLevel.Mixed ->
"Some parts of this connection are encrypted and some are plain. The app " +
"prefers a secure route when one is reachable."
ConnectionSecurityLevel.Plain ->
"Not encrypted. Only safe on a network you fully trust — anyone in between " +
"could read this traffic."
ConnectionSecurityLevel.Unknown -> ""
private fun explainerResOrNull(level: ConnectionSecurityLevel): Int? = when (level) {
ConnectionSecurityLevel.Tls -> R.string.security_sheet_explain_tls
ConnectionSecurityLevel.Overlay -> R.string.security_sheet_explain_overlay
ConnectionSecurityLevel.Mixed -> R.string.security_sheet_explain_mixed
ConnectionSecurityLevel.Plain -> R.string.security_sheet_explain_plain
ConnectionSecurityLevel.Unknown -> null
}
@@ -23,8 +23,10 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection
/**
@@ -61,7 +63,7 @@ fun ConnectionSwitcherSheet(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "Switch connection",
text = stringResource(R.string.conn_switcher_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp),
)
@@ -70,7 +72,7 @@ fun ConnectionSwitcherSheet(
// Defensive: the legacy migration should always seed connection 0,
// but fall back to a Manage-only state if the list is empty.
Text(
text = "No connections yet",
text = stringResource(R.string.conn_switcher_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 12.dp),
@@ -79,7 +81,7 @@ fun ConnectionSwitcherSheet(
onClick = onManageConnections,
modifier = Modifier.fillMaxWidth(),
) {
Text("Manage connections…")
Text(stringResource(R.string.conn_switcher_manage))
}
} else {
LazyColumn(
@@ -105,7 +107,7 @@ fun ConnectionSwitcherSheet(
onClick = onManageConnections,
modifier = Modifier.fillMaxWidth(),
) {
Text("Manage connections…")
Text(stringResource(R.string.conn_switcher_manage))
}
}
}
@@ -119,11 +121,9 @@ private fun ConnectionRow(
onClick: () -> Unit,
) {
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val statusLine = if (connection.pairedAt == null) {
"$hostname • Hermes"
} else {
"$hostname • Paired"
}
val hermesStatus = stringResource(R.string.conn_info_hostname_hermes, hostname)
val pairedStatus = stringResource(R.string.conn_info_hostname_paired, hostname)
val statusLine = if (connection.pairedAt == null) hermesStatus else pairedStatus
Row(
modifier = Modifier
File diff suppressed because it is too large Load Diff
@@ -35,9 +35,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.RelayRefresh
import kotlin.math.roundToInt
@@ -120,13 +122,15 @@ private fun ContextMeterBarContent(
}
val label = "$percent%$tokenSuffix"
val contextUsedDesc = stringResource(R.string.cd_context_used, percent)
Row(
modifier = modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier)
.padding(horizontal = 12.dp, vertical = 3.dp)
.semantics {
contentDescription = "Context $percent% used" +
contentDescription = contextUsedDesc +
if (tokenSuffix.isNotEmpty()) ", ${fmtTokens(usedTokens!!)} of ${fmtTokens(maxTokens!!)} tokens" else ""
},
verticalAlignment = Alignment.CenterVertically,
@@ -158,7 +162,7 @@ private fun ContextMeterBarContent(
Spacer(Modifier.width(6.dp))
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = "View injected context",
contentDescription = stringResource(R.string.cd_view_injected),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(13.dp),
)
@@ -38,12 +38,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.hermesandroid.relay.R
import com.hermesandroid.relay.util.CrashReport
import com.hermesandroid.relay.util.CrashReporter
import com.hermesandroid.relay.util.IssueReport
@@ -111,7 +113,7 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
)
Spacer(Modifier.width(12.dp))
Text(
text = "Hermes-Relay closed unexpectedly",
text = stringResource(R.string.crash_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
@@ -119,8 +121,7 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
Spacer(Modifier.height(8.dp))
Text(
text = "The last session crashed. Sending this report helps get it fixed — " +
"nothing is sent automatically.",
text = stringResource(R.string.crash_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -155,13 +156,13 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text("Dismiss") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, "Crash report copied")
},
) { Text("Copy") }
) { Text(stringResource(R.string.common_copy)) }
// Universal, GitHub-free path: hand the full report to the
// system share sheet (email, chat apps, notes, Drive…). The
// user picks the destination, so nothing leaves the device
@@ -180,7 +181,7 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
}
onDismiss()
},
) { Text("Share") }
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
// Copy the FULL report first; the URL only carries the
@@ -194,7 +195,7 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
)
onDismiss()
},
) { Text("Report") }
) { Text(stringResource(R.string.common_report)) }
}
}
}
@@ -24,6 +24,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
@@ -32,6 +33,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
/**
@@ -76,7 +78,7 @@ fun DemoModeBanner(
modifier = Modifier.size(16.dp),
)
Text(
text = "Demo mode — sample data, not connected. Connect →",
text = stringResource(R.string.dmb_demo_mode),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = on,
@@ -125,19 +127,19 @@ fun DemoUnavailableContent(
modifier = Modifier.size(40.dp),
)
Text(
text = "This is a demo",
text = stringResource(R.string.dmb_this_is_demo),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Connect your Hermes server to use $feature.",
text = stringResource(R.string.dmb_connect_feature, feature),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(4.dp))
Button(onClick = onConnect) {
Text("Connect")
Text(stringResource(R.string.dmb_connect))
}
}
}
@@ -35,6 +35,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
/**
* Phase 3 — safety-rails `bridge-safety-rails`
@@ -106,7 +108,7 @@ fun DestructiveVerbConfirmDialog(
tint = Color(0xFFFFA726),
)
Text(
text = "Confirm destructive action",
text = stringResource(R.string.destructive_confirm_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
@@ -135,7 +137,7 @@ fun DestructiveVerbConfirmDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = fullText.ifBlank { "(no text)" },
text = fullText.ifBlank { stringResource(R.string.destructive_no_text) },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -143,7 +145,7 @@ fun DestructiveVerbConfirmDialog(
}
Text(
text = "If you didn't expect this, tap Deny.",
text = stringResource(R.string.destructive_confirm_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -184,7 +186,7 @@ fun DestructiveVerbConfirmDialog(
modifier = Modifier.weight(1f),
onClick = onDeny,
) {
Text("Deny")
Text(stringResource(R.string.destructive_confirm_deny))
}
// Allow is intentionally lower-emphasis (amber caution, not a
// loud red CTA) so proceeding with a risky action never reads
@@ -196,7 +198,7 @@ fun DestructiveVerbConfirmDialog(
contentColor = Color(0xFFE65100),
),
) {
Text("Allow this action")
Text(stringResource(R.string.destructive_confirm_allow))
}
}
}
@@ -204,12 +206,14 @@ fun DestructiveVerbConfirmDialog(
}
}
@Composable
private fun labelFor(method: String): String = when (method) {
"/tap_text" -> "Target text (tap)"
"/type" -> "Text to type"
else -> "Payload"
"/tap_text" -> stringResource(R.string.destructive_label_tap_text)
"/type" -> stringResource(R.string.destructive_label_type)
else -> stringResource(R.string.destructive_label_payload)
}
@Composable
private fun verbPhrase(method: String, verb: String): String {
val action = when (method) {
"/tap_text" -> "tap a button containing"
@@ -236,7 +240,7 @@ fun BridgeStatusOverlayChip(unattended: Boolean = false) {
// the device. Default red dot + "Hermes active" preserves the v0.4
// appearance for the regular bridge-active path.
val dotColor = if (unattended) Color(0xFFFFA000) else Color(0xFFE53935)
val label = if (unattended) "Unattended ON" else "Hermes active"
val label = if (unattended) stringResource(R.string.destructive_chip_unattended) else stringResource(R.string.destructive_chip_active)
Box(
modifier = Modifier
.background(
@@ -33,6 +33,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -40,6 +41,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.hermesandroid.relay.BuildConfig
import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.util.DiagnosticIssuePrefill
@@ -133,7 +135,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
}
} else {
Text(
text = "No further detail captured for this entry.",
text = stringResource(R.string.diagnostic_no_detail),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -146,7 +148,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
onValueChange = { expectation = it },
label = { Text("What were you expecting to happen?") },
supportingText = {
Text("This is a routine log entry — telling us what looked wrong turns it into an answerable report.")
Text(stringResource(R.string.diagnostic_routine_hint))
},
minLines = 2,
modifier = Modifier.fillMaxWidth(),
@@ -159,13 +161,13 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text("Close") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, plainText)
toast(context, "Diagnostic copied")
},
) { Text("Copy") }
) { Text(stringResource(R.string.common_copy)) }
OutlinedButton(
onClick = {
val shared = IssueReport.share(
@@ -179,7 +181,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
toast(context, "Copied — no app found to share to")
}
},
) { Text("Export") }
) { Text(stringResource(R.string.common_export)) }
Button(
enabled = !expectationVisible || expectation.isNotBlank(),
onClick = {
@@ -207,7 +209,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
else "Copied — no browser found to open GitHub",
)
},
) { Text("Report") }
) { Text(stringResource(R.string.common_report)) }
}
}
}
@@ -31,10 +31,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
@@ -43,7 +45,7 @@ import com.hermesandroid.relay.diagnostics.DiagnosticsLog
@Composable
fun DiagnosticsLogPanel(
modifier: Modifier = Modifier,
title: String = "Recent activity",
title: String? = null,
categories: Set<DiagnosticCategory>? = null,
limit: Int = 8,
showCategory: Boolean = false,
@@ -51,6 +53,7 @@ fun DiagnosticsLogPanel(
showSeverityFilter: Boolean = false,
) {
val entries by DiagnosticsLog.entries.collectAsState()
val resolvedTitle = title ?: stringResource(R.string.diagnostics_title)
// Self-contained detail-view state — tapping a row opens DiagnosticDetailDialog.
// No nav route; nothing to wire in RelayApp.
@@ -74,13 +77,13 @@ fun DiagnosticsLogPanel(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
text = resolvedTitle,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
if (showClear && entries.isNotEmpty()) {
TextButton(onClick = { DiagnosticsLog.clear() }) {
Text("Clear")
Text(stringResource(R.string.diagnostics_clear))
}
}
}
@@ -90,7 +93,7 @@ fun DiagnosticsLogPanel(
FilterChip(
selected = severityFilter == null,
onClick = { severityFilter = null },
label = { Text("All") },
label = { Text(stringResource(R.string.diagnostics_all)) },
)
DiagnosticSeverity.entries.forEach { sev ->
FilterChip(
@@ -104,7 +107,7 @@ fun DiagnosticsLogPanel(
if (visible.isEmpty()) {
Text(
text = "No recent activity",
text = stringResource(R.string.diagnostics_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -44,8 +44,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.SurfaceSecurityKind
@@ -114,19 +116,25 @@ fun EndpointsCard(
onEditRoute: ((EndpointCandidate) -> Unit)? = null,
onRemoveRoute: ((EndpointCandidate) -> Unit)? = null,
) {
// Pre-resolve strings
val noRoutesStoredText = stringResource(R.string.endpoints_no_routes_stored)
val addRouteText = stringResource(R.string.endpoints_add_route)
val resolvingText = stringResource(R.string.endpoints_resolving)
val manualUntilDisconnectText = stringResource(R.string.endpoints_manual_until_disconnect)
val preferredText = stringResource(R.string.endpoints_preferred)
val automaticText = stringResource(R.string.endpoints_automatic)
val currentRouteText = stringResource(R.string.endpoints_current_route)
if (endpoints.isEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "No route candidates stored for this connection yet. " +
"Add a remote route (Tailscale, public URL) for automatic " +
"switching when the phone leaves this network — or scan a " +
"v3 pairing QR (Hermes 0.4.2+) if you use Relay.",
text = noRoutesStoredText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (onAddRoute != null) {
TextButton(onClick = onAddRoute) {
Text("Add route")
Text(addRouteText)
}
}
}
@@ -139,13 +147,20 @@ fun EndpointsCard(
val manualSwitchActive = manualOverrideRole != null &&
!manualOverrideRole.equals(preferredRole, ignoreCase = true)
// Pre-resolve cancel manual switch string (needs preferredRole which may be null)
val cancelManualSwitchText = stringResource(R.string.endpoints_cancel_manual_switch)
val stopPreferringText = stringResource(R.string.endpoints_stop_preferring)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Current: ${activeEndpoint?.displayLabel() ?: "Resolving"}" + when {
manualSwitchActive -> " · manual (until disconnect)"
manualOverrideRole != null -> " · preferred"
else -> " · automatic"
},
text = currentRouteText.format(
activeEndpoint?.displayLabel() ?: resolvingText,
when {
manualSwitchActive -> manualUntilDisconnectText
manualOverrideRole != null -> preferredText
else -> automaticText
}
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -175,22 +190,20 @@ fun EndpointsCard(
if (onAddRoute != null) {
HorizontalDivider()
TextButton(onClick = onAddRoute, modifier = Modifier.fillMaxWidth()) {
Text("Add route")
Text(addRouteText)
}
}
if (manualSwitchActive) {
HorizontalDivider()
TextButton(onClick = onCancelUseNow, modifier = Modifier.fillMaxWidth()) {
Text(
"Cancel manual switch (back to ${preferredRole ?: "automatic"})",
)
Text(cancelManualSwitchText.format(preferredRole ?: automaticText))
}
}
if (preferredRole != null) {
HorizontalDivider()
TextButton(onClick = onClearPreferred, modifier = Modifier.fillMaxWidth()) {
Text("Stop preferring $preferredRole (back to automatic)")
Text(stopPreferringText.format(preferredRole))
}
}
}
@@ -218,6 +231,7 @@ private fun EndpointRow(
var pinDialogText by remember { mutableStateOf<String?>(null) }
var confirmRemove by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val noPinRecordedText = stringResource(R.string.endpoints_no_pin_recorded)
Column(modifier = Modifier.fillMaxWidth()) {
Row(
@@ -246,11 +260,11 @@ private fun EndpointRow(
)
SurfaceSecurityGlyph(kind = candidate.routeSecurityKind())
if (isActive) {
ActiveChip()
ActiveChip(stringResource(R.string.endpoints_active))
} else if (isPreferred) {
PreferredChip()
PreferredChip(stringResource(R.string.endpoints_preferred_chip))
} else {
FallbackChip()
FallbackChip(stringResource(R.string.endpoints_fallback))
}
if (!candidate.isKnownRole()) {
// Show the raw role for custom-VPN entries so users
@@ -277,18 +291,18 @@ private fun EndpointRow(
)
when {
isProbing -> Text(
text = "Checking…",
text = stringResource(R.string.endpoints_checking),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
outcome == null -> Unit // never probed — say nothing
outcome.reachable -> Text(
text = "Reachable",
text = stringResource(R.string.endpoints_reachable),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
else -> Text(
text = "Unreachable — ${outcome.detail ?: "no detail"}",
text = stringResource(R.string.endpoints_unreachable, outcome.detail ?: stringResource(R.string.endpoints_unreachable_no_detail)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
@@ -304,14 +318,14 @@ private fun EndpointRow(
// sticky "Prefer this route" lives in the menu below.
if (!isActive) {
TextButton(onClick = onUseNow) {
Text("Use now")
Text(stringResource(R.string.endpoints_use_now))
}
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = "Endpoint actions",
contentDescription = stringResource(R.string.endpoints_actions),
)
}
DropdownMenu(
@@ -322,13 +336,13 @@ private fun EndpointRow(
text = {
Column {
Text(
if (isPreferred) "Stop preferring" else "Prefer this route",
text = if (isPreferred) stringResource(R.string.endpoints_stop_preferring_menu) else stringResource(R.string.endpoints_prefer_this_route),
)
Text(
text = if (isPreferred) {
"Back to automatic choice"
stringResource(R.string.endpoints_back_to_automatic)
} else {
"Always try this route first"
stringResource(R.string.endpoints_always_try_first)
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -341,26 +355,25 @@ private fun EndpointRow(
},
)
DropdownMenuItem(
text = { Text("Probe now") },
text = { Text(stringResource(R.string.endpoints_probe_now)) },
onClick = {
menuOpen = false
onProbeNow()
},
)
DropdownMenuItem(
text = { Text("View pin") },
text = { Text(stringResource(R.string.endpoints_view_pin)) },
onClick = {
menuOpen = false
scope.launch {
pinDialogText = onViewPin(candidate)
?: "No pin recorded yet — the phone will " +
"record one on first TOFU connect."
?: noPinRecordedText
}
},
)
if (onEdit != null) {
DropdownMenuItem(
text = { Text("Edit route") },
text = { Text(stringResource(R.string.endpoints_edit_route)) },
onClick = {
menuOpen = false
onEdit()
@@ -369,7 +382,7 @@ private fun EndpointRow(
}
if (onRemove != null) {
DropdownMenuItem(
text = { Text("Remove route") },
text = { Text(stringResource(R.string.endpoints_remove_route)) },
onClick = {
menuOpen = false
confirmRemove = true
@@ -384,11 +397,10 @@ private fun EndpointRow(
if (confirmRemove && onRemove != null) {
AlertDialog(
onDismissRequest = { confirmRemove = false },
title = { Text("Remove ${candidate.displayLabel()} route?") },
title = { Text(stringResource(R.string.endpoints_remove_route_title, candidate.displayLabel())) },
text = {
Text(
text = "${candidate.api.host}:${candidate.api.port} will no longer be " +
"probed as a fallback. You can add it back any time.",
text = stringResource(R.string.endpoints_remove_route_body, "${candidate.api.host}:${candidate.api.port}"),
style = MaterialTheme.typography.bodySmall,
)
},
@@ -398,10 +410,10 @@ private fun EndpointRow(
confirmRemove = false
onRemove()
},
) { Text("Remove") }
) { Text(stringResource(R.string.endpoints_remove)) }
},
dismissButton = {
TextButton(onClick = { confirmRemove = false }) { Text("Cancel") }
TextButton(onClick = { confirmRemove = false }) { Text(stringResource(R.string.endpoints_cancel)) }
},
)
}
@@ -409,7 +421,7 @@ private fun EndpointRow(
pinDialogText?.let { body ->
AlertDialog(
onDismissRequest = { pinDialogText = null },
title = { Text("TOFU pin · ${candidate.api.host}") },
title = { Text(stringResource(R.string.endpoints_pin_title, candidate.api.host)) },
text = {
Text(
text = body,
@@ -418,14 +430,14 @@ private fun EndpointRow(
)
},
confirmButton = {
TextButton(onClick = { pinDialogText = null }) { Text("Close") }
TextButton(onClick = { pinDialogText = null }) { Text(stringResource(R.string.endpoints_close)) }
},
)
}
}
@Composable
private fun ActiveChip() {
private fun ActiveChip(label: String) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
@@ -441,7 +453,7 @@ private fun ActiveChip() {
)
Spacer(Modifier.size(4.dp))
Text(
text = "Active",
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
@@ -449,7 +461,7 @@ private fun ActiveChip() {
}
@Composable
private fun PreferredChip() {
private fun PreferredChip(label: String) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
@@ -457,7 +469,7 @@ private fun PreferredChip() {
.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Text(
text = "Preferred",
text = label,
style = MaterialTheme.typography.labelSmall,
color = Color(0xFFB26A00),
)
@@ -471,7 +483,7 @@ private fun PreferredChip() {
* as "available fallback" not "something is happening here".
*/
@Composable
private fun FallbackChip() {
private fun FallbackChip(label: String) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
@@ -484,7 +496,7 @@ private fun FallbackChip() {
.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Text(
text = "Fallback",
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -560,13 +572,16 @@ fun RouteEditorDialog(
AlertDialog(
onDismissRequest = { if (!saving) onDismiss() },
title = { Text(if (original == null) "Add route" else "Edit route") },
title = {
Text(
if (original == null) stringResource(R.string.endpoints_add_route_title)
else stringResource(R.string.endpoints_edit_route_title)
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "A fallback route the phone switches to when the " +
"primary stops answering — e.g. your server's " +
"Tailscale or public URL.",
text = stringResource(R.string.endpoints_route_editor_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -574,25 +589,25 @@ fun RouteEditorDialog(
FilterChip(
selected = selectedRole == "tailscale",
onClick = { selectedRole = "tailscale" },
label = { Text("Tailscale") },
label = { Text(stringResource(R.string.endpoints_tailscale)) },
)
FilterChip(
selected = selectedRole == "public",
onClick = { selectedRole = "public" },
label = { Text("Public") },
label = { Text(stringResource(R.string.endpoints_public)) },
)
FilterChip(
selected = selectedRole == CUSTOM_ROLE,
onClick = { selectedRole = CUSTOM_ROLE },
label = { Text("Custom") },
label = { Text(stringResource(R.string.endpoints_custom)) },
)
}
if (selectedRole == CUSTOM_ROLE) {
OutlinedTextField(
value = customRole,
onValueChange = { customRole = it },
label = { Text("Route name") },
placeholder = { Text("wireguard-home") },
label = { Text(stringResource(R.string.endpoints_route_name)) },
placeholder = { Text(stringResource(R.string.endpoints_route_name_placeholder)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -616,23 +631,21 @@ fun RouteEditorDialog(
url = it
errorText = null
},
label = { Text("API server URL or host") },
placeholder = { Text("100.64.0.1 or http://host:8642") },
label = { Text(stringResource(R.string.endpoints_api_url_host)) },
placeholder = { Text(stringResource(R.string.endpoints_url_host_placeholder)) },
singleLine = true,
isError = errorText != null,
supportingText = {
val supportingBlank = stringResource(R.string.endpoints_url_supporting_blank)
val supportingEnter = stringResource(R.string.endpoints_url_supporting_enter)
Text(
text = errorText ?: when {
url.isBlank() ->
"Use the API server port (8642 by default) — " +
"not the dashboard's 9119. http:// is " +
"assumed unless you type https://."
supportingBlank
previewCandidate != null ->
"Will save: ${previewCandidate.api.url} — relay " +
"and dashboard URLs are derived from the host"
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.api.url)
else ->
"Enter a host/IP or an http(s):// URL " +
"(API port 8642, not dashboard 9119)"
supportingEnter
},
)
},
@@ -655,10 +668,17 @@ fun RouteEditorDialog(
}
}
},
) { Text(if (saving) "Saving…" else "Save") }
) {
Text(
if (saving) stringResource(R.string.endpoints_saving)
else stringResource(R.string.endpoints_save)
)
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") }
TextButton(onClick = onDismiss, enabled = !saving) {
Text(stringResource(R.string.endpoints_cancel))
}
},
)
}
@@ -30,6 +30,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.TerminalViewModel.SpecialKey
/**
@@ -88,20 +90,20 @@ fun ExtraKeysToolbar(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ToolbarKey(label = "ESC", onClick = onEsc)
ToolbarKey(label = "TAB", onClick = onTab)
ToolbarKey(label = "CTRL", active = ctrlActive, onClick = onCtrlToggle)
ToolbarKey(label = "ALT", active = altActive, onClick = onAltToggle)
ToolbarKey(label = stringResource(R.string.extra_keys_esc), onClick = onEsc)
ToolbarKey(label = stringResource(R.string.extra_keys_tab), onClick = onTab)
ToolbarKey(label = stringResource(R.string.extra_keys_ctrl), active = ctrlActive, onClick = onCtrlToggle)
ToolbarKey(label = stringResource(R.string.extra_keys_alt), active = altActive, onClick = onAltToggle)
// Clipboard + keyboard cluster — selecting/copying/pasting and
// raising the soft keyboard are all unreliable through long-press
// inside an Android WebView, so these explicit keys are the
// dependable path.
onCopy?.let { copy ->
ToolbarKey(label = "COPY", onClick = copy)
ToolbarKey(label = stringResource(R.string.extra_keys_copy), onClick = copy)
}
onPaste?.let { paste ->
ToolbarKey(label = "PASTE", onClick = paste)
ToolbarKey(label = stringResource(R.string.extra_keys_paste), onClick = paste)
}
onToggleKeyboard?.let { toggle ->
ToolbarKey(label = "⌨", onClick = toggle) // show/hide soft keyboard
@@ -47,12 +47,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.network.upstream.GatewayProcess
/**
@@ -119,7 +121,7 @@ fun GatewayBackgroundProcessStrip(
}
Spacer(Modifier.width(9.dp))
Text(
text = "Background · $displayedCount",
text = stringResource(R.string.background_process_count, displayedCount),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f),
)
@@ -176,9 +178,9 @@ fun GatewayBackgroundProcessSheet(
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Background processes", style = MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.background_processes_title), style = MaterialTheme.typography.titleLarge)
Text(
"Current chat · live output and recent results",
stringResource(R.string.background_processes_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -187,7 +189,7 @@ fun GatewayBackgroundProcessSheet(
if (loading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh processes")
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.background_processes_refresh_a11y))
}
}
}
@@ -322,10 +324,10 @@ private fun GatewayProcessRow(
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text(if (stopping) "Stopping" else "Stop")
Text(if (stopping) stringResource(R.string.background_processes_stopping) else stringResource(R.string.background_processes_stop))
}
} else if (onDismiss != null) {
TextButton(onClick = onDismiss) { Text("Dismiss") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
}
if (output.isNotBlank()) {
Icon(
@@ -379,13 +381,13 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
)
failed -> Icon(
Icons.Filled.ErrorOutline,
contentDescription = "Failed",
contentDescription = stringResource(R.string.tool_failed_a11y),
modifier = Modifier.size(18.dp),
tint = tint,
)
else -> Icon(
Icons.Filled.CheckCircle,
contentDescription = "Completed",
contentDescription = stringResource(R.string.tool_completed_a11y),
modifier = Modifier.size(18.dp),
tint = tint,
)
@@ -70,10 +70,12 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.HermesCardAction
import com.hermesandroid.relay.data.HermesCardDispatch
@@ -134,6 +136,7 @@ fun HermesCardBubble(
val accentColor = accentToColor(card.accent)
val typeIcon = iconForType(card.type)
val alreadyChosen = dispatches.firstOrNull { it.cardKey == cardKey }
val cardDescription = stringResource(R.string.card_a11y, card.title ?: card.type)
// Expiry clock for timed asks. Ticks once a second while the deadline
// is ahead; freezes after. Keyed on the deadline so a re-used card id
@@ -155,7 +158,7 @@ fun HermesCardBubble(
.widthIn(max = maxWidth)
.fillMaxWidth()
.semantics {
contentDescription = "Card: ${card.title ?: card.type}"
contentDescription = cardDescription
},
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
@@ -260,7 +263,7 @@ fun HermesCardBubble(
expired -> {
Spacer(Modifier.height(10.dp))
ChoseRow(
text = "Expired — not granted",
text = stringResource(R.string.card_expired),
icon = Icons.Filled.HourglassBottom,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -293,7 +296,7 @@ fun HermesCardBubble(
val remainingSec = ((expiresAt - nowMillis) / 1000).coerceAtLeast(0)
Spacer(Modifier.height(8.dp))
Text(
text = "expires in %d:%02d".format(remainingSec / 60, remainingSec % 60),
text = stringResource(R.string.card_expires_in, remainingSec / 60, remainingSec % 60),
style = relayMetadataStyle(),
color = if (remainingSec < 30) RelayRefresh.Amber
else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -459,7 +462,7 @@ private fun CardInputSlot(
)
Spacer(Modifier.height(6.dp))
Text(
text = "Not stored in chat history",
text = stringResource(R.string.card_not_stored),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
@@ -480,7 +483,7 @@ private fun CardInputSlot(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send answer",
contentDescription = stringResource(R.string.card_send_answer_a11y),
tint = if (answerText.isNotBlank()) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
@@ -512,7 +515,7 @@ private fun CardInputSlot(
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) { Text("Submit", style = MaterialTheme.typography.labelMedium) }
) { Text(stringResource(R.string.card_submit), style = MaterialTheme.typography.labelMedium) }
}
}
}
@@ -538,7 +541,7 @@ private fun InlineAnswerField(
) {
if (value.isEmpty()) {
Text(
text = "Type an answer…",
text = stringResource(R.string.card_answer_placeholder),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
@@ -7,6 +7,8 @@ import android.graphics.pdf.PdfRenderer
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.widget.Toast
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
@@ -153,7 +155,7 @@ private fun LoadingCard(
val sizeHint = attachment.fileSize?.takeIf { it > 0 }
?.let { " · ${formatBytes(it)}" } ?: ""
Text(
text = if (isManualCta) "Tap to download" else "Downloading…$sizeHint",
text = if (isManualCta) stringResource(R.string.inbound_attach_tap_download) else stringResource(R.string.inbound_attach_downloading, sizeHint),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -175,7 +177,7 @@ private fun LoadingCard(
IconButton(onClick = onCancel, modifier = Modifier.size(28.dp)) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Cancel download",
contentDescription = stringResource(R.string.inbound_attach_cd_cancel),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
@@ -219,12 +221,12 @@ private fun FailedCard(
Text(text = "\u26A0\uFE0F", style = MaterialTheme.typography.titleMedium)
Column(modifier = Modifier.weight(1f)) {
Text(
text = attachment.errorMessage ?: "Attachment failed",
text = attachment.errorMessage ?: stringResource(R.string.inbound_attach_failed),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = "Tap to retry",
text = stringResource(R.string.inbound_attach_tap_retry),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
)
@@ -467,7 +469,7 @@ private fun FileCardRender(
) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = "Save",
contentDescription = stringResource(R.string.inbound_attach_cd_save),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
@@ -500,7 +502,7 @@ fun AttachmentActionsMenu(
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
DropdownMenuItem(
text = { Text("Open externally") },
text = { Text(stringResource(R.string.inbound_attach_open)) },
leadingIcon = { Icon(Icons.Filled.OpenInNew, contentDescription = null) },
onClick = {
onDismiss()
@@ -508,14 +510,14 @@ fun AttachmentActionsMenu(
},
)
DropdownMenuItem(
text = { Text("Share") },
text = { Text(stringResource(R.string.inbound_attach_share)) },
onClick = {
onDismiss()
scope.launch { shareAttachment(context, attachment) }
},
)
DropdownMenuItem(
text = { Text("Save to device") },
text = { Text(stringResource(R.string.inbound_attach_save)) },
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null) },
onClick = {
onDismiss()
@@ -536,7 +538,7 @@ fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
IconButton(onClick = onClick, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = "Save",
contentDescription = stringResource(R.string.inbound_attach_cd_save),
tint = Color.White,
modifier = Modifier.size(18.dp),
)
@@ -549,7 +551,7 @@ fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
private suspend fun shareAttachment(context: Context, attachment: Attachment) {
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
attachmentToast(context, "Couldn't read this file")
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
return
}
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
@@ -559,7 +561,7 @@ private suspend fun shareAttachment(context: Context, attachment: Attachment) {
suspend fun saveAttachment(context: Context, attachment: Attachment) {
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
attachmentToast(context, "Couldn't read this file")
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
return
}
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
@@ -569,13 +571,13 @@ suspend fun saveAttachment(context: Context, attachment: Attachment) {
}
when (result) {
is MediaSaver.SaveResult.Saved ->
attachmentToast(context, "Saved to ${result.location}")
attachmentToast(context, context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
is MediaSaver.SaveResult.Failed ->
attachmentToast(context, "Save failed: ${result.message}")
attachmentToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
}
}
@@ -591,7 +593,7 @@ private suspend fun openAttachmentExternally(context: Context, attachment: Attac
if (uri != null) {
MediaSaver.open(context, uri, attachment.contentType)
} else {
attachmentToast(context, "Couldn't open this file")
attachmentToast(context, context.getString(R.string.inbound_attach_open_failed))
}
}
@@ -707,16 +709,25 @@ private fun attachmentToast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
@Composable
private fun emojiAndLabelFor(
mode: AttachmentRenderMode,
contentType: String
): Pair<String, String> = when (mode) {
AttachmentRenderMode.IMAGE -> "\uD83D\uDDBC\uFE0F" to "Image"
AttachmentRenderMode.VIDEO -> "\uD83C\uDFAC" to "Video"
AttachmentRenderMode.AUDIO -> "\uD83C\uDFB5" to "Audio"
AttachmentRenderMode.PDF -> "\uD83D\uDCC4" to "PDF"
AttachmentRenderMode.TEXT -> "\uD83D\uDCDD" to contentTypeToLabel(contentType, fallback = "Text")
AttachmentRenderMode.GENERIC -> "\uD83D\uDCCE" to contentTypeToLabel(contentType, fallback = "File")
): Pair<String, String> {
val imageLabel = stringResource(R.string.inbound_attach_type_image)
val videoLabel = stringResource(R.string.inbound_attach_type_video)
val audioLabel = stringResource(R.string.inbound_attach_type_audio)
val pdfLabel = stringResource(R.string.inbound_attach_type_pdf)
val textLabel = stringResource(R.string.inbound_attach_type_text)
val fileLabel = stringResource(R.string.inbound_attach_type_file)
return when (mode) {
AttachmentRenderMode.IMAGE -> "\uD83D\uDDBC\uFE0F" to imageLabel
AttachmentRenderMode.VIDEO -> "\uD83C\uDFAC" to videoLabel
AttachmentRenderMode.AUDIO -> "\uD83C\uDFB5" to audioLabel
AttachmentRenderMode.PDF -> "\uD83D\uDCC4" to pdfLabel
AttachmentRenderMode.TEXT -> "\uD83D\uDCDD" to contentTypeToLabel(contentType, fallback = textLabel)
AttachmentRenderMode.GENERIC -> "\uD83D\uDCCE" to contentTypeToLabel(contentType, fallback = fileLabel)
}
}
private fun contentTypeToLabel(contentType: String, fallback: String): String {
@@ -16,8 +16,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.ChatViewModel
/**
@@ -45,66 +47,67 @@ fun InjectedContextSheet(
.padding(start = 20.dp, end = 20.dp, bottom = 28.dp),
) {
Text(
text = "What the agent sees",
text = stringResource(R.string.injected_context_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(4.dp))
Text(
text = "The exact extra context prepended to your next turn, for " +
"transparency. Transport: ${context.transport}.",
text = stringResource(R.string.injected_context_subtitle, context.transport),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
val personaTitle = stringResource(R.string.injected_context_persona_title)
val phoneStatusTitle = stringResource(R.string.injected_context_phone_status_title)
val mediaTitle = stringResource(R.string.injected_context_media_title)
val relayTitle = stringResource(R.string.injected_context_relay_title)
val turnTitle = stringResource(R.string.injected_context_turn_title)
val personaServerSide = stringResource(R.string.injected_context_persona_server_side)
val personaNotSet = stringResource(R.string.injected_context_persona_not_set)
val phoneStatusNotSet = stringResource(R.string.injected_context_phone_status_not_set)
val mediaRelayActive = stringResource(R.string.injected_context_media_relay_active)
val mediaNoRelay = stringResource(R.string.injected_context_media_no_relay)
val relayNotSet = stringResource(R.string.injected_context_relay_not_set)
val turnNotSet = stringResource(R.string.injected_context_turn_not_set)
ContextSection(
title = "Persona / profile",
title = personaTitle,
body = context.personaPrompt,
emptyNote = if (context.personaOwnedServerSide) {
"Added server-side by Hermes (profile soul + personality " +
"overlay) — not sent from this device."
personaServerSide
} else {
"No persona prompt is being sent — the server uses its " +
"configured default."
personaNotSet
},
)
ContextSection(
title = "Phone status",
title = phoneStatusTitle,
body = context.appContext,
emptyNote = "No phone-status block — enable it in App Context settings.",
emptyNote = phoneStatusNotSet,
)
ContextSection(
title = "Media capability",
title = mediaTitle,
body = context.mediaCapability,
emptyNote = if (context.relayMediaAvailable) {
// Gateway path: media WORKS (client renders server-local images
// via the relay) — just no injected hint, since the gateway has
// no per-turn system slot. Say so, rather than "not set".
"Relay route active — server-local images and files render " +
"in-app via the relay (client-side). The gateway transport " +
"has no system slot, so no hint is injected here, but media " +
"still works."
mediaRelayActive
} else {
"No relay route configured — the agent can't fetch server-local " +
"images or files by path."
mediaNoRelay
},
)
ContextSection(
title = "Relay context (server-side)",
title = relayTitle,
body = context.relayServerBlocks
.takeIf { it.isNotEmpty() }
?.joinToString("\n\n") { (name, text) ->
"[$name]\n$text"
},
emptyNote = "No relay server-side context blocks are active, or the relay " +
"context layer is disabled.",
emptyNote = relayNotSet,
)
ContextSection(
title = "This turn",
title = turnTitle,
body = context.interfaceContext,
emptyNote = "Nothing extra for a typed turn. Voice turns add a " +
"spoken-output hint here.",
emptyNote = turnNotSet,
)
}
}
@@ -23,8 +23,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
/**
* One-time acknowledgment dialog shown the first time the user flips
@@ -50,10 +52,13 @@ fun InsecureConnectionAckDialog(
) {
var selectedReason by remember { mutableStateOf<String?>(null) }
val reasonLan = stringResource(R.string.insecure_ack_reason_lan)
val reasonTailscale = stringResource(R.string.insecure_ack_reason_tailscale)
val reasonDev = stringResource(R.string.insecure_ack_reason_dev)
val reasonOptions = listOf(
"lan_only" to "LAN only (trusted network)",
"tailscale_vpn" to "Tailscale or VPN",
"local_dev" to "Local development only",
"lan_only" to reasonLan,
"tailscale_vpn" to reasonTailscale,
"local_dev" to reasonDev,
)
AlertDialog(
@@ -67,7 +72,7 @@ fun InsecureConnectionAckDialog(
},
title = {
Text(
text = "Allow insecure connections?",
text = stringResource(R.string.insecure_ack_title),
style = MaterialTheme.typography.titleLarge
)
},
@@ -77,16 +82,12 @@ fun InsecureConnectionAckDialog(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Insecure mode lets this app connect over plain " +
"ws:// and http://. Anyone on the network between " +
"your phone and the server can read your chat " +
"messages, session tokens, and terminal traffic.",
text = stringResource(R.string.insecure_ack_body_1),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Only use this on networks you control. Pick " +
"the reason that best describes your setup:",
text = stringResource(R.string.insecure_ack_body_2),
style = MaterialTheme.typography.bodyMedium
)
@@ -124,12 +125,12 @@ fun InsecureConnectionAckDialog(
enabled = selectedReason != null,
onClick = { selectedReason?.let(onConfirm) }
) {
Text("I understand")
Text(stringResource(R.string.insecure_ack_confirm))
}
},
dismissButton = {
TextButton(onClick = onCancel) {
Text("Cancel")
Text(stringResource(R.string.insecure_ack_cancel))
}
}
)
@@ -44,8 +44,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.UiMessage
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.UiMessageSeverity
@@ -197,7 +199,7 @@ private fun MessageBannerContent(
)
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = "Show recent messages",
contentDescription = stringResource(R.string.cd_show_recent),
modifier = Modifier.size(18.dp),
)
}
@@ -229,7 +231,7 @@ private fun MessageBannerContent(
if (index == 0) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = "Collapse",
contentDescription = stringResource(R.string.cd_collapse),
modifier = Modifier.size(18.dp),
)
}
@@ -53,6 +53,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
@@ -330,7 +331,7 @@ fun MessageBubble(
onDismissRequest = { showMessageActions = false },
) {
DropdownMenuItem(
text = { Text("Copy") },
text = { Text(stringResource(R.string.msg_bubble_copy)) },
onClick = {
showMessageActions = false
onCopyMessage(message.content)
@@ -338,7 +339,7 @@ fun MessageBubble(
)
if (onQuoteMessage != null) {
DropdownMenuItem(
text = { Text("Quote in reply") },
text = { Text(stringResource(R.string.msg_bubble_quote)) },
onClick = {
showMessageActions = false
onQuoteMessage(message.content)
@@ -347,7 +348,7 @@ fun MessageBubble(
}
if (showEditAction) {
DropdownMenuItem(
text = { Text("Edit & resend") },
text = { Text(stringResource(R.string.msg_bubble_edit)) },
onClick = {
showMessageActions = false
onEditMessage(message)
@@ -521,9 +522,9 @@ fun MessageBubble(
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (recoveringAnswer) {
"Reconnecting to your answer…"
stringResource(R.string.msg_bubble_reconnecting)
} else {
"Still working…"
stringResource(R.string.msg_bubble_still_working)
},
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = 0.6f),
@@ -550,10 +551,17 @@ fun MessageBubble(
// message routed over the relay proactive channel). Null on every
// ordinary chat message, which render nothing here.
message.deliveryStatus?.takeIf { isUser }?.let { status ->
val (label, alpha) = when (status) {
MessageDeliveryStatus.SENDING -> "Sending…" to 0.5f
MessageDeliveryStatus.DELIVERED -> "Delivered" to 0.5f
MessageDeliveryStatus.FAILED -> "Not sent" to 0.7f
val label = stringResource(
when (status) {
MessageDeliveryStatus.SENDING -> R.string.msg_bubble_sending
MessageDeliveryStatus.DELIVERED -> R.string.msg_bubble_delivered
MessageDeliveryStatus.FAILED -> R.string.msg_bubble_not_sent
}
)
val alpha = when (status) {
MessageDeliveryStatus.SENDING -> 0.5f
MessageDeliveryStatus.DELIVERED -> 0.5f
MessageDeliveryStatus.FAILED -> 0.7f
}
Spacer(modifier = Modifier.height(2.dp))
Text(
@@ -37,8 +37,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
/**
* Searchable model picker as a bottom sheet — a cleaner surface than the inline
@@ -142,7 +144,7 @@ fun ModelPickerSheet(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
placeholder = { Text("Search models or providers…") },
placeholder = { Text(stringResource(R.string.model_picker_search)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
@@ -155,7 +157,7 @@ fun ModelPickerSheet(
IconButton(onClick = { query = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Clear search",
contentDescription = stringResource(R.string.model_picker_cd_clear),
modifier = Modifier.size(20.dp),
)
}
@@ -202,7 +204,7 @@ fun ModelPickerSheet(
contentAlignment = Alignment.Center,
) {
Text(
text = "No models match your search",
text = stringResource(R.string.model_picker_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -252,7 +254,7 @@ private fun ModelPickerRow(
if (option.selected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
contentDescription = stringResource(R.string.cd_selected),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
@@ -26,38 +26,38 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import com.hermesandroid.relay.R
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
enum class PowerFeatureGateStatus(
val label: String,
val actionLabel: String,
val explanation: String,
val labelRes: Int,
val actionLabelRes: Int,
val explanationRes: Int,
) {
RequiresPairing(
label = "Requires pairing",
actionLabel = "Pair to unlock",
explanation = "This feature requires the Relay plugin. Make sure it is installed " +
"and running on your Hermes server, then pair this device to unlock it.",
labelRes = R.string.power_feature_requires_pairing_label,
actionLabelRes = R.string.power_feature_requires_pairing_action,
explanationRes = R.string.power_feature_requires_pairing_explain,
),
PairingExpired(
label = "Pairing expired",
actionLabel = "Pair again",
explanation = "Your relay session is no longer accepted. Pair again to get a fresh grant.",
labelRes = R.string.power_feature_pairing_expired_label,
actionLabelRes = R.string.power_feature_pairing_expired_action,
explanationRes = R.string.power_feature_pairing_expired_explain,
),
Unavailable(
label = "Unavailable on this server",
actionLabel = "View connection",
explanation = "This Hermes server isn't exposing the Relay plugin (or it's an older " +
"version). Install or update the Relay plugin on the server to use this feature.",
labelRes = R.string.power_feature_unavailable_label,
actionLabelRes = R.string.power_feature_unavailable_action,
explanationRes = R.string.power_feature_unavailable_explain,
),
DashboardSignInRequired(
label = "Dashboard sign-in required",
actionLabel = "Open sign-in",
explanation = "This standard dashboard feature needs a dashboard session before it can load.",
labelRes = R.string.power_feature_dashboard_signin_label,
actionLabelRes = R.string.power_feature_dashboard_signin_action,
explanationRes = R.string.power_feature_dashboard_signin_explain,
);
companion object {
@@ -90,6 +90,7 @@ fun PowerFeatureGateScreen(
Scaffold(
modifier = modifier,
topBar = {
val backDesc = stringResource(R.string.power_feature_back_desc)
TopAppBar(
title = { Text(title) },
navigationIcon = {
@@ -97,7 +98,7 @@ fun PowerFeatureGateScreen(
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = backDesc,
)
}
}
@@ -134,6 +135,9 @@ fun PowerFeatureGateCard(
onPrimaryAction: () -> Unit,
modifier: Modifier = Modifier,
) {
val resolvedLabel = stringResource(status.labelRes)
val resolvedExplanation = stringResource(status.explanationRes)
val resolvedActionLabel = stringResource(status.actionLabelRes)
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
@@ -161,7 +165,7 @@ fun PowerFeatureGateCard(
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = status.label,
text = resolvedLabel,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
@@ -178,7 +182,7 @@ fun PowerFeatureGateCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = status.explanation,
text = resolvedExplanation,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -187,7 +191,7 @@ fun PowerFeatureGateCard(
onClick = onPrimaryAction,
modifier = Modifier.fillMaxWidth(),
) {
Text(status.actionLabel)
Text(resolvedActionLabel)
}
}
}
@@ -22,7 +22,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.ui.theme.gradientBorder
@@ -87,14 +89,14 @@ fun ProfileInspectorCard(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = "Inspect Agent",
text = stringResource(R.string.profile_inspector_card_title),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = if (enabled) {
"View config, SOUL, memory, skills"
stringResource(R.string.profile_inspector_card_subtitle)
} else {
"No active agent"
stringResource(R.string.profile_inspector_card_no_agent)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -66,6 +66,8 @@ import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.RelayEndpoint
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -534,6 +536,9 @@ fun QrPairingScanner(
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// Pre-resolve strings used inside non-Composable contexts (CameraX
// listeners, exception handlers, etc. don't have a Composable scope).
val cameraErrorMsg = stringResource(R.string.qr_scanner_camera_error)
// AtomicBoolean for thread-safe detection flag (accessed from camera executor thread)
val hasDetected = remember { AtomicBoolean(false) }
val cameraProviderRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
@@ -618,12 +623,12 @@ fun QrPairingScanner(
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close scanner",
contentDescription = stringResource(R.string.qr_scanner_cd_close),
tint = MaterialTheme.colorScheme.onSurface
)
}
Text(
text = "Scan Hermes QR",
text = stringResource(R.string.qr_scanner_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.Center)
@@ -670,7 +675,7 @@ fun QrPairingScanner(
cameraProviderFuture.get()
} catch (t: Throwable) {
Log.e("QrPairingScanner", "Camera provider init failed", t)
cameraError = "Couldn't start the camera on this device."
cameraError = cameraErrorMsg
return@addListener
}
cameraProviderRef.value = cameraProvider
@@ -758,7 +763,7 @@ fun QrPairingScanner(
)
} catch (e: Exception) {
Log.e("QrPairingScanner", "Camera bind failed", e)
cameraError = "Couldn't start the camera on this device."
cameraError = cameraErrorMsg
}
}, ContextCompat.getMainExecutor(ctx))
@@ -793,13 +798,13 @@ fun QrPairingScanner(
modifier = Modifier.size(32.dp)
)
Text(
text = "Scan a Hermes setup QR",
text = stringResource(R.string.qr_scanner_instruction),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Text(
text = "Ask Hermes: \"Generate a QR code with my API URL and API key.\" Relay pairing QRs require the Relay plugin.",
text = stringResource(R.string.qr_scanner_subtext),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
@@ -843,14 +848,14 @@ private fun CameraUnavailableCard(
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "You can pair without the camera.",
text = stringResource(R.string.qr_scanner_fallback_message),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onPairManually) {
Text("Pair manually")
Text(stringResource(R.string.qr_scanner_pair_manual))
}
}
}
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -55,9 +56,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
@@ -65,18 +69,18 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private enum class SessionDrawerFilter(val label: String) {
All("All"),
Threads("Threads"),
Pinned("Pinned"),
Archive("Archive"),
private enum class SessionDrawerFilter {
All,
Threads,
Pinned,
Archive,
}
@Composable
fun SessionDrawerContent(
sessions: List<ChatSession>,
currentSessionId: String?,
scopeTitle: String = "Sessions",
scopeTitle: String = "",
scopeSubtitle: String? = null,
isLoading: Boolean = false,
isOpen: Boolean = true,
@@ -204,7 +208,7 @@ fun SessionDrawerContent(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = scopeTitle,
text = scopeTitle.ifBlank { stringResource(R.string.drawer_filter_sessions) },
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f),
)
@@ -218,7 +222,7 @@ fun SessionDrawerContent(
) {
Icon(
Icons.Filled.FilterList,
contentDescription = "Filter by source",
contentDescription = stringResource(R.string.drawer_filter_by_source),
tint = if (presentSources.any { it in hiddenSources }) {
RelayRefresh.Relay
} else {
@@ -232,7 +236,7 @@ fun SessionDrawerContent(
onDismissRequest = { sourceFilterOpen = false },
) {
Text(
text = "Show sources",
text = stringResource(R.string.drawer_show_sources),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
@@ -299,7 +303,7 @@ fun SessionDrawerContent(
IconButton(onClick = refresh, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Filled.Refresh,
contentDescription = "Refresh sessions",
contentDescription = stringResource(R.string.drawer_refresh_sessions),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
@@ -326,7 +330,7 @@ fun SessionDrawerContent(
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("New Chat")
Text(stringResource(R.string.drawer_new_chat))
}
Spacer(modifier = Modifier.height(8.dp))
@@ -338,7 +342,7 @@ fun SessionDrawerContent(
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
placeholder = { Text("Search sessions or id...") },
placeholder = { Text(stringResource(R.string.drawer_search_placeholder)) },
)
Spacer(modifier = Modifier.height(8.dp))
Row(
@@ -357,11 +361,21 @@ fun SessionDrawerContent(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(text = item.label, style = relayMetadataStyle())
Text(text = when (item) {
SessionDrawerFilter.All -> stringResource(R.string.drawer_filter_all)
SessionDrawerFilter.Threads -> stringResource(R.string.drawer_filter_threads)
SessionDrawerFilter.Pinned -> stringResource(R.string.drawer_filter_pinned)
SessionDrawerFilter.Archive -> stringResource(R.string.drawer_filter_archive)
}, style = relayMetadataStyle())
BetaChip()
}
} else {
Text(text = item.label, style = relayMetadataStyle())
Text(text = when (item) {
SessionDrawerFilter.All -> stringResource(R.string.drawer_filter_all)
SessionDrawerFilter.Threads -> stringResource(R.string.drawer_filter_threads)
SessionDrawerFilter.Pinned -> stringResource(R.string.drawer_filter_pinned)
SessionDrawerFilter.Archive -> stringResource(R.string.drawer_filter_archive)
}, style = relayMetadataStyle())
}
},
)
@@ -380,7 +394,7 @@ fun SessionDrawerContent(
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Text("New Thread")
Text(stringResource(R.string.drawer_new_thread))
}
}
if (!autoTitlesSupported) {
@@ -390,7 +404,7 @@ fun SessionDrawerContent(
// rather than broken — rename is one tap away via ⋮. (issue #133)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Chats aren't auto-named on this connection — use ⋮ → Rename.",
text = stringResource(R.string.drawer_chats_not_named),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -422,7 +436,7 @@ fun SessionDrawerContent(
strokeWidth = 2.dp,
)
Text(
text = "Loading sessions…",
text = stringResource(R.string.drawer_loading_sessions),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -435,12 +449,12 @@ fun SessionDrawerContent(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (sessions.isEmpty()) "No sessions yet" else "No matching sessions",
text = if (sessions.isEmpty()) stringResource(R.string.drawer_no_sessions) else stringResource(R.string.drawer_no_matching_sessions),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Start a conversation to see it here",
text = stringResource(R.string.drawer_start_conversation),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -482,12 +496,12 @@ fun SessionDrawerContent(
var threadName by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { newThreadDialog = false },
title = { Text("New Thread") },
title = { Text(stringResource(R.string.drawer_new_thread)) },
text = {
OutlinedTextField(
value = threadName,
onValueChange = { threadName = it },
label = { Text("Thread name") },
label = { Text(stringResource(R.string.drawer_thread_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -500,12 +514,12 @@ fun SessionDrawerContent(
newThreadDialog = false
}
}) {
Text("Create")
Text(stringResource(R.string.drawer_create))
}
},
dismissButton = {
TextButton(onClick = { newThreadDialog = false }) {
Text("Cancel")
Text(stringResource(R.string.drawer_cancel))
}
},
)
@@ -516,12 +530,12 @@ fun SessionDrawerContent(
var newTitle by remember(session) { mutableStateOf(session.title ?: "") }
AlertDialog(
onDismissRequest = { renameDialogSession = null },
title = { Text("Rename Session") },
title = { Text(stringResource(R.string.drawer_rename_session)) },
text = {
OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
label = { Text("Title") },
label = { Text(stringResource(R.string.drawer_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
@@ -533,12 +547,12 @@ fun SessionDrawerContent(
}
renameDialogSession = null
}) {
Text("Rename")
Text(stringResource(R.string.drawer_rename))
}
},
dismissButton = {
TextButton(onClick = { renameDialogSession = null }) {
Text("Cancel")
Text(stringResource(R.string.drawer_cancel))
}
}
)
@@ -548,21 +562,21 @@ fun SessionDrawerContent(
deleteDialogSession?.let { session ->
AlertDialog(
onDismissRequest = { deleteDialogSession = null },
title = { Text("Delete Session?") },
title = { Text(stringResource(R.string.drawer_delete_session_title)) },
text = {
Text("This will permanently delete \"${session.title ?: "Untitled"}\" and its message history.")
Text(stringResource(R.string.drawer_delete_session_prefix) + (session.title ?: stringResource(R.string.drawer_untitled)) + stringResource(R.string.drawer_delete_session_suffix))
},
confirmButton = {
TextButton(onClick = {
onDeleteSession(session.sessionId)
deleteDialogSession = null
}) {
Text("Delete", color = MaterialTheme.colorScheme.error)
Text(stringResource(R.string.drawer_delete), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { deleteDialogSession = null }) {
Text("Cancel")
Text(stringResource(R.string.drawer_cancel))
}
}
)
@@ -583,6 +597,8 @@ private fun SessionItem(
) {
var menuOpen by remember { mutableStateOf(false) }
val locale = LocalLocale.current.platformLocale
val context = LocalContext.current
val untitledLabel = stringResource(R.string.drawer_untitled)
val backgroundColor = if (isActive) {
MaterialTheme.colorScheme.secondaryContainer
} else {
@@ -603,7 +619,7 @@ private fun SessionItem(
.padding(end = 8.dp),
) {
Text(
text = session.title ?: "Untitled",
text = session.title ?: untitledLabel,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -633,7 +649,7 @@ private fun SessionItem(
tint = RelayRefresh.Relay,
)
Text(
text = "Thread",
text = stringResource(R.string.drawer_thread),
style = relayMetadataStyle(),
color = RelayRefresh.Relay,
maxLines = 1,
@@ -645,7 +661,7 @@ private fun SessionItem(
sourceBadge(session.source)?.let { badge ->
SourceChip(badge)
}
sessionTimestampText(session, locale)?.let { timestamp ->
sessionTimestampText(session, locale, context)?.let { timestamp ->
Text(
text = timestamp,
style = MaterialTheme.typography.bodySmall,
@@ -657,7 +673,7 @@ private fun SessionItem(
}
if (session.messageCount > 0) {
Text(
text = "${session.messageCount} msgs",
text = "${session.messageCount}" + stringResource(R.string.drawer_msgs),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -673,7 +689,7 @@ private fun SessionItem(
) {
Icon(
Icons.Filled.Star,
contentDescription = if (pinned) "Unpin session" else "Pin session",
contentDescription = if (pinned) stringResource(R.string.drawer_unpin_session) else stringResource(R.string.drawer_pin_session),
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(19.dp),
)
@@ -686,7 +702,7 @@ private fun SessionItem(
) {
Icon(
Icons.Filled.MoreVert,
contentDescription = "Session actions",
contentDescription = stringResource(R.string.drawer_session_actions),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(19.dp),
)
@@ -696,7 +712,7 @@ private fun SessionItem(
onDismissRequest = { menuOpen = false },
) {
DropdownMenuItem(
text = { Text("Rename") },
text = { Text(stringResource(R.string.drawer_rename)) },
leadingIcon = {
Icon(Icons.Filled.Edit, contentDescription = null)
},
@@ -706,7 +722,7 @@ private fun SessionItem(
},
)
DropdownMenuItem(
text = { Text(if (archived) "Restore" else "Archive") },
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
leadingIcon = {
Icon(
Icons.Filled.Archive,
@@ -726,7 +742,7 @@ private fun SessionItem(
DropdownMenuItem(
text = {
Text(
text = "Delete",
text = stringResource(R.string.drawer_delete),
color = MaterialTheme.colorScheme.error,
)
},
@@ -747,22 +763,22 @@ private fun SessionItem(
}
}
private fun sessionTimestampText(session: ChatSession, locale: Locale): String? {
private fun sessionTimestampText(session: ChatSession, locale: Locale, context: Context): String? {
val timestamp = session.activityTimestamp
if (timestamp <= 0L) return null
val hasDistinctActivity =
session.lastActivityAt > 0L &&
session.startTimestamp > 0L &&
session.lastActivityAt != session.startTimestamp
val prefix = if (hasDistinctActivity) "Active" else "Started"
return "$prefix ${formatTimestamp(timestamp, locale)}"
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_active) else context.getString(R.string.drawer_timestamp_started)
return "$prefix ${formatTimestamp(timestamp, locale, context)}"
}
private fun formatTimestamp(millis: Long, locale: Locale): String {
private fun formatTimestamp(millis: Long, locale: Locale, context: Context): String {
val now = System.currentTimeMillis()
val diff = now - millis
return when {
diff < 60_000 -> "Just now"
diff < 60_000 -> context.getString(R.string.drawer_just_now)
diff < 3_600_000 -> "${diff / 60_000}m ago"
diff < 86_400_000 -> SimpleDateFormat("h:mm a", locale).format(Date(millis))
else -> SimpleDateFormat("MMM d", locale).format(Date(millis))
@@ -39,6 +39,8 @@ import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.network.relay.ConnectionState
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.ServerCapabilities
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
// ---------------------------------------------------------------------------
// Session-path summary — the glanceable "which transport/route am I on" view
@@ -68,8 +70,7 @@ import com.hermesandroid.relay.network.upstream.ServerCapabilities
* raw token NEVER reaches the user.
*/
internal data class SessionPathTransport(
val friendlyName: String,
val descriptor: String,
val type: String,
val icon: ImageVector?,
/** True only for the gateway transport — gates the "Live thinking" chip. */
val isGateway: Boolean,
@@ -78,37 +79,48 @@ internal data class SessionPathTransport(
internal fun sessionPathTransport(resolvedTransport: String): SessionPathTransport =
when (resolvedTransport) {
"gateway" -> SessionPathTransport(
friendlyName = "Gateway",
descriptor = "live thinking",
type = "gateway",
icon = Icons.Filled.Bolt,
isGateway = true,
)
"sessions" -> SessionPathTransport(
friendlyName = "Sessions API",
descriptor = "streaming",
type = "sessions",
icon = null,
isGateway = false,
)
"completions" -> SessionPathTransport(
friendlyName = "Chat Completions",
descriptor = "streaming",
type = "completions",
icon = null,
isGateway = false,
)
"runs" -> SessionPathTransport(
friendlyName = "Runs API",
descriptor = "structured streaming",
type = "runs",
icon = null,
isGateway = false,
)
else -> SessionPathTransport(
friendlyName = "Direct chat",
descriptor = "streaming",
type = "other",
icon = null,
isGateway = false,
)
}
@Composable
internal fun transportFriendlyName(type: String): String = when (type) {
"gateway" -> stringResource(R.string.session_path_gateway)
"sessions" -> stringResource(R.string.session_path_sessions_api)
"completions" -> stringResource(R.string.session_path_chat_completions)
"runs" -> stringResource(R.string.session_path_runs_api)
else -> stringResource(R.string.session_path_direct_chat)
}
@Composable
internal fun transportDescriptor(type: String): String = when (type) {
"gateway" -> stringResource(R.string.session_path_live_thinking)
"runs" -> stringResource(R.string.session_path_structured_streaming)
else -> stringResource(R.string.session_path_streaming)
}
/**
* One capability the session may or may not expose, paired with whether it is
* actually available right now. [available] is set strictly from a confirming
@@ -116,7 +128,7 @@ internal fun sessionPathTransport(resolvedTransport: String): SessionPathTranspo
* the detail list can show it muted instead of hiding it (never-hide principle).
*/
internal data class SessionCapability(
val label: String,
val type: String,
val available: Boolean,
val reason: String? = null,
)
@@ -134,6 +146,7 @@ internal data class SessionCapability(
* - Voice → `voiceReady` (standard dashboard voice OR relay voice — whichever
* the connection actually has).
*/
@Composable
internal fun sessionCapabilities(
transport: SessionPathTransport,
gatewayAvailability: GatewayAvailability,
@@ -144,52 +157,62 @@ internal fun sessionCapabilities(
): List<SessionCapability> {
val liveThinkingReason = when {
transport.isGateway -> null
gatewayAvailability == GatewayAvailability.Unknown -> "Checking gateway…"
gatewayAvailability == GatewayAvailability.Unknown -> stringResource(R.string.session_path_checking_gateway)
gatewayAvailability == GatewayAvailability.SignInRequired ->
"Sign in under Manage for the live-thinking gateway."
else -> "Available on the gateway transport — this session streams over the API server."
stringResource(R.string.session_path_signin_for_gateway)
else -> stringResource(R.string.session_path_gateway_api_server)
}
return listOf(
SessionCapability(
label = "Live thinking",
type = "live_thinking",
available = transport.isGateway,
reason = liveThinkingReason,
),
SessionCapability(
label = "Media",
type = "media",
available = relayConnected,
reason = when {
relayConnected -> null
relayConfigured -> "Relay paired but not connected."
else -> "Pair the relay to send and receive media."
relayConfigured -> stringResource(R.string.session_path_relay_not_connected)
else -> stringResource(R.string.session_path_pair_relay_media)
},
),
SessionCapability(
label = "Terminal",
type = "terminal",
available = relayConnected,
reason = when {
relayConnected -> null
relayConfigured -> "Relay paired but not connected."
else -> "Pair the relay for terminal access."
relayConfigured -> stringResource(R.string.session_path_relay_not_connected)
else -> stringResource(R.string.session_path_pair_relay_terminal)
},
),
SessionCapability(
label = "Voice",
type = "voice",
available = voiceReady,
reason = if (voiceReady) null else "Voice not ready on this connection.",
reason = if (voiceReady) null else stringResource(R.string.session_path_voice_not_ready),
),
SessionCapability(
label = "Threads",
type = "threads",
available = threadsActive,
reason = if (threadsActive) {
null
} else {
"Pair the relay and turn on “Let Hermes message me” so the agent can open Threads."
stringResource(R.string.session_path_threads_pair_relay)
},
),
)
}
@Composable
internal fun capabilityLabel(type: String): String = when (type) {
"live_thinking" -> stringResource(R.string.session_path_live_thinking)
"media" -> stringResource(R.string.session_path_media)
"terminal" -> stringResource(R.string.session_path_terminal)
"voice" -> stringResource(R.string.session_path_voice)
"threads" -> stringResource(R.string.session_path_threads)
else -> type
}
/**
* The always-visible session-path summary that anchors the Connection section:
* a friendly transport + route line, then a strip of chips for capabilities
@@ -226,13 +249,15 @@ internal fun SessionPathSummary(
)
}
val routeSuffix = routeLabel?.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
val friendlyName = transportFriendlyName(transport.type)
val descriptor = transportDescriptor(transport.type)
Text(
text = buildString {
append(transport.friendlyName)
append(friendlyName)
append(routeSuffix)
if (transport.descriptor.isNotBlank()) {
if (descriptor.isNotBlank()) {
append(" — ")
append(transport.descriptor)
append(descriptor)
}
},
style = MaterialTheme.typography.bodyMedium,
@@ -255,16 +280,17 @@ internal fun SessionPathSummary(
verticalAlignment = Alignment.CenterVertically,
) {
activeCaps.forEach { cap ->
if (cap.label == "Threads") {
val label = capabilityLabel(cap.type)
if (cap.type == "threads") {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
CapabilityChip(label = cap.label)
CapabilityChip(label = label)
BetaChip()
}
} else {
CapabilityChip(label = cap.label)
CapabilityChip(label = label)
}
}
}
@@ -307,6 +333,8 @@ internal fun SessionPathDetails(
serverCapabilities: ServerCapabilities,
modifier: Modifier = Modifier,
) {
val friendlyName = transportFriendlyName(transport.type)
var expanded by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxWidth()) {
Row(
@@ -319,7 +347,7 @@ internal fun SessionPathDetails(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (expanded) "Hide session details" else "Session details",
text = if (expanded) stringResource(R.string.session_path_hide_details) else stringResource(R.string.session_path_details),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
@@ -329,16 +357,16 @@ internal fun SessionPathDetails(
} else {
Icons.Filled.KeyboardArrowDown
},
contentDescription = if (expanded) "Hide session details" else "Show session details",
contentDescription = if (expanded) stringResource(R.string.session_path_hide_details_cd) else stringResource(R.string.session_path_show_details_cd),
tint = MaterialTheme.colorScheme.primary,
)
}
if (expanded) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
DetailRow(label = "Transport", value = transport.friendlyName)
DetailRow(label = "Route", value = routeLabel?.takeIf { it.isNotBlank() } ?: "—")
DetailChipRow(label = "Relay state") {
DetailRow(label = stringResource(R.string.session_path_transport), value = friendlyName)
DetailRow(label = stringResource(R.string.session_path_route), value = routeLabel?.takeIf { it.isNotBlank() } ?: "—")
DetailChipRow(label = stringResource(R.string.session_path_relay_state)) {
SessionPathConnectionChip(relayConnectionState)
}
@@ -362,8 +390,8 @@ internal fun SessionPathDetails(
Spacer(modifier = Modifier.size(2.dp))
DetailRow(label = "API", value = apiServerUrl, monospace = true)
DetailRow(label = "Relay", value = relayUrl, monospace = true)
DetailRow(label = stringResource(R.string.session_path_api), value = apiServerUrl, monospace = true)
DetailRow(label = stringResource(R.string.session_path_relay), value = relayUrl, monospace = true)
}
}
}
@@ -387,20 +415,20 @@ internal fun TransportTierStepper(
val status = remember(streamingEndpoint, gatewayAvailability, serverCapabilities) {
resolveChatTransportStatus(streamingEndpoint, gatewayAvailability, serverCapabilities)
}
// basic → best, paired with whether THIS server exposes the tier.
// basic -> best, paired with whether THIS server exposes the tier.
val tiers = listOf(
Triple(ChatTransportTier.Completions, "Chat Completions", serverCapabilities.portable),
Triple(ChatTransportTier.Runs, "Runs API", serverCapabilities.runs),
Triple(ChatTransportTier.Sessions, "Sessions API", serverCapabilities.sessionsChatStream),
Triple(ChatTransportTier.Completions, stringResource(R.string.session_path_chat_completions), serverCapabilities.portable),
Triple(ChatTransportTier.Runs, stringResource(R.string.session_path_runs_api), serverCapabilities.runs),
Triple(ChatTransportTier.Sessions, stringResource(R.string.session_path_sessions_api), serverCapabilities.sessionsChatStream),
Triple(
ChatTransportTier.Gateway,
"Gateway · live thinking",
stringResource(R.string.session_path_gateway_live_thinking),
gatewayAvailability == GatewayAvailability.Ready,
),
)
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = "Transport path · basic → best",
text = stringResource(R.string.session_path_transport_path_ladder),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -470,9 +498,9 @@ private fun TransportTierRow(
)
Text(
text = when {
isActive -> "active"
available -> "available"
else -> "not exposed by this server"
isActive -> stringResource(R.string.session_path_active)
available -> stringResource(R.string.session_path_available)
else -> stringResource(R.string.session_path_not_exposed)
},
style = MaterialTheme.typography.labelSmall,
color = if (isActive) {
@@ -536,6 +564,7 @@ private fun DetailChipRow(label: String, chip: @Composable () -> Unit) {
@Composable
private fun CapabilityDetailRow(cap: SessionCapability) {
val label = capabilityLabel(cap.type)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -543,7 +572,7 @@ private fun CapabilityDetailRow(cap: SessionCapability) {
) {
Icon(
imageVector = if (cap.available) Icons.Filled.Check else Icons.Filled.Remove,
contentDescription = if (cap.available) "Available" else "Unavailable",
contentDescription = if (cap.available) stringResource(R.string.session_path_available) else stringResource(R.string.session_path_unavailable),
tint = if (cap.available) {
MaterialTheme.colorScheme.primary
} else {
@@ -553,7 +582,7 @@ private fun CapabilityDetailRow(cap: SessionCapability) {
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = cap.label,
text = label,
style = MaterialTheme.typography.bodyMedium,
color = if (cap.available) {
MaterialTheme.colorScheme.onSurface
@@ -578,22 +607,22 @@ private fun CapabilityDetailRow(cap: SessionCapability) {
private fun SessionPathConnectionChip(state: ConnectionState) {
val (label, bg, fg) = when (state) {
ConnectionState.Connected -> Triple(
"Connected",
stringResource(R.string.session_path_connected),
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.onPrimaryContainer,
)
ConnectionState.Connecting -> Triple(
"Connecting…",
stringResource(R.string.session_path_connecting),
MaterialTheme.colorScheme.tertiaryContainer,
MaterialTheme.colorScheme.onTertiaryContainer,
)
ConnectionState.Reconnecting -> Triple(
"Reconnecting…",
stringResource(R.string.session_path_reconnecting),
MaterialTheme.colorScheme.tertiaryContainer,
MaterialTheme.colorScheme.onTertiaryContainer,
)
ConnectionState.Disconnected -> Triple(
"Disconnected",
stringResource(R.string.session_path_disconnected),
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -24,8 +24,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.annotation.StringRes
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.PairingPreferences
/**
@@ -83,7 +86,7 @@ fun SessionTtlPickerDialog(
},
title = {
Text(
text = "Keep this pairing for…",
text = stringResource(R.string.ttl_title),
style = MaterialTheme.typography.titleLarge
)
},
@@ -93,17 +96,16 @@ fun SessionTtlPickerDialog(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Your phone will reconnect automatically during " +
"this window. After it expires you'll need to re-pair.",
text = stringResource(R.string.ttl_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Transport hint / Tailscale helper line
val helperLine = when {
isTailscaleDetected -> "Transport: Tailscale detected"
transportHint.equals("wss", ignoreCase = true) -> "Transport: TLS (wss://)"
transportHint.equals("ws", ignoreCase = true) -> "Transport: plain ws://"
isTailscaleDetected -> stringResource(R.string.ttl_helper_tailscale)
transportHint.equals("wss", ignoreCase = true) -> stringResource(R.string.ttl_helper_tls)
transportHint.equals("ws", ignoreCase = true) -> stringResource(R.string.ttl_helper_plain)
else -> null
}
if (helperLine != null) {
@@ -134,7 +136,7 @@ fun SessionTtlPickerDialog(
onClick = { selectedIndex = index }
)
Text(
text = option.label,
text = stringResource(option.labelRes),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 8.dp)
)
@@ -160,10 +162,7 @@ fun SessionTtlPickerDialog(
.height(16.dp)
)
Text(
text = "This device will stay paired until you " +
"revoke it manually from Relay sessions. " +
"Only choose this if you control the network " +
"— LAN, Tailscale, VPN, or TLS.",
text = stringResource(R.string.ttl_never_expire_warning),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -175,12 +174,12 @@ fun SessionTtlPickerDialog(
TextButton(
onClick = { onConfirm(options[selectedIndex].seconds) }
) {
Text("Pair")
Text(stringResource(R.string.ttl_pair))
}
},
dismissButton = {
TextButton(onClick = onCancel) {
Text("Cancel")
Text(stringResource(R.string.ttl_cancel))
}
}
)
@@ -190,16 +189,16 @@ fun SessionTtlPickerDialog(
* A single entry in the TTL picker. `seconds == 0` means "never expire"
* (wire contract alignment — matches `ttl_seconds: 0` on the QR payload).
*/
data class TtlOption(val label: String, val seconds: Long)
data class TtlOption(@StringRes val labelRes: Int, val seconds: Long)
/** The canonical set of TTL options shown in the picker. */
fun ttlPickerOptions(): List<TtlOption> = listOf(
TtlOption("1 day", 24L * 60 * 60),
TtlOption("7 days", 7L * 24 * 60 * 60),
TtlOption("30 days", 30L * 24 * 60 * 60),
TtlOption("90 days", 90L * 24 * 60 * 60),
TtlOption("1 year", 365L * 24 * 60 * 60),
TtlOption("Never expire", PairingPreferences.TTL_NEVER),
TtlOption(R.string.ttl_option_1d, 24L * 60 * 60),
TtlOption(R.string.ttl_option_7d, 7L * 24 * 60 * 60),
TtlOption(R.string.ttl_option_30d, 30L * 24 * 60 * 60),
TtlOption(R.string.ttl_option_90d, 90L * 24 * 60 * 60),
TtlOption(R.string.ttl_option_1y, 365L * 24 * 60 * 60),
TtlOption(R.string.ttl_option_never, PairingPreferences.TTL_NEVER),
)
/** Fallback default when no previous selection and no QR hint — 30 days. */
@@ -1,8 +1,11 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.BrandPalette
import com.hermesandroid.relay.ui.theme.BrandPalettes
@@ -36,18 +39,44 @@ data class SphereReactivity(
* renderer feeds it (the chat/voice [MorphingSphere] does not). */
val gaze: Boolean = false,
) {
/** Human-readable capability list for the picker ("Voice · Tools"). */
/** Ordered list of enabled capability flags (locale-independent so test
* assertions stay stable). The UI maps each flag to a localized label via
* [reactivityLabels]. */
fun flags(): List<ReactivityFlag> = buildList {
if (voice) add(ReactivityFlag.VOICE)
if (tools) add(ReactivityFlag.TOOLS)
if (intensity) add(ReactivityFlag.ACTIVITY)
if (gaze) add(ReactivityFlag.GAZE)
}
/** Legacy locale-independent summary for backward compat. Prefer [flags] +
* [reactivityLabels] in UI code. */
fun summary(): String {
val on = buildList {
if (voice) add("Voice")
if (tools) add("Tools")
if (intensity) add("Activity")
if (gaze) add("Gaze")
}
val on = flags().map { it.name.lowercase().replaceFirstChar { c -> c.uppercase() } }
return if (on.isEmpty()) "Static" else on.joinToString(" · ")
}
}
/** Individual capability flags exposed by a [SphereReactivity]. Used as
* locale-independent keys; the UI maps each to a localized label. */
enum class ReactivityFlag { VOICE, TOOLS, ACTIVITY, GAZE }
/** Pre-composed localized summary string from a list of [ReactivityFlag],
* joined with the middle-dot separator. */
@androidx.compose.runtime.Composable
fun reactivityLabels(flags: List<ReactivityFlag>): String {
if (flags.isEmpty()) return stringResource(R.string.sphere_reactivity_static)
val labels = flags.map { flag ->
when (flag) {
ReactivityFlag.VOICE -> stringResource(R.string.sphere_reactivity_voice)
ReactivityFlag.TOOLS -> stringResource(R.string.sphere_reactivity_tools)
ReactivityFlag.ACTIVITY -> stringResource(R.string.sphere_reactivity_activity)
ReactivityFlag.GAZE -> stringResource(R.string.sphere_reactivity_gaze)
}
}
return labels.joinToString(" · ")
}
enum class SphereSkinSource { ADAPTIVE, BUILT_IN, USER }
@Immutable
@@ -42,6 +42,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.hermesandroid.relay.data.AppAnalytics
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.AppStats
import com.hermesandroid.relay.data.ToolCallEvent
import com.hermesandroid.relay.viewmodel.VoiceStats
@@ -79,22 +81,22 @@ fun StatsForNerds(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Overview",
text = stringResource(R.string.stats_overview),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
Row {
if (expanded) {
TextButton(onClick = { showResetDialog = true }) {
Text("Reset", color = MaterialTheme.colorScheme.error,
Text(stringResource(R.string.stats_reset), color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall)
}
}
TextButton(onClick = { expanded = !expanded }) {
Text(if (expanded) "Collapse" else "Expand")
Text(if (expanded) stringResource(R.string.stats_collapse) else stringResource(R.string.stats_expand))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
contentDescription = if (expanded) stringResource(R.string.stats_collapse_cd) else stringResource(R.string.stats_expand_cd),
modifier = Modifier.size(18.dp)
)
}
@@ -106,10 +108,13 @@ fun StatsForNerds(
val tokensPerMsg = if (appStats.totalMessagesSent > 0)
totalTokens / appStats.totalMessagesSent else 0L
Text(
text = "${appStats.totalMessagesSent} messages · " +
"${formatTokenCount(totalTokens)} tokens" +
(if (tokensPerMsg > 0) " (~${formatTokenCount(tokensPerMsg)}/msg)" else "") +
" · ${appStats.sessionCount} sessions",
text = stringResource(
R.string.stats_summary_format,
appStats.totalMessagesSent,
formatTokenCount(totalTokens),
if (tokensPerMsg > 0) stringResource(R.string.stats_tokens_per_msg, formatTokenCount(tokensPerMsg)) else "",
appStats.sessionCount
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -126,7 +131,7 @@ fun StatsForNerds(
// -- Response Time Chart (TTFT) --
if (appStats.recentResponseTimesMs.isNotEmpty()) {
ChartSection(
title = "Time to First Token",
title = stringResource(R.string.stats_time_to_first_token),
data = appStats.recentResponseTimesMs,
unit = "ms"
)
@@ -135,7 +140,7 @@ fun StatsForNerds(
// -- Completion Time Chart --
if (appStats.recentCompletionTimesMs.isNotEmpty()) {
ChartSection(
title = "Completion Time",
title = stringResource(R.string.stats_completion_time),
data = appStats.recentCompletionTimesMs,
unit = "ms"
)
@@ -174,19 +179,19 @@ fun StatsForNerds(
if (showResetDialog) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("Reset Analytics?") },
text = { Text("This will clear all recorded stats including token counts, response times, and stream history. This cannot be undone.") },
title = { Text(stringResource(R.string.stats_reset_analytics_title)) },
text = { Text(stringResource(R.string.stats_reset_analytics_body)) },
confirmButton = {
TextButton(onClick = {
AppAnalytics.resetAll()
showResetDialog = false
}) {
Text("Reset", color = MaterialTheme.colorScheme.error)
Text(stringResource(R.string.stats_reset_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) {
Text("Cancel")
Text(stringResource(R.string.stats_cancel))
}
}
)
@@ -221,9 +226,9 @@ private fun ChartSection(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
StatLabel("Min", formatMsWithSeconds(minVal))
StatLabel("Avg", formatMsWithSeconds(avgVal))
StatLabel("Max", formatMsWithSeconds(maxVal))
StatLabel(stringResource(R.string.stats_min), formatMsWithSeconds(minVal))
StatLabel(stringResource(R.string.stats_avg), formatMsWithSeconds(avgVal))
StatLabel(stringResource(R.string.stats_max), formatMsWithSeconds(maxVal))
}
Spacer(modifier = Modifier.height(8.dp))
@@ -326,7 +331,7 @@ private fun StatLabel(label: String, value: String) {
private fun TokenUsageSection(stats: AppStats) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Token Usage",
text = stringResource(R.string.stats_token_usage),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
@@ -334,7 +339,7 @@ private fun TokenUsageSection(stats: AppStats) {
// Current session
Text(
text = "Current Session",
text = stringResource(R.string.stats_current_session),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -342,14 +347,14 @@ private fun TokenUsageSection(stats: AppStats) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TokenStat("In", formatTokenCount(stats.currentSessionTokensIn))
TokenStat("Out", formatTokenCount(stats.currentSessionTokensOut))
TokenStat("Total", formatTokenCount(stats.currentSessionTokensIn + stats.currentSessionTokensOut))
TokenStat(stringResource(R.string.stats_in), formatTokenCount(stats.currentSessionTokensIn))
TokenStat(stringResource(R.string.stats_out), formatTokenCount(stats.currentSessionTokensOut))
TokenStat(stringResource(R.string.stats_total), formatTokenCount(stats.currentSessionTokensIn + stats.currentSessionTokensOut))
}
// Lifetime
Text(
text = "Lifetime",
text = stringResource(R.string.stats_lifetime),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -357,9 +362,9 @@ private fun TokenUsageSection(stats: AppStats) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TokenStat("In", formatTokenCount(stats.totalTokensIn))
TokenStat("Out", formatTokenCount(stats.totalTokensOut))
TokenStat("Total", formatTokenCount(stats.totalTokensIn + stats.totalTokensOut))
TokenStat(stringResource(R.string.stats_in), formatTokenCount(stats.totalTokensIn))
TokenStat(stringResource(R.string.stats_out), formatTokenCount(stats.totalTokensOut))
TokenStat(stringResource(R.string.stats_total), formatTokenCount(stats.totalTokensIn + stats.totalTokensOut))
}
}
}
@@ -388,7 +393,7 @@ private fun TokenStat(label: String, value: String) {
private fun ConnectionHealthSection(stats: AppStats) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Connection Health",
text = stringResource(R.string.stats_connection_health),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
@@ -396,7 +401,7 @@ private fun ConnectionHealthSection(stats: AppStats) {
if (stats.healthChecksTotal == 0) {
Text(
text = "No health checks recorded yet",
text = stringResource(R.string.stats_no_health_checks),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -408,7 +413,7 @@ private fun ConnectionHealthSection(stats: AppStats) {
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Success rate",
text = stringResource(R.string.stats_success_rate),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -437,7 +442,7 @@ private fun ConnectionHealthSection(stats: AppStats) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Avg latency",
text = stringResource(R.string.stats_avg_latency),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -455,7 +460,7 @@ private fun ConnectionHealthSection(stats: AppStats) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Total checks",
text = stringResource(R.string.stats_total_checks),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -478,7 +483,7 @@ private fun StreamStatsSection(stats: AppStats) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Stream Stats",
text = stringResource(R.string.stats_stream_stats),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
@@ -486,7 +491,7 @@ private fun StreamStatsSection(stats: AppStats) {
if (totalStreams == 0) {
Text(
text = "No streams recorded yet",
text = stringResource(R.string.stats_no_streams),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -498,7 +503,7 @@ private fun StreamStatsSection(stats: AppStats) {
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Success rate",
text = stringResource(R.string.stats_success_rate),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -528,9 +533,9 @@ private fun StreamStatsSection(stats: AppStats) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
StreamCounter("Completed", stats.streamsCompleted, MaterialTheme.colorScheme.primary)
StreamCounter("Errored", stats.streamsErrored, MaterialTheme.colorScheme.error)
StreamCounter("Cancelled", stats.streamsCancelled, MaterialTheme.colorScheme.onSurfaceVariant)
StreamCounter(stringResource(R.string.stats_completed), stats.streamsCompleted, MaterialTheme.colorScheme.primary)
StreamCounter(stringResource(R.string.stats_errored), stats.streamsErrored, MaterialTheme.colorScheme.error)
StreamCounter(stringResource(R.string.stats_cancelled), stats.streamsCancelled, MaterialTheme.colorScheme.onSurfaceVariant)
}
// Avg times
@@ -541,7 +546,7 @@ private fun StreamStatsSection(stats: AppStats) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Avg TTFT",
text = stringResource(R.string.stats_avg_ttft),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -558,7 +563,7 @@ private fun StreamStatsSection(stats: AppStats) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Peak TTFT",
text = stringResource(R.string.stats_peak_ttft),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -578,7 +583,7 @@ private fun StreamStatsSection(stats: AppStats) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Avg completion",
text = stringResource(R.string.stats_avg_completion),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -595,7 +600,7 @@ private fun StreamStatsSection(stats: AppStats) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Slowest",
text = stringResource(R.string.stats_slowest),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -636,7 +641,7 @@ private fun StreamCounter(label: String, count: Int, color: Color) {
private fun VoiceSection(stats: VoiceStats) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Voice",
text = stringResource(R.string.stats_voice),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
@@ -644,85 +649,85 @@ private fun VoiceSection(stats: VoiceStats) {
// STT subsection
Text(
text = "STT",
text = stringResource(R.string.stats_stt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
KeyValueRow(
label = "Last transcript",
label = stringResource(R.string.stats_last_transcript),
value = truncate(stats.lastTranscript.ifBlank { "—" }, 80),
)
KeyValueRow(
label = "Latency",
label = stringResource(R.string.stats_latency),
value = if (stats.sttCallCount == 0) "—" else formatMsWithSeconds(stats.lastSttLatencyMs),
)
KeyValueRow(
label = "Uploaded",
label = stringResource(R.string.stats_uploaded),
value = formatByteCount(stats.sttBytesUploaded),
)
KeyValueRow(
label = "Calls",
label = stringResource(R.string.stats_calls),
value = "${stats.sttCallCount}",
)
// TTS subsection
Text(
text = "TTS",
text = stringResource(R.string.stats_tts),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
KeyValueRow(
label = "Last sentence",
label = stringResource(R.string.stats_last_sentence),
value = truncate(stats.lastSynthesizedSentence.ifBlank { "—" }, 80),
)
val avgTtsLatency = if (stats.recentTtsLatenciesMs.isNotEmpty()) {
stats.recentTtsLatenciesMs.sum() / stats.recentTtsLatenciesMs.size
} else 0L
KeyValueRow(
label = "Avg latency (last ${stats.recentTtsLatenciesMs.size.coerceAtLeast(0)})",
label = stringResource(R.string.stats_avg_latency_last_format, stats.recentTtsLatenciesMs.size.coerceAtLeast(0)),
value = if (stats.recentTtsLatenciesMs.isEmpty()) "—" else formatMsWithSeconds(avgTtsLatency),
)
KeyValueRow(
label = "Response chunks",
label = stringResource(R.string.stats_response_chunks),
value = "${stats.currentResponseTtsChunks}",
)
KeyValueRow(
label = "Last chunk gap",
label = stringResource(R.string.stats_last_chunk_gap),
value = if (stats.currentResponseTtsChunks <= 1) "—" else formatMsWithSeconds(stats.lastTtsChunkGapMs),
)
KeyValueRow(
label = "Received",
label = stringResource(R.string.stats_received),
value = formatByteCount(stats.ttsBytesReceived),
)
KeyValueRow(
label = "Calls",
label = stringResource(R.string.stats_calls),
value = "${stats.ttsCallCount}",
)
// Barge-in + playback subsection
Text(
text = "Barge-in / Playback",
text = stringResource(R.string.stats_barge_in_playback),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
KeyValueRow(
label = "Barge-in events",
label = stringResource(R.string.stats_barge_in_events),
value = "${stats.bargeInCount}",
)
KeyValueRow(
label = "VAD threshold",
label = stringResource(R.string.stats_vad_threshold),
value = if (stats.vadThresholdMs <= 0) "—" else "${stats.vadThresholdMs} ms",
)
KeyValueRow(
label = "Mode",
label = stringResource(R.string.stats_mode),
value = stats.interactionMode,
)
KeyValueRow(
label = "Queue depth",
label = stringResource(R.string.stats_queue_depth),
value = "${stats.ttsQueueDepth}",
)
KeyValueRow(
label = "Player",
label = stringResource(R.string.stats_player),
value = stats.playerState,
)
}
@@ -734,7 +739,7 @@ private fun VoiceSection(stats: VoiceStats) {
private fun ToolCallsSection(events: List<ToolCallEvent>) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = "Tool Calls",
text = stringResource(R.string.stats_tool_calls),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
@@ -754,9 +759,9 @@ private fun ToolCallRow(event: ToolCallEvent, nowMs: Long) {
else -> MaterialTheme.colorScheme.error
}
val statusLabel = when {
!event.isComplete -> "running"
event.success == true -> "ok"
else -> "failed"
!event.isComplete -> stringResource(R.string.stats_running)
event.success == true -> stringResource(R.string.stats_ok)
else -> stringResource(R.string.stats_failed)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(
@@ -861,14 +866,15 @@ private fun truncate(text: String, limit: Int): String =
if (text.length <= limit) text else "${text.take(limit - 1)}…"
/** Relative timestamp like "just now" / "3s ago" / "2m ago" / "1h ago". */
@Composable
private fun relativeTime(eventMs: Long, nowMs: Long): String {
val deltaMs = (nowMs - eventMs).coerceAtLeast(0L)
val seconds = deltaMs / 1000
return when {
seconds < 2 -> "just now"
seconds < 60 -> "${seconds}s ago"
seconds < 3600 -> "${seconds / 60}m ago"
seconds < 86400 -> "${seconds / 3600}h ago"
else -> "${seconds / 86400}d ago"
seconds < 2 -> stringResource(R.string.stats_just_now)
seconds < 60 -> stringResource(R.string.stats_seconds_ago, seconds)
seconds < 3600 -> stringResource(R.string.stats_minutes_ago, seconds / 60)
seconds < 86400 -> stringResource(R.string.stats_hours_ago, seconds / 3600)
else -> stringResource(R.string.stats_days_ago, seconds / 86400)
}
}
@@ -36,10 +36,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
@@ -79,7 +81,7 @@ fun SubagentLane(
val laneLabel = calls.firstNotNullOfOrNull { call ->
call.taskLabel?.takeIf { it.isNotBlank() }
} ?: "Agent ${taskIndex + 1}"
} ?: stringResource(R.string.subagent_lane_label_fallback, taskIndex + 1)
// Lane duration: first child start → last child completion.
val duration = run {
@@ -90,12 +92,18 @@ fun SubagentLane(
} else null
}
val toolCountLabel = "${calls.size} tool${if (calls.size == 1) "" else "s"}"
val toolCountLabel = stringResource(R.string.subagent_lane_tool_count, calls.size)
val statusMeta = when {
anyRunning -> "$runningCount running"
anyRunning -> stringResource(R.string.subagent_lane_running_count, runningCount)
duration != null -> "$toolCountLabel · $duration"
else -> toolCountLabel
}
val laneA11y = stringResource(R.string.subagent_lane_a11y, laneLabel, statusMeta) +
if (anyFailed) ", " + stringResource(R.string.cd_subagent_failed) else ""
val completionLabel = stringResource(R.string.cd_subagent_completed)
val collapseLabel = stringResource(R.string.cd_subagent_collapse)
val expandLabel = stringResource(R.string.cd_subagent_expand)
val failureLabel = stringResource(R.string.cd_subagent_failed)
var expanded by remember { mutableStateOf(!allComplete) }
@@ -111,8 +119,7 @@ fun SubagentLane(
.padding(start = 8.dp)
.height(IntrinsicSize.Min)
.semantics {
contentDescription = "Subagent $laneLabel, $statusMeta" +
if (anyFailed) ", failed" else ""
contentDescription = laneA11y
},
) {
// Guide rail
@@ -165,7 +172,7 @@ fun SubagentLane(
Spacer(modifier = Modifier.width(6.dp))
Icon(
imageVector = if (anyFailed) Icons.Filled.Close else Icons.Filled.Check,
contentDescription = if (anyFailed) "Failed" else "Completed",
contentDescription = if (anyFailed) failureLabel else completionLabel,
modifier = Modifier.size(14.dp),
tint = if (anyFailed) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary,
@@ -176,7 +183,7 @@ fun SubagentLane(
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
contentDescription = if (expanded) collapseLabel else expandLabel,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -26,10 +26,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import com.hermesandroid.relay.R
/**
* Inline search row that appears below the terminal top app bar.
@@ -85,7 +87,7 @@ fun TerminalSearchBar(
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester),
placeholder = { Text("Search scrollback") },
placeholder = { Text(stringResource(R.string.term_search_placeholder)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
@@ -106,7 +108,7 @@ fun TerminalSearchBar(
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = "Previous match",
contentDescription = stringResource(R.string.term_search_cd_previous),
)
}
IconButton(
@@ -115,13 +117,13 @@ fun TerminalSearchBar(
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = "Next match",
contentDescription = stringResource(R.string.term_search_cd_next),
)
}
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close search",
contentDescription = stringResource(R.string.term_search_cd_close),
)
}
}
@@ -43,6 +43,8 @@ import com.hermesandroid.relay.auth.PairedSession
import com.hermesandroid.relay.viewmodel.TerminalViewModel
import java.text.DateFormat
import java.util.Date
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
/**
* Bottom-sheet dialog showing full metadata for a single terminal tab plus
@@ -110,9 +112,9 @@ fun TerminalSessionInfoSheet(
Column {
Text(
text = if (tab.displayName.isNullOrBlank()) {
"Tab ${tab.tabId}"
stringResource(R.string.term_info_tab_format, tab.tabId)
} else {
"Tab ${tab.tabId} · ${tab.displayName}"
stringResource(R.string.term_info_tab_name_format, tab.tabId, tab.displayName)
},
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
@@ -141,27 +143,27 @@ fun TerminalSessionInfoSheet(
) {
StatusChip(
label = when {
tab.attached -> "Attached"
tab.attaching -> "Attaching…"
tab.error != null -> "Error"
!tab.userStarted -> "Not started"
else -> "Detached"
tab.attached -> stringResource(R.string.term_info_attached)
tab.attaching -> stringResource(R.string.term_info_attaching)
tab.error != null -> stringResource(R.string.term_info_error)
!tab.userStarted -> stringResource(R.string.term_info_not_started)
else -> stringResource(R.string.term_info_detached)
},
isPositive = tab.attached,
)
if (tab.tmuxAvailable) {
StatusChip(label = "tmux", isPositive = true)
StatusChip(label = stringResource(R.string.term_info_tmux), isPositive = true)
}
}
HorizontalDivider()
// Per-tab metadata.
InfoRow("PID", tab.pid?.toString() ?: "—")
InfoRow("Shell", tab.shell ?: "—", monospace = true)
InfoRow("Grid", "${tab.cols} × ${tab.rows}")
InfoRow(stringResource(R.string.term_info_pid), tab.pid?.toString() ?: "—")
InfoRow(stringResource(R.string.term_info_shell), tab.shell ?: "—", monospace = true)
InfoRow(stringResource(R.string.term_info_grid), stringResource(R.string.term_info_grid_format, tab.cols, tab.rows))
if (tab.error != null) {
InfoRow("Last error", tab.error, valueColor = MaterialTheme.colorScheme.error)
InfoRow(stringResource(R.string.term_info_last_error), tab.error, valueColor = MaterialTheme.colorScheme.error)
}
HorizontalDivider()
@@ -172,7 +174,7 @@ fun TerminalSessionInfoSheet(
// PairedDevicesScreen + ConnectionInfoSheet for visual parity.
if (pairedSession != null) {
Text(
text = "Relay session",
text = stringResource(R.string.term_info_relay_session),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -182,12 +184,12 @@ fun TerminalSessionInfoSheet(
size = TransportSecuritySize.Row,
)
InfoRow(
label = "Expires",
label = stringResource(R.string.term_info_expires),
value = formatExpiry(pairedSession.expiresAt),
)
if (pairedSession.grants.isNotEmpty()) {
Text(
text = "Channel grants",
text = stringResource(R.string.term_info_channel_grants),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -203,7 +205,7 @@ fun TerminalSessionInfoSheet(
}
} else {
Text(
text = "No paired session",
text = stringResource(R.string.term_info_no_paired_session),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -227,7 +229,7 @@ fun TerminalSessionInfoSheet(
},
modifier = Modifier.weight(1f),
) {
Text("Start session")
Text(stringResource(R.string.term_info_start_session))
}
} else {
OutlinedButton(
@@ -237,7 +239,7 @@ fun TerminalSessionInfoSheet(
},
modifier = Modifier.weight(1f),
) {
Text("Reattach")
Text(stringResource(R.string.term_info_reattach))
}
}
OutlinedButton(
@@ -248,7 +250,7 @@ fun TerminalSessionInfoSheet(
enabled = canCloseTab,
modifier = Modifier.weight(1f),
) {
Text("Close tab")
Text(stringResource(R.string.term_info_close_tab))
}
}
@@ -269,7 +271,7 @@ fun TerminalSessionInfoSheet(
),
modifier = Modifier.fillMaxWidth(),
) {
Text("Kill session")
Text(stringResource(R.string.term_info_kill_session))
}
}
@@ -278,7 +280,7 @@ fun TerminalSessionInfoSheet(
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) {
Text("Done")
Text(stringResource(R.string.term_info_done))
}
}
@@ -303,8 +305,8 @@ private fun RenameRow(
value = draft,
onValueChange = { draft = it.take(40) },
singleLine = true,
label = { Text("Name") },
placeholder = { Text("e.g. build, logs, claude") },
label = { Text(stringResource(R.string.term_info_name)) },
placeholder = { Text(stringResource(R.string.term_info_name_placeholder)) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onCommit(draft) }),
modifier = Modifier.weight(1f),
@@ -313,7 +315,7 @@ private fun RenameRow(
onClick = { onCommit(draft) },
enabled = isDirty,
) {
Text(if (draft.isBlank() && currentName.isNotEmpty()) "Clear" else "Save")
Text(if (draft.isBlank() && currentName.isNotEmpty()) stringResource(R.string.term_info_clear) else stringResource(R.string.term_info_save))
}
}
}
@@ -368,6 +370,7 @@ private fun InfoRow(
@Composable
private fun GrantChipLocal(channel: String, expiresAt: Long?) {
val neverText = stringResource(R.string.term_info_never)
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
@@ -378,7 +381,7 @@ private fun GrantChipLocal(channel: String, expiresAt: Long?) {
text = buildString {
append(channel)
append(" · ")
append(if (expiresAt == null) "never" else formatExpiryShort(expiresAt))
append(if (expiresAt == null) neverText else formatExpiryShort(expiresAt))
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -386,8 +389,9 @@ private fun GrantChipLocal(channel: String, expiresAt: Long?) {
}
}
@Composable
private fun formatExpiry(epochSeconds: Long?): String {
if (epochSeconds == null) return "Never"
if (epochSeconds == null) return stringResource(R.string.term_info_never)
return try {
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
.format(Date(epochSeconds * 1000L))
@@ -26,9 +26,11 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.TerminalViewModel
/**
@@ -88,7 +90,7 @@ fun TerminalTabBar(
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "New terminal tab",
contentDescription = stringResource(R.string.cd_new_terminal_tab),
tint = if (canAddMore) {
MaterialTheme.colorScheme.onSurface
} else {
@@ -188,7 +190,7 @@ private fun TerminalTabChip(
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close tab ${tab.tabId}",
contentDescription = stringResource(R.string.cd_close_tab, tab.tabId),
tint = fg,
modifier = Modifier.size(12.dp),
)
@@ -38,10 +38,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ToolCallEvent
import com.hermesandroid.relay.diagnostics.CheckStatus
import com.hermesandroid.relay.diagnostics.StatusCheck
@@ -95,7 +97,7 @@ fun TimelineView(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Timeline",
text = stringResource(R.string.timeline_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -110,7 +112,7 @@ fun TimelineView(
if (buckets.isEmpty()) {
Text(
text = "No activity yet",
text = stringResource(R.string.timeline_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -219,7 +221,7 @@ fun StatusCheckTimeline(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Status checks",
text = stringResource(R.string.timeline_status_checks),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -238,7 +240,7 @@ fun StatusCheckTimeline(
if (checks.isEmpty()) {
Text(
text = "No checks yet — connect to a server to populate diagnostics.",
text = stringResource(R.string.timeline_checks_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -361,7 +363,7 @@ private fun StatusCheckRow(
if (hasDetail) {
Text(
text = "Tap for log detail",
text = stringResource(R.string.timeline_tap_detail),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
@@ -49,11 +49,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ToolCall
@Composable
@@ -115,10 +117,10 @@ fun ToolProgressCard(
val toolIcon = toolIcon(toolCall.name)
val statusText = when {
toolCall.isComplete && toolCall.success == true -> "completed"
toolCall.isComplete && toolCall.success == false -> "failed"
isPreparing -> "preparing"
else -> "running"
toolCall.isComplete && toolCall.success == true -> stringResource(R.string.tool_progress_status_completed)
toolCall.isComplete && toolCall.success == false -> stringResource(R.string.tool_progress_status_failed)
isPreparing -> stringResource(R.string.tool_preparing_a11y)
else -> stringResource(R.string.tool_progress_status_running)
}
// Slow alpha breathe on the tool icon while preparing — an indeterminate
@@ -137,12 +139,14 @@ fun ToolProgressCard(
val seconds = (toolCall.completedAt - toolCall.startedAt) / 1000.0
String.format("%.1fs", seconds)
} else null
val durationDescription = duration?.let { stringResource(R.string.tool_duration_a11y, it) }.orEmpty()
val toolDescription = stringResource(R.string.tool_a11y, toolCall.name, statusText, durationDescription)
Card(
modifier = modifier
.fillMaxWidth()
.semantics {
contentDescription = "Tool ${toolCall.name} $statusText${duration?.let { " in $it" } ?: ""}"
contentDescription = toolDescription
},
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
@@ -239,7 +243,7 @@ fun ToolProgressCard(
Column(modifier = Modifier.padding(top = 8.dp)) {
toolCall.runId?.takeIf { it.isNotBlank() }?.let { runId ->
Text(
text = "Run: $runId",
text = stringResource(R.string.tool_run_id, runId),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -259,7 +263,7 @@ fun ToolProgressCard(
toolCall.args?.let { args ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Arguments:",
text = stringResource(R.string.tool_arguments),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -275,7 +279,7 @@ fun ToolProgressCard(
val preview = compactToolDetail(error)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Error:",
text = stringResource(R.string.tool_error),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
@@ -292,7 +296,7 @@ fun ToolProgressCard(
val preview = compactToolDetail(result)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Result:",
text = stringResource(R.string.tool_result),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -23,6 +23,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ConnectionSecurity
import com.hermesandroid.relay.data.ConnectionSecurityLevel
import com.hermesandroid.relay.data.SurfaceSecurityKind
@@ -195,20 +197,21 @@ private fun RenderBadge(
* Label copy uses "Plain" rather than "Insecure" — amber, not red, and
* matches the UX pass where plaintext LAN is treated as informational.
*/
@Composable
fun insecureReasonLabel(reason: String?, activeRole: String? = null): String {
val roleLabel = when (activeRole?.lowercase()) {
"lan" -> "Plain (on LAN)"
"tailscale" -> "Plain (on Tailscale)"
"public" -> "Plain (on public URL)"
"lan" -> stringResource(R.string.transport_badge_insecure_lan)
"tailscale" -> stringResource(R.string.transport_badge_insecure_tailscale)
"public" -> stringResource(R.string.transport_badge_insecure_public)
null, "" -> null
else -> "Plain (on $activeRole)"
else -> stringResource(R.string.transport_badge_insecure_role_format, activeRole)
}
if (roleLabel != null) return roleLabel
return when (reason) {
"lan_only" -> "Plain (LAN only)"
"tailscale_vpn" -> "Plain (Tailscale)"
"local_dev" -> "Plain (dev only)"
else -> "Plain (no TLS)"
"lan_only" -> stringResource(R.string.transport_badge_insecure_lan_only)
"tailscale_vpn" -> stringResource(R.string.transport_badge_insecure_tailscale_vpn)
"local_dev" -> stringResource(R.string.transport_badge_insecure_dev)
else -> stringResource(R.string.transport_badge_insecure_no_tls)
}
}
@@ -220,7 +223,7 @@ private fun resolveStateAppearance(state: TransportSecurityState): BadgeAppearan
TransportSecurityState.AllSecure -> {
val green = Color(0xFF2E7D32)
BadgeAppearance(
label = "Secure \u2014 TLS",
label = stringResource(R.string.transport_badge_secure_tls),
bg = green.copy(alpha = 0.14f),
fg = green,
)
@@ -229,7 +232,7 @@ private fun resolveStateAppearance(state: TransportSecurityState): BadgeAppearan
// Amber (not red): secure fallback exists, so this is informational.
val amber = Color(0xFFF9A825)
BadgeAppearance(
label = "Mixed \u2014 secure fallback available",
label = stringResource(R.string.transport_badge_mixed),
bg = amber.copy(alpha = 0.16f),
fg = amber,
)
@@ -237,7 +240,7 @@ private fun resolveStateAppearance(state: TransportSecurityState): BadgeAppearan
TransportSecurityState.AllInsecure -> {
val red = MaterialTheme.colorScheme.error
BadgeAppearance(
label = "All routes plain \u2014 dev only",
label = stringResource(R.string.transport_badge_plain),
bg = red.copy(alpha = 0.16f),
fg = red,
)
@@ -255,7 +258,7 @@ private fun resolveAppearance(
if (isSecure) {
val green = Color(0xFF2E7D32)
return BadgeAppearance(
label = "Secure (TLS)",
label = stringResource(R.string.transport_badge_secure_tls_short),
bg = green.copy(alpha = 0.14f),
fg = green,
)
@@ -320,35 +323,35 @@ private fun connSecAppearance(security: ConnectionSecurity): ConnSecAppearance {
val red = MaterialTheme.colorScheme.error
return when (security.level) {
ConnectionSecurityLevel.Tls -> ConnSecAppearance(
label = "Encrypted · TLS",
label = stringResource(R.string.transport_badge_encrypted_tls),
icon = Icons.Filled.Lock,
bg = green.copy(alpha = 0.14f),
fg = green,
)
ConnectionSecurityLevel.Overlay -> ConnSecAppearance(
label = "Encrypted · ${security.mechanism}",
label = stringResource(R.string.transport_badge_encrypted_mech_format, security.mechanism),
icon = Icons.Filled.Shield,
bg = green.copy(alpha = 0.14f),
fg = green,
)
ConnectionSecurityLevel.Mixed -> ConnSecAppearance(
label = "Mixed routes",
label = stringResource(R.string.transport_badge_mixed_routes),
icon = Icons.Filled.Shield,
bg = amber.copy(alpha = 0.16f),
fg = amber,
)
ConnectionSecurityLevel.Plain -> ConnSecAppearance(
label = if (security.mechanism.isNotBlank() && security.mechanism != "Plain") {
"Not encrypted · ${security.mechanism}"
stringResource(R.string.transport_badge_not_encrypted_mech_format, security.mechanism)
} else {
"Not encrypted"
stringResource(R.string.transport_badge_not_encrypted)
},
icon = Icons.Filled.LockOpen,
bg = red.copy(alpha = 0.16f),
fg = red,
)
ConnectionSecurityLevel.Unknown -> ConnSecAppearance(
label = "Checking…",
label = stringResource(R.string.transport_badge_checking),
icon = Icons.Filled.Shield,
bg = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
fg = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -405,11 +408,11 @@ fun SurfaceSecurityGlyph(
val green = Color(0xFF2E7D32)
val amber = Color(0xFFF9A825)
val (icon, tint, desc) = when (kind) {
SurfaceSecurityKind.Tls -> Triple(Icons.Filled.Lock, green, "Encrypted (TLS)")
SurfaceSecurityKind.Overlay -> Triple(Icons.Filled.Shield, green, "Encrypted")
SurfaceSecurityKind.Tls -> Triple(Icons.Filled.Lock, green, stringResource(R.string.transport_badge_glyph_tls))
SurfaceSecurityKind.Overlay -> Triple(Icons.Filled.Shield, green, stringResource(R.string.transport_badge_glyph_encrypted))
// Per-route plaintext is amber (informational), not red — a secure
// route may exist alongside it.
SurfaceSecurityKind.Plain -> Triple(Icons.Filled.LockOpen, amber, "Not encrypted")
SurfaceSecurityKind.Plain -> Triple(Icons.Filled.LockOpen, amber, stringResource(R.string.transport_badge_glyph_not_encrypted))
}
Icon(
imageVector = icon,
@@ -31,6 +31,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
/**
* v0.4.1 — sideload-only "unattended access" toggle row + one-time
@@ -94,19 +96,16 @@ fun UnattendedAccessRow(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Unattended Access",
text = stringResource(R.string.unattended_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
Text(
text = when {
!masterEnabled -> "Requires Agent Control — " +
"enable the master switch above first."
enabled -> "On — agent may wake the screen " +
"and act while you're away."
else -> "Off — bridge actions only land when " +
"the screen is already on."
!masterEnabled -> stringResource(R.string.unattended_requires_master)
enabled -> stringResource(R.string.unattended_on)
else -> stringResource(R.string.unattended_off)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -130,10 +129,7 @@ fun UnattendedAccessRow(
}
Text(
text = "Acquires a screen-bright wake lock on each " +
"incoming bridge command and asks the system to " +
"dismiss the keyguard. Hard-bounded by the bridge " +
"auto-disable timer.",
text = stringResource(R.string.unattended_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -204,16 +200,12 @@ private fun KeyguardDetectedAlert() {
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Keyguard detected",
text = stringResource(R.string.unattended_keyguard_detected),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "The screen will wake but stop at the lock " +
"screen. Android won't let third-party apps " +
"dismiss PIN / pattern / biometric locks. Set " +
"your lock to None or Swipe in Settings > " +
"Security to let the agent reach apps.",
text = stringResource(R.string.unattended_keyguard_body),
style = MaterialTheme.typography.bodySmall,
)
}
@@ -246,81 +238,54 @@ private fun UnattendedScaryDialog(
tint = MaterialTheme.colorScheme.error,
)
},
title = { Text("Enable Unattended Access?") },
title = { Text(stringResource(R.string.unattended_dialog_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "Unattended access lets your Hermes agent wake " +
"the screen and drive your phone while you're not " +
"watching it. This is a powerful capability with " +
"real risk — make sure you understand it before " +
"enabling.",
text = stringResource(R.string.unattended_dialog_intro),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "What it does:",
text = stringResource(R.string.unattended_dialog_what_it_does),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "• Each incoming bridge command holds a screen-" +
"bright wake lock so taps and gestures land on a " +
"lit display.\n" +
"• On lock screens with no credential set (None " +
"or Swipe), the system is asked to dismiss the " +
"keyguard so the agent can reach app UIs.\n" +
"• The 'Hermes has device control' notification — " +
"posted by the master Agent Control switch, not " +
"this toggle — stays visible so you can see the " +
"bridge is running.",
text = stringResource(R.string.unattended_dialog_what_it_does_body),
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "Limitation: credential locks cannot be dismissed.",
text = stringResource(R.string.unattended_dialog_limitation),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.error,
)
Text(
text = if (credentialLockDetected) {
"Your device currently has a PIN, pattern, or " +
"biometric lock set. Android does not let " +
"third-party apps dismiss these — the screen " +
"will wake, but stop at the lock screen. To " +
"let the agent reach apps, change your lock " +
"to None or Swipe in Settings > Security."
stringResource(R.string.unattended_dialog_limitation_lock_detected)
} else {
"If you later set a PIN, pattern, or biometric " +
"lock, Android will not let Hermes dismiss it. " +
"The screen will wake, but stop at the lock " +
"screen, and the bridge will report a " +
"'keyguard_blocked' error to the agent."
stringResource(R.string.unattended_dialog_limitation_no_lock)
},
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "How to disable:",
text = stringResource(R.string.unattended_dialog_how_to_disable),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "• Flip this same toggle off at any time.\n" +
"• The bridge auto-disable timer (default 30 minutes " +
"of idle) will turn unattended access off along " +
"with the master bridge toggle.\n" +
"• Disconnecting from the relay also drops the " +
"wake lock immediately.",
text = stringResource(R.string.unattended_dialog_how_to_disable_body),
style = MaterialTheme.typography.bodySmall,
)
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text("I understand — enable")
Text(stringResource(R.string.unattended_dialog_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.unattended_dialog_cancel)) }
},
)
}
@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
@@ -32,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
/**
* Global single-line banner rendered at the top of [RelayApp]'s scaffold
@@ -107,7 +109,7 @@ fun UnattendedGlobalBanner(
) {
PulsingStatusDot(color = amberFill)
Text(
text = "Unattended access ON — agent can wake and drive this device",
text = stringResource(R.string.unattended_banner_text),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = amberOn,
@@ -43,12 +43,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.hermesandroid.relay.BuildConfig
import com.hermesandroid.relay.R
import com.hermesandroid.relay.update.UpdateAvailabilitySource
import com.hermesandroid.relay.update.UpdateDismissalPreferences
import com.hermesandroid.relay.update.UpdateStatus
@@ -318,21 +320,21 @@ fun UpdateAvailableBanner(
when (status) {
is UpdateStatus.Available -> {
title = "Update available"
subtitle = "${displayVersion(status.versionLabel)} is ready to install."
actionLabel = "Update"
title = stringResource(R.string.update_banner_available)
subtitle = stringResource(R.string.update_banner_subtitle_available, displayVersion(status.versionLabel))
actionLabel = stringResource(R.string.update_banner_update)
showDismiss = true
}
is UpdateStatus.Downloading -> {
title = "Downloading update…"
title = stringResource(R.string.update_banner_downloading)
subtitle = displayVersion(status.versionLabel)
actionLabel = null
showDismiss = true
}
is UpdateStatus.Downloaded -> {
title = "Update ready — restart"
subtitle = "${displayVersion(status.versionLabel)} downloaded. Restart to finish."
actionLabel = "Restart"
title = stringResource(R.string.update_banner_ready)
subtitle = stringResource(R.string.update_banner_subtitle_downloaded, displayVersion(status.versionLabel))
actionLabel = stringResource(R.string.update_banner_restart)
showDismiss = false
}
UpdateStatus.UpToDate, UpdateStatus.Unsupported -> return
@@ -416,7 +418,7 @@ fun UpdateAvailableBanner(
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "Dismiss",
contentDescription = stringResource(R.string.update_banner_cd_dismiss),
tint = contentColor,
)
}
@@ -28,6 +28,8 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.update.AvailableUpdate
/**
@@ -80,12 +82,12 @@ fun UpdateBanner(
modifier = Modifier.weight(1f),
) {
Text(
text = "Update available — v${update.latestVersion}",
text = stringResource(R.string.update_banner_available),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Text(
text = "You're on ${update.currentVersion}. Tap Update to download.",
text = stringResource(R.string.update_banner_body_fmt, update.currentVersion),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
@@ -104,12 +106,12 @@ fun UpdateBanner(
),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 4.dp),
) {
Text("Update")
Text(stringResource(R.string.update_banner_update))
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "Dismiss",
contentDescription = stringResource(R.string.update_banner_cd_dismiss),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
@@ -93,6 +93,8 @@ import com.hermesandroid.relay.viewmodel.VoiceUiState
import coil3.compose.AsyncImage
import kotlinx.coroutines.flow.SharedFlow
import java.io.File
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
/**
* Full-screen voice-mode overlay. Renders the MorphingSphere in its voiceMode
@@ -509,7 +511,7 @@ fun VoiceModeOverlay(
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.size(4.dp))
Text("Retry")
Text(stringResource(R.string.voice_overlay_retry))
}
}
}
@@ -662,7 +664,7 @@ private fun VoiceMicButton(
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = "Voice mic",
contentDescription = stringResource(R.string.voice_overlay_mic_cd),
tint = Color.White,
modifier = Modifier.size(iconSize.dp),
)
@@ -678,12 +680,13 @@ private fun voiceStateToSphereState(state: VoiceState): SphereState = when (stat
VoiceState.Idle -> SphereState.Idle
}
@Composable
private fun stateHint(state: VoiceState): String = when (state) {
VoiceState.Idle -> "Tap the mic to speak"
VoiceState.Listening -> "Listening..."
VoiceState.Transcribing -> "Transcribing..."
VoiceState.Thinking -> "Thinking..."
VoiceState.Speaking -> "Speaking..."
VoiceState.Idle -> stringResource(R.string.voice_overlay_tap_mic)
VoiceState.Listening -> stringResource(R.string.voice_overlay_listening)
VoiceState.Transcribing -> stringResource(R.string.voice_overlay_transcribing)
VoiceState.Thinking -> stringResource(R.string.voice_overlay_thinking)
VoiceState.Speaking -> stringResource(R.string.voice_overlay_speaking)
VoiceState.Error -> ""
}
@@ -713,10 +716,11 @@ internal fun pendingVoiceTranscriptText(
return if (alreadyRendered) null else transcribed
}
@Composable
private fun InteractionMode.label(): String = when (this) {
InteractionMode.TapToTalk -> "Tap to talk"
InteractionMode.HoldToTalk -> "Hold to talk"
InteractionMode.Continuous -> "Continuous"
InteractionMode.TapToTalk -> stringResource(R.string.voice_overlay_tap_to_talk)
InteractionMode.HoldToTalk -> stringResource(R.string.voice_overlay_hold_to_talk)
InteractionMode.Continuous -> stringResource(R.string.voice_overlay_continuous)
}
@OptIn(ExperimentalLayoutApi::class)
@@ -747,17 +751,17 @@ private fun VoiceSessionPill(
) {
val engineText = voiceEngineLabel(engineMode)
val providerText = voiceProviderLabel(provider, model, voice, outputEnabled)
val profileText = profileName?.takeIf { it.isNotBlank() } ?: "default profile"
val profileText = profileName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.voice_overlay_default_profile)
val scopeText = when (configScope) {
"profile" -> "profile voice"
"relay" -> "relay voice"
"global" -> "global voice"
"profile" -> stringResource(R.string.voice_overlay_profile_voice)
"relay" -> stringResource(R.string.voice_overlay_relay_voice)
"global" -> stringResource(R.string.voice_overlay_global_voice)
else -> null
}
val headlineText = if (focusMode) {
"$engineText / $profileText / $providerText"
} else {
"${stateHint(uiState.state).ifBlank { "Voice ready" }} / $engineText / $providerText"
"${stateHint(uiState.state).ifBlank { stringResource(R.string.voice_overlay_voice_ready) }} / $engineText / $providerText"
}
Surface(
modifier = modifier,
@@ -801,7 +805,7 @@ private fun VoiceSessionPill(
)
}
Text(
text = "Voice",
text = stringResource(R.string.voice_overlay_voice),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -819,7 +823,7 @@ private fun VoiceSessionPill(
)
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse voice controls" else "Expand voice controls",
contentDescription = if (expanded) stringResource(R.string.voice_overlay_collapse_cd) else stringResource(R.string.voice_overlay_expand_cd),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
@@ -829,7 +833,7 @@ private fun VoiceSessionPill(
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Exit voice mode",
contentDescription = stringResource(R.string.voice_overlay_exit_cd),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
@@ -911,7 +915,7 @@ private fun VoiceSessionPill(
modifier = Modifier.weight(1f),
) {
Text(
if (focusMode) "Compact" else "Focus",
if (focusMode) stringResource(R.string.voice_overlay_compact) else stringResource(R.string.voice_overlay_focus),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -923,13 +927,13 @@ private fun VoiceSessionPill(
},
modifier = Modifier.weight(1f),
) {
Text("Overlay", maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(stringResource(R.string.voice_overlay_overlay), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
TextButton(
onClick = onExit,
modifier = Modifier.weight(1f),
) {
Text("Exit", maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(stringResource(R.string.voice_overlay_exit), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
// Settings link (4c): exit voice mode before navigating
// so the overlay isn't left floating over the Voice
@@ -942,7 +946,7 @@ private fun VoiceSessionPill(
) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = "Voice settings",
contentDescription = stringResource(R.string.voice_overlay_settings_cd),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -1168,32 +1172,35 @@ private fun VoiceHandoffStrip(
}
}
@Composable
private fun voiceProviderLabel(
provider: String?,
model: String?,
voice: String?,
outputEnabled: Boolean?,
): String {
if (outputEnabled == false) return "output off"
val providerPart = provider?.takeIf { it.isNotBlank() } ?: "provider ..."
if (outputEnabled == false) return stringResource(R.string.voice_overlay_provider_output_off)
val providerPart = provider?.takeIf { it.isNotBlank() } ?: stringResource(R.string.voice_overlay_provider_placeholder)
val modelPart = model?.takeIf { it.isNotBlank() }
val voicePart = voice?.takeIf { it.isNotBlank() }
return listOfNotNull(providerPart, modelPart, voicePart).joinToString(" / ")
}
@Composable
private fun voiceEngineLabel(engineMode: String?): String = when (engineMode) {
"realtime_agent" -> "Realtime Agent"
"hermes_voice_output" -> "Hermes voice"
null, "" -> "Voice engine ..."
"realtime_agent" -> stringResource(R.string.voice_overlay_engine_realtime)
"hermes_voice_output" -> stringResource(R.string.voice_overlay_engine_hermes)
null, "" -> stringResource(R.string.voice_overlay_engine_placeholder)
else -> engineMode
.replace('_', ' ')
.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
}
@Composable
private fun InteractionMode.shortLabel(): String = when (this) {
InteractionMode.TapToTalk -> "Tap"
InteractionMode.HoldToTalk -> "Hold"
InteractionMode.Continuous -> "Auto"
InteractionMode.TapToTalk -> stringResource(R.string.voice_overlay_tap)
InteractionMode.HoldToTalk -> stringResource(R.string.voice_overlay_hold)
InteractionMode.Continuous -> stringResource(R.string.voice_overlay_auto)
}
/**
@@ -1232,9 +1239,9 @@ private fun CompactTranscriptRow(
val isVoiceActionBubble = message.role == MessageRole.ASSISTANT &&
message.id.startsWith("voice-intent-")
val caption = when {
isVoiceActionBubble -> "ACTION"
message.role == MessageRole.USER -> "YOU"
else -> "AGENT"
isVoiceActionBubble -> stringResource(R.string.voice_overlay_caption_action)
message.role == MessageRole.USER -> stringResource(R.string.voice_overlay_caption_you)
else -> stringResource(R.string.voice_overlay_caption_agent)
}
val captionColor = when {
isVoiceActionBubble -> MaterialTheme.colorScheme.tertiary
@@ -1301,12 +1308,20 @@ private fun CompactTranscriptRow(
@Composable
private fun VoiceToolStatusRow(toolCall: ToolCall) {
val status = when {
// Internal enum-style status drives both color and the localized label
// shown to the user. Localized text comes from stringResource() in the
// Composable so VoiceModeOverlay can switch locales mid-session.
val statusKey = when {
toolCall.isComplete && toolCall.success == true -> "done"
toolCall.isComplete && toolCall.success == false -> "failed"
else -> "running"
}
val statusColor = when (status) {
val statusLabel = when (statusKey) {
"done" -> stringResource(R.string.voice_overlay_tool_status_done)
"failed" -> stringResource(R.string.voice_overlay_tool_status_failed)
else -> stringResource(R.string.voice_overlay_tool_status_running)
}
val statusColor = when (statusKey) {
"done" -> MaterialTheme.colorScheme.primary
"failed" -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.tertiary
@@ -1348,7 +1363,7 @@ private fun VoiceToolStatusRow(toolCall: ToolCall) {
modifier = Modifier.weight(1f),
)
Text(
text = duration?.let { "$status $it" } ?: status,
text = duration?.let { "$statusLabel $it" } ?: statusLabel,
style = MaterialTheme.typography.labelSmall,
color = statusColor,
)
@@ -1407,9 +1422,9 @@ private fun HermesConfirmationCard(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = { onAnswer("allow") }) { Text("Allow") }
TextButton(onClick = { onAnswer("deny") }) { Text("Deny") }
TextButton(onClick = { onAnswer("cancel") }) { Text("Cancel") }
TextButton(onClick = { onAnswer("allow") }) { Text(stringResource(R.string.voice_overlay_allow)) }
TextButton(onClick = { onAnswer("deny") }) { Text(stringResource(R.string.voice_overlay_deny)) }
TextButton(onClick = { onAnswer("cancel") }) { Text(stringResource(R.string.voice_overlay_cancel)) }
}
}
}
@@ -1472,9 +1487,9 @@ private fun DestructiveCountdownRow(
) {
Text(
text = if (label.isNotBlank()) {
"$label in ${durationSec}s — say cancel to stop"
stringResource(R.string.voice_overlay_confirming_format, label, durationSec)
} else {
"Confirming…"
stringResource(R.string.voice_overlay_confirming)
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
@@ -1548,7 +1563,7 @@ private fun BackgroundRunChip(
// no pulse, no live ticker.
val dotAlpha = if (done) 1f else pulse
val title = when (display.phase) {
BackgroundRunPhase.RECONNECTING -> "Reconnecting — your task is still running"
BackgroundRunPhase.RECONNECTING -> stringResource(R.string.voice_overlay_reconnecting_task)
BackgroundRunPhase.DELIVERING -> display.message
BackgroundRunPhase.RUNNING -> display.message
BackgroundRunPhase.DONE -> display.message
@@ -1559,8 +1574,11 @@ private fun BackgroundRunChip(
?.let { add(it.trimEnd('.', '…')) }
if (display.completedToolCount > 0) {
add(
"${display.completedToolCount} step" +
if (display.completedToolCount == 1) "" else "s"
stringResource(
if (display.completedToolCount == 1) R.string.voice_overlay_steps_one
else R.string.voice_overlay_steps_many,
display.completedToolCount,
)
)
}
if (display.queuedCount > 0) {
@@ -1677,7 +1695,7 @@ private fun PermissionDeniedChip(
)
}
Text(
text = "OPEN",
text = stringResource(R.string.voice_overlay_open),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer,
@@ -17,6 +17,8 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -56,7 +58,7 @@ fun WhatsNewDialog(
onDismissRequest = onDismiss,
title = {
Column {
Text("What's New")
Text(stringResource(R.string.whats_new_title))
notes.version?.let { version ->
Text(
text = version,
@@ -76,7 +78,7 @@ fun WhatsNewDialog(
) {
if (notes.groups.isEmpty()) {
Text(
text = notes.fallback ?: "No release notes available.",
text = notes.fallback ?: stringResource(R.string.whats_new_no_notes),
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -85,7 +87,7 @@ fun WhatsNewDialog(
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Got it")
Text(stringResource(R.string.whats_new_got_it))
}
}
)
@@ -314,6 +316,6 @@ private fun loadWhatsNew(context: Context): String {
return try {
context.assets.open("whats_new.txt").bufferedReader().readText()
} catch (_: Exception) {
"No release notes available."
context.getString(R.string.whats_new_no_notes)
}
}
@@ -50,6 +50,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
@@ -134,25 +135,21 @@ fun OnboardingScreen(
if (showSkipConfirm) {
AlertDialog(
onDismissRequest = { showSkipConfirm = false },
title = { Text("Skip setup?") },
title = { Text(stringResource(R.string.onboarding_skip_setup_title)) },
text = {
Text(
"No problem — you can explore the demo to see how Hermes-Relay works, " +
"and connect your own Hermes server anytime from Settings → Connections. " +
"Relay pairing for power tools can be added later too."
)
Text(stringResource(R.string.onboarding_skip_setup_text))
},
confirmButton = {
TextButton(onClick = {
showSkipConfirm = false
onComplete()
}) {
Text("Skip for now")
Text(stringResource(R.string.onboarding_skip_for_now))
}
},
dismissButton = {
TextButton(onClick = { showSkipConfirm = false }) {
Text("Go back")
Text(stringResource(R.string.onboarding_go_back))
}
}
)
@@ -171,7 +168,7 @@ fun OnboardingScreen(
if (pages[pagerState.currentPage] != OnboardingPage.Connect) {
TextButton(onClick = { showSkipConfirm = true }) {
Text(
text = "Skip",
text = stringResource(R.string.onboarding_skip),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -244,7 +241,7 @@ fun OnboardingScreen(
}
}
) {
Text(text = "Back")
Text(text = stringResource(R.string.onboarding_back))
}
}
if (pagerState.currentPage == 0) {
@@ -260,7 +257,13 @@ fun OnboardingScreen(
}
}
) {
Text(text = if (pagerState.currentPage == lastPage - 1) "Connect" else "Next")
Text(
text = if (pagerState.currentPage == lastPage - 1) {
stringResource(R.string.onboarding_connect)
} else {
stringResource(R.string.onboarding_next)
}
)
}
}
}
@@ -274,8 +277,8 @@ private fun WelcomePage() {
val context = LocalContext.current
OnboardingPage(
icon = Icons.Outlined.RocketLaunch,
title = "Hermes-Relay for Android",
description = "Chat with Hermes and manage your dashboard from your phone.",
title = stringResource(R.string.onboarding_welcome_title),
description = stringResource(R.string.onboarding_welcome_description),
transparentHero = true,
heroContent = {
Box(modifier = Modifier.fillMaxSize()) {
@@ -298,7 +301,7 @@ private fun WelcomePage() {
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Text(
text = "Welcome",
text = stringResource(R.string.onboarding_welcome_badge),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -316,7 +319,7 @@ private fun WelcomePage() {
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = "Hermes logo",
contentDescription = stringResource(R.string.onboarding_hermes_logo),
modifier = Modifier.size(30.dp)
)
Text(
@@ -334,17 +337,17 @@ private fun WelcomePage() {
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
label = "Chat & Manage",
description = "Connect to your running Hermes dashboard and API. No Relay install or pairing required.",
label = stringResource(R.string.onboarding_chat_manage_label),
description = stringResource(R.string.onboarding_chat_manage_description),
)
SetupPathSummary(
label = "Power tools",
description = "Add Hermes-Relay for Terminal, Bridge, relay sessions, and channel grants.",
label = stringResource(R.string.onboarding_power_tools_label),
description = stringResource(R.string.onboarding_power_tools_description),
)
}
Text(
text = "The setup guide has copy/paste commands when you need to start Hermes on a computer or server.",
text = stringResource(R.string.onboarding_setup_guide_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -367,7 +370,7 @@ private fun WelcomePage() {
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text("Setup Guide")
Text(stringResource(R.string.onboarding_setup_guide))
}
OutlinedButton(
@@ -384,12 +387,12 @@ private fun WelcomePage() {
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text("Hermes Docs")
Text(stringResource(R.string.onboarding_hermes_docs))
}
}
Text(
text = "Hermes API server docs",
text = stringResource(R.string.onboarding_api_server_docs),
style = MaterialTheme.typography.bodySmall.copy(
textDecoration = TextDecoration.Underline
),
@@ -437,24 +440,24 @@ private fun SetupPathSummary(
private fun ChatPage() {
OnboardingPage(
icon = Icons.Outlined.Forum,
title = "Chat",
description = "Your Hermes agent, streaming in real time.",
title = stringResource(R.string.onboarding_chat_title),
description = stringResource(R.string.onboarding_chat_description),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
label = "Streaming",
description = "Live responses with tool progress, markdown, and rich cards as the agent works.",
label = stringResource(R.string.onboarding_streaming_label),
description = stringResource(R.string.onboarding_streaming_description),
)
SetupPathSummary(
label = "Profiles",
description = "Switch agent profiles mid-flow — each keeps its own sessions, model, and persona.",
label = stringResource(R.string.onboarding_profiles_label),
description = stringResource(R.string.onboarding_profiles_description),
)
SetupPathSummary(
label = "Voice",
description = "Tap the mic to talk. Speech runs through your Hermes server — no extra install.",
label = stringResource(R.string.onboarding_voice_label),
description = stringResource(R.string.onboarding_voice_description),
)
}
}
@@ -464,24 +467,24 @@ private fun ChatPage() {
private fun ManagePage() {
OnboardingPage(
icon = Icons.Filled.Settings,
title = "Manage",
description = "Your Hermes dashboard, pocket-sized.",
title = stringResource(R.string.onboarding_manage_title),
description = stringResource(R.string.onboarding_manage_description),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
label = "Control",
description = "Profiles, skills, automations, MCP servers, models, and provider keys.",
label = stringResource(R.string.onboarding_control_label),
description = stringResource(R.string.onboarding_control_description),
)
SetupPathSummary(
label = "Skills hub",
description = "Search the hub, read a skill before installing, and install from your phone.",
label = stringResource(R.string.onboarding_skills_hub_label),
description = stringResource(R.string.onboarding_skills_hub_description),
)
SetupPathSummary(
label = "One sign-in",
description = "Signing into the dashboard once also unlocks voice for this connection.",
label = stringResource(R.string.onboarding_one_sign_in_label),
description = stringResource(R.string.onboarding_one_sign_in_description),
)
}
}
@@ -493,24 +496,24 @@ private fun PowerToolsPage(
) {
OnboardingPage(
icon = Icons.Outlined.Terminal,
title = "Power tools",
description = "Pair the optional Relay when you want more than standard.",
title = stringResource(R.string.onboarding_power_title),
description = stringResource(R.string.onboarding_power_description),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
label = "Terminal",
description = "A real tmux session on your server, in your pocket.",
label = stringResource(R.string.onboarding_terminal_label),
description = stringResource(R.string.onboarding_terminal_description),
)
SetupPathSummary(
label = "Bridge",
description = "Let the agent operate this phone — with safety rails (sideload builds).",
label = stringResource(R.string.onboarding_bridge_label),
description = stringResource(R.string.onboarding_bridge_description),
)
SetupPathSummary(
label = "Realtime",
description = "Provider-native realtime voice agent and profile-aware voice providers.",
label = stringResource(R.string.onboarding_realtime_label),
description = stringResource(R.string.onboarding_realtime_description),
)
OutlinedButton(
onClick = onOpenPermissions,
@@ -522,7 +525,7 @@ private fun PowerToolsPage(
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text("Review permissions")
Text(stringResource(R.string.onboarding_review_permissions))
}
}
}
@@ -1,3 +1,5 @@
@file:Suppress("LocalContextGetResourceValueCall")
package com.hermesandroid.relay.ui.screens
import android.content.Intent
@@ -52,6 +54,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -82,6 +85,11 @@ fun AboutScreen(
val scope = rememberCoroutineScope()
val isDarkTheme = LocalBrand.current.isDark
// Pre-resolved Toast messages (Toast is not a composable scope)
val devUnlockedMsg = stringResource(R.string.about_dev_options_unlocked)
val updateCopiedMsg = stringResource(R.string.about_update_copied)
val emDash = stringResource(R.string.about_em_dash)
// Tap-7x unlock state (mirrors the old SettingsScreen locals)
val devOptionsUnlocked by FeatureFlags.devOptionsUnlocked(context).collectAsState(initial = FeatureFlags.isDevBuild)
var versionTapCount by remember { mutableStateOf(0) }
@@ -92,12 +100,12 @@ fun AboutScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("About") },
title = { Text(stringResource(R.string.about_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.about_back),
)
}
},
@@ -117,7 +125,7 @@ fun AboutScreen(
) {
// About section
Text(
text = "About",
text = stringResource(R.string.about_section_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -148,18 +156,18 @@ fun AboutScreen(
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = "Hermes-Relay",
contentDescription = stringResource(R.string.about_app_name),
modifier = Modifier.size(80.dp)
)
}
Text(
text = "Hermes-Relay",
text = stringResource(R.string.about_app_name),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
Text(
text = "Native Android client for Hermes Agent",
text = stringResource(R.string.about_app_tagline),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
@@ -170,14 +178,14 @@ fun AboutScreen(
// Version info (dynamic)
val versionName = remember {
try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "—"
} catch (_: Exception) { "—" }
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: emDash
} catch (_: Exception) { emDash }
}
val versionCode = remember {
try {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionCode.toString()
} catch (_: Exception) { "—" }
} catch (_: Exception) { emDash }
}
Row(
@@ -196,19 +204,20 @@ fun AboutScreen(
when {
remaining <= 0 -> {
scope.launch { FeatureFlags.unlockDevOptions(context) }
Toast.makeText(context, "Developer options unlocked", Toast.LENGTH_SHORT).show()
Toast.makeText(context, devUnlockedMsg, Toast.LENGTH_SHORT).show()
versionTapCount = 0
onUnlockDeveloperOptions()
}
remaining <= 3 -> {
Toast.makeText(context, "$remaining taps to unlock developer options", Toast.LENGTH_SHORT).show()
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
Toast.makeText(context, tapsMsg, Toast.LENGTH_SHORT).show()
}
}
},
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Version",
text = stringResource(R.string.about_version),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -229,7 +238,7 @@ fun AboutScreen(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Track",
text = stringResource(R.string.about_track),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -259,15 +268,15 @@ fun AboutScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Relay",
text = stringResource(R.string.about_relay),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val subtitle = when {
ru.updateAvailable && !ru.latest.isNullOrBlank() ->
"Update available — v${ru.current} → v${ru.latest}"
ru.current.isNotBlank() -> "On v${ru.current} — up to date"
else -> "Connected"
stringResource(R.string.about_update_available, ru.current, ru.latest)
ru.current.isNotBlank() -> stringResource(R.string.about_up_to_date, ru.current)
else -> stringResource(R.string.about_connected)
}
Text(
text = subtitle,
@@ -284,17 +293,17 @@ fun AboutScreen(
clipboard.setText(AnnotatedString(ru.updateCommand))
Toast.makeText(
context,
"Update command copied",
updateCopiedMsg,
Toast.LENGTH_SHORT,
).show()
}) {
Text("Copy fix")
Text(stringResource(R.string.about_copy_fix))
}
}
}
if (ru.updateAvailable && !ru.updateCommand.isNullOrBlank()) {
Text(
text = "Run on your Hermes host, then restart the gateway:\n${ru.updateCommand}",
text = stringResource(R.string.about_update_command_body, ru.updateCommand),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
@@ -317,16 +326,16 @@ fun AboutScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Updates",
text = stringResource(R.string.about_updates),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val subtitle = when (val s = state) {
UpdateCheckResult.Idle -> "Tap to check GitHub for a new release"
UpdateCheckResult.Checking -> "Checking…"
UpdateCheckResult.UpToDate -> "You're on the latest release"
is UpdateCheckResult.Available -> "Update available — v${s.update.latestVersion}"
is UpdateCheckResult.Error -> "Check failed: ${s.message}"
UpdateCheckResult.Idle -> stringResource(R.string.about_updates_tap)
UpdateCheckResult.Checking -> stringResource(R.string.about_updates_checking)
UpdateCheckResult.UpToDate -> stringResource(R.string.about_updates_latest)
is UpdateCheckResult.Available -> stringResource(R.string.about_updates_available, s.update.latestVersion)
is UpdateCheckResult.Error -> stringResource(R.string.about_updates_failed, s.message)
}
Text(
text = subtitle,
@@ -349,9 +358,9 @@ fun AboutScreen(
) {
Text(
text = when (state) {
is UpdateCheckResult.Available -> "Download"
is UpdateCheckResult.Available -> stringResource(R.string.about_download)
UpdateCheckResult.Checking -> "…"
else -> "Check"
else -> stringResource(R.string.about_check)
}
)
}
@@ -378,7 +387,7 @@ fun AboutScreen(
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(6.dp))
Text("GitHub")
Text(stringResource(R.string.about_github))
}
OutlinedButton(
onClick = {
@@ -393,7 +402,7 @@ fun AboutScreen(
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(6.dp))
Text("App Docs")
Text(stringResource(R.string.about_app_docs))
}
}
Row(
@@ -413,7 +422,7 @@ fun AboutScreen(
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(6.dp))
Text("Hermes Docs")
Text(stringResource(R.string.about_hermes_docs))
}
}
@@ -431,17 +440,17 @@ fun AboutScreen(
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(6.dp))
Text("Privacy Policy")
Text(stringResource(R.string.about_privacy_policy))
}
// What's New
TextButton(onClick = { showWhatsNew = true }) {
Text("What's New in This Version")
Text(stringResource(R.string.about_whats_new))
}
// Credits
Text(
text = "Axiom Labs \u2764\uFE0F Hermes Agent · Nous Research",
text = stringResource(R.string.about_credits),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
textAlign = TextAlign.Center,
@@ -20,7 +20,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import com.hermesandroid.relay.R
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.components.StatsForNerds
import com.hermesandroid.relay.ui.components.TimelineView
@@ -50,12 +52,12 @@ fun AnalyticsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Analytics") },
title = { Text(stringResource(R.string.analytics_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.analytics_back),
)
}
},
@@ -78,13 +80,13 @@ fun AnalyticsScreen(
// and each card's own header below).
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = "Stats for Nerds",
text = stringResource(R.string.analytics_section_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Local, on-device performance and usage metrics.",
text = stringResource(R.string.analytics_section_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -64,13 +64,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.LocalAvailableSphereSkins
import com.hermesandroid.relay.ui.components.SphereRegistry
import com.hermesandroid.relay.ui.components.SphereSkin
import com.hermesandroid.relay.ui.components.SphereSkinSource
import com.hermesandroid.relay.ui.components.SphereState
import com.hermesandroid.relay.ui.components.reactivityLabels
import com.hermesandroid.relay.ui.components.avatar.AgentAvatar
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.AvatarSource
@@ -119,16 +122,16 @@ fun AppearanceSettingsScreen(
pendingDelete?.let { target ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Remove pet?") },
text = { Text("Remove “${target.label}”? You can re-import it later.") },
title = { Text(stringResource(R.string.appearance_remove_pet_title)) },
text = { Text(stringResource(R.string.appearance_remove_pet_body, target.label)) },
confirmButton = {
TextButton(onClick = {
connectionViewModel.deleteUserAvatar(target.id, target.label)
pendingDelete = null
}) { Text("Remove") }
}) { Text(stringResource(R.string.appearance_remove)) }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.appearance_cancel)) }
},
)
}
@@ -136,12 +139,12 @@ fun AppearanceSettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Appearance") },
title = { Text(stringResource(R.string.appearance_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.appearance_back),
)
}
},
@@ -162,7 +165,7 @@ fun AppearanceSettingsScreen(
) {
// Theme gallery section
Text(
text = "Theme",
text = stringResource(R.string.appearance_theme),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -183,8 +186,7 @@ fun AppearanceSettingsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Pick a look. The brand chrome, accents, and chat " +
"background all follow your choice.",
text = stringResource(R.string.appearance_theme_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -207,7 +209,7 @@ fun AppearanceSettingsScreen(
// Appearance (mode + font) section
Text(
text = "Appearance",
text = stringResource(R.string.appearance_appearance),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -228,13 +230,13 @@ fun AppearanceSettingsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Light / Dark",
text = stringResource(R.string.appearance_light_dark),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val themeOptions = listOf("auto", "light", "dark")
val themeLabels = listOf("Auto", "Light", "Dark")
val themeLabels = listOf(stringResource(R.string.appearance_theme_auto), stringResource(R.string.appearance_theme_light), stringResource(R.string.appearance_theme_dark))
val selectedIndex = themeOptions.indexOf(theme).coerceAtLeast(0)
// The mode toggle only applies to themes that ship both a
// light and dark palette (Hermes Relay). Fixed-mode themes
@@ -261,10 +263,9 @@ fun AppearanceSettingsScreen(
Text(
text = when (selectedTheme.mode) {
ThemeMode.LIGHT_ONLY ->
"${selectedTheme.label} is a fixed light theme."
stringResource(R.string.appearance_fixed_light, selectedTheme.label)
else ->
"${selectedTheme.label} is a fixed dark theme. " +
"Switch to Hermes Relay for light/dark control."
stringResource(R.string.appearance_fixed_dark, selectedTheme.label)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -278,7 +279,7 @@ fun AppearanceSettingsScreen(
// window.setFontSize through TerminalWebView.
val fontScale by connectionViewModel.fontScale.collectAsState()
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.3f)
val fontScaleLabels = listOf("Small", "Normal", "Large", "Larger")
val fontScaleLabels = listOf(stringResource(R.string.appearance_font_small), stringResource(R.string.appearance_font_normal), stringResource(R.string.appearance_font_large), stringResource(R.string.appearance_font_larger))
// Match the closest stop — float equality is fragile.
val selectedFontScaleIndex = fontScaleOptions
.withIndex()
@@ -287,7 +288,7 @@ fun AppearanceSettingsScreen(
?: 1
Text(
text = "Font size",
text = stringResource(R.string.appearance_font_size),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -314,7 +315,7 @@ fun AppearanceSettingsScreen(
// LocalDensity once they tap a stop.
val previewBase = MaterialTheme.typography.bodyMedium
Text(
text = "Aa — sample text",
text = stringResource(R.string.appearance_font_preview),
style = previewBase.copy(
fontSize = previewBase.fontSize * fontScaleOptions[selectedFontScaleIndex]
),
@@ -327,7 +328,7 @@ fun AppearanceSettingsScreen(
// its own label + sample line IN that font so the choice is legible
// before tapping; the selection re-themes every screen live.
Text(
text = "Font",
text = stringResource(R.string.appearance_font),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -351,8 +352,7 @@ fun AppearanceSettingsScreen(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Sets the typeface across the whole app. Code and " +
"timestamps stay monospaced.",
text = stringResource(R.string.appearance_font_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -369,7 +369,7 @@ fun AppearanceSettingsScreen(
// Animation section
Text(
text = "Animation",
text = stringResource(R.string.appearance_animation),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -400,11 +400,11 @@ fun AppearanceSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "ASCII sphere",
text = stringResource(R.string.appearance_ascii_sphere),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Show animated sphere on empty chat screen and ambient mode",
text = stringResource(R.string.appearance_ascii_sphere_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -427,11 +427,11 @@ fun AppearanceSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Behind messages",
text = stringResource(R.string.appearance_behind_messages),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Show subtle sphere animation behind chat messages",
text = stringResource(R.string.appearance_behind_messages_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -448,8 +448,7 @@ fun AppearanceSettingsScreen(
// (including for screen-reader users browsing settings).
if (animEnabled) {
Text(
text = "Tip: long-press the chat background for a fullscreen " +
"ambient sphere; tap anywhere to return.",
text = stringResource(R.string.appearance_ambient_tip),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -461,7 +460,7 @@ fun AppearanceSettingsScreen(
// plus any imported "pets"; add/remove pets in-app below). When the
// sphere avatar is selected its skin chips nest below: avatar → skin.
Text(
text = "Agent avatar",
text = stringResource(R.string.appearance_agent_avatar),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -493,8 +492,7 @@ fun AppearanceSettingsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Pick the agent's avatar. Each shows which live " +
"signals it reacts to.",
text = stringResource(R.string.appearance_agent_avatar_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -539,19 +537,17 @@ fun AppearanceSettingsScreen(
modifier = Modifier.size(18.dp),
)
Text(
text = "Add a pet",
text = stringResource(R.string.appearance_add_pet),
modifier = Modifier.padding(start = 6.dp),
)
}
TextButton(onClick = { connectionViewModel.refreshAgentAvatars() }) {
Text("Rescan")
Text(stringResource(R.string.appearance_rescan))
}
}
Text(
text = "Import a pet pack (.zip), or a single image to use as a " +
"static avatar. Generate one with AI or hand-author it — " +
"see docs/pet-spec.md.",
text = stringResource(R.string.appearance_add_pet_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -559,7 +555,7 @@ fun AppearanceSettingsScreen(
// Installed-pet management: a labeled list with per-pet remove.
if (userAvatars.isNotEmpty()) {
Text(
text = "Installed pets",
text = stringResource(R.string.appearance_installed_pets),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -577,7 +573,7 @@ fun AppearanceSettingsScreen(
IconButton(onClick = { pendingDelete = pet }) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Remove ${pet.label}",
contentDescription = stringResource(R.string.appearance_remove_pet_cd, pet.label),
tint = MaterialTheme.colorScheme.error,
)
}
@@ -592,7 +588,7 @@ fun AppearanceSettingsScreen(
val petSpeed by connectionViewModel.petSpeed.collectAsState()
Text(
text = "Playback speed — ${"%.1f".format(java.util.Locale.US, petSpeed)}×",
text = stringResource(R.string.appearance_playback_speed, "%.1f".format(java.util.Locale.US, petSpeed)),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -603,8 +599,7 @@ fun AppearanceSettingsScreen(
steps = 9,
)
Text(
text = "Scales the selected pet's animation speed. " +
"1.0× plays each clip at its authored rate.",
text = stringResource(R.string.appearance_playback_speed_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -616,12 +611,11 @@ fun AppearanceSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Stabilize frames",
text = stringResource(R.string.appearance_stabilize),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Auto-centers each frame so the pet doesn't drift or jump " +
"between frames (fixes wobbly AI sheets).",
text = stringResource(R.string.appearance_stabilize_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -637,22 +631,20 @@ fun AppearanceSettingsScreen(
// verify look/speed/stabilization without running the agent.
HorizontalDivider()
Text(
text = "Preview",
text = stringResource(R.string.appearance_preview),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
val previewStates = remember {
listOf(
Triple("Idle", SphereState.Idle, 0f),
Triple("Thinking", SphereState.Thinking, 0f),
Triple("Working", SphereState.Thinking, 1f),
Triple("Writing", SphereState.Streaming, 0f),
Triple("Speaking", SphereState.Speaking, 0f),
Triple("Listening", SphereState.Listening, 0f),
Triple("Error", SphereState.Error, 0f),
)
}
val previewStates = listOf(
Triple(stringResource(R.string.appearance_state_idle), SphereState.Idle, 0f),
Triple(stringResource(R.string.appearance_state_thinking), SphereState.Thinking, 0f),
Triple(stringResource(R.string.appearance_state_working), SphereState.Thinking, 1f),
Triple(stringResource(R.string.appearance_state_writing), SphereState.Streaming, 0f),
Triple(stringResource(R.string.appearance_state_speaking), SphereState.Speaking, 0f),
Triple(stringResource(R.string.appearance_state_listening), SphereState.Listening, 0f),
Triple(stringResource(R.string.appearance_state_error), SphereState.Error, 0f),
)
var previewIdx by remember { mutableIntStateOf(0) }
var greetKey by remember { mutableIntStateOf(0) }
var overrideState by remember { mutableStateOf<SphereState?>(null) }
@@ -703,7 +695,7 @@ fun AppearanceSettingsScreen(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(onClick = { greetKey++ }) { Text("Greet") }
OutlinedButton(onClick = { greetKey++ }) { Text(stringResource(R.string.appearance_greet)) }
OutlinedButton(onClick = {
// Replay celebrate by driving Speaking → Idle on the
// live instance (the transition fires the one-shot).
@@ -714,12 +706,11 @@ fun AppearanceSettingsScreen(
delay(2500)
overrideState = null
}
}) { Text("Done") }
}) { Text(stringResource(R.string.appearance_done)) }
}
Text(
text = "Tap a state to preview it; Greet/Done replay the one-shot " +
"reactions. Reflects the speed and stabilize settings above.",
text = stringResource(R.string.appearance_preview_tip),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -731,7 +722,7 @@ fun AppearanceSettingsScreen(
HorizontalDivider()
Text(
text = "Sphere skin — choose the orb's look.",
text = stringResource(R.string.appearance_sphere_skin),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface
)
@@ -754,9 +745,7 @@ fun AppearanceSettingsScreen(
HorizontalDivider()
Text(
text = "Add your own: drop a sphere JSON spec into the app's " +
"private spheres/ folder, then reopen this screen. See " +
"docs/sphere-spec.md for the format.",
text = stringResource(R.string.appearance_sphere_custom_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -930,8 +919,8 @@ private fun SphereSkinChip(
val poleA = swatch.getOrElse(0) { MaterialTheme.colorScheme.primary }
val poleB = swatch.getOrElse(1) { poleA }
val capability = buildString {
append(skin.reactivity.summary())
if (skin.source == SphereSkinSource.USER) append(" · Custom")
append(reactivityLabels(skin.reactivity.flags()))
if (skin.source == SphereSkinSource.USER) append(stringResource(R.string.appearance_custom_suffix))
}
Column(
modifier = Modifier
@@ -1074,7 +1063,7 @@ private fun AgentAvatarChip(
maxLines = 1,
)
Text(
text = avatar.reactivity.summary(),
text = reactivityLabels(avatar.reactivity.flags()),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -41,8 +41,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
import com.hermesandroid.relay.ui.components.RelayHeroPanel
import com.hermesandroid.relay.ui.components.RelayNavTile
@@ -77,7 +79,7 @@ fun BridgeCoreScreen(
onNavigateToSettings: () -> Unit = {},
returnTitle: String? = null,
returnSubtitle: String = "",
returnLabel: String = "Back",
returnLabel: String = "",
onReturn: (() -> Unit)? = null,
) {
val relayState by connectionViewModel.relayUiState.collectAsState()
@@ -86,24 +88,24 @@ fun BridgeCoreScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Bridge") },
title = { Text(stringResource(R.string.bridge_core_title)) },
navigationIcon = {
RelayChromeIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back to chat",
contentDescription = stringResource(R.string.bridge_core_back_to_chat),
onClick = onNavigateToChat,
)
},
actions = {
RelayChromeIconButton(
icon = Icons.Filled.Code,
contentDescription = "Terminal",
contentDescription = stringResource(R.string.bridge_core_terminal),
onClick = onNavigateToTerminal,
modifier = Modifier.padding(end = 4.dp),
)
RelayChromeIconButton(
icon = Icons.Filled.Tune,
contentDescription = "Settings",
contentDescription = stringResource(R.string.bridge_core_settings),
onClick = onNavigateToSettings,
modifier = Modifier.padding(end = 4.dp),
)
@@ -129,25 +131,25 @@ fun BridgeCoreScreen(
icon = Icons.AutoMirrored.Filled.ArrowBack,
title = returnTitle,
subtitle = returnSubtitle,
label = returnLabel,
label = returnLabel.ifBlank { stringResource(R.string.bridge_core_back) },
onClick = onReturn,
)
}
RelayHeroPanel(
title = if (relayConnected) "Phone bridge is paired" else "Bridge Core is waiting",
title = if (relayConnected) stringResource(R.string.bridge_core_paired_title) else stringResource(R.string.bridge_core_waiting_title),
subtitle = if (relayConnected) {
"Terminal, voice, notification, media, and relay-session controls share this grant."
stringResource(R.string.bridge_core_paired_subtitle)
} else {
"Pair Relay to use Terminal and phone bridge tools. Chat and Manage continue over standard Hermes API."
stringResource(R.string.bridge_core_waiting_subtitle)
},
action = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RelayStatusPill(
text = relayState.statusText("connected").lowercase(),
text = relayState.statusText(stringResource(R.string.bridge_core_connected_label)).lowercase(),
active = relayConnected,
)
RelayStatusPill(
text = "safety on",
text = stringResource(R.string.bridge_core_safety_on),
active = true,
)
}
@@ -165,14 +167,13 @@ fun BridgeCoreScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "Hermes Bridge Core",
text = stringResource(R.string.bridge_core_hermes_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "Relay features for your self-hosted Hermes server: chat, " +
"voice, terminal, notifications, media, and session grants.",
text = stringResource(R.string.bridge_core_hermes_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -191,9 +192,7 @@ fun BridgeCoreScreen(
modifier = Modifier.size(20.dp),
)
Text(
text = "This build does not include AccessibilityService, " +
"screen reading, taps, typing, screenshots, SMS, calls, " +
"or unattended phone control.",
text = stringResource(R.string.bridge_core_no_access_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
@@ -214,19 +213,18 @@ fun BridgeCoreScreen(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Relay",
text = stringResource(R.string.bridge_core_relay_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
Text(
text = relayState.statusText("Connected"),
text = relayState.statusText(stringResource(R.string.bridge_core_connected_label2)),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Bridge Core uses the same paired relay session as " +
"terminal, notification, media, and voice features.",
text = stringResource(R.string.bridge_core_relay_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -234,44 +232,44 @@ fun BridgeCoreScreen(
}
RelaySectionCaption(
title = "Bridge Surface",
meta = "not hidden in settings",
title = stringResource(R.string.bridge_core_surface_title),
meta = stringResource(R.string.bridge_core_surface_meta),
)
RelayNavTile(
icon = Icons.Filled.Link,
title = "Connections",
subtitle = "Pair, switch, and verify relay routes",
title = stringResource(R.string.bridge_core_connections),
subtitle = stringResource(R.string.bridge_core_connections_sub),
onClick = onNavigateToConnections,
)
RelayNavTile(
icon = Icons.Filled.Code,
title = "Terminal",
subtitle = "Attach to your Hermes relay terminal",
title = stringResource(R.string.bridge_core_terminal_tile),
subtitle = stringResource(R.string.bridge_core_terminal_sub),
onClick = onNavigateToTerminal,
selected = relayConnected,
)
RelayNavTile(
icon = Icons.Filled.GraphicEq,
title = "Voice",
subtitle = "Provider, model, output voice",
title = stringResource(R.string.bridge_core_voice),
subtitle = stringResource(R.string.bridge_core_voice_sub),
onClick = onNavigateToVoiceSettings,
)
RelayNavTile(
icon = Icons.Filled.Notifications,
title = "Notifications",
subtitle = "Shared app notifications",
title = stringResource(R.string.bridge_core_notifications),
subtitle = stringResource(R.string.bridge_core_notifications_sub),
onClick = onNavigateToNotificationCompanion,
)
RelayNavTile(
icon = Icons.Filled.Image,
title = "Media",
subtitle = "Inbound attachments and cache behavior",
title = stringResource(R.string.bridge_core_media),
subtitle = stringResource(R.string.bridge_core_media_sub),
onClick = onNavigateToMediaSettings,
)
RelayNavTile(
icon = Icons.Filled.Devices,
title = "Relay sessions",
subtitle = "Review active grants for this server",
title = stringResource(R.string.bridge_core_relay_sessions),
subtitle = stringResource(R.string.bridge_core_relay_sessions_sub),
onClick = onNavigateToRelaySessions,
)
@@ -51,9 +51,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -114,12 +116,12 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Bridge safety") },
title = { Text(stringResource(R.string.bss_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
contentDescription = stringResource(R.string.bss_back)
)
}
},
@@ -139,9 +141,9 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
) {
// ── Blocklist ───────────────────────────────────────────────
SectionCard(title = "Blocked apps") {
SectionCard(title = stringResource(R.string.bss_blocked_apps)) {
Text(
text = "The agent cannot act on any app in this list. Defaults ship with banking apps and password managers.",
text = stringResource(R.string.bss_blocked_apps_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -151,7 +153,7 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
onValueChange = { appSearch = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Search apps") },
label = { Text(stringResource(R.string.bss_search_apps)) },
)
Spacer(Modifier.size(8.dp))
val filtered = remember(installedApps, appSearch, settings.blocklist) {
@@ -204,9 +206,9 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
}
// ── Destructive verbs ───────────────────────────────────────
SectionCard(title = "Destructive verbs") {
SectionCard(title = stringResource(R.string.bss_destructive_verbs)) {
Text(
text = "Tap_text / type commands whose text contains these words (word-boundary match) trigger a confirmation modal.",
text = stringResource(R.string.bss_destructive_verbs_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -221,7 +223,7 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
onValueChange = { newVerb = it },
modifier = Modifier.weight(1f),
singleLine = true,
label = { Text("Add a verb") },
label = { Text(stringResource(R.string.bss_add_verb)) },
)
IconButton(onClick = {
val v = newVerb.trim()
@@ -230,7 +232,7 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
newVerb = ""
}
}) {
Icon(Icons.Filled.Add, contentDescription = "Add verb")
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.bss_add_verb_cd))
}
}
Spacer(Modifier.size(6.dp))
@@ -253,7 +255,7 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
) {
Icon(
Icons.Filled.Close,
contentDescription = "Remove",
contentDescription = stringResource(R.string.bss_remove_cd),
modifier = Modifier.size(14.dp),
)
}
@@ -266,15 +268,15 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
}
// ── Auto-disable timer ──────────────────────────────────────
SectionCard(title = "Auto-disable after idle") {
SectionCard(title = stringResource(R.string.bss_auto_disable)) {
Text(
text = "Master toggle flips off after this many minutes with no bridge commands. Rescheduled on every command.",
text = stringResource(R.string.bss_auto_disable_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(4.dp))
Text(
text = "${settings.autoDisableMinutes} min",
text = stringResource(R.string.bss_auto_disable_value, settings.autoDisableMinutes),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
@@ -289,21 +291,21 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
}
// ── Status overlay ──────────────────────────────────────────
SectionCard(title = "Status overlay") {
SectionCard(title = stringResource(R.string.bss_status_overlay)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Show floating indicator",
text = stringResource(R.string.bss_show_floating),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = if (canDrawOverlays) {
"Permission granted."
stringResource(R.string.bss_overlay_granted)
} else {
"Tap to grant overlay permission."
stringResource(R.string.bss_overlay_not_granted)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -330,15 +332,15 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
}
// ── Confirmation timeout ────────────────────────────────────
SectionCard(title = "Confirmation timeout") {
SectionCard(title = stringResource(R.string.bss_confirmation_timeout)) {
Text(
text = "Destructive-verb modal waits this long for your response before defaulting to Deny.",
text = stringResource(R.string.bss_confirmation_timeout_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(4.dp))
Text(
text = "${settings.confirmationTimeoutSeconds} s",
text = stringResource(R.string.bss_confirmation_timeout_value, settings.confirmationTimeoutSeconds),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
@@ -424,9 +426,9 @@ private fun BridgeSafetySettingsScreenPreview() {
// The real screen needs a Context for PackageManager lookups, so
// this preview just shows one section to verify styling.
Column(Modifier.padding(16.dp)) {
SectionCard(title = "Destructive verbs") {
SectionCard(title = stringResource(R.string.bss_destructive_verbs)) {
Text(
text = "Tap_text / type commands whose text contains these words trigger a confirmation modal.",
text = stringResource(R.string.bss_destructive_verbs_desc),
style = MaterialTheme.typography.bodySmall,
)
}
@@ -49,9 +49,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -119,7 +121,7 @@ fun BridgeScreen(
onNavigateToSettings: () -> Unit = {},
returnTitle: String? = null,
returnSubtitle: String = "",
returnLabel: String = "Back",
returnLabel: String = "",
onReturn: (() -> Unit)? = null,
) {
val masterToggle by viewModel.masterToggle.collectAsState()
@@ -212,18 +214,18 @@ fun BridgeScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Bridge") },
title = { Text(stringResource(R.string.bridge_title)) },
navigationIcon = {
RelayChromeIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back to chat",
contentDescription = stringResource(R.string.bridge_back_to_chat),
onClick = onNavigateToChat,
)
},
actions = {
RelayChromeIconButton(
icon = Icons.Filled.Tune,
contentDescription = "Settings",
contentDescription = stringResource(R.string.bridge_settings),
onClick = onNavigateToSettings,
modifier = Modifier.padding(end = 4.dp),
)
@@ -249,21 +251,21 @@ fun BridgeScreen(
icon = Icons.AutoMirrored.Filled.ArrowBack,
title = returnTitle,
subtitle = returnSubtitle,
label = returnLabel,
label = returnLabel.ifBlank { stringResource(R.string.bridge_back) },
onClick = onReturn,
)
}
RelayHeroPanel(
title = if (relayReady) "Phone bridge is paired" else "Bridge controls are staged",
title = if (relayReady) stringResource(R.string.bridge_paired_title) else stringResource(R.string.bridge_staged_title),
subtitle = if (relayReady) {
"Terminal, voice, notification, media, and advanced phone controls share this grant."
stringResource(R.string.bridge_paired_subtitle)
} else {
"Pair Relay to receive bridge commands. You can still configure permissions and safety before pairing."
stringResource(R.string.bridge_staged_subtitle)
},
action = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RelayStatusPill("relay", relayReady)
RelayStatusPill("safety", true)
RelayStatusPill(stringResource(R.string.bridge_relay_label), relayReady)
RelayStatusPill(stringResource(R.string.bridge_safety_label), true)
}
},
)
@@ -301,14 +303,12 @@ fun BridgeScreen(
)
Column {
Text(
text = "Relay not connected",
text = stringResource(R.string.bridge_not_connected_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = "Bridge commands travel over the relay. " +
"Pair a relay in Settings → Connections for " +
"the bridge to actually do anything.",
text = stringResource(R.string.bridge_not_connected_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
@@ -347,6 +347,9 @@ fun BridgeScreen(
// 1. Master toggle (with inline status rows — device, battery,
// screen, current app). This is the parent gate for the page.
val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
// Pre-resolve snackbar strings for accessibility-needed callback (non-Composable lambda)
val snackbarAccessibilityNeeded = stringResource(R.string.bridge_accessibility_needed)
val snackbarAccessibilityAction = stringResource(R.string.bridge_open_settings)
BridgeMasterToggle(
enabled = masterToggle,
status = bridgeStatus,
@@ -371,8 +374,8 @@ fun BridgeScreen(
onAccessibilityNeeded = {
coroutineScope.launch {
val result = snackbarHost.showSnackbar(
message = "Accessibility Service must be enabled first.",
actionLabel = "Open Settings",
message = snackbarAccessibilityNeeded,
actionLabel = snackbarAccessibilityAction,
duration = androidx.compose.material3.SnackbarDuration.Long,
)
if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
@@ -389,9 +392,9 @@ fun BridgeScreen(
// reading). Sideload: "Agent Control" (full phone control).
// The label shapes user expectation + is what reviewers read.
label = if (BuildFlavor.isSideload)
"Allow Agent Control"
stringResource(R.string.bridge_allow_agent_control)
else
"Enable Bridge Mode",
stringResource(R.string.bridge_enable_bridge_mode),
)
// 2. Permissions — prerequisites come before advanced features.
@@ -507,7 +510,7 @@ private fun AdvancedSectionHeader() {
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "Advanced",
text = stringResource(R.string.bridge_advanced),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
@@ -553,15 +556,13 @@ private fun OverlayPermissionNagCard(onTap: () -> Unit) {
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Grant 'Display over other apps'",
text = stringResource(R.string.bridge_overlay_perm_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = "Without this, confirmation prompts can't show when " +
"the agent acts. Destructive actions will be silently denied. " +
"Tap to grant.",
text = stringResource(R.string.bridge_overlay_perm_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
@@ -612,17 +613,16 @@ private fun TrustedActionsRow(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Trusted actions",
text = stringResource(R.string.bridge_trusted_actions),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = if (trustedCount == 0) {
"Every action still prompts"
stringResource(R.string.bridge_trusted_empty)
} else {
"$trustedCount action${if (trustedCount == 1) "" else "s"} " +
"bypass confirmation"
"$trustedCount" + stringResource(R.string.bridge_trusted_count_suffix)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -632,19 +632,17 @@ private fun TrustedActionsRow(
onClick = { showConfirm = true },
enabled = trustedCount > 0,
) {
Text("Reset")
Text(stringResource(R.string.bridge_reset))
}
}
}
if (showConfirm) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showConfirm = false },
title = { Text("Reset trusted actions?") },
title = { Text(stringResource(R.string.bridge_reset_trusted_title)) },
text = {
Text(
"After reset, every destructive action will prompt " +
"for confirmation again. You can re-enable " +
"\"Don't ask again\" from any future confirmation dialog."
stringResource(R.string.bridge_reset_trusted_body)
)
},
confirmButton = {
@@ -654,14 +652,14 @@ private fun TrustedActionsRow(
showConfirm = false
},
) {
Text("Reset")
Text(stringResource(R.string.bridge_reset))
}
},
dismissButton = {
androidx.compose.material3.TextButton(
onClick = { showConfirm = false },
) {
Text("Cancel")
Text(stringResource(R.string.bridge_cancel))
}
},
)
@@ -32,7 +32,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import com.hermesandroid.relay.R
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.components.ChangelogStore
import com.hermesandroid.relay.ui.components.ChangelogVersion
@@ -62,12 +64,12 @@ fun ChangelogScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("What's New") },
title = { Text(stringResource(R.string.changelog_title)) },
actions = {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close",
contentDescription = stringResource(R.string.changelog_close),
)
}
},
@@ -85,7 +87,7 @@ fun ChangelogScreen(
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text(
text = "No release notes available.",
text = stringResource(R.string.changelog_no_notes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -160,7 +162,7 @@ private fun ChangelogVersionCard(
} else {
Icons.Filled.ExpandMore
},
contentDescription = if (expanded) "Collapse" else "Expand",
contentDescription = if (expanded) stringResource(R.string.changelog_collapse) else stringResource(R.string.changelog_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -1,3 +1,5 @@
@file:Suppress("LocalContextGetResourceValueCall")
package com.hermesandroid.relay.ui.screens
import androidx.compose.animation.AnimatedVisibility
@@ -101,6 +103,8 @@ import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -708,6 +712,8 @@ fun ChatScreen(
val listState = rememberLazyListState()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val copiedToClipboardMsg = stringResource(R.string.chat_copied_to_clipboard)
val hermesMessageLabel = stringResource(R.string.chat_hermes_message)
val focusManager = LocalFocusManager.current
val finishSuccessfulSend: () -> Unit = {
if (closeDrawerOnSend && drawerState.isOpen) {
@@ -770,7 +776,7 @@ fun ChatScreen(
}
scope.launch {
snackbarHostState.showSnackbar(
message = "Enable Display over other apps, then return to start Voice Overlay.",
message = context.getString(R.string.chat_overlay_perm_enable),
duration = SnackbarDuration.Short,
)
}
@@ -806,7 +812,7 @@ fun ChatScreen(
if (!shown) {
scope.launch {
snackbarHostState.showSnackbar(
message = "Voice Overlay could not be started.",
message = context.getString(R.string.chat_overlay_start_failed),
duration = SnackbarDuration.Short,
)
}
@@ -840,7 +846,7 @@ fun ChatScreen(
pendingVoiceOverlayPermission = false
scope.launch {
snackbarHostState.showSnackbar(
message = "Voice Overlay permission was not granted.",
message = context.getString(R.string.chat_overlay_perm_denied),
duration = SnackbarDuration.Short,
)
}
@@ -911,7 +917,7 @@ fun ChatScreen(
cameraLauncher.launch(uri)
}.onFailure {
pendingCameraUri = null
Toast.makeText(context, "Couldn't open the camera", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.chat_camera_open_failed), Toast.LENGTH_SHORT).show()
}
}
var pendingCameraAfterPermission by remember { mutableStateOf(false) }
@@ -925,7 +931,7 @@ fun ChatScreen(
} else if (!granted) {
Toast.makeText(
context,
"Camera permission needed to take a photo",
context.getString(R.string.chat_camera_perm_needed),
Toast.LENGTH_SHORT,
).show()
}
@@ -954,7 +960,7 @@ fun ChatScreen(
}
if (!handled) {
snackbarHostState.showSnackbar(
message = "No image on the clipboard",
message = context.getString(R.string.chat_no_clipboard_image),
duration = SnackbarDuration.Short,
)
}
@@ -1380,18 +1386,18 @@ fun ChatScreen(
gesturesEnabled = !voiceUiState.voiceMode,
drawerContent = {
val drawerTitle = if (selectedProfile != null) {
"$agentDisplayName sessions"
stringResource(R.string.chat_profile_sessions, agentDisplayName)
} else {
"Server default sessions"
stringResource(R.string.chat_server_default_sessions)
}
val drawerSubtitle = when {
selectedProfile?.hasIsolatedApi == true ->
"Profile API: ${selectedProfile?.apiServerUrl}"
"${stringResource(R.string.chat_profile_api_label)}: ${selectedProfile?.apiServerUrl}"
selectedProfile != null ->
"Compatibility overlay on ${activeConnection?.label ?: "active connection"}"
stringResource(R.string.chat_compatibility_overlay, activeConnection?.label ?: stringResource(R.string.chat_active_connection))
activeConnection?.label?.isNotBlank() == true ->
"Connection: ${activeConnection?.label}"
else -> "Active connection"
stringResource(R.string.chat_connection_label, activeConnection?.label ?: "")
else -> stringResource(R.string.chat_active_connection)
}
val threadsProactiveEnabled by connectionViewModel.proactiveEnabled.collectAsState()
val threadsAuthState by connectionViewModel.authState.collectAsState()
@@ -1453,7 +1459,7 @@ fun ChatScreen(
TopAppBar(
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = "Sessions")
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
}
},
title = {
@@ -1467,9 +1473,17 @@ fun ChatScreen(
var everConnected by remember { mutableStateOf(false) }
if (headerApiReachable) everConnected = true
val statusText = when {
headerApiReachable -> if (isStreaming) "Streaming" else "Connected"
isConnecting -> if (everConnected) "Reconnecting…" else "Connecting…"
else -> "Disconnected"
headerApiReachable -> if (isStreaming) {
stringResource(R.string.chat_streaming)
} else {
stringResource(R.string.chat_connected_label)
}
isConnecting -> if (everConnected) {
stringResource(R.string.chat_reconnecting_dots)
} else {
stringResource(R.string.chat_connecting_dots)
}
else -> stringResource(R.string.chat_disconnected_label)
}
val statusColor = when {
headerApiReachable -> Color(0xFF4CAF50)
@@ -1622,7 +1636,7 @@ fun ChatScreen(
} else {
Column {
Text(
text = if (agentDisplayName.isNotBlank()) agentDisplayName else "Hermes",
text = 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,
@@ -1672,7 +1686,7 @@ fun ChatScreen(
if (yoloEnabled == true) {
RelayChromeIconButton(
icon = Icons.Filled.Bolt,
contentDescription = "Approvals off — tap for details",
contentDescription = stringResource(R.string.cd_approvals_off),
onClick = { showAgentInfo = true },
tint = RelayRefresh.Amber,
borderColor = RelayRefresh.Amber.copy(alpha = 0.5f),
@@ -1689,13 +1703,13 @@ fun ChatScreen(
// width for the title subtitle.)
RelayChromeIconButton(
icon = Icons.Filled.Code,
contentDescription = "Terminal",
contentDescription = stringResource(R.string.cd_terminal),
onClick = onNavigateToTerminal,
modifier = Modifier.padding(end = 4.dp),
)
RelayChromeIconButton(
icon = Icons.Filled.Tune,
contentDescription = "Settings",
contentDescription = stringResource(R.string.cd_settings),
onClick = onNavigateToSettings,
modifier = Modifier.padding(end = 4.dp),
)
@@ -1709,7 +1723,7 @@ fun ChatScreen(
Box {
RelayChromeIconButton(
icon = Icons.Filled.MoreVert,
contentDescription = "More actions",
contentDescription = stringResource(R.string.chat_more_actions_a11y),
onClick = { showOverflowMenu = true },
modifier = Modifier.padding(end = 4.dp),
)
@@ -1718,7 +1732,7 @@ fun ChatScreen(
onDismissRequest = { showOverflowMenu = false },
) {
DropdownMenuItem(
text = { Text("Share conversation") },
text = { Text(stringResource(R.string.chat_share_conversation)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Share,
@@ -1786,10 +1800,10 @@ fun ChatScreen(
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
TextButton(onClick = { chatViewModel.retryLastMessage() }) {
Text("Retry")
Text(stringResource(R.string.chat_retry))
}
TextButton(onClick = { chatViewModel.clearError() }) {
Text("Dismiss")
Text(stringResource(R.string.chat_dismiss))
}
}
}
@@ -1810,7 +1824,7 @@ fun ChatScreen(
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Loading messages...",
text = stringResource(R.string.chat_loading_messages),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -1861,9 +1875,9 @@ fun ChatScreen(
)
} else {
val suggestions = listOf(
"What can you do?",
"Help me code",
"Explain something"
stringResource(R.string.chat_prompt_what_can_you_do),
stringResource(R.string.chat_prompt_help_me_code),
stringResource(R.string.chat_prompt_explain),
)
Box(
@@ -1904,9 +1918,13 @@ fun ChatScreen(
// thread itself (not just the header) -
// the desktop's intro.
ChatConnectState.Ready ->
if (selectedProfile != null) "Chat with $agentDisplayName" else "Start a conversation"
ChatConnectState.Connecting -> "Connecting to Hermes..."
ChatConnectState.NeedsConnection -> "Connect to Hermes"
if (selectedProfile != null) {
stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
} else {
stringResource(R.string.chat_start_conversation)
}
ChatConnectState.Connecting -> stringResource(R.string.chat_connect_to_hermes_dots)
ChatConnectState.NeedsConnection -> stringResource(R.string.chat_needs_connection)
},
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
@@ -1946,7 +1964,7 @@ fun ChatScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "Connect your Hermes server to start chatting — or explore a quick demo first. You can connect anytime.",
text = stringResource(R.string.chat_empty_state_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -1954,14 +1972,14 @@ fun ChatScreen(
onClick = onNavigateToConnect,
modifier = Modifier.fillMaxWidth(),
) {
Text("Connect Hermes")
Text(stringResource(R.string.chat_connect_hermes))
}
if (onTryDemo != null) {
TextButton(
onClick = onTryDemo,
modifier = Modifier.fillMaxWidth(),
) {
Text("Try the demo")
Text(stringResource(R.string.chat_try_demo))
}
}
}
@@ -2218,13 +2236,13 @@ fun ChatScreen(
clipboard.setClipEntry(
ClipEntry(
ClipData.newPlainText(
"Hermes message",
hermesMessageLabel,
text
)
)
)
snackbarHostState.showSnackbar(
message = "Copied to clipboard",
message = copiedToClipboardMsg,
duration = SnackbarDuration.Short
)
}
@@ -2430,7 +2448,11 @@ fun ChatScreen(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${queuedMessages.size} message${if (queuedMessages.size > 1) "s" else ""} queued · delivers after this turn",
text = pluralStringResource(
R.plurals.chat_queue_count,
queuedMessages.size,
queuedMessages.size,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
@@ -2439,7 +2461,7 @@ fun ChatScreen(
modifier = Modifier.height(28.dp)
) {
Text(
"Clear",
stringResource(R.string.chat_clear),
style = MaterialTheme.typography.labelSmall
)
}
@@ -2525,7 +2547,7 @@ fun ChatScreen(
}
Column(modifier = Modifier.widthIn(max = 100.dp)) {
Text(
text = attachment.fileName ?: "File",
text = attachment.fileName ?: stringResource(R.string.chat_attachment_file_fallback),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
@@ -2542,7 +2564,7 @@ fun ChatScreen(
) {
Icon(
Icons.Filled.Close,
contentDescription = "Remove",
contentDescription = stringResource(R.string.cd_remove),
modifier = Modifier.size(16.dp)
)
}
@@ -2569,7 +2591,7 @@ fun ChatScreen(
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Editing — will rewind the conversation",
text = stringResource(R.string.chat_editing_notice),
style = relayMetadataStyle(),
color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.9f),
modifier = Modifier.weight(1f),
@@ -2583,7 +2605,7 @@ fun ChatScreen(
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Cancel editing",
contentDescription = stringResource(R.string.cd_cancel_editing),
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -2611,11 +2633,14 @@ fun ChatScreen(
else -> null
}
val inputPlaceholder = when {
editingMessage != null -> "Edit your message…"
isStreaming && steerableTurn -> "Steer the response…"
isStreaming -> "Queue a message..."
else -> "Message..."
editingMessage != null -> stringResource(R.string.chat_placeholder_edit)
isStreaming && steerableTurn -> stringResource(R.string.chat_placeholder_steer)
isStreaming -> stringResource(R.string.chat_placeholder_queue)
else -> stringResource(R.string.chat_placeholder_message)
}
val editBusyMessage = stringResource(R.string.chat_edit_busy_snackbar)
val stoppedMessage = stringResource(R.string.chat_stopped_snackbar)
val attachmentPlaceholder = stringResource(R.string.chat_attachment_placeholder)
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
(availableModels.mapNotNull(AgentDisplay::displayModelName) +
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } +
@@ -2637,6 +2662,10 @@ fun ChatScreen(
val serverDefaultModelDetail = AgentDisplay.displayModelName(serverModelName)
?: AgentDisplay.displayModelName(effectiveProfile?.model)
val hasModelChoices = modelProviders.any { it.models.isNotEmpty() } || sseModelOptions.isNotEmpty()
val serverDefaultLabel = stringResource(R.string.chat_server_default_sessions)
val notOnPlanLabel = stringResource(R.string.chat_not_on_plan)
val needsSetupLabel = stringResource(R.string.chat_needs_setup)
val modelDefaultLabel = stringResource(R.string.chat_model_label)
val modelPickerOptions = remember(
modelProviders,
sseModelOptions,
@@ -2652,9 +2681,9 @@ fun ChatScreen(
buildList {
add(
ChatInputPickerOption(
label = "Server default",
label = serverDefaultLabel,
value = null,
secondary = serverDefaultModelDetail?.let { compactModelChipLabel(it) },
secondary = serverDefaultModelDetail?.let { compactModelChipLabel(it, modelDefaultLabel) },
selected = selectedModelOverride == null,
),
)
@@ -2676,8 +2705,8 @@ fun ChatScreen(
provider = provider.slug,
group = provider.name,
secondary = when {
unavailable -> "Not on your plan"
!provider.authenticated -> provider.warning ?: "Needs setup"
unavailable -> notOnPlanLabel
!provider.authenticated -> provider.warning ?: needsSetupLabel
else -> null
},
selected = selectedModelOverride == model,
@@ -2702,17 +2731,25 @@ fun ChatScreen(
}
val modelControl = modelPickerOptions.takeIf { it.isNotEmpty() }?.let {
ChatInputPickerControl(
value = compactModelChipLabel(currentModelForInput),
contentDescription = "Select model",
value = compactModelChipLabel(currentModelForInput, modelDefaultLabel),
contentDescription = stringResource(R.string.cd_select_model),
options = it,
enabled = chatReady && !isStreaming && it.size > 1,
)
}
val normalizedEffort = normalizeReasoningEffortForInput(selectedReasoningEffort)
val effortLabels = mapOf(
"none" to stringResource(R.string.chat_reasoning_none),
"minimal" to stringResource(R.string.chat_reasoning_minimal),
"low" to stringResource(R.string.chat_reasoning_low),
"medium" to stringResource(R.string.chat_reasoning_medium),
"high" to stringResource(R.string.chat_reasoning_high),
"xhigh" to stringResource(R.string.chat_reasoning_high),
)
val effortPickerOptions = remember(normalizedEffort) {
CHAT_INPUT_REASONING_EFFORTS.map { effort ->
ChatInputPickerOption(
label = reasoningEffortChipLabel(effort),
label = reasoningEffortChipLabel(effort, effortLabels),
value = effort,
selected = effort == normalizedEffort,
)
@@ -2729,8 +2766,8 @@ fun ChatScreen(
// disabled-with-reason version there.
val effortControl = if (chatGatewayAvailability != GatewayAvailability.Unreachable) {
ChatInputPickerControl(
value = reasoningEffortChipLabel(normalizedEffort),
contentDescription = "Select reasoning effort",
value = reasoningEffortChipLabel(normalizedEffort, effortLabels),
contentDescription = stringResource(R.string.chat_select_reasoning_effort),
options = effortPickerOptions,
enabled = isGatewayTransport && chatReady && !isStreaming,
)
@@ -2753,15 +2790,16 @@ fun ChatScreen(
inputText = ""
finishSuccessfulSend()
} else {
val editBusyMsg = editBusyMessage
scope.launch {
snackbarHostState.showSnackbar(
message = "Can't edit right now — wait for the current turn to finish",
message = editBusyMsg,
duration = SnackbarDuration.Short,
)
}
}
} else {
chatViewModel.sendMessage(inputText.ifBlank { "[attachment]" })
chatViewModel.sendMessage(inputText.ifBlank { attachmentPlaceholder })
inputText = ""
finishSuccessfulSend()
}
@@ -2803,9 +2841,10 @@ fun ChatScreen(
// (see ChatViewModel.cancelStream) so the cancel is
// unmistakable, not just a transient toast.
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
val stoppedMsg = stoppedMessage
scope.launch {
snackbarHostState.showSnackbar(
message = "Stopped",
message = stoppedMsg,
duration = SnackbarDuration.Short,
)
}
@@ -2870,12 +2909,12 @@ fun ChatScreen(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
text = "Microphone permission needed",
text = stringResource(R.string.chat_mic_perm_needed),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = "Tap Open Settings and grant Microphone access to use voice mode.",
text = stringResource(R.string.chat_mic_perm_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
@@ -2884,7 +2923,7 @@ fun ChatScreen(
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { micPermissionDenied = false }) {
Text("Dismiss")
Text(stringResource(R.string.chat_dismiss))
}
TextButton(onClick = {
val intent = Intent(
@@ -2894,7 +2933,7 @@ fun ChatScreen(
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}) {
Text("Open Settings")
Text(stringResource(R.string.chat_open_settings))
}
}
}
@@ -2914,7 +2953,7 @@ fun ChatScreen(
.padding(bottom = 96.dp),
) {
Text(
text = "Hold the chat for a clean, focused view",
text = stringResource(R.string.chat_clean_view_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
@@ -3273,7 +3312,7 @@ private fun ChatLoadingCommandPanel(
}
}
TextButton(onClick = onNavigateToConnections) {
Text("Manage connections")
Text(stringResource(R.string.chat_manage_connections))
}
}
}
@@ -3460,7 +3499,7 @@ private fun ingestAttachmentFromUri(
if (bytes.size > maxSize) {
Toast.makeText(
context,
"File too large (max $maxAttachmentMb MB)",
context.getString(R.string.chat_file_too_large, maxAttachmentMb),
Toast.LENGTH_SHORT,
).show()
return
@@ -3565,9 +3604,9 @@ private fun createCameraCaptureUri(context: android.content.Context): Uri {
private val CHAT_INPUT_REASONING_EFFORTS = listOf("none", "minimal", "low", "medium", "high", "xhigh")
private fun compactModelChipLabel(model: String?): String {
private fun compactModelChipLabel(model: String?, defaultLabel: String): String {
val raw = model?.trim().orEmpty()
if (raw.isBlank()) return "Model"
if (raw.isBlank()) return defaultLabel
val label = raw.substringAfterLast('/').ifBlank { raw }
return if (label.length <= 18) label else label.take(15).trimEnd() + "..."
}
@@ -3577,15 +3616,8 @@ private fun normalizeReasoningEffortForInput(value: String?): String {
return normalized.takeIf { it in CHAT_INPUT_REASONING_EFFORTS } ?: "medium"
}
private fun reasoningEffortChipLabel(value: String): String = when (value) {
"none" -> "None"
"minimal" -> "Minimal"
"low" -> "Low"
"medium" -> "Medium"
"high" -> "High"
"xhigh" -> "XHigh"
else -> "Medium"
}
private fun reasoningEffortChipLabel(value: String, labels: Map<String, String>): String =
labels[value] ?: labels["medium"] ?: value
private fun isSameDay(ts1: Long, ts2: Long): Boolean {
val d1 = java.time.Instant.ofEpochMilli(ts1).atZone(java.time.ZoneId.systemDefault()).toLocalDate()
@@ -3601,8 +3633,8 @@ private fun DateSeparator(timestamp: Long) {
.toLocalDate()
val label = when {
messageDate == today -> "Today"
messageDate == today.minusDays(1) -> "Yesterday"
messageDate == today -> stringResource(R.string.chat_date_today)
messageDate == today.minusDays(1) -> stringResource(R.string.chat_date_yesterday)
messageDate.year == today.year -> messageDate.format(
java.time.format.DateTimeFormatter.ofPattern("EEE, MMM d")
)
@@ -3658,9 +3690,9 @@ private fun shareConversation(
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(android.content.Intent.EXTRA_TEXT, body)
putExtra(android.content.Intent.EXTRA_SUBJECT, "Hermes conversation")
putExtra(android.content.Intent.EXTRA_SUBJECT, context.getString(R.string.chat_share_subject))
}
context.startActivity(
android.content.Intent.createChooser(intent, "Share conversation"),
android.content.Intent.createChooser(intent, context.getString(R.string.chat_share_conversation)),
)
}
@@ -55,9 +55,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.ServerCapabilities
import com.hermesandroid.relay.ui.components.ChatTransportStatus
@@ -104,12 +106,12 @@ fun ChatSettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Chat") },
title = { Text(stringResource(R.string.settings_chat)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.chat_settings_back),
)
}
},
@@ -155,11 +157,11 @@ fun ChatSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Smooth auto-scroll",
text = stringResource(R.string.chat_settings_smooth_auto_scroll),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Follow new messages while streaming. Scroll up to pause; tap the arrow or scroll back to the bottom to resume.",
text = stringResource(R.string.chat_settings_smooth_auto_scroll_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -189,11 +191,11 @@ fun ChatSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Thinking indicator",
text = stringResource(R.string.chat_settings_thinking_indicator),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "The animation shown in a reply bubble while Hermes is working.",
text = stringResource(R.string.chat_settings_thinking_indicator_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -214,7 +216,7 @@ fun ChatSettingsScreen(
}
}
val styleOptions = listOf("dots", "matrix")
val styleLabels = listOf("Dots", "Matrix")
val styleLabels = listOf(stringResource(R.string.chat_settings_dots), stringResource(R.string.chat_settings_matrix))
val selectedStyleIndex =
styleOptions.indexOf(thinkingIndicatorStyle).coerceAtLeast(0)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
@@ -239,7 +241,7 @@ fun ChatSettingsScreen(
AnimatedVisibility(visible = thinkingIndicatorStyle == "matrix") {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = "Pattern",
text = stringResource(R.string.chat_settings_pattern),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -276,7 +278,7 @@ fun ChatSettingsScreen(
// Accents come from the active theme, so the same
// choice re-themes (e.g. Amber → bronze in Ember).
Text(
text = "Color",
text = stringResource(R.string.chat_settings_color),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -330,11 +332,11 @@ fun ChatSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Close sessions on send",
text = stringResource(R.string.chat_settings_close_sessions_on_send),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Return to the conversation after sending from the session drawer. Off keeps the drawer open for session triage.",
text = stringResource(R.string.chat_settings_close_sessions_on_send_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -354,11 +356,11 @@ fun ChatSettingsScreen(
val hiddenSources by connectionViewModel.hiddenSources.collectAsState()
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Session sources",
text = stringResource(R.string.chat_settings_session_sources),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Hide a gateway's sessions from the drawer (cron and webhook are hidden by default). Your chats and Threads always show.",
text = stringResource(R.string.chat_settings_session_sources_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -396,11 +398,11 @@ fun ChatSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Recent prompt chips",
text = stringResource(R.string.chat_settings_recent_prompt_chips),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Show your recent messages as tappable chips above the composer to send them again. Off by default.",
text = stringResource(R.string.chat_settings_recent_prompt_chips_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -422,11 +424,11 @@ fun ChatSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Keep keyboard open on send",
text = stringResource(R.string.chat_settings_keep_keyboard_open),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Stay in the composer after sending. Turn off to dismiss the keyboard after each sent message.",
text = stringResource(R.string.chat_settings_keep_keyboard_open_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -457,11 +459,11 @@ fun ChatSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Notify when Hermes finishes",
text = stringResource(R.string.chat_settings_notify_when_finishes),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Post a notification when a reply completes while the app is in the background",
text = stringResource(R.string.chat_settings_notify_when_finishes_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -497,11 +499,11 @@ fun ChatSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Share phone status with agent",
text = stringResource(R.string.chat_settings_share_phone_status),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Include a short system message about the app and phone on every chat turn",
text = stringResource(R.string.chat_settings_share_phone_status_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -522,11 +524,11 @@ fun ChatSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Bridge + permissions",
text = stringResource(R.string.chat_settings_bridge_permissions),
style = MaterialTheme.typography.bodySmall
)
Text(
text = "Whether accessibility, screen capture, overlay, notifications are granted",
text = stringResource(R.string.chat_settings_bridge_permissions_desc),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -545,11 +547,11 @@ fun ChatSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Foreground app",
text = stringResource(R.string.chat_settings_foreground_app),
style = MaterialTheme.typography.bodySmall
)
Text(
text = "The package name of whatever app is currently visible. Off by default.",
text = stringResource(R.string.chat_settings_foreground_app_desc),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -568,11 +570,11 @@ fun ChatSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Battery level",
text = stringResource(R.string.chat_settings_battery_level),
style = MaterialTheme.typography.bodySmall
)
Text(
text = "Current battery percent. Off by default.",
text = stringResource(R.string.chat_settings_battery_level_desc),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -591,11 +593,11 @@ fun ChatSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Safety rails",
text = stringResource(R.string.chat_settings_safety_rails),
style = MaterialTheme.typography.bodySmall
)
Text(
text = "Blocklist count, destructive-verb count, and auto-disable timer",
text = stringResource(R.string.chat_settings_safety_rails_desc),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -648,12 +650,12 @@ fun ChatSettingsScreen(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "Preview",
text = stringResource(R.string.chat_settings_preview),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = previewText ?: "(no system message will be sent)",
text = previewText ?: stringResource(R.string.chat_settings_no_system_message),
style = MaterialTheme.typography.bodySmall,
color = if (previewText == null)
MaterialTheme.colorScheme.onSurfaceVariant
@@ -700,11 +702,11 @@ fun ChatSettingsScreen(
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "Parse tool annotations",
text = stringResource(R.string.chat_settings_parse_tool_annotations),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Experimental",
text = stringResource(R.string.chat_settings_experimental),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiary,
modifier = Modifier
@@ -717,9 +719,9 @@ fun ChatSettingsScreen(
}
Text(
text = if (isTextAnnotationMode) {
"Detect tool usage from text markers on endpoints without structured tool events."
stringResource(R.string.chat_settings_parse_tool_annotations_desc)
} else {
"Only available for text-stream endpoints — Runs should provide structured tool events."
stringResource(R.string.chat_settings_parse_tool_annotations_note)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -749,11 +751,11 @@ fun ChatSettingsScreen(
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "Show system messages",
text = stringResource(R.string.chat_settings_show_system_messages),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Debug",
text = stringResource(R.string.chat_settings_debug),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiary,
modifier = Modifier
@@ -765,8 +767,7 @@ fun ChatSettingsScreen(
)
}
Text(
text = "Show the server's hidden \"[System: …]\" markers " +
"(model / personality changes). Off matches the desktop/TUI.",
text = stringResource(R.string.chat_settings_show_system_messages_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -782,29 +783,27 @@ fun ChatSettingsScreen(
// Streaming endpoint selector
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Streaming endpoint",
text = stringResource(R.string.chat_settings_streaming_endpoint),
style = MaterialTheme.typography.bodyMedium
)
val resolvedHelp = when (streamingEndpoint) {
"auto" -> {
"Auto: picks the best path based on what your server exposes. " +
"Currently using: $resolvedStreamingEndpoint" +
stringResource(R.string.chat_settings_streaming_endpoint_auto_prefix) +
resolvedStreamingEndpoint +
when {
resolvedStreamingEndpoint == "gateway" ->
" (live thinking via the dashboard WebSocket)"
stringResource(R.string.chat_settings_gateway_suffix)
!serverCaps.sessionsChatStream && serverCaps.portable ->
" (chat via /v1/chat/completions)"
stringResource(R.string.chat_settings_chat_completions_suffix)
!serverCaps.sessionsChatStream && serverCaps.runs ->
" (chat via explicitly streamed /v1/runs)"
stringResource(R.string.chat_settings_runs_suffix)
else -> ""
}
}
"gateway" -> "Gateway: live thinking + rich tool events over the " +
"dashboard WebSocket (/api/ws) — what the desktop app uses. " +
"Requires Manage sign-in; falls back to SSE per turn when unavailable."
"sessions" -> "Sessions: Hermes-native /api/sessions/{id}/chat/stream."
"completions" -> "Chat: OpenAI-compatible SSE via /v1/chat/completions."
"runs" -> "Runs: use only when your server streams /v1/runs directly."
"gateway" -> stringResource(R.string.chat_settings_gateway_desc)
"sessions" -> stringResource(R.string.chat_settings_sessions_desc)
"completions" -> stringResource(R.string.chat_settings_chat_desc)
"runs" -> stringResource(R.string.chat_settings_runs_desc)
else -> ""
}
Text(
@@ -825,7 +824,7 @@ fun ChatSettingsScreen(
)
val endpointOptions = listOf("auto", "gateway", "sessions", "completions", "runs")
val endpointLabels = listOf("Auto", "Gateway", "Sessions", "Chat", "Runs")
val endpointLabels = listOf(stringResource(R.string.chat_settings_auto), stringResource(R.string.chat_settings_gateway), stringResource(R.string.chat_settings_sessions), stringResource(R.string.settings_chat), stringResource(R.string.chat_settings_runs))
val selectedEndpointIndex = endpointOptions.indexOf(streamingEndpoint).coerceAtLeast(0)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
@@ -865,12 +864,12 @@ fun ChatSettingsScreen(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Limits",
text = stringResource(R.string.chat_settings_limits),
style = MaterialTheme.typography.bodyMedium
)
Icon(
imageVector = if (limitsExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (limitsExpanded) "Collapse" else "Expand",
contentDescription = if (limitsExpanded) stringResource(R.string.chat_settings_collapse) else stringResource(R.string.chat_settings_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
@@ -885,7 +884,7 @@ fun ChatSettingsScreen(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Max attachment size",
text = stringResource(R.string.chat_settings_max_attachment_size),
style = MaterialTheme.typography.bodySmall
)
Text(
@@ -918,7 +917,7 @@ fun ChatSettingsScreen(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Max message length",
text = stringResource(R.string.chat_settings_max_message_length),
style = MaterialTheme.typography.bodySmall
)
Text(
@@ -928,7 +927,7 @@ fun ChatSettingsScreen(
)
}
val lengthOptions = listOf(1024, 2048, 4096, 8192, 16384)
val lengthLabels = listOf("1K", "2K", "4K", "8K", "16K")
val lengthLabels = listOf(stringResource(R.string.chat_settings_1k), stringResource(R.string.chat_settings_2k), stringResource(R.string.chat_settings_4k), stringResource(R.string.chat_settings_8k), stringResource(R.string.chat_settings_16k))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
lengthOptions.forEachIndexed { index, len ->
SegmentedButton(
@@ -1026,7 +1025,13 @@ private fun TransportTierCell(
),
) {
Text(
text = tier.ladderLabel(),
text = when (tier) {
ChatTransportTier.Gateway -> stringResource(R.string.chat_settings_gateway)
ChatTransportTier.Sessions -> stringResource(R.string.chat_settings_sessions)
ChatTransportTier.Completions -> stringResource(R.string.chat_settings_completions)
ChatTransportTier.Runs -> stringResource(R.string.chat_settings_runs)
ChatTransportTier.Offline -> stringResource(R.string.chat_settings_offline)
},
style = MaterialTheme.typography.labelSmall.copy(fontWeight = if (active) {
FontWeight.Bold
} else {
@@ -1060,11 +1065,11 @@ private fun GatewaySignInCallout() {
)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = "Gateway needs Manage sign-in",
text = stringResource(R.string.chat_settings_gateway_needs_signin),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
)
Text(
text = "Sign in once from the Manage tab to unlock live thinking over /api/ws.",
text = stringResource(R.string.chat_settings_gateway_needs_signin_desc),
style = MaterialTheme.typography.bodySmall,
)
}
@@ -1084,12 +1089,3 @@ private fun tierAvailable(
ChatTransportTier.Gateway -> gatewayAvailability == GatewayAvailability.Ready
ChatTransportTier.Offline -> false
}
private fun ChatTransportTier.ladderLabel(): String =
when (this) {
ChatTransportTier.Gateway -> "Gateway"
ChatTransportTier.Sessions -> "Sessions"
ChatTransportTier.Completions -> "Completions"
ChatTransportTier.Runs -> "Runs"
ChatTransportTier.Offline -> "offline"
}
@@ -43,8 +43,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
@@ -157,7 +159,7 @@ fun ConnectionDetailScreen(
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Text(
text = "Active",
text = stringResource(R.string.detail_active),
modifier = Modifier.padding(horizontal = 6.dp),
)
}
@@ -168,7 +170,7 @@ fun ConnectionDetailScreen(
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.detail_back),
)
}
},
@@ -176,7 +178,7 @@ fun ConnectionDetailScreen(
IconButton(onClick = { menuExpanded = true }) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = "More actions",
contentDescription = stringResource(R.string.detail_more_actions),
)
}
DropdownMenu(
@@ -184,7 +186,7 @@ fun ConnectionDetailScreen(
onDismissRequest = { menuExpanded = false },
) {
DropdownMenuItem(
text = { Text("Rename") },
text = { Text(stringResource(R.string.detail_rename)) },
onClick = {
menuExpanded = false
showRenameDialog = true
@@ -192,7 +194,7 @@ fun ConnectionDetailScreen(
)
DropdownMenuItem(
text = {
Text(if (connection.pairedAt == null) "Pair Relay" else "Re-pair")
Text(if (connection.pairedAt == null) stringResource(R.string.detail_pair_relay) else stringResource(R.string.detail_repair))
},
onClick = {
menuExpanded = false
@@ -201,7 +203,7 @@ fun ConnectionDetailScreen(
)
if (connection.pairedAt != null) {
DropdownMenuItem(
text = { Text("Revoke") },
text = { Text(stringResource(R.string.detail_revoke)) },
onClick = {
menuExpanded = false
showRevokeConfirm = true
@@ -210,7 +212,7 @@ fun ConnectionDetailScreen(
}
DropdownMenuItem(
text = {
Text("Remove", color = MaterialTheme.colorScheme.error)
Text(stringResource(R.string.detail_remove), color = MaterialTheme.colorScheme.error)
},
onClick = {
menuExpanded = false
@@ -236,7 +238,14 @@ fun ConnectionDetailScreen(
Tab(
selected = safeIndex == index,
onClick = { selectedTab = index },
text = { Text(tab.label) },
text = {
Text(when (tab) {
DetailTab.Overview -> stringResource(R.string.detail_tab_overview)
DetailTab.Routes -> stringResource(R.string.detail_tab_routes)
DetailTab.Advanced -> stringResource(R.string.detail_tab_advanced)
DetailTab.Security -> stringResource(R.string.detail_tab_security)
})
},
)
}
}
@@ -257,7 +266,7 @@ fun ConnectionDetailScreen(
connectionViewModel = connectionViewModel,
connection = connection,
relayConfigured = relayConfigured,
relayStatusText = relayRow.statusText(connectedLabel = "Connected"),
relayStatusText = relayRow.statusText(connectedLabel = stringResource(R.string.detail_connected)),
relayUiState = relayUiState,
relayEnabled = relayEnabled,
onReconnect = onReconnect,
@@ -342,12 +351,9 @@ fun ConnectionDetailScreen(
if (showRevokeConfirm) {
AlertDialog(
onDismissRequest = { showRevokeConfirm = false },
title = { Text("Revoke this connection?") },
title = { Text(stringResource(R.string.detail_revoke_title)) },
text = {
Text(
"The server session will be invalidated. The connection stays " +
"on this device but will need to be re-paired to use again.",
)
Text(stringResource(R.string.detail_revoke_body))
},
confirmButton = {
TextButton(
@@ -355,22 +361,19 @@ fun ConnectionDetailScreen(
onRevoke(connectionId)
showRevokeConfirm = false
},
) { Text("Revoke") }
) { Text(stringResource(R.string.detail_revoke)) }
},
dismissButton = {
TextButton(onClick = { showRevokeConfirm = false }) { Text("Cancel") }
TextButton(onClick = { showRevokeConfirm = false }) { Text(stringResource(R.string.detail_cancel)) }
},
)
}
if (showRemoveConfirm) {
AlertDialog(
onDismissRequest = { showRemoveConfirm = false },
title = { Text("Remove this connection?") },
title = { Text(stringResource(R.string.detail_remove_title)) },
text = {
Text(
"The connection will be deleted from this device along with its " +
"saved session token. This cannot be undone.",
)
Text(stringResource(R.string.detail_remove_body))
},
confirmButton = {
TextButton(
@@ -381,21 +384,21 @@ fun ConnectionDetailScreen(
showRemoveConfirm = false
},
) {
Text("Remove", color = MaterialTheme.colorScheme.error)
Text(stringResource(R.string.detail_remove), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showRemoveConfirm = false }) { Text("Cancel") }
TextButton(onClick = { showRemoveConfirm = false }) { Text(stringResource(R.string.detail_cancel)) }
},
)
}
}
private enum class DetailTab(val label: String) {
Overview("Overview"),
Routes("Routes"),
Advanced("Advanced"),
Security("Security"),
private enum class DetailTab {
Overview,
Routes,
Advanced,
Security,
}
/**
@@ -419,7 +422,7 @@ private fun ActiveOverview(
onOpenSessionInfo: () -> Unit,
) {
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val headerLine = if (relayConfigured) relayStatusText else "Standard · $hostname"
val headerLine = if (relayConfigured) relayStatusText else stringResource(R.string.detail_standard_prefix) + hostname
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
@@ -448,8 +451,7 @@ private fun ActiveOverview(
}
Text(
text = "What this connection can do. Routes only choose how the phone " +
"reaches Hermes.",
text = stringResource(R.string.detail_routes_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -468,10 +470,10 @@ private fun ActiveOverview(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (relayUiState == RelayUiState.Stale) {
Button(onClick = onReconnect) { Text("Reconnect") }
Button(onClick = onReconnect) { Text(stringResource(R.string.detail_reconnect)) }
}
TextButton(onClick = onRepair) {
Text(if (connection.pairedAt == null) "Pair Relay" else "Re-pair")
Text(if (connection.pairedAt == null) stringResource(R.string.detail_pair_relay) else stringResource(R.string.detail_repair))
}
}
}
@@ -489,9 +491,9 @@ private fun InactiveOverview(
) {
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val statusLine = when {
connection.pairedAt != null -> "Paired · relay configured"
connection.apiServerUrl.isNotBlank() -> "Standard · relay not paired"
else -> "Not configured"
connection.pairedAt != null -> stringResource(R.string.detail_paired_relay_configured)
connection.apiServerUrl.isNotBlank() -> stringResource(R.string.detail_standard_not_paired)
else -> stringResource(R.string.detail_not_configured)
}
Surface(
@@ -514,8 +516,7 @@ private fun InactiveOverview(
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = "This connection isn't active. Switch to it to see its live " +
"status and manage routes, advanced settings, and relay sessions.",
text = stringResource(R.string.detail_inactive_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -523,9 +524,9 @@ private fun InactiveOverview(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(onClick = onSwitch) { Text("Switch to this connection") }
Button(onClick = onSwitch) { Text(stringResource(R.string.detail_switch_to)) }
TextButton(onClick = onRepair) {
Text(if (connection.pairedAt == null) "Pair Relay" else "Re-pair")
Text(if (connection.pairedAt == null) stringResource(R.string.detail_pair_relay) else stringResource(R.string.detail_repair))
}
}
}
@@ -542,7 +543,7 @@ private fun RenameConnectionDialog(
val validation = com.hermesandroid.relay.data.ConnectionValidation.validateLabel(input)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename connection") },
title = { Text(stringResource(R.string.detail_rename_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
@@ -562,11 +563,11 @@ private fun RenameConnectionDialog(
onClick = { onConfirm(input.trim()) },
enabled = validation == null && input.trim() != initialLabel,
) {
Text("Rename")
Text(stringResource(R.string.detail_rename))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.detail_cancel)) }
},
)
}
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.screens
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -41,8 +42,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.network.relay.RelayUrlDeriver
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -97,12 +101,12 @@ fun ConnectionsSettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Connections") },
title = { Text(stringResource(R.string.conn_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.conn_back),
)
}
},
@@ -115,7 +119,7 @@ fun ConnectionsSettingsScreen(
ExtendedFloatingActionButton(
onClick = onAddConnection,
icon = { Icon(imageVector = Icons.Filled.Add, contentDescription = null) },
text = { Text("Add connection") },
text = { Text(stringResource(R.string.conn_add_connection)) },
)
},
) { innerPadding ->
@@ -130,9 +134,9 @@ fun ConnectionsSettingsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = "No connections yet", style = MaterialTheme.typography.titleMedium)
Text(text = stringResource(R.string.conn_no_connections), style = MaterialTheme.typography.titleMedium)
Text(
text = "Tap Add connection to connect to Hermes.",
text = stringResource(R.string.conn_no_connections_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -182,6 +186,7 @@ private fun ConnectionListCard(
relayConfigured: Boolean,
onClick: () -> Unit,
) {
val context = LocalContext.current
// Active card: muted indigo wash instead of full-strength primaryContainer —
// a card-sized fill of the brand blue overwhelmed body text (2026-06-10
// feedback); small accents keep the vivid blue.
@@ -216,7 +221,7 @@ private fun ConnectionListCard(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Text(text = "Active", modifier = Modifier.padding(horizontal = 6.dp))
Text(text = stringResource(R.string.conn_active), modifier = Modifier.padding(horizontal = 6.dp))
}
}
Icon(
@@ -233,10 +238,10 @@ private fun ConnectionListCard(
val pairedStatus = when {
liveState != null &&
(connection.pairedAt != null || liveState != RelayUiState.NotConfigured) ->
liveState.statusText(connectedLabel = "Connected")
connection.pairedAt != null -> formatPairedRelative(connection.pairedAt)
hasStandardApi -> "Standard · Relay not paired"
else -> "Not paired"
liveState.statusText(connectedLabel = stringResource(R.string.conn_connected))
connection.pairedAt != null -> formatPairedRelative(context, connection.pairedAt)
hasStandardApi -> stringResource(R.string.conn_standard_not_paired)
else -> stringResource(R.string.conn_not_paired)
}
val statusColor = if (liveState == RelayUiState.Stale) {
MaterialTheme.colorScheme.tertiary
@@ -311,39 +316,40 @@ private fun ConnectionSurfaceSummary(
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val apiText = when {
connection.apiServerUrl.isBlank() -> "Missing"
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking"
activeApiReachable == true -> "Ready"
isActive && activeApiReachable == false -> "Offline"
else -> "Configured"
connection.apiServerUrl.isBlank() -> stringResource(R.string.conn_api_missing)
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> stringResource(R.string.conn_api_checking)
activeApiReachable == true -> stringResource(R.string.conn_api_ready)
isActive && activeApiReachable == false -> stringResource(R.string.conn_api_offline)
else -> stringResource(R.string.conn_api_configured)
}
val apiTone = when (apiText) {
"Ready" -> SummaryTone.Good
"Offline", "Missing" -> SummaryTone.Warning
val apiTone = when {
activeApiReachable == true -> SummaryTone.Good
connection.apiServerUrl.isBlank() -> SummaryTone.Warning
isActive && activeApiReachable == false -> SummaryTone.Warning
else -> SummaryTone.Neutral
}
val dashboardText = when {
connection.resolvedDashboardUrl.isBlank() -> "Missing"
dashboardStatus == null -> "Unchecked"
!dashboardStatus.reachable -> "Offline"
dashboardSignInRequired -> "Sign in"
dashboardStatus.authenticated == true -> "Signed in"
else -> "Available"
connection.resolvedDashboardUrl.isBlank() -> stringResource(R.string.conn_dashboard_missing)
dashboardStatus == null -> stringResource(R.string.conn_dashboard_unchecked)
!dashboardStatus.reachable -> stringResource(R.string.conn_dashboard_offline)
dashboardSignInRequired -> stringResource(R.string.conn_dashboard_sign_in)
dashboardStatus.authenticated == true -> stringResource(R.string.conn_dashboard_signed_in)
else -> stringResource(R.string.conn_dashboard_available)
}
val dashboardTone = when (dashboardText) {
"Signed in", "Available" -> SummaryTone.Good
"Sign in" -> SummaryTone.Info
"Offline", "Missing" -> SummaryTone.Warning
val dashboardTone = when {
dashboardStatus?.authenticated == true && dashboardStatus.reachable -> SummaryTone.Good
dashboardSignInRequired -> SummaryTone.Info
connection.resolvedDashboardUrl.isBlank() || (dashboardStatus != null && !dashboardStatus.reachable) -> SummaryTone.Warning
else -> SummaryTone.Neutral
}
val relayText = when {
!relayConfigured -> "Optional"
liveState != null -> liveState.statusText(connectedLabel = "Ready")
connection.pairedAt != null -> "Paired"
connection.relayUrl.isNotBlank() -> "Configured"
else -> "Configure"
!relayConfigured -> stringResource(R.string.conn_relay_optional)
liveState != null -> liveState.statusText(connectedLabel = stringResource(R.string.conn_relay_ready))
connection.pairedAt != null -> stringResource(R.string.conn_relay_paired)
connection.relayUrl.isNotBlank() -> stringResource(R.string.conn_relay_configured)
else -> stringResource(R.string.conn_relay_configure)
}
val relayTone = when {
!relayConfigured -> SummaryTone.Neutral
@@ -353,18 +359,18 @@ private fun ConnectionSurfaceSummary(
}
val voiceText = when {
activeVoiceReady == true -> "Ready"
standardVoiceAvailability == StandardVoiceAvailability.SignInRequired -> "Sign in"
standardVoiceAvailability == StandardVoiceAvailability.Unsupported -> "Unsupported"
standardVoiceAvailability == StandardVoiceAvailability.Unreachable -> "Offline"
standardVoiceAvailability == StandardVoiceAvailability.Unknown && isActive -> "Checking"
relayConfigured -> "Relay"
else -> "Optional"
activeVoiceReady == true -> stringResource(R.string.conn_voice_ready)
standardVoiceAvailability == StandardVoiceAvailability.SignInRequired -> stringResource(R.string.conn_voice_sign_in)
standardVoiceAvailability == StandardVoiceAvailability.Unsupported -> stringResource(R.string.conn_voice_unsupported)
standardVoiceAvailability == StandardVoiceAvailability.Unreachable -> stringResource(R.string.conn_voice_offline)
standardVoiceAvailability == StandardVoiceAvailability.Unknown && isActive -> stringResource(R.string.conn_voice_checking)
relayConfigured -> stringResource(R.string.conn_voice_relay)
else -> stringResource(R.string.conn_voice_optional)
}
val voiceTone = when (voiceText) {
"Ready" -> SummaryTone.Good
"Sign in", "Relay" -> SummaryTone.Info
"Unsupported", "Offline" -> SummaryTone.Warning
val voiceTone = when {
activeVoiceReady == true -> SummaryTone.Good
standardVoiceAvailability == StandardVoiceAvailability.SignInRequired || relayConfigured -> SummaryTone.Info
standardVoiceAvailability == StandardVoiceAvailability.Unsupported || standardVoiceAvailability == StandardVoiceAvailability.Unreachable -> SummaryTone.Warning
else -> SummaryTone.Neutral
}
@@ -376,23 +382,23 @@ private fun ConnectionSurfaceSummary(
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
ConnectionSurfaceRow(label = "API", value = apiText, tone = apiTone)
ConnectionSurfaceRow(label = stringResource(R.string.conn_api_label), value = apiText, tone = apiTone)
SurfaceRowDivider()
ConnectionSurfaceRow(
label = "Dashboard",
label = stringResource(R.string.conn_dashboard_label),
value = dashboardText,
tone = dashboardTone,
onClick = if (dashboardSignInRequired) onOpenDashboard else null,
)
SurfaceRowDivider()
ConnectionSurfaceRow(
label = "Voice",
label = stringResource(R.string.conn_voice_label),
value = voiceText,
tone = voiceTone,
onClick = if (voiceText == "Sign in") onOpenDashboard else null,
onClick = if (standardVoiceAvailability == StandardVoiceAvailability.SignInRequired) onOpenDashboard else null,
)
SurfaceRowDivider()
ConnectionSurfaceRow(label = "Relay", value = relayText, tone = relayTone)
ConnectionSurfaceRow(label = stringResource(R.string.conn_relay_label), value = relayText, tone = relayTone)
}
}
}
@@ -475,17 +481,17 @@ private fun Connection.hasConfiguredRelay(): Boolean {
* Hand-rolled "N minutes ago" formatter for the card subtitle. `DateUtils`
* returns awkward copy ("in 0 minutes") for small deltas.
*/
private fun formatPairedRelative(pairedAtMillis: Long): String {
private fun formatPairedRelative(context: Context, pairedAtMillis: Long): String {
val deltaMs = System.currentTimeMillis() - pairedAtMillis
if (deltaMs < 0) return "Just paired"
if (deltaMs < 0) return context.getString(R.string.conn_just_paired)
val minutes = TimeUnit.MILLISECONDS.toMinutes(deltaMs)
val hours = TimeUnit.MILLISECONDS.toHours(deltaMs)
val days = TimeUnit.MILLISECONDS.toDays(deltaMs)
return when {
minutes < 1L -> "Just paired"
minutes < 60L -> "Paired $minutes minute${if (minutes == 1L) "" else "s"} ago"
hours < 24L -> "Paired $hours hour${if (hours == 1L) "" else "s"} ago"
days < 30L -> "Paired $days day${if (days == 1L) "" else "s"} ago"
else -> "Paired"
minutes < 1L -> context.getString(R.string.conn_just_paired)
minutes < 60L -> context.resources.getQuantityString(R.plurals.conn_paired_minutes_ago, minutes.toInt(), minutes)
hours < 24L -> context.resources.getQuantityString(R.plurals.conn_paired_hours_ago, hours.toInt(), hours)
days < 30L -> context.resources.getQuantityString(R.plurals.conn_paired_days_ago, days.toInt(), days)
else -> context.getString(R.string.conn_paired)
}
}
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,5 @@
@file:Suppress("LocalContextGetResourceValueCall")
package com.hermesandroid.relay.ui.screens
import android.widget.Toast
@@ -47,7 +49,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
@@ -91,7 +95,7 @@ fun DeveloperSettingsScreen(
connectionViewModel.writeBackupToUri(uri, backupJson!!) { success ->
Toast.makeText(
context,
if (success) "Settings exported" else "Export failed",
if (success) context.getString(R.string.dev_settings_exported) else context.getString(R.string.dev_settings_export_failed),
Toast.LENGTH_SHORT
).show()
backupJson = null
@@ -107,7 +111,7 @@ fun DeveloperSettingsScreen(
connectionViewModel.importFromUri(uri) { success ->
Toast.makeText(
context,
if (success) "Settings imported" else "Import failed — invalid file",
if (success) context.getString(R.string.dev_settings_imported) else context.getString(R.string.dev_settings_import_failed),
Toast.LENGTH_SHORT
).show()
}
@@ -117,12 +121,12 @@ fun DeveloperSettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Developer options") },
title = { Text(stringResource(R.string.dev_settings_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.dev_settings_back),
)
}
},
@@ -142,7 +146,7 @@ fun DeveloperSettingsScreen(
) {
// Data Management section
Text(
text = "Data",
text = stringResource(R.string.dev_settings_data_section),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -170,22 +174,22 @@ fun DeveloperSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Reset Onboarding",
text = stringResource(R.string.dev_settings_reset_onboarding),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Show the setup guide again on next launch",
text = stringResource(R.string.dev_settings_reset_onboarding_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = {
connectionViewModel.resetOnboarding()
Toast.makeText(context, "Onboarding will show on next launch", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.dev_settings_onboarding_reset_toast), Toast.LENGTH_SHORT).show()
}) {
Icon(
imageVector = Icons.Filled.RestartAlt,
contentDescription = "Reset onboarding"
contentDescription = stringResource(R.string.dev_settings_reset_onboarding_cd)
)
}
}
@@ -200,11 +204,11 @@ fun DeveloperSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Export Settings",
text = stringResource(R.string.dev_settings_export_settings),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Full backup with API keys, tokens, and dashboard cookies",
text = stringResource(R.string.dev_settings_export_settings_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -212,7 +216,7 @@ fun DeveloperSettingsScreen(
IconButton(onClick = { showExportDialog = true }) {
Icon(
imageVector = Icons.Filled.FileDownload,
contentDescription = "Export settings"
contentDescription = stringResource(R.string.dev_settings_export_settings_cd)
)
}
}
@@ -225,11 +229,11 @@ fun DeveloperSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Import Settings",
text = stringResource(R.string.dev_settings_import_settings),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Restore full backup and replace saved connections",
text = stringResource(R.string.dev_settings_import_settings_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -237,7 +241,7 @@ fun DeveloperSettingsScreen(
IconButton(onClick = { showImportDialog = true }) {
Icon(
imageVector = Icons.Filled.FileUpload,
contentDescription = "Import settings"
contentDescription = stringResource(R.string.dev_settings_import_settings_cd)
)
}
}
@@ -252,12 +256,12 @@ fun DeveloperSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Reset All Data",
text = stringResource(R.string.dev_settings_reset_all_data),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
Text(
text = "Clear all settings, tokens, API keys, and cached data",
text = stringResource(R.string.dev_settings_reset_all_data_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -265,7 +269,7 @@ fun DeveloperSettingsScreen(
IconButton(onClick = { showResetDialog = true }) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Reset all data",
contentDescription = stringResource(R.string.dev_settings_reset_all_data_cd),
tint = MaterialTheme.colorScheme.error
)
}
@@ -275,7 +279,7 @@ fun DeveloperSettingsScreen(
// Developer Options section
Text(
text = "Developer Options",
text = stringResource(R.string.dev_options_section),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.tertiary
)
@@ -313,12 +317,12 @@ fun DeveloperSettingsScreen(
tint = MaterialTheme.colorScheme.tertiary
)
Text(
text = "Relay features",
text = stringResource(R.string.dev_settings_relay_features),
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = "Show Relay Server and Pairing settings for Bridge/Terminal development",
text = stringResource(R.string.dev_settings_relay_features_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -348,12 +352,12 @@ fun DeveloperSettingsScreen(
tint = MaterialTheme.colorScheme.tertiary
)
Text(
text = "Realtime voice lab",
text = stringResource(R.string.dev_settings_realtime_voice_lab),
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = "Open the provider websocket testbench for dev builds",
text = stringResource(R.string.dev_settings_realtime_voice_lab_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -361,7 +365,7 @@ fun DeveloperSettingsScreen(
IconButton(onClick = onNavigateToRealtimeVoice) {
Icon(
imageVector = Icons.Filled.Science,
contentDescription = "Open realtime voice lab"
contentDescription = stringResource(R.string.dev_settings_open_realtime_voice_lab_cd)
)
}
}
@@ -376,23 +380,23 @@ fun DeveloperSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Lock developer options",
text = stringResource(R.string.dev_settings_lock_dev_options),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Hide this section and disable experimental features",
text = stringResource(R.string.dev_settings_lock_dev_options_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = {
scope.launch { FeatureFlags.lockDevOptions(context) }
Toast.makeText(context, "Developer options locked", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.dev_settings_locked_toast), Toast.LENGTH_SHORT).show()
onBack()
}) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = "Lock developer options"
contentDescription = stringResource(R.string.dev_settings_lock_dev_options_cd)
)
}
}
@@ -405,7 +409,7 @@ fun DeveloperSettingsScreen(
// ships in a release APK.
if (FeatureFlags.isDevBuild) {
Text(
text = "Test harness",
text = stringResource(R.string.dev_settings_test_harness),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.tertiary,
)
@@ -426,56 +430,56 @@ fun DeveloperSettingsScreen(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TestHarnessRow(
title = "Emit sample diagnostics",
subtitle = "Push Info / Warning / Error entries into the diagnostics log",
title = stringResource(R.string.dev_settings_test_emit_diagnostics),
subtitle = stringResource(R.string.dev_settings_test_emit_diagnostics_desc),
icon = Icons.Filled.Science,
onClick = {
DiagnosticsLog.record(
category = DiagnosticCategory.Api,
severity = DiagnosticSeverity.Info,
title = "Sample info diagnostic",
detail = "Emitted from the Developer options test harness.",
title = context.getString(R.string.dev_settings_sample_info_title),
detail = context.getString(R.string.dev_settings_sample_info_detail),
)
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Sample warning diagnostic",
detail = "Relay reachability degraded (synthetic).",
title = context.getString(R.string.dev_settings_sample_warning_title),
detail = context.getString(R.string.dev_settings_sample_warning_detail),
)
DiagnosticsLog.recordError(
category = DiagnosticCategory.Voice,
title = "Sample error diagnostic",
detail = "Synthetic failure for the detail view.",
title = context.getString(R.string.dev_settings_sample_error_title),
detail = context.getString(R.string.dev_settings_sample_error_detail),
throwable = RuntimeException(
"Sample stacktrace — Developer options test harness",
context.getString(R.string.dev_settings_sample_stacktrace),
),
)
Toast.makeText(context, "3 sample diagnostics emitted", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.dev_settings_diagnostics_emitted_toast), Toast.LENGTH_SHORT).show()
},
)
HorizontalDivider()
TestHarnessRow(
title = "Preview update banner",
subtitle = "Cycle the in-app update banner: Available → Downloaded → off",
title = stringResource(R.string.dev_settings_test_preview_update_banner),
subtitle = stringResource(R.string.dev_settings_test_preview_update_banner_desc),
icon = Icons.Filled.Science,
onClick = {
UpdateDebugOverride.cycle()
val state = when (UpdateDebugOverride.flow.value) {
is UpdateStatus.Available -> "Available"
is UpdateStatus.Downloaded -> "Downloaded"
else -> "off"
is UpdateStatus.Available -> context.getString(R.string.dev_settings_update_state_available)
is UpdateStatus.Downloaded -> context.getString(R.string.dev_settings_update_state_downloaded)
else -> context.getString(R.string.dev_settings_update_state_off)
}
Toast.makeText(context, "Update banner preview: $state", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.dev_settings_update_banner_preview_toast, state), Toast.LENGTH_SHORT).show()
},
)
HorizontalDivider()
TestHarnessRow(
title = "Show What's New",
subtitle = "Open the What's New dialog now",
title = stringResource(R.string.dev_settings_test_show_whats_new),
subtitle = stringResource(R.string.dev_settings_test_show_whats_new_desc),
icon = Icons.Filled.Science,
onClick = {
connectionViewModel.showWhatsNewNow()
@@ -486,8 +490,8 @@ fun DeveloperSettingsScreen(
HorizontalDivider()
TestHarnessRow(
title = "Force a test crash",
subtitle = "Throws an uncaught exception — the crash report shows on next launch",
title = stringResource(R.string.dev_settings_test_force_crash),
subtitle = stringResource(R.string.dev_settings_test_force_crash_desc),
icon = Icons.Filled.Warning,
tint = MaterialTheme.colorScheme.error,
onClick = {
@@ -505,10 +509,10 @@ fun DeveloperSettingsScreen(
if (showExportDialog) {
AlertDialog(
onDismissRequest = { showExportDialog = false },
title = { Text("Export sensitive backup?") },
title = { Text(stringResource(R.string.dev_settings_export_sensitive_backup_title)) },
text = {
Text(
"This backup includes saved connections, API keys, relay session tokens, device IDs, and dashboard cookies. Anyone with the file may be able to access your Hermes server."
stringResource(R.string.dev_settings_export_sensitive_backup_body)
)
},
confirmButton = {
@@ -521,12 +525,12 @@ fun DeveloperSettingsScreen(
}
}
) {
Text("Export")
Text(stringResource(R.string.dev_settings_export_action))
}
},
dismissButton = {
TextButton(onClick = { showExportDialog = false }) {
Text("Cancel")
Text(stringResource(R.string.dev_settings_cancel))
}
}
)
@@ -535,10 +539,10 @@ fun DeveloperSettingsScreen(
if (showImportDialog) {
AlertDialog(
onDismissRequest = { showImportDialog = false },
title = { Text("Import backup?") },
title = { Text(stringResource(R.string.dev_settings_import_backup_title)) },
text = {
Text(
"Importing a backup can restore API keys, relay tokens, device IDs, and dashboard cookies. It replaces the saved connection list on this device."
stringResource(R.string.dev_settings_import_backup_body)
)
},
confirmButton = {
@@ -548,12 +552,12 @@ fun DeveloperSettingsScreen(
importLauncher.launch(arrayOf("application/json"))
}
) {
Text("Choose file")
Text(stringResource(R.string.dev_settings_choose_file))
}
},
dismissButton = {
TextButton(onClick = { showImportDialog = false }) {
Text("Cancel")
Text(stringResource(R.string.dev_settings_cancel))
}
}
)
@@ -563,10 +567,10 @@ fun DeveloperSettingsScreen(
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("Reset all app data?") },
title = { Text(stringResource(R.string.dev_settings_reset_all_app_data_title)) },
text = {
Text(
"This clears saved connections, API keys, Relay tokens, dashboard cookies, device IDs, settings, and cached data. Use dashboard sign out or Relay pairing controls when you only need to clear one connection path. This cannot be undone."
stringResource(R.string.dev_settings_reset_all_app_data_body)
)
},
confirmButton = {
@@ -574,15 +578,15 @@ fun DeveloperSettingsScreen(
onClick = {
showResetDialog = false
connectionViewModel.resetAppData()
Toast.makeText(context, "App data reset", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.dev_settings_app_data_reset_toast), Toast.LENGTH_SHORT).show()
}
) {
Text("Reset", color = MaterialTheme.colorScheme.error)
Text(stringResource(R.string.dev_settings_reset_action), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) {
Text("Cancel")
Text(stringResource(R.string.dev_settings_cancel))
}
}
)
@@ -23,7 +23,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.auth.AuthState
import com.hermesandroid.relay.diagnostics.CheckStatus
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
@@ -69,6 +71,8 @@ fun DiagnosticsScreen(
val relayVoiceReady by connectionViewModel.relayVoiceReady.collectAsState()
val entries by DiagnosticsLog.entries.collectAsState()
val context = androidx.compose.ui.platform.LocalContext.current
val checks = remember(
network, apiHealth, apiUrl, capabilities, authState, chatReady,
relayConfigured, relayHealth, relayReady, voiceReady, relayVoiceReady, entries,
@@ -86,6 +90,7 @@ fun DiagnosticsScreen(
voiceReady = voiceReady,
relayVoiceReady = relayVoiceReady,
recentEntries = entries,
context = context,
)
}
@@ -95,12 +100,12 @@ fun DiagnosticsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Diagnostics") },
title = { Text(stringResource(R.string.diag_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.diag_back),
)
}
},
@@ -119,7 +124,7 @@ fun DiagnosticsScreen(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Status",
text = stringResource(R.string.diag_status),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
@@ -136,19 +141,18 @@ fun DiagnosticsScreen(
)
Text(
text = "Recent diagnostics",
text = stringResource(R.string.diag_recent_diagnostics),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Recent app-level connection and voice events. Secrets and raw " +
"payloads are hidden.",
text = stringResource(R.string.diag_recent_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
DiagnosticsLogPanel(
title = "Activity log",
title = stringResource(R.string.diag_activity_log),
limit = 80,
showCategory = true,
showClear = true,
@@ -188,6 +192,7 @@ internal fun buildStatusChecks(
voiceReady: Boolean,
relayVoiceReady: Boolean,
recentEntries: List<DiagnosticLogEntry>,
context: android.content.Context,
): List<StatusCheck> {
// Most recent ERROR for a category (entries are oldest -> newest).
fun recentError(category: DiagnosticCategory): DiagnosticLogEntry? =
@@ -200,175 +205,207 @@ internal fun buildStatusChecks(
val checks = mutableListOf<StatusCheck>()
// 1) Network reachability.
val networkLabel = context.getString(R.string.diag_check_network)
val deviceOnline = context.getString(R.string.diag_check_device_online)
val networkLost = context.getString(R.string.diag_check_network_lost)
val noNetwork = context.getString(R.string.diag_check_no_network)
checks += when (network) {
ConnectivityObserver.Status.Available ->
StatusCheck("Network", CheckStatus.Pass, reason = "Device is online")
StatusCheck(networkLabel, CheckStatus.Pass, reason = deviceOnline)
ConnectivityObserver.Status.Lost ->
StatusCheck(
"Network", CheckStatus.Fail,
reason = "Network connection lost",
networkLabel, CheckStatus.Fail,
reason = networkLost,
category = DiagnosticCategory.Endpoint,
)
ConnectivityObserver.Status.Unavailable ->
StatusCheck(
"Network", CheckStatus.Warn,
reason = "No active network detected",
networkLabel, CheckStatus.Warn,
reason = noNetwork,
category = DiagnosticCategory.Endpoint,
)
}
// 2) API server reachability.
val apiLabel = context.getString(R.string.diag_check_api_server)
val reachableAt = context.getString(R.string.diag_check_reachable_at)
val reachable = context.getString(R.string.diag_check_reachable)
val notReachableAt = context.getString(R.string.diag_check_not_reachable_at)
val notReachable = context.getString(R.string.diag_check_not_reachable)
val probing = context.getString(R.string.diag_check_probing)
val notChecked = context.getString(R.string.diag_check_not_checked)
val host = DiagnosticsLog.sanitizeUrl(apiUrl)
val apiErr = recentError(DiagnosticCategory.Api)
checks += when (apiHealth) {
ConnectionViewModel.HealthStatus.Reachable ->
StatusCheck(
"API server", CheckStatus.Pass,
reason = host?.let { "Reachable at $it" } ?: "Reachable",
apiLabel, CheckStatus.Pass,
reason = host?.let { context.getString(R.string.diag_check_reachable_at, it) } ?: reachable,
category = DiagnosticCategory.Api,
)
ConnectionViewModel.HealthStatus.Unreachable ->
StatusCheck(
"API server", CheckStatus.Fail,
reason = apiErr?.message() ?: (host?.let { "Not reachable at $it" } ?: "Not reachable"),
apiLabel, CheckStatus.Fail,
reason = apiErr?.message() ?: (host?.let { context.getString(R.string.diag_check_not_reachable_at, it) } ?: notReachable),
category = DiagnosticCategory.Api,
timestampMs = apiErr?.timestampMs,
)
ConnectionViewModel.HealthStatus.Probing ->
StatusCheck(
"API server", CheckStatus.Unknown,
reason = "Probing…",
apiLabel, CheckStatus.Unknown,
reason = probing,
category = DiagnosticCategory.Api,
)
ConnectionViewModel.HealthStatus.Unknown ->
StatusCheck(
"API server", CheckStatus.Unknown,
reason = "Not checked yet",
apiLabel, CheckStatus.Unknown,
reason = notChecked,
category = DiagnosticCategory.Api,
)
}
// 3) Server capabilities (which chat surfaces the server advertises).
val capsLabel = context.getString(R.string.diag_check_server_capabilities)
val capsNoHealthy = context.getString(R.string.diag_check_no_server_yet)
val capsNativeSessions = context.getString(R.string.diag_check_native_sessions)
val capsSseFallback = context.getString(R.string.diag_check_sse_fallback)
val capsNoEndpoint = context.getString(R.string.diag_check_no_chat_endpoint)
checks += when {
!capabilities.healthy ->
StatusCheck(
"Server capabilities", CheckStatus.Unknown,
reason = "Not probed — no healthy server yet",
capsLabel, CheckStatus.Unknown,
reason = capsNoHealthy,
category = DiagnosticCategory.Api,
)
capabilities.sessionsChatStream ->
StatusCheck(
"Server capabilities", CheckStatus.Pass,
reason = "Native session streaming available",
capsLabel, CheckStatus.Pass,
reason = capsNativeSessions,
category = DiagnosticCategory.Api,
)
capabilities.sessionsApi || capabilities.runs || capabilities.portable ->
StatusCheck(
"Server capabilities", CheckStatus.Warn,
reason = "No session SSE — falling back to ${capabilities.preferredChatEndpoint()}",
capsLabel, CheckStatus.Warn,
reason = context.getString(R.string.diag_check_sse_fallback, capabilities.preferredChatEndpoint()),
category = DiagnosticCategory.Api,
)
else ->
StatusCheck(
"Server capabilities", CheckStatus.Fail,
reason = "No usable chat endpoint advertised",
capsLabel, CheckStatus.Fail,
reason = capsNoEndpoint,
category = DiagnosticCategory.Api,
)
}
// 4) Chat transport readiness.
val chatLabel = context.getString(R.string.diag_check_chat_transport)
val chatReadyFmt = context.getString(R.string.diag_check_ready_with)
val chatNotReady = context.getString(R.string.diag_check_not_ready)
val chatErr = recentError(DiagnosticCategory.Session) ?: recentError(DiagnosticCategory.Api)
checks += if (chatReady) {
StatusCheck(
"Chat transport", CheckStatus.Pass,
reason = "Ready · ${capabilities.preferredChatEndpoint()}",
chatLabel, CheckStatus.Pass,
reason = context.getString(R.string.diag_check_ready_with, capabilities.preferredChatEndpoint()),
category = DiagnosticCategory.Session,
)
} else {
val degraded = apiHealth == ConnectionViewModel.HealthStatus.Reachable
StatusCheck(
"Chat transport",
chatLabel,
if (degraded) CheckStatus.Warn else CheckStatus.Fail,
reason = chatErr?.message() ?: "Not ready — no usable streaming endpoint",
reason = chatErr?.message() ?: chatNotReady,
category = DiagnosticCategory.Session,
timestampMs = chatErr?.timestampMs,
)
}
// 5) Relay / pairing auth.
val authLabel = context.getString(R.string.diag_check_pairing_auth)
val authRelayActive = context.getString(R.string.diag_check_relay_active)
val authPairingProg = context.getString(R.string.diag_check_pairing_progress)
val authNotPaired = context.getString(R.string.diag_check_not_paired)
val authErr = recentError(DiagnosticCategory.Auth)
checks += when (authState) {
is AuthState.Paired ->
StatusCheck(
"Pairing / auth", CheckStatus.Pass,
reason = "Relay session active",
authLabel, CheckStatus.Pass,
reason = authRelayActive,
category = DiagnosticCategory.Auth,
)
is AuthState.Pairing ->
StatusCheck(
"Pairing / auth", CheckStatus.Warn,
reason = "Pairing in progress…",
authLabel, CheckStatus.Warn,
reason = authPairingProg,
category = DiagnosticCategory.Auth,
)
is AuthState.Failed ->
StatusCheck(
"Pairing / auth", CheckStatus.Fail,
authLabel, CheckStatus.Fail,
reason = authState.reason,
category = DiagnosticCategory.Auth,
timestampMs = authErr?.timestampMs,
)
is AuthState.Unpaired ->
StatusCheck(
"Pairing / auth", CheckStatus.Unknown,
reason = "Not paired — vanilla Hermes path doesn't require pairing",
authLabel, CheckStatus.Unknown,
reason = authNotPaired,
category = DiagnosticCategory.Auth,
)
}
// 6) Relay server (optional — Unknown when not paired/configured).
val relayLabel = context.getString(R.string.diag_check_relay_server)
val relayNotConfigured = context.getString(R.string.diag_check_relay_not_configured)
val relayConnected = context.getString(R.string.diag_check_connected)
val relayReachableNotReady = context.getString(R.string.diag_check_reachable_not_ready)
val relayConfiguredNotReachable = context.getString(R.string.diag_check_configured_not_reachable)
val relayErr = recentError(DiagnosticCategory.Relay)
checks += when {
!relayConfigured ->
StatusCheck(
"Relay server", CheckStatus.Unknown,
reason = "Not paired — relay features are optional",
relayLabel, CheckStatus.Unknown,
reason = relayNotConfigured,
category = DiagnosticCategory.Relay,
)
relayReady ->
StatusCheck(
"Relay server", CheckStatus.Pass,
reason = "Connected",
relayLabel, CheckStatus.Pass,
reason = relayConnected,
category = DiagnosticCategory.Relay,
)
relayHealth == ConnectionViewModel.HealthStatus.Reachable ->
StatusCheck(
"Relay server", CheckStatus.Warn,
reason = relayErr?.message() ?: "Reachable but session not ready",
relayLabel, CheckStatus.Warn,
reason = relayErr?.message() ?: relayReachableNotReady,
category = DiagnosticCategory.Relay,
timestampMs = relayErr?.timestampMs,
)
else ->
StatusCheck(
"Relay server", CheckStatus.Fail,
reason = relayErr?.message() ?: "Configured but not reachable",
relayLabel, CheckStatus.Fail,
reason = relayErr?.message() ?: relayConfiguredNotReachable,
category = DiagnosticCategory.Relay,
timestampMs = relayErr?.timestampMs,
)
}
// 7) Voice readiness.
val voiceLabel = context.getString(R.string.diag_check_voice)
val voiceRelayReady = context.getString(R.string.diag_check_voice_relay)
val voiceStandardReady = context.getString(R.string.diag_check_voice_standard)
val voiceNotConfigured = context.getString(R.string.diag_check_voice_not_configured)
val voiceErr = recentError(DiagnosticCategory.Voice)
checks += if (voiceReady) {
StatusCheck(
"Voice", CheckStatus.Pass,
reason = if (relayVoiceReady) "Relay voice ready" else "Standard voice ready",
voiceLabel, CheckStatus.Pass,
reason = if (relayVoiceReady) voiceRelayReady else voiceStandardReady,
category = DiagnosticCategory.Voice,
)
} else {
StatusCheck(
"Voice",
voiceLabel,
if (voiceErr != null) CheckStatus.Fail else CheckStatus.Unknown,
reason = voiceErr?.message() ?: "Not configured or unavailable",
reason = voiceErr?.message() ?: voiceNotConfigured,
category = DiagnosticCategory.Voice,
timestampMs = voiceErr?.timestampMs,
)
@@ -1,5 +1,8 @@
@file:Suppress("LocalContextGetResourceValueCall")
package com.hermesandroid.relay.ui.screens
import android.content.Context
import android.widget.Toast
import com.hermesandroid.relay.ui.theme.LocalBrand
import androidx.compose.foundation.layout.Arrangement
@@ -41,7 +44,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.BlurMode
import com.hermesandroid.relay.data.MediaSettings
import com.hermesandroid.relay.data.MediaSettingsRepository
@@ -87,12 +92,12 @@ fun MediaSettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Media") },
title = { Text(stringResource(R.string.media_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.media_back),
)
}
},
@@ -126,14 +131,12 @@ fun MediaSettingsScreen(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Controls how the app handles files sent by tool results " +
"(screenshots, PDFs, etc.) over the relay.",
text = stringResource(R.string.media_intro_1),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Relay only — these settings don't affect images you attach " +
"in chat or anything on a standard (no-Relay) connection.",
text = stringResource(R.string.media_intro_2),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -145,11 +148,11 @@ fun MediaSettingsScreen(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Max inbound attachment size",
text = stringResource(R.string.media_max_inbound),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "${settings.maxInboundSizeMb} MB",
text = stringResource(R.string.media_max_inbound_value, settings.maxInboundSizeMb),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -161,7 +164,7 @@ fun MediaSettingsScreen(
steps = 18 // (100 - 5) / 5 - 1
)
Text(
text = "Files larger than this are rejected after download.",
text = stringResource(R.string.media_max_inbound_desc),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -176,11 +179,11 @@ fun MediaSettingsScreen(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Auto-fetch threshold",
text = stringResource(R.string.media_auto_fetch_threshold),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "${settings.autoFetchThresholdMb} MB",
text = stringResource(R.string.media_auto_fetch_value, settings.autoFetchThresholdMb),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -192,7 +195,7 @@ fun MediaSettingsScreen(
steps = 49
)
Text(
text = "Files at or below this size are fetched automatically on Wi-Fi.",
text = stringResource(R.string.media_auto_fetch_desc),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -208,11 +211,11 @@ fun MediaSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Auto-fetch on cellular",
text = stringResource(R.string.media_auto_fetch_cellular),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "When off, media over cellular shows a tap-to-download card instead of auto-downloading.",
text = stringResource(R.string.media_auto_fetch_cellular_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -232,11 +235,11 @@ fun MediaSettingsScreen(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Cached media cap",
text = stringResource(R.string.media_cache_cap),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "${settings.cachedMediaCapMb} MB",
text = stringResource(R.string.media_cache_cap_value, settings.cachedMediaCapMb),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -248,7 +251,7 @@ fun MediaSettingsScreen(
steps = 17 // (500 - 50) / 25 - 1
)
Text(
text = "Oldest files are evicted when the cache exceeds this limit.",
text = stringResource(R.string.media_cache_cap_desc),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -264,11 +267,11 @@ fun MediaSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Clear cached media",
text = stringResource(R.string.media_clear_cache),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Currently using ${formatBytesHuman(currentCacheBytes)}",
text = stringResource(R.string.media_clear_cache_current, formatBytesHuman(context, currentCacheBytes)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -278,15 +281,19 @@ fun MediaSettingsScreen(
scope.launch {
val freed = runCatching { cacheWriter.clear() }.getOrDefault(0L)
currentCacheBytes = runCatching { cacheWriter.currentSizeBytes() }.getOrDefault(0L)
val freedMsg = context.getString(
R.string.media_clear_cache_freed,
formatBytesHuman(context, freed)
)
Toast.makeText(
context,
"Freed ${formatBytesHuman(freed)}",
freedMsg,
Toast.LENGTH_SHORT
).show()
}
}
) {
Text("Clear")
Text(stringResource(R.string.media_clear))
}
}
}
@@ -311,20 +318,18 @@ fun MediaSettingsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Sensitive media",
text = stringResource(R.string.media_sensitive),
style = MaterialTheme.typography.titleSmall
)
Text(
text = "Blur sensitive media behind a tap-to-reveal cover. " +
"Works on any connection - the All images option needs " +
"no server support.",
text = stringResource(R.string.media_sensitive_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val blurOptions = listOf(
BlurMode.OFF to "Off",
BlurMode.FLAGGED to "Flagged",
BlurMode.ALL_IMAGES to "All images"
BlurMode.OFF to stringResource(R.string.media_blur_off),
BlurMode.FLAGGED to stringResource(R.string.media_blur_flagged),
BlurMode.ALL_IMAGES to stringResource(R.string.media_blur_all)
)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
blurOptions.forEachIndexed { index, (mode, label) ->
@@ -342,11 +347,9 @@ fun MediaSettingsScreen(
}
Text(
text = when (settings.blurSensitive) {
BlurMode.OFF -> "Media shows immediately."
BlurMode.FLAGGED ->
"Only media the agent marks sensitive is blurred."
BlurMode.ALL_IMAGES ->
"Every inbound image is blurred until you tap to reveal."
BlurMode.OFF -> stringResource(R.string.media_blur_off_desc)
BlurMode.FLAGGED -> stringResource(R.string.media_blur_flagged_desc)
BlurMode.ALL_IMAGES -> stringResource(R.string.media_blur_all_desc)
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -361,15 +364,15 @@ fun MediaSettingsScreen(
* Human-readable byte count for the inbound-media Settings UI. Keeps the
* logic out of the composable so it stays cheap on recomposition.
*/
private fun formatBytesHuman(bytes: Long): String {
if (bytes <= 0) return "0 B"
private fun formatBytesHuman(context: Context, bytes: Long): String {
if (bytes <= 0) return context.getString(R.string.media_bytes_0)
val kb = bytes / 1024.0
val mb = kb / 1024.0
val gb = mb / 1024.0
return when {
gb >= 1.0 -> "%.2f GB".format(gb)
mb >= 1.0 -> "%.1f MB".format(mb)
kb >= 1.0 -> "%.0f KB".format(kb)
else -> "$bytes B"
gb >= 1.0 -> String.format(context.getString(R.string.media_bytes_gb), gb)
mb >= 1.0 -> String.format(context.getString(R.string.media_bytes_mb), mb)
kb >= 1.0 -> String.format(context.getString(R.string.media_bytes_kb), kb)
else -> context.getString(R.string.media_bytes_b, bytes)
}
}
@@ -52,10 +52,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.hermesandroid.relay.R
import com.hermesandroid.relay.notifications.HermesNotificationCompanion
import com.hermesandroid.relay.notifications.NotificationTriggerAction
import com.hermesandroid.relay.notifications.NotificationTriggerRule
@@ -107,12 +109,12 @@ fun NotificationCompanionSettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Notification companion") },
title = { Text(stringResource(R.string.ncs_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.ncs_back),
)
}
},
@@ -171,7 +173,7 @@ private fun NotificationAccessCard(
onRefresh: () -> Unit,
onOpenSettings: () -> Unit,
) {
NotifSectionCard(title = "Status") {
NotifSectionCard(title = stringResource(R.string.ncs_status)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -187,14 +189,14 @@ private fun NotificationAccessCard(
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (granted) "Access granted" else "Access not granted",
text = if (granted) stringResource(R.string.ncs_access_granted) else stringResource(R.string.ncs_access_not_granted),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = if (granted) {
"Hermes-Relay can read posted notifications"
stringResource(R.string.ncs_can_read_notifications)
} else {
"Tap below to enable in Android Settings"
stringResource(R.string.ncs_tap_to_enable)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -203,7 +205,7 @@ private fun NotificationAccessCard(
IconButton(onClick = onRefresh) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh status",
contentDescription = stringResource(R.string.ncs_refresh_status),
)
}
}
@@ -219,7 +221,7 @@ private fun NotificationAccessCard(
contentDescription = null,
)
Spacer(Modifier.size(8.dp))
Text(if (granted) "Manage in Android Settings" else "Open Android Settings")
Text(if (granted) stringResource(R.string.ncs_manage_in_settings) else stringResource(R.string.ncs_open_android_settings))
}
}
}
@@ -256,39 +258,39 @@ private fun NotificationTriggerCard(
val hasAnyFilter = appPackage.isNotBlank() || titleContains.isNotBlank() || textContains.isNotBlank()
NotifSectionCard(title = "Event triggers (MVP)") {
NotifSectionCard(title = stringResource(R.string.ncs_triggers_title)) {
Text(
text = "Rules are off by default. When enabled, the first MVP action is safe: post a local “Ask Hermes?” prompt when a matching notification arrives.",
text = stringResource(R.string.ncs_triggers_intro),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
LabeledSwitchRow(
title = "Enable proactive triggers",
subtitle = "Explicit opt-in. Existing notification forwarding still works when this is off.",
title = stringResource(R.string.ncs_triggers_enable),
subtitle = stringResource(R.string.ncs_triggers_enable_desc),
checked = settings.masterEnabled,
onCheckedChange = onMasterChanged,
)
LabeledSwitchRow(
title = "Kill switch",
subtitle = "Immediately pauses all trigger actions without deleting rules or the log.",
title = stringResource(R.string.ncs_kill_switch),
subtitle = stringResource(R.string.ncs_kill_switch_desc),
checked = settings.killSwitch,
onCheckedChange = onKillSwitchChanged,
)
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
Text("Rule", style = MaterialTheme.typography.titleSmall)
Text(stringResource(R.string.ncs_rule), style = MaterialTheme.typography.titleSmall)
Text(
text = "Match by app package and optional title/text contains filters. Example package: com.slack.",
text = stringResource(R.string.ncs_rule_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
LabeledSwitchRow(
title = "Rule enabled",
title = stringResource(R.string.ncs_rule_enabled),
subtitle = savedRule.summary(),
checked = ruleEnabled,
onCheckedChange = { ruleEnabled = it },
@@ -298,14 +300,14 @@ private fun NotificationTriggerCard(
onValueChange = { label = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Rule label") },
label = { Text(stringResource(R.string.ncs_rule_label)) },
)
OutlinedTextField(
value = appPackage,
onValueChange = { appPackage = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("App package") },
label = { Text(stringResource(R.string.ncs_app_package)) },
placeholder = { Text("com.example.app") },
)
OutlinedTextField(
@@ -313,14 +315,14 @@ private fun NotificationTriggerCard(
onValueChange = { titleContains = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Title contains (optional)") },
label = { Text(stringResource(R.string.ncs_title_contains)) },
)
OutlinedTextField(
value = textContains,
onValueChange = { textContains = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Text contains (optional)") },
label = { Text(stringResource(R.string.ncs_text_contains)) },
)
Button(
onClick = {
@@ -340,11 +342,11 @@ private fun NotificationTriggerCard(
enabled = hasAnyFilter,
modifier = Modifier.fillMaxWidth(),
) {
Text("Save rule")
Text(stringResource(R.string.ncs_save_rule))
}
if (!hasAnyFilter) {
Text(
text = "Set at least one filter before saving so triggers do not match every notification on the phone.",
text = stringResource(R.string.ncs_rule_filter_required),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
@@ -357,25 +359,25 @@ private fun NotificationActivityLogCard(
settings: NotificationTriggerSettings,
onClear: () -> Unit,
) {
NotifSectionCard(title = "Activity log") {
NotifSectionCard(title = stringResource(R.string.ncs_activity_log)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "Latest trigger matches",
text = stringResource(R.string.ncs_latest_matches),
style = MaterialTheme.typography.bodyMedium,
)
TextButton(
onClick = onClear,
enabled = settings.activityLog.isNotEmpty(),
) { Text("Clear") }
) { Text(stringResource(R.string.ncs_clear)) }
}
if (settings.activityLog.isEmpty()) {
Text(
text = "No trigger activity yet.",
text = stringResource(R.string.ncs_no_activity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -417,20 +419,20 @@ private fun NotificationActivityLogCard(
@Composable
private fun NotificationAboutCard() {
NotifSectionCard(title = "About") {
NotifSectionCard(title = stringResource(R.string.ncs_about)) {
Text(
text = "Lets your Hermes assistant help you triage notifications. When enabled, your phone forwards each notification's app, title, and text to your paired Hermes server through the Relay pairing used by phone tools.",
text = stringResource(R.string.ncs_about_body1),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Requires Android's notification access permission. You can grant or revoke it at any time in Android Settings. This is the same permission Wear OS, Android Auto, and Tasker use.",
text = stringResource(R.string.ncs_about_body2),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Confirmation policy: local prompts and local log writes can run automatically after opt-in. Anything that sends a message, replies in another app, routes content to a person/channel, uses bridge gestures, or spends/changes data still requires an explicit user confirmation first.",
text = stringResource(R.string.ncs_confirmation_policy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -439,9 +441,11 @@ private fun NotificationAboutCard() {
@Composable
private fun NotificationTestCard(granted: Boolean) {
NotifSectionCard(title = "Test") {
val listenerNotBound = stringResource(R.string.ncs_listener_not_bound)
val accessNotGranted = stringResource(R.string.ncs_access_not_granted_err)
NotifSectionCard(title = stringResource(R.string.ncs_test)) {
Text(
text = "Tap below to fetch the last few notifications the listener has captured this session. Helpful for verifying the connection is working end-to-end.",
text = stringResource(R.string.ncs_test_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -458,9 +462,9 @@ private fun NotificationTestCard(granted: Boolean) {
if (service == null) {
lastSnapshot = emptyList()
lastError = if (granted) {
"Listener has not bound yet. Try posting a test notification and re-tap."
listenerNotBound
} else {
"Notification access is not granted."
accessNotGranted
}
} else {
val active = service.activeNotifications
@@ -489,7 +493,7 @@ private fun NotificationTestCard(granted: Boolean) {
contentDescription = null,
)
Spacer(Modifier.size(8.dp))
Text("Fetch recent")
Text(stringResource(R.string.ncs_fetch_recent))
}
lastError?.let { err ->
@@ -1,3 +1,5 @@
@file:Suppress("LocalContextGetResourceValueCall")
package com.hermesandroid.relay.ui.screens
import android.widget.Toast
@@ -17,6 +19,8 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.ConnectionWizard
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -61,12 +65,12 @@ fun PairScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Connect to Hermes") },
title = { Text(stringResource(R.string.pair_connect_to_hermes)) },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close",
contentDescription = stringResource(R.string.pair_close),
)
}
},
@@ -82,7 +86,7 @@ fun PairScreen(
ConnectionWizard(
connectionViewModel = connectionViewModel,
onComplete = {
Toast.makeText(context, "Connection updated", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.pair_connection_updated), Toast.LENGTH_SHORT).show()
onComplete()
},
onCancel = onCancel,
@@ -56,9 +56,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.PairedDeviceInfo
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.displayLabel
@@ -121,12 +123,12 @@ fun PairedDevicesScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Relay sessions") },
title = { Text(stringResource(R.string.paired_devices_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
contentDescription = stringResource(R.string.paired_devices_back)
)
}
},
@@ -134,7 +136,7 @@ fun PairedDevicesScreen(
IconButton(onClick = { connectionViewModel.loadPairedDevices() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh"
contentDescription = stringResource(R.string.paired_devices_refresh)
)
}
},
@@ -178,37 +180,38 @@ fun PairedDevicesScreen(
val isCurrentDevice = target.isCurrent ||
(currentPairedSession?.token?.startsWith(target.tokenPrefix) == true)
val unnamedDevice = stringResource(R.string.paired_devices_unnamed_device)
val revokeThisTitle = stringResource(R.string.paired_devices_revoke_this_title)
val revokeTitle = stringResource(R.string.paired_devices_revoke_title)
val revokeCurrentBody = stringResource(R.string.paired_devices_revoke_current_body)
val revokeOtherBody = stringResource(R.string.paired_devices_revoke_other_body)
val unpairedMessage = stringResource(R.string.paired_devices_unpaired)
val revokedMessage = stringResource(R.string.paired_devices_revoked)
val revokeFailedMessage = stringResource(R.string.paired_devices_revoke_failed)
AlertDialog(
onDismissRequest = { pendingRevoke = null },
title = {
Text(
text = if (isCurrentDevice) "Revoke this device?"
else "Revoke device?"
text = if (isCurrentDevice) revokeThisTitle else revokeTitle
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = target.deviceName.ifBlank { "Unnamed device" },
text = target.deviceName.ifBlank { unnamedDevice },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Text(
text = "Token prefix: ${target.tokenPrefix}",
text = stringResource(R.string.paired_devices_token_prefix, target.tokenPrefix),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace
)
Spacer(Modifier.height(4.dp))
Text(
text = if (isCurrentDevice) {
"This is the current device. Revoking will " +
"end the session on this phone and you'll " +
"need to re-pair with the server."
} else {
"This will end that device's relay session. " +
"It can re-pair later using a new pairing code."
},
text = if (isCurrentDevice) revokeCurrentBody else revokeOtherBody,
style = MaterialTheme.typography.bodyMedium
)
}
@@ -223,13 +226,13 @@ fun PairedDevicesScreen(
if (success) {
if (isCurrentDevice) {
connectionViewModel.clearSession()
snackbarHostState.showSnackbar("This device was unpaired. Re-pair to continue.")
snackbarHostState.showSnackbar(unpairedMessage)
onRequestRepair()
} else {
snackbarHostState.showSnackbar("Device revoked")
snackbarHostState.showSnackbar(revokedMessage)
}
} else {
snackbarHostState.showSnackbar("Revoke failed")
snackbarHostState.showSnackbar(revokeFailedMessage)
}
}
},
@@ -237,12 +240,12 @@ fun PairedDevicesScreen(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Revoke")
Text(stringResource(R.string.paired_devices_revoke))
}
},
dismissButton = {
TextButton(onClick = { pendingRevoke = null }) {
Text("Cancel")
Text(stringResource(R.string.paired_devices_cancel))
}
}
)
@@ -257,6 +260,9 @@ fun PairedDevicesScreen(
val remaining: Long = target.expiresAt?.let { (it.toLong() - nowSec).coerceAtLeast(0L) }
?: 0L // 0 = never expire (matches the picker's "Never" option)
val initialTtl = if (target.expiresAt == null) 0L else remaining
val setNeverMessage = stringResource(R.string.paired_devices_session_never)
val updatedMessage = stringResource(R.string.paired_devices_session_updated)
val failedToUpdateMessage = stringResource(R.string.paired_devices_session_update_failed)
SessionTtlPickerDialog(
initialTtlSeconds = initialTtl,
isTailscaleDetected = isTailscaleDetected,
@@ -270,11 +276,10 @@ fun PairedDevicesScreen(
)
if (success) {
snackbarHostState.showSnackbar(
if (newTtl == 0L) "Session set to never expire"
else "Session expiry updated"
if (newTtl == 0L) setNeverMessage else updatedMessage
)
} else {
snackbarHostState.showSnackbar("Failed to update session")
snackbarHostState.showSnackbar(failedToUpdateMessage)
}
}
},
@@ -285,25 +290,43 @@ fun PairedDevicesScreen(
// === PER-CHANNEL-REVOKE: confirm dialog ===
pendingChannelRevoke?.let { (device, channel) ->
val channelLabel = grantDisplayName(channel)
val noneLabel = stringResource(R.string.paired_devices_none)
val thisDeviceLabel = stringResource(R.string.paired_devices_this_device)
// Resolve display names for every grant up-front (grantDisplayName
// is @Composable, so we can't call it inside joinToString's lambda).
val grantLabels = device.grants.keys.associateWith { grantDisplayName(it) }
val otherChannels = device.grants.keys
.filter { it != channel }
.sortedWith(::compareGrantNames)
.joinToString(", ") { grantDisplayName(it) }
.ifBlank { "none" }
.joinToString(", ") { grantLabels[it] ?: it }
.ifBlank { noneLabel }
val revokeChannelTitle = stringResource(R.string.paired_devices_revoke_channel_title)
val revokeChannelBody = stringResource(
R.string.paired_devices_revoke_channel_body,
channelLabel,
device.deviceName.ifBlank { thisDeviceLabel },
)
val revokeChannelDesc = stringResource(
R.string.paired_devices_revoke_channel_desc,
channelLabel,
otherChannels,
)
val channelRevokedMsg = stringResource(R.string.paired_devices_channel_revoked, channelLabel)
val channelRevokeFailedMsg = stringResource(
R.string.paired_devices_channel_revoke_failed,
channelLabel,
)
AlertDialog(
onDismissRequest = { pendingChannelRevoke = null },
title = { Text("Revoke channel access?") },
title = { Text(revokeChannelTitle) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Revoke $channelLabel access for " +
device.deviceName.ifBlank { "this device" } + "?",
text = revokeChannelBody,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "The session itself stays paired — only the " +
"$channelLabel grant is removed. Other channels " +
"($otherChannels) keep their current expiry.",
text = revokeChannelDesc,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -320,8 +343,7 @@ fun PairedDevicesScreen(
ch,
)
snackbarHostState.showSnackbar(
if (ok) "$channelLabel access revoked"
else "Failed to revoke $channelLabel access"
if (ok) channelRevokedMsg else channelRevokeFailedMsg
)
}
},
@@ -329,12 +351,12 @@ fun PairedDevicesScreen(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("Revoke")
Text(stringResource(R.string.paired_devices_revoke))
}
},
dismissButton = {
TextButton(onClick = { pendingChannelRevoke = null }) {
Text("Cancel")
Text(stringResource(R.string.paired_devices_cancel))
}
},
)
@@ -370,7 +392,7 @@ private fun ErrorState(message: String, onRetry: () -> Unit) {
)
Spacer(Modifier.height(16.dp))
Text(
text = "Couldn't load relay sessions",
text = stringResource(R.string.paired_devices_load_error),
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(4.dp))
@@ -381,7 +403,7 @@ private fun ErrorState(message: String, onRetry: () -> Unit) {
)
Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = onRetry) {
Text("Try again")
Text(stringResource(R.string.paired_devices_try_again))
}
}
}
@@ -403,17 +425,17 @@ private fun EmptyState(onRequestRepair: () -> Unit) {
)
Spacer(Modifier.height(16.dp))
Text(
text = "No relay sessions (yet)",
text = stringResource(R.string.paired_devices_empty_title),
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(4.dp))
Text(
text = "Pair this phone with your relay to see it here.",
text = stringResource(R.string.paired_devices_empty_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(16.dp))
Button(onClick = onRequestRepair) { Text("Pair now") }
Button(onClick = onRequestRepair) { Text(stringResource(R.string.paired_devices_pair_now)) }
}
}
@@ -444,10 +466,7 @@ private fun DeviceList(
// to the device cards below.
item {
Text(
text = "Each row is a phone that has paired with this server.\n" +
"Channel grants are per-feature authorizations (chat, bridge, terminal, TUI, voice) " +
"that can expire independently.\n" +
"Revoke a session to cut off that phone's access without affecting others.",
text = stringResource(R.string.paired_devices_header),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 0.dp, vertical = 0.dp),
@@ -477,23 +496,16 @@ private fun DeviceList(
if (showChannelInfoSheet) {
AlertDialog(
onDismissRequest = { showChannelInfoSheet = false },
title = { Text("About channel grants") },
title = { Text(stringResource(R.string.paired_devices_about_grants_title)) },
text = {
Text(
text = "Channel grants are per-feature permissions.\n\n" +
"• Chat controls sent/received messages.\n" +
"• Bridge controls accessibility actions.\n" +
"• Terminal and TUI control shell-style remote sessions.\n" +
"• Voice is split into config, speech-to-text, and text-to-speech grants.\n\n" +
"Each grant has its own expiry, so a phone can keep chat " +
"access long after bridge or voice access has lapsed — or you can " +
"revoke one channel without ending the whole session.",
text = stringResource(R.string.paired_devices_about_grants_body),
style = MaterialTheme.typography.bodyMedium,
)
},
confirmButton = {
TextButton(onClick = { showChannelInfoSheet = false }) {
Text("Got it")
Text(stringResource(R.string.paired_devices_got_it))
}
},
)
@@ -531,8 +543,9 @@ private fun DeviceCard(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
val unnamedDevice = stringResource(R.string.paired_devices_unnamed_device)
Text(
text = device.deviceName.ifBlank { "Unnamed device" },
text = device.deviceName.ifBlank { unnamedDevice },
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
@@ -592,7 +605,7 @@ private fun DeviceCard(
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = "Channel grants",
text = stringResource(R.string.paired_devices_channel_grants),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -602,7 +615,7 @@ private fun DeviceCard(
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = "About channel grants",
contentDescription = stringResource(R.string.paired_devices_about_grants_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
@@ -627,10 +640,10 @@ private fun DeviceCard(
// Metadata rows (first seen / last seen)
if (device.createdAt != null) {
MetaRow(label = "First seen", value = formatEpoch(device.createdAt.toLong()))
MetaRow(label = stringResource(R.string.paired_devices_first_seen), value = formatEpoch(device.createdAt.toLong()))
}
if (device.lastSeen != null) {
MetaRow(label = "Last seen", value = formatEpoch(device.lastSeen.toLong()))
MetaRow(label = stringResource(R.string.paired_devices_last_seen), value = formatEpoch(device.lastSeen.toLong()))
}
// Extend + Revoke actions
@@ -642,7 +655,7 @@ private fun DeviceCard(
onClick = onExtend,
modifier = Modifier.weight(1f)
) {
Text("Extend")
Text(stringResource(R.string.paired_devices_extend))
}
OutlinedButton(
onClick = onRevoke,
@@ -651,7 +664,10 @@ private fun DeviceCard(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text(if (isCurrent) "Revoke this" else "Revoke")
Text(
if (isCurrent) stringResource(R.string.paired_devices_revoke_this)
else stringResource(R.string.paired_devices_revoke)
)
}
}
}
@@ -675,7 +691,7 @@ private fun CurrentBadge() {
modifier = Modifier.height(14.dp)
)
Text(
text = "This device",
text = stringResource(R.string.paired_devices_this_device_badge),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
@@ -685,8 +701,8 @@ private fun CurrentBadge() {
@Composable
private fun ExpiryRow(expiresAt: Double?) {
val text = when {
expiresAt == null -> "Never expires"
else -> "Expires ${formatEpoch(expiresAt.toLong())}"
expiresAt == null -> stringResource(R.string.paired_devices_never_expires)
else -> stringResource(R.string.paired_devices_expires, formatEpoch(expiresAt.toLong()))
}
val color = when {
expiresAt == null -> MaterialTheme.colorScheme.primary
@@ -725,7 +741,7 @@ private fun GrantChip(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "$channelLabel · ${formatRelativeTtl(expiresAt, nowSec)}",
text = stringResource(R.string.paired_devices_grant_chip, channelLabel, formatRelativeTtl(expiresAt, nowSec)),
style = MaterialTheme.typography.labelSmall,
color = fg,
)
@@ -746,7 +762,7 @@ private fun GrantChip(
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Revoke $channelLabel access",
contentDescription = stringResource(R.string.paired_devices_revoke_channel_access, channelLabel),
modifier = Modifier.height(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -770,14 +786,15 @@ private fun grantSortKey(channel: String): Int = when (channel.lowercase()) {
else -> 100
}
@Composable
private fun grantDisplayName(channel: String): String = when (channel.lowercase()) {
"chat" -> "Chat"
"bridge" -> "Bridge"
"terminal" -> "Terminal"
"tui" -> "TUI"
"voice:config" -> "Voice config"
"voice:stt" -> "Voice STT"
"voice:tts" -> "Voice TTS"
"chat" -> stringResource(R.string.paired_devices_grant_chat)
"bridge" -> stringResource(R.string.paired_devices_grant_bridge)
"terminal" -> stringResource(R.string.paired_devices_grant_terminal)
"tui" -> stringResource(R.string.paired_devices_grant_tui)
"voice:config" -> stringResource(R.string.paired_devices_grant_voice_config)
"voice:stt" -> stringResource(R.string.paired_devices_grant_voice_stt)
"voice:tts" -> stringResource(R.string.paired_devices_grant_voice_tts)
else -> channel
}
@@ -791,18 +808,19 @@ private fun grantDisplayName(channel: String): String = when (channel.lowercase(
* hours → "in Nh"
* days → "in Nd"
*/
@Composable
private fun formatRelativeTtl(expiresAt: Double?, nowSec: Double): String {
if (expiresAt == null) return "never"
if (expiresAt == null) return stringResource(R.string.paired_devices_ttl_never)
val deltaSec = (expiresAt - nowSec).toLong()
if (deltaSec <= 0) return "expired"
if (deltaSec <= 0) return stringResource(R.string.paired_devices_ttl_expired)
val days = deltaSec / 86_400
val hours = deltaSec / 3_600
val minutes = deltaSec / 60
return when {
days >= 1 -> "in ${days}d"
hours >= 1 -> "in ${hours}h"
minutes >= 1 -> "in ${minutes}m"
else -> "in <1m"
days >= 1 -> stringResource(R.string.paired_devices_ttl_days, days)
hours >= 1 -> stringResource(R.string.paired_devices_ttl_hours, hours)
minutes >= 1 -> stringResource(R.string.paired_devices_ttl_minutes, minutes)
else -> stringResource(R.string.paired_devices_ttl_seconds)
}
}
// === END PER-CHANNEL-REVOKE ===
@@ -847,7 +865,7 @@ private fun EndpointsSubList(
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "Routes",
text = stringResource(R.string.paired_devices_routes),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -889,7 +907,7 @@ private fun EndpointsSubList(
)
if (isActive) {
Text(
text = "active",
text = stringResource(R.string.paired_devices_active),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
@@ -60,12 +60,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.permissions.AppPermissionStatus
import com.hermesandroid.relay.permissions.AppPermissionStatusProbe
@@ -104,12 +106,12 @@ fun PermissionsStatusScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Permissions") },
title = { Text(stringResource(R.string.perms_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.perms_back),
)
}
},
@@ -117,7 +119,7 @@ fun PermissionsStatusScreen(
IconButton(onClick = { refreshStatus() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh status",
contentDescription = stringResource(R.string.perms_refresh_status_cd),
)
}
},
@@ -138,69 +140,69 @@ fun PermissionsStatusScreen(
PermissionsIntroCard()
PermissionSection(
title = "Hermes",
subtitle = "Chat and Manage use your configured Hermes API/dashboard connection.",
title = stringResource(R.string.perms_section_hermes),
subtitle = stringResource(R.string.perms_section_hermes_desc),
) {
PermissionStatusRow(
icon = Icons.Filled.CheckCircle,
title = "Chat and Manage",
subtitle = "No Android runtime permission needed. API/dashboard auth is configured separately.",
badge = "Included",
statusLabel = "Ready",
title = stringResource(R.string.perms_chat_and_manage),
subtitle = stringResource(R.string.perms_chat_and_manage_desc),
badge = stringResource(R.string.perms_badge_included),
statusLabel = stringResource(R.string.perms_status_ready),
granted = true,
onClick = null,
)
}
PermissionSection(
title = "On demand",
subtitle = "These stay optional until you use the matching feature.",
title = stringResource(R.string.perms_section_on_demand),
subtitle = stringResource(R.string.perms_section_on_demand_desc),
) {
PermissionStatusRow(
icon = Icons.Filled.CameraAlt,
title = "Camera",
subtitle = "Scan setup QR codes or attach photos taken in-app.",
badge = "Optional",
statusLabel = optionalStatus(status.cameraPermitted),
title = stringResource(R.string.perms_camera),
subtitle = stringResource(R.string.perms_camera_desc),
badge = stringResource(R.string.perms_badge_optional),
statusLabel = optionalStatus(context, status.cameraPermitted),
granted = status.cameraPermitted,
onClick = { openAppDetailsSettings(context) },
)
PermissionStatusRow(
icon = Icons.Filled.Mic,
title = "Microphone",
subtitle = "Talk to Hermes in voice mode.",
badge = "Optional",
statusLabel = optionalStatus(status.microphonePermitted),
title = stringResource(R.string.perms_microphone),
subtitle = stringResource(R.string.perms_microphone_desc),
badge = stringResource(R.string.perms_badge_optional),
statusLabel = optionalStatus(context, status.microphonePermitted),
granted = status.microphonePermitted,
onClick = { openAppDetailsSettings(context) },
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
PermissionStatusRow(
icon = Icons.Filled.Notifications,
title = "App notifications",
subtitle = "Turn-complete alerts and foreground-service indicators.",
badge = "Optional",
statusLabel = optionalStatus(status.notificationsPermitted),
title = stringResource(R.string.perms_app_notifications),
subtitle = stringResource(R.string.perms_app_notifications_desc),
badge = stringResource(R.string.perms_badge_optional),
statusLabel = optionalStatus(context, status.notificationsPermitted),
granted = status.notificationsPermitted,
onClick = { openAppDetailsSettings(context) },
)
} else {
PermissionStatusRow(
icon = Icons.Filled.Notifications,
title = "App notifications",
subtitle = "This Android version does not require a runtime notification grant.",
badge = "Included",
statusLabel = "Ready",
title = stringResource(R.string.perms_app_notifications),
subtitle = stringResource(R.string.perms_app_notifications_legacy_desc),
badge = stringResource(R.string.perms_badge_included),
statusLabel = stringResource(R.string.perms_status_ready),
granted = true,
onClick = null,
)
}
PermissionStatusRow(
icon = Icons.Filled.Notifications,
title = "Notification access",
subtitle = "Let the notification companion share posted notifications with paired Relay tools.",
badge = "Optional",
statusLabel = optionalStatus(status.notificationListenerPermitted),
title = stringResource(R.string.perms_notification_access),
subtitle = stringResource(R.string.perms_notification_access_desc),
badge = stringResource(R.string.perms_badge_optional),
statusLabel = optionalStatus(context, status.notificationListenerPermitted),
granted = status.notificationListenerPermitted,
onClick = { openNotificationListenerSettings(context) },
)
@@ -243,12 +245,12 @@ private fun PermissionsIntroCard() {
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "Permissions and capabilities",
text = stringResource(R.string.perms_intro_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "Grant only the features you use. Standard Chat and Manage do not need phone-control permissions.",
text = stringResource(R.string.perms_intro_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -265,34 +267,35 @@ private fun SideloadPermissionsSection(
onOpenAccessibility: () -> Unit,
onOpenOverlay: () -> Unit,
) {
val context = LocalContext.current
PermissionSection(
title = "Sideload Device Control",
subtitle = "Only needed when the agent operates this phone.",
title = stringResource(R.string.perms_section_sideload),
subtitle = stringResource(R.string.perms_section_sideload_desc),
) {
PermissionStatusRow(
icon = Icons.Filled.Accessibility,
title = "Accessibility Service",
subtitle = "Required for screen reading, taps, typing, and app navigation.",
badge = "Required",
statusLabel = requiredStatus(status.accessibilityServiceEnabled),
title = stringResource(R.string.perms_accessibility_service),
subtitle = stringResource(R.string.perms_accessibility_service_desc),
badge = stringResource(R.string.perms_badge_required),
statusLabel = requiredStatus(context, status.accessibilityServiceEnabled),
granted = status.accessibilityServiceEnabled,
onClick = onOpenAccessibility,
)
PermissionStatusRow(
icon = Icons.Filled.PictureInPicture,
title = "Display over other apps",
subtitle = "Required for cross-app confirmation prompts and the Bridge status chip.",
badge = "Required",
statusLabel = requiredStatus(status.overlayPermitted),
title = stringResource(R.string.perms_display_over_apps),
subtitle = stringResource(R.string.perms_display_over_apps_desc),
badge = stringResource(R.string.perms_badge_required),
statusLabel = requiredStatus(context, status.overlayPermitted),
granted = status.overlayPermitted,
onClick = onOpenOverlay,
)
PermissionStatusRow(
icon = Icons.AutoMirrored.Filled.ScreenShare,
title = "Screen capture",
subtitle = "Per-session Android consent for screenshots. Open Bridge to grant or refresh it.",
badge = "Session",
statusLabel = if (status.screenCapturePermitted) "Active" else "Per-session",
title = stringResource(R.string.perms_screen_capture),
subtitle = stringResource(R.string.perms_screen_capture_desc),
badge = stringResource(R.string.perms_badge_session),
statusLabel = if (status.screenCapturePermitted) stringResource(R.string.perms_status_active) else stringResource(R.string.perms_status_per_session),
granted = status.screenCapturePermitted,
onClick = onOpenBridge,
)
@@ -302,37 +305,37 @@ private fun SideloadPermissionsSection(
)
PermissionStatusRow(
icon = Icons.Filled.Contacts,
title = "Contacts",
subtitle = "Resolve names to phone numbers for sideload Relay tools.",
badge = "Optional",
statusLabel = optionalStatus(status.contactsPermitted),
title = stringResource(R.string.perms_contacts),
subtitle = stringResource(R.string.perms_contacts_desc),
badge = stringResource(R.string.perms_badge_optional),
statusLabel = optionalStatus(context, status.contactsPermitted),
granted = status.contactsPermitted,
onClick = onOpenAppDetails,
)
PermissionStatusRow(
icon = Icons.Filled.Sms,
title = "SMS",
subtitle = "Send text messages directly after Bridge safety confirmation.",
badge = "Optional",
statusLabel = optionalStatus(status.smsPermitted),
title = stringResource(R.string.perms_sms),
subtitle = stringResource(R.string.perms_sms_desc),
badge = stringResource(R.string.perms_badge_optional),
statusLabel = optionalStatus(context, status.smsPermitted),
granted = status.smsPermitted,
onClick = onOpenAppDetails,
)
PermissionStatusRow(
icon = Icons.Filled.Call,
title = "Phone",
subtitle = "Place calls directly instead of opening the dialer.",
badge = "Optional",
statusLabel = optionalStatus(status.phonePermitted),
title = stringResource(R.string.perms_phone),
subtitle = stringResource(R.string.perms_phone_desc),
badge = stringResource(R.string.perms_badge_optional),
statusLabel = optionalStatus(context, status.phonePermitted),
granted = status.phonePermitted,
onClick = onOpenAppDetails,
)
PermissionStatusRow(
icon = Icons.Filled.LocationOn,
title = "Location",
subtitle = "Share last-known location with sideload Relay tools.",
badge = "Optional",
statusLabel = optionalStatus(status.locationPermitted),
title = stringResource(R.string.perms_location),
subtitle = stringResource(R.string.perms_location_desc),
badge = stringResource(R.string.perms_badge_optional),
statusLabel = optionalStatus(context, status.locationPermitted),
granted = status.locationPermitted,
onClick = onOpenAppDetails,
)
@@ -361,12 +364,12 @@ private fun GooglePlayBridgeCoreCard() {
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "Bridge Core",
text = stringResource(R.string.perms_bridge_core),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "Sideload Device Control permissions are not included in the Google Play build.",
text = stringResource(R.string.perms_bridge_core_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -532,11 +535,11 @@ private fun StatusBadge(label: String, granted: Boolean) {
}
}
private fun optionalStatus(granted: Boolean): String =
if (granted) "Granted" else "Optional"
private fun optionalStatus(context: Context, granted: Boolean): String =
if (granted) context.getString(R.string.perms_status_granted) else context.getString(R.string.perms_status_optional)
private fun requiredStatus(granted: Boolean): String =
if (granted) "Granted" else "Needed"
private fun requiredStatus(context: Context, granted: Boolean): String =
if (granted) context.getString(R.string.perms_status_granted) else context.getString(R.string.perms_status_needed)
private fun openAppDetailsSettings(context: Context) {
runCatching {
@@ -38,8 +38,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -88,12 +90,12 @@ fun ProactiveSettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Threads") },
title = { Text(stringResource(R.string.proactive_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.proactive_back),
)
}
},
@@ -111,20 +113,18 @@ fun ProactiveSettingsScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ProactiveSectionCard(title = "Let Hermes message me") {
ProactiveSectionCard(title = stringResource(R.string.proactive_let_hermes_message_me)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Allow proactive messages",
text = stringResource(R.string.proactive_allow_proactive_messages),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = "Your agent can reach out on its own — reminders, " +
"finished jobs, alerts. Its messages appear as Threads " +
"in Chat, where you can reply.",
text = stringResource(R.string.proactive_allow_proactive_messages_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -141,19 +141,16 @@ fun ProactiveSettingsScreen(
if (enabled && !paired) {
Spacer(Modifier.height(8.dp))
Text(
text = "Not paired yet — pair with your Hermes server under " +
"Settings → Connections to start receiving messages.",
text = stringResource(R.string.proactive_not_paired_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
ProactiveSectionCard(title = "Your Threads") {
ProactiveSectionCard(title = stringResource(R.string.proactive_your_threads)) {
Text(
text = "Each conversation your agent starts shows as a Thread in " +
"Chat — open the session list, filter to Threads, and reply " +
"right there.",
text = stringResource(R.string.proactive_your_threads_desc),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
@@ -166,23 +163,18 @@ fun ProactiveSettingsScreen(
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
Text("Open Chat")
Text(stringResource(R.string.proactive_open_chat))
}
}
ProactiveSectionCard(title = "About") {
ProactiveSectionCard(title = stringResource(R.string.proactive_about)) {
Text(
text = "When on, your phone tells the relay it's open to " +
"agent-initiated messages. The agent delivers them over " +
"the same paired connection the relay already uses — no " +
"new permissions beyond notifications.",
text = stringResource(R.string.proactive_about_desc),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Your server must also enable the phone platform " +
"(set PHONE_ENABLED on the server). Until both sides are " +
"on and the phone is paired, nothing is pushed.",
text = stringResource(R.string.proactive_about_server_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)

Some files were not shown because too many files have changed in this diff Show More