Compare commits

...
Author SHA1 Message Date
Bailey Dixon 7440ef2948 Merge pull request #343 from Codename-11/dev
release: server 1.7.0 and desktop 0.4.0-alpha.8
2026-08-13 17:14:58 -04:00
Bailey Dixon a88539bc59 fix(android): dequeue reach frames compatibly 2026-08-13 16:56:54 -04:00
Bailey Dixon b2ccfdc500 fix(security): carry secure link trust anchor 2026-08-13 16:49:44 -04:00
Bailey Dixon 481c62ac59 fix(desktop): bind pinned secure link probes 2026-08-13 16:42:37 -04:00
Bailey Dixon 5d415fbaf0 fix(android): use compatible reach buffer removal 2026-08-13 16:39:26 -04:00
Bailey Dixon 074b715055 fix(android): complete secure route translations 2026-08-13 16:37:32 -04:00
Bailey Dixon f63ee8721e release(desktop): desktop-v0.4.0-alpha.8 2026-08-13 16:21:51 -04:00
Bailey Dixon 777bc80bcc release(server): server-v1.7.0 2026-08-13 16:21:50 -04:00
Bailey Dixon 9539975bb5 merge: integrate native secure routes 2026-08-13 15:41:46 -04:00
Bailey Dixon 2863a1bc8f merge: reconcile native secure routes with dev 2026-08-13 15:31:53 -04:00
Bailey Dixon b0a7cf0494 feat: add self-hosted secure connection routes 2026-08-13 15:31:46 -04:00
Bailey Dixon 76b4084310 merge: integrate Android and Relay upstream work 2026-08-13 13:24:32 -04:00
Bailey Dixon 2ace70c4fc test: close integration verification gaps 2026-08-13 13:24:02 -04:00
Bailey Dixon 4f52f371ba fix(ops): fail closed on unsafe certification state 2026-08-13 11:16:27 -04:00
Bailey Dixon 064c89bda4 docs: record Android and Relay integration 2026-08-13 11:08:27 -04:00
Bailey Dixon 0cb1e3642f feat(android): add gateway-native profile editor 2026-08-13 11:03:10 -04:00
Bailey Dixon cf4bf87242 fix(android): honor upstream routing contracts 2026-08-13 11:00:39 -04:00
Bailey Dixon 9cbed21014 fix(android): preserve durable gateway rewinds 2026-08-13 10:58:55 -04:00
Bailey Dixon ce75c0fa01 docs: add controlled runtime safety preflight 2026-08-13 10:50:52 -04:00
Bailey Dixon bf2aece6e6 docs: reconcile upstream architecture evaluations 2026-08-13 10:50:14 -04:00
Bailey Dixon 198da78fc8 fix(android): honor upstream approval and compression outcomes 2026-08-13 10:50:14 -04:00
Bailey Dixon 986ce3b12b fix(plugin): isolate profile-owned registrations 2026-08-13 10:50:14 -04:00
Bailey Dixon b53f757830 fix(plugin): support strict phone targets 2026-08-13 10:50:14 -04:00
Bailey Dixon cdeccd69e4 feat: add secure relay route selection 2026-08-12 20:10:41 -04:00
Bailey Dixon bb72516bb5 Merge pull request #340 from Codename-11/chore/backmerge-server-1.6.4
chore: back-merge server-v1.6.4 hotfix
2026-08-12 18:43:50 -04:00
Bailey Dixon 3a51644342 chore: merge server-v1.6.4 release history into dev
# Conflicts:
#	docs/decisions.md
#	user-docs/desktop/tools.md
2026-08-12 18:43:39 -04:00
Bailey Dixon 51c0c7dee9 Merge pull request #338 from Codename-11/fix/server-multidevice-hotfix
fix(server): release targeted multi-desktop routing
2026-08-12 18:39:31 -04:00
Bailey Dixon 7ef2420c85 release(server): server-v1.6.4 2026-08-12 18:38:32 -04:00
Bailey Dixon 6a810c850b fix(server): route concurrent desktop clients explicitly 2026-08-12 18:38:02 -04:00
Bailey Dixon d383002583 Merge pull request #336 from Codename-11/release/server-1.6.4
release(server): server-v1.6.4
2026-08-12 18:35:31 -04:00
Bailey Dixon e3aae829e1 release(server): server-v1.6.4 2026-08-12 18:35:16 -04:00
Bailey Dixon 5207ed4193 Merge pull request #335 from Codename-11/fix/desktop-placement-device-identity
feat(desktop): support targeted multi-device control
2026-08-12 18:32:06 -04:00
Bailey Dixon b46bb00ea8 test(desktop): serialize cross-platform suite 2026-08-12 18:31:50 -04:00
Bailey Dixon b8dde409c5 merge: synchronize desktop management with dev
# Conflicts:
#	CHANGELOG.md
#	docs/decisions.md
2026-08-12 18:26:20 -04:00
Bailey Dixon ca7ded3939 test(server): prove concurrent desktop routing 2026-08-12 18:24:44 -04:00
Bailey Dixon db85a26c68 feat(desktop): add contextual approvals and UI pairing 2026-08-12 18:24:43 -04:00
Bailey Dixon aa2595629d feat(desktop): expand tray management controls 2026-08-12 17:55:56 -04:00
Bailey Dixon d79146dc90 feat(desktop): add ask every time access preset 2026-08-12 17:13:51 -04:00
Bailey Dixon eabc4dd328 refactor(desktop): clarify access navigation 2026-08-12 16:57:50 -04:00
Bailey Dixon e165bfeff3 feat(desktop): animate bidirectional relay traffic 2026-08-12 15:42:43 -04:00
Bailey Dixon 40bcd796d1 refactor(desktop): simplify host access presets 2026-08-12 13:29:29 -04:00
Bailey Dixon 57ae0c9456 feat(desktop): unify capabilities and activity drilldown 2026-08-12 13:06:34 -04:00
Bailey Dixon 9b31a16c89 fix(desktop): preserve Hermes shortcut icons 2026-08-12 11:44:17 -04:00
Bailey Dixon f97bbdd395 feat(desktop): add host-wide raw USB control 2026-08-12 11:38:09 -04:00
Bailey Dixon 722a294947 feat(desktop): adopt compact capability ledger 2026-08-12 11:13:50 -04:00
Bailey Dixon bbfb57b462 feat(desktop): harden targeted remote management 2026-08-12 10:37:12 -04:00
Bailey Dixon 6db12a0bec merge: complete upstream app and Relay workflows 2026-08-12 09:24:24 -04:00
Bailey Dixon f1de957848 fix(android): translate Manage workflows 2026-08-12 09:08:31 -04:00
Bailey Dixon cc01d9c8ad test(android): compile upstream workflow fixtures 2026-08-12 09:00:04 -04:00
Bailey Dixon d574182d84 fix(android): wire Manage workflow dialogs 2026-08-12 08:46:10 -04:00
Bailey Dixon a328763da3 fix(android): localize backup completion 2026-08-12 08:46:05 -04:00
Bailey Dixon f53db68e7d feat(android): complete upstream Manage workflows 2026-08-12 07:45:55 -04:00
Bailey Dixon eb9e570fc0 feat(android): show session repository and PR state 2026-08-12 07:41:35 -04:00
Bailey Dixon 260f119637 fix(voice): align upstream auth and transport 2026-08-12 07:39:42 -04:00
Bailey Dixon d0fa2ea39d fix(android): preserve clarify selection semantics 2026-08-12 07:37:28 -04:00
Bailey Dixon a1c74b1567 fix(plugin): enumerate phone home target 2026-08-12 07:34:19 -04:00
Bailey Dixon 7c45acd38d chore: merge server-v1.6.3 release history into dev 2026-08-11 22:03:36 -04:00
Bailey Dixon 4605b87c10 Merge pull request #333 from Codename-11/dev
release(server): server-v1.6.3
2026-08-11 22:00:43 -04:00
Bailey Dixon 6a91d6ee7e fix(android): complete upstream feature translations 2026-08-11 21:46:45 -04:00
Bailey Dixon 95a2813efe fix(server): validate translated media path components 2026-08-11 21:37:54 -04:00
Bailey Dixon 2ecf521c8c chore: merge main release history into dev 2026-08-11 21:35:40 -04:00
Bailey Dixon 7752c5c404 release(server): server-v1.6.3 2026-08-11 21:35:05 -04:00
Bailey Dixon e1d3764cd2 feat(android): browse sessions across profiles 2026-08-11 20:50:36 -04:00
Bailey Dixon e34171b5ad feat(android): add gateway message reactions 2026-08-11 20:48:09 -04:00
Bailey Dixon 8040cac39a feat(android): redirect running subagents 2026-08-11 20:45:33 -04:00
Bailey Dixon aab70520ca feat(android): open referenced Hermes sessions 2026-08-11 20:40:27 -04:00
Bailey Dixon 5a0cd8123c feat(android): expand Hermes management surfaces 2026-08-11 20:37:03 -04:00
Bailey Dixon 4370d9a925 feat(android): adopt richer gateway chat contracts 2026-08-11 20:30:38 -04:00
Bailey Dixon 726308d2ef fix: harden gateway recovery diagnostics 2026-08-11 20:26:05 -04:00
Bailey Dixon 1acc3a4c80 fix: align app and relay upstream contracts 2026-08-11 20:04:43 -04:00
Bailey Dixon 11ccbd6e5c Merge pull request #332 from Codename-11/dev
release(desktop): desktop-v0.4.0-alpha.7
2026-08-11 19:55:05 -04:00
Bailey Dixon d13af35357 chore: merge main release history into dev 2026-08-11 19:48:26 -04:00
Bailey Dixon e3752d43f3 release(desktop): desktop-v0.4.0-alpha.7 2026-08-11 19:48:24 -04:00
Bailey Dixon 97293b62c3 Merge pull request #331 from Codename-11/dev
release(desktop): desktop-v0.4.0-alpha.6
2026-08-11 19:33:13 -04:00
Bailey Dixon 454e770648 chore: merge main release history into dev 2026-08-11 19:26:15 -04:00
Bailey Dixon e18572e8e3 release(desktop): desktop-v0.4.0-alpha.6 2026-08-11 19:24:27 -04:00
Bailey Dixon 83f69725b9 Merge pull request #330 from Codename-11/dev
release: Desktop 0.4.0-alpha.5
2026-08-11 19:05:39 -04:00
Bailey Dixon 72aa7c3046 chore: merge main release history into dev 2026-08-11 18:59:06 -04:00
Bailey Dixon 3995c64493 release(desktop): desktop-v0.4.0-alpha.5 2026-08-11 18:59:04 -04:00
Bailey Dixon ce538ced3d Merge pull request #329 from Codename-11/dev
release: Desktop 0.4.0-alpha.4
2026-08-11 18:43:32 -04:00
Bailey Dixon 352bc5b439 chore: merge main release history into dev 2026-08-11 18:35:52 -04:00
Bailey Dixon 9ec163b27b release(desktop): desktop-v0.4.0-alpha.4 2026-08-11 18:35:32 -04:00
Bailey Dixon 7a3efa2c4d Merge pull request #328 from Codename-11/dev
release: Desktop 0.4.0-alpha.3 and Server 1.6.2
2026-08-11 18:19:36 -04:00
Bailey Dixon c28be7c92e style(desktop): format tray contract test 2026-08-11 18:03:02 -04:00
Bailey Dixon 8d1758ec8b fix(desktop): build tray assets before Rust checks 2026-08-11 18:01:17 -04:00
Bailey Dixon ce8b8702c3 test(desktop): make UI install coverage portable 2026-08-11 17:53:56 -04:00
Bailey Dixon ca0a9eb524 release(desktop): desktop-v0.4.0-alpha.3 2026-08-11 17:51:25 -04:00
Bailey Dixon b91d8c9a09 release(server): server-v1.6.2 2026-08-11 17:51:13 -04:00
Bailey Dixon bebd327816 Merge pull request #327 from Codename-11/feature/desktop-relay-management-release
feat: ship desktop management UI and paired-device identity
2026-08-11 17:49:18 -04:00
Bailey Dixon 8fa97e0b46 feat(desktop): add host management tray UI 2026-08-11 17:48:47 -04:00
Bailey Dixon 45dc82e573 feat(server): enrich paired device identity 2026-08-11 17:48:09 -04:00
dependabot[bot] 48b23f4d9e chore(deps): bump gradle-wrapper from 9.6.1 to 9.7.0 (#326)
Bumps [gradle-wrapper](https://github.com/gradle/gradle) from 9.6.1 to 9.7.0.
- [Release notes](https://github.com/gradle/gradle/releases)
- [Commits](https://github.com/gradle/gradle/compare/v9.6.1...v9.7.0)

---
updated-dependencies:
- dependency-name: gradle-wrapper
  dependency-version: 9.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 11:56:21 +00:00
dependabot[bot] f708ef3353 chore(deps): bump androidx.media3:media3-exoplayer from 1.10.1 to 1.11.0 (#325)
Bumps [androidx.media3:media3-exoplayer](https://github.com/androidx/media) from 1.10.1 to 1.11.0.
- [Release notes](https://github.com/androidx/media/releases)
- [Changelog](https://github.com/androidx/media/blob/release/RELEASENOTES.md)
- [Commits](https://github.com/androidx/media/compare/1.10.1...1.11.0)

---
updated-dependencies:
- dependency-name: androidx.media3:media3-exoplayer
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 11:55:39 +00:00
dependabot[bot] 3224595a46 chore(deps): bump gradle/actions from 6.2.0 to 6.3.0 (#324)
Bumps [gradle/actions](https://github.com/gradle/actions) from 6.2.0 to 6.3.0.
- [Release notes](https://github.com/gradle/actions/releases)
- [Commits](https://github.com/gradle/actions/compare/v6.2.0...v6.3.0)

---
updated-dependencies:
- dependency-name: gradle/actions
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 11:55:08 +00:00
dependabot[bot] c0453f040d chore(deps): bump the testing group with 2 updates (#323)
Bumps the testing group with 2 updates: [io.github.takahirom.roborazzi:roborazzi](https://github.com/takahirom/roborazzi) and [io.github.takahirom.roborazzi:roborazzi-compose](https://github.com/takahirom/roborazzi).


Updates `io.github.takahirom.roborazzi:roborazzi` from 1.70.0 to 1.71.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.70.0...1.71.0)

Updates `io.github.takahirom.roborazzi:roborazzi-compose` from 1.70.0 to 1.71.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.70.0...1.71.0)

---
updated-dependencies:
- dependency-name: io.github.takahirom.roborazzi:roborazzi
  dependency-version: 1.71.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
- dependency-name: io.github.takahirom.roborazzi:roborazzi-compose
  dependency-version: 1.71.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 11:54:54 +00:00
Bailey Dixon 33a0ea3216 chore: merge main back after android-v1.8.1 2026-08-10 00:05:29 -04:00
Bailey Dixon cec05ceec2 Merge pull request #322 from Codename-11/dev
release(android): android-v1.8.1
2026-08-09 23:46:51 -04:00
Bailey Dixon 0e30699fff release(android): android-v1.8.1 2026-08-09 23:20:15 -04:00
Bailey Dixon d4041e4528 chore: merge main back after android-v1.8.0 2026-08-09 23:15:39 -04:00
Bailey Dixon c5ae402cd7 fix(android): align gateway and transcript contracts 2026-08-09 23:08:26 -04:00
Bailey Dixon 56eb213bbf Merge pull request #321 from Codename-11/dev
release(android): android-v1.8.0
2026-08-09 22:53:38 -04:00
Bailey Dixon 3165ebb8ce release(android): android-v1.8.0 2026-08-09 22:03:44 -04:00
Bailey Dixon a2457a39d4 Revert "Merge branch 'feature/hermes-management-profile-detail' into dev"
This reverts commit b481f1f337, reversing
changes made to 7b61ed4758.
2026-08-09 21:35:33 -04:00
Bailey Dixon c2799082b0 Revert "Merge branch 'feature/hermes-management-detail-surfaces' into dev"
This reverts commit 2a467bc03f, reversing
changes made to 44293030c9.
2026-08-09 21:35:33 -04:00
Bailey Dixon 2a467bc03f Merge branch 'feature/hermes-management-detail-surfaces' into dev 2026-08-09 21:13:35 -04:00
Bailey Dixon a30d3dc6ed feat(android): refine Hermes management surfaces 2026-08-09 21:13:29 -04:00
Bailey Dixon 44293030c9 fix(android): contain appearance header in viewport 2026-08-09 20:50:53 -04:00
Bailey Dixon 42ac8c5589 feat(marketing): refresh product screenshots 2026-08-09 20:08:42 -04:00
Bailey Dixon b481f1f337 Merge branch 'feature/hermes-management-profile-detail' into dev 2026-08-09 19:59:56 -04:00
Bailey Dixon 62c4017eeb feat(android): refine Hermes profile management detail 2026-08-09 19:59:40 -04:00
Bailey Dixon 7b61ed4758 Merge branch 'fix/hermes-management-detail-ui' into dev 2026-08-09 19:22:30 -04:00
Bailey Dixon 21259230b2 fix(android): restore Hermes management hub 2026-08-09 19:22:20 -04:00
Bailey Dixon 7ef1e93544 fix(android): reconcile profile shelf chat status 2026-08-09 19:13:34 -04:00
Bailey Dixon 61a50dc968 Merge branch 'feature/hermes-management-ui' into dev 2026-08-09 19:13:16 -04:00
Bailey Dixon 18b9ed005b chore(marketing): refresh production UI screenshots 2026-08-09 19:13:04 -04:00
Bailey Dixon c597187fa3 Merge branch 'feature/android-profile-shelf' into dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
#	app/src/main/kotlin/com/hermesandroid/relay/ui/components/ConnectionInfoSheet.kt
#	app/src/main/kotlin/com/hermesandroid/relay/ui/components/MessageBubble.kt
#	app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ChatScreen.kt
#	app/src/main/res/values-b+pt+BR/strings.xml
#	app/src/main/res/values-b+zh+Hans/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-ja/strings.xml
#	app/src/main/res/values-ru/strings.xml
2026-08-09 19:11:50 -04:00
Bailey Dixon c97f3a3359 feat(android): streamline Hermes management UI 2026-08-09 19:10:45 -04:00
Bailey Dixon 2d9ccd4712 Revert "Merge branch 'feature/agent-profile-defaults' into dev"
This reverts commit 93643be844, reversing
changes made to 1132fdf576.
2026-08-09 18:37:16 -04:00
Bailey Dixon 93643be844 Merge branch 'feature/agent-profile-defaults' into dev 2026-08-09 18:18:00 -04:00
Bailey Dixon b82ee95b8f feat(android): add profile defaults editor 2026-08-09 18:17:53 -04:00
Bailey Dixon 1132fdf576 fix(android): stabilize passport scrolling and model layout 2026-08-09 18:17:31 -04:00
Bailey Dixon 04c69cd15f fix(android): preserve gateway model during api catalog loads 2026-08-09 18:05:16 -04:00
Bailey Dixon b4edcad1e7 fix(android): keep passport catalog refresh read only 2026-08-09 17:54:08 -04:00
Bailey Dixon bef9579917 fix(android): keep passport controls session scoped 2026-08-09 17:11:24 -04:00
Bailey Dixon c84e037b77 fix(android): stop message narration safely 2026-08-09 16:38:27 -04:00
Bailey Dixon 3ad33691cb fix(android): recover persisted tool activity and chat speech 2026-08-09 16:21:40 -04:00
Bailey Dixon bb0a810798 fix(android): protect chat identity from pets 2026-08-09 16:09:09 -04:00
Bailey Dixon b675c1498c Merge branch 'feature/android-clean-activity' into dev 2026-08-09 15:46:02 -04:00
Bailey Dixon 6da1ad1a99 Merge branch 'fix/android-avatar-pet-regressions' into dev 2026-08-09 15:46:01 -04:00
Bailey Dixon 380b9e918d feat(android): clean up transcript activity 2026-08-09 15:44:05 -04:00
Bailey Dixon d531b4d377 fix(android): restore chat and pet interactions 2026-08-09 15:36:57 -04:00
Bailey Dixon 8cba61d2d8 Merge branch 'fix/android-chat-input-polish' into dev 2026-08-09 13:30:59 -04:00
Bailey Dixon 4106fd4b78 fix(android): polish chat message presentation 2026-08-09 13:30:53 -04:00
Bailey Dixon 47426db290 Merge branch 'feature/appearance-customization' into dev 2026-08-09 13:21:01 -04:00
Bailey Dixon b7945b072f feat(android): enhance appearance and visual assets 2026-08-09 13:20:53 -04:00
Bailey Dixon 3aead772a0 Merge branch 'fix/android-chat-input-polish' into dev 2026-08-09 12:41:22 -04:00
Bailey Dixon ccf94e1d9d feat(android): refine chat experience 2026-08-09 12:41:17 -04:00
Bailey Dixon 6b6edc1322 Merge branch 'fix/android-pet-ui-awareness' into dev 2026-08-09 12:31:28 -04:00
Bailey Dixon 2644b94fa9 fix(android): keep floating pets clear of UI 2026-08-09 12:31:14 -04:00
Bailey Dixon 440e3d5bb9 fix(android): unbox thinking status 2026-08-08 22:40:07 -04:00
Bailey Dixon 97158bc861 fix(android): stabilize server-default shelf avatar 2026-08-08 22:35:32 -04:00
Bailey Dixon d5a313ab5d feat(android): refine profile and chat handoffs 2026-08-08 20:27:01 -04:00
Bailey Dixon 3033e331b0 chore: backmerge Android 1.7.1 and Server 1.6.1 releases 2026-08-08 20:06:51 -04:00
Bailey Dixon ca98f2ae97 Merge pull request #317 from Codename-11/dev
Release Android 1.7.1 and Server 1.6.1
2026-08-08 19:44:56 -04:00
Bailey Dixon 07bbb16671 feat(android): add profile shelf 2026-08-08 19:25:20 -04:00
Bailey Dixon ee08187c67 release(server): server-v1.6.1 2026-08-08 19:15:32 -04:00
Bailey Dixon 7ccde11a2d release(android): android-v1.7.1 2026-08-08 19:15:31 -04:00
Bailey Dixon 2378e6ab9f Merge branch 'fix/android-stream-tail-follow' into dev 2026-08-08 18:37:07 -04:00
Bailey Dixon 9045550317 fix(android): follow growing streamed replies 2026-08-08 18:37:01 -04:00
Bailey Dixon 7074920625 test(android): reconcile post-release issue coverage 2026-08-08 18:01:50 -04:00
Bailey Dixon cac8a517b4 Merge branch 'fix/android-nous-hosted-onboarding' into dev 2026-08-08 16:26:38 -04:00
Bailey Dixon c27019cdbb fix(connections): stabilize hosted Hermes onboarding 2026-08-08 16:26:33 -04:00
Bailey Dixon d0b060fe12 Merge branch 'fix/android-session-owned-queue' into dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
#	app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ChatViewModel.kt
2026-08-08 13:48:09 -04:00
Bailey Dixon 098cc82711 Merge branch 'fix/android-agent-passport-safety-sheet' into dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
2026-08-08 13:30:55 -04:00
Bailey Dixon 98a311afb5 fix(android): keep queued messages with origin session 2026-08-08 13:29:21 -04:00
Bailey Dixon cb1560fff3 fix(android): improve agent passport safety controls 2026-08-08 13:27:32 -04:00
Bailey Dixon 76bb9f8dcb Merge branch 'fix/issue-316-markdown-completion' into dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
2026-08-08 12:39:54 -04:00
Bailey Dixon 1eb0393dd9 fix(android): render markdown when replies complete 2026-08-08 12:38:13 -04:00
Bailey Dixon 96e6d29c9e Merge branch 'fix/android-approval-explicit-consent' into dev 2026-08-08 12:34:55 -04:00
Bailey Dixon f9bf4e384f fix(android): require explicit approval decisions 2026-08-08 12:34:46 -04:00
Bailey Dixon 615119b100 Merge branch 'fix/android-session-pin-archive' into dev
# Conflicts:
#	CHANGELOG.md
#	docs/decisions.md
2026-08-08 09:24:40 -04:00
Bailey Dixon cfe86b6cc3 fix(android): persist session pin and archive state 2026-08-08 08:56:55 -04:00
Bailey Dixon 677914a51d Merge branch 'fix/android-active-tool-card-expansion' into dev 2026-08-08 08:47:13 -04:00
Bailey Dixon 8b3adf10c6 fix(android): preserve live tool card expansion 2026-08-08 08:47:07 -04:00
Bailey Dixon 5cc8e7187b Merge branch 'fix/android-model-inventory-identity' into dev 2026-08-08 08:42:55 -04:00
Bailey Dixon ece46cae24 fix(android): normalize model inventory identities 2026-08-08 08:42:47 -04:00
Bailey Dixon c4d3934cfe chore: backmerge Android 1.7.0 and Server 1.6.0 releases 2026-08-06 21:59:49 -04:00
Bailey Dixon 545d238fd2 Merge pull request #311 from Codename-11/dev
release: Android 1.7.0 and Server 1.6.0
2026-08-06 21:38:49 -04:00
Bailey Dixon 9b36a34e2a release(android): android-v1.7.0 2026-08-06 21:01:01 -04:00
Bailey Dixon 7c35a51aed release(server): server-v1.6.0 2026-08-06 21:00:52 -04:00
Bailey Dixon 474147cb62 test(server): align session TTL route expectations 2026-08-06 20:59:59 -04:00
Bailey Dixon 0bd246586a chore: backmerge released main into dev 2026-08-06 20:16:54 -04:00
Bailey Dixon fc6aaffc11 fix(android): keep restored chats bottom-pinned 2026-08-05 22:08:55 -04:00
Bailey Dixon 197b23f344 feat: resolve Codex effort levels dynamically 2026-08-05 21:28:43 -04:00
Bailey Dixon 0862b4c346 Merge branch 'fix/android-provider-effort-levels' into dev 2026-08-05 21:09:51 -04:00
Bailey Dixon 9c7bc46c7c feat(android): explain reasoning effort compatibility 2026-08-05 21:09:37 -04:00
Bailey Dixon 9554eab757 Merge fix/android-provider-effort-levels into dev 2026-08-05 20:38:32 -04:00
Bailey Dixon 3cacc7d6d6 Merge dev into fix/android-provider-effort-levels
# Conflicts:
#	DEVLOG.md
#	app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ChatViewModel.kt
2026-08-05 20:37:14 -04:00
Bailey Dixon 311888a9fb fix(android): settle chat viewport and pet grounding 2026-08-05 20:35:22 -04:00
Bailey Dixon 96dfe472ef feat: resolve reasoning efforts through relay 2026-08-05 20:34:56 -04:00
Bailey Dixon 7d6a0215ba fix(android): stabilize chat drawer interactions 2026-08-05 19:55:55 -04:00
Bailey Dixon 51ec55e08c fix(android): restore developer settings imports 2026-08-05 19:23:31 -04:00
Bailey Dixon 4edcd18398 Merge fix/standard-voice-speech into dev
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/viewmodel/VoiceViewModel.kt
2026-08-05 19:19:27 -04:00
Bailey Dixon 3f50a94d03 Merge fix/developer-options-data into dev
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/data/FeatureFlags.kt
#	app/src/main/kotlin/com/hermesandroid/relay/ui/components/ActiveConnectionSections.kt
#	app/src/main/kotlin/com/hermesandroid/relay/ui/screens/DeveloperSettingsScreen.kt
#	app/src/test/kotlin/com/hermesandroid/relay/data/FeatureFlagsTest.kt
#	docs/localization-status.json
2026-08-05 19:18:59 -04:00
Bailey Dixon 94d2231a80 Merge chore/backmerge-main-after-android-1.6.0 into dev 2026-08-05 19:17:30 -04:00
Bailey Dixon 5ac798d5b3 Merge feature/local-dev-work-20260805 into dev 2026-08-05 19:17:17 -04:00
Bailey Dixon ebe1a8ebc4 chore(android): streamline local dev loop 2026-08-05 19:16:36 -04:00
Bailey Dixon fc3a1c169b fix(android): stabilize pet and model sync feedback 2026-08-05 19:08:27 -04:00
Bailey Dixon 6eb2c8bd9e fix(android): smooth active session glow loop 2026-08-05 18:53:31 -04:00
Bailey Dixon cbe323cf6d Merge fix/android-chat-tail-settle into dev 2026-08-05 18:35:50 -04:00
Bailey Dixon 5401e8f274 fix(android): keep completed chat tail stable 2026-08-05 17:58:09 -04:00
Bailey Dixon 0b62afa6c3 Merge feature/android-session-activity-ui into dev 2026-08-05 16:54:50 -04:00
Bailey Dixon 02801dda70 feat(android): surface active session states 2026-08-05 16:52:56 -04:00
Bailey Dixon 39011d5600 Merge pull request #307 from Codename-11/fix/android-focus-pointer-input
fix(android): restore focus voice controls
2026-08-05 16:21:32 -04:00
Bailey Dixon e50c644d09 fix(android): restore focus voice controls 2026-08-05 14:30:20 -04:00
Bailey Dixon 3ff4e7cf8e Merge pull request #304 from Codename-11/fix/android-actionable-diagnostics
fix(android): make connection diagnostics actionable
2026-08-05 10:52:31 -04:00
Bailey Dixon 683d9712d8 fix(android): make connection diagnostics actionable 2026-08-05 10:36:56 -04:00
Bailey Dixon 7ae4a3987e Merge pull request #301 from Codename-11/feature/android-reliability-support
feat(android): unify local reliability reporting
2026-08-04 21:44:52 -04:00
Bailey Dixon 9dcb280eff feat(android): unify local reliability reporting 2026-08-04 21:32:18 -04:00
Bailey Dixon a90067292a Merge pull request #300 from Codename-11/fix/android-chat-render-identity
fix(android): stabilize chat identity and issue labeling
2026-08-04 21:26:06 -04:00
Bailey Dixon 4447886e80 chore(ci): disable automatic issue labeling 2026-08-04 21:15:54 -04:00
Bailey Dixon 5d00885291 fix(android): enforce stable chat render identity 2026-08-04 20:50:41 -04:00
Bailey Dixon bbea5d1b73 Merge pull request #297 from Codename-11/dev
fix(release): repair Server 1.5.1 metadata
2026-08-03 22:25:29 -04:00
Bailey Dixon cce4b9b1d6 Merge pull request #296 from Codename-11/fix/server-1.5.1-release-heading
fix(release): align server 1.5.1 changelog heading
2026-08-03 22:23:19 -04:00
Bailey Dixon d3a4bd5423 fix(release): align server changelog heading 2026-08-03 22:22:38 -04:00
Bailey Dixon c3ff201ecc Merge pull request #294 from Codename-11/dev
release: server-v1.5.1 and android-v1.6.1
2026-08-03 22:20:48 -04:00
Bailey Dixon 20b75d9454 Merge pull request #295 from Codename-11/release/android-1.6.1
release(android): android-v1.6.1
2026-08-03 22:03:27 -04:00
Bailey Dixon d04264a224 release(android): android-v1.6.1 2026-08-03 21:53:39 -04:00
Bailey Dixon ee30ec4bf7 Merge pull request #293 from Codename-11/release/server-1.5.1
release(server): server-v1.5.1
2026-08-03 21:50:58 -04:00
Bailey Dixon 111c5409bf release(server): server-v1.5.1 2026-08-03 21:49:50 -04:00
Bailey Dixon 03ce9aa8d5 Merge pull request #291 from Codename-11/fix/android-relay-session-recovery
fix: repair Relay recovery and Android session UX
2026-08-03 21:46:33 -04:00
Bailey Dixon 41c2d10700 fix(android): refresh localization source hashes 2026-08-03 21:36:59 -04:00
Bailey Dixon c508392261 Merge dev into fix/android-relay-session-recovery 2026-08-03 20:40:45 -04:00
Bailey Dixon e9c0620d24 fix(android): hide clean-view hint during voice mode 2026-08-03 20:38:08 -04:00
Bailey Dixon 217746a243 Merge fix/android-int-map-crash into dev
# Conflicts:
#	CHANGELOG.md
2026-08-03 20:21:29 -04:00
Bailey Dixon a40e6a5b01 Merge origin/dev into dev
# Conflicts:
#	CHANGELOG.md
2026-08-03 20:21:05 -04:00
Bailey Dixon 118bf07638 fix(android): stabilize message text selection 2026-08-03 20:20:10 -04:00
Bailey Dixon 95f6721b06 Merge pull request #290 from Codename-11/fix/android-mic-handoff-hardening
fix(android): harden microphone capture handoff
2026-08-03 20:16:13 -04:00
Bailey Dixonandluimu64 ac023c0e24 fix(android): harden microphone capture handoff
Wait for barge-in microphone teardown without blocking the UI, cancel abandoned pending captures, and classify AudioRecord startup failures with actionable localized guidance.

Co-authored-by: luimu64 <luimu@luimu.dev>
2026-08-03 20:05:17 -04:00
Bailey Dixon cd7792918e Merge fix/android-relay-session-recovery into dev 2026-08-03 18:46:28 -04:00
Bailey Dixon e617809930 fix(android): reconnect Relay without stale backoff 2026-08-03 18:38:35 -04:00
Bailey Dixon 79532c6c9b Merge fix/android-relay-session-recovery into dev 2026-08-03 17:57:17 -04:00
Bailey Dixon 0af1cda38e fix: repair optional Relay session recovery 2026-08-03 17:57:05 -04:00
Bailey Dixon 454bd44e7a Merge fix/android-session-list-pagination into dev 2026-08-03 17:11:05 -04:00
Bailey Dixon b92a40174e fix(android): page session drawer requests 2026-08-03 17:10:48 -04:00
dependabot[bot] c6ab26ef13 chore(deps): bump com.microsoft.onnxruntime:onnxruntime-android (#288)
Bumps [com.microsoft.onnxruntime:onnxruntime-android](https://github.com/microsoft/onnxruntime) from 1.27.0 to 1.28.0.
- [Release notes](https://github.com/microsoft/onnxruntime/releases)
- [Changelog](https://github.com/microsoft/onnxruntime/blob/main/docs/ReleaseManagement.md)
- [Commits](https://github.com/microsoft/onnxruntime/compare/v1.27.0...v1.28.0)

---
updated-dependencies:
- dependency-name: com.microsoft.onnxruntime:onnxruntime-android
  dependency-version: 1.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 11:56:37 +00:00
dependabot[bot] 414ade5fa0 chore(deps): bump gradle/actions from 6 to 6.2.0 (#287)
Bumps [gradle/actions](https://github.com/gradle/actions) from 6 to 6.2.0.
- [Release notes](https://github.com/gradle/actions/releases)
- [Commits](https://github.com/gradle/actions/compare/v6...v6.2.0)

---
updated-dependencies:
- dependency-name: gradle/actions
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 11:55:44 +00:00
Bailey Dixon 9fdb11ae94 chore(android): script foreground service demo capture 2026-08-02 20:56:31 -04:00
Bailey Dixon fd35c9b2b6 docs(android): document Play foreground services 2026-08-02 19:47:52 -04:00
Bailey Dixon 9dc625ddd0 chore: backmerge Android 1.6.0 release 2026-08-02 19:46:48 -04:00
Bailey Dixon e5b25ab650 Merge pull request #284 from Codename-11/dev
release(android): android-v1.6.0
2026-08-02 19:28:01 -04:00
Bailey Dixon ea16750af4 Merge pull request #283 from Codename-11/release/android-1.6.0
release(android): android-v1.6.0
2026-08-02 18:58:50 -04:00
Bailey Dixon 7ae95915bf chore(release): reconcile Server 1.5.0 main release 2026-08-02 18:58:33 -04:00
Bailey Dixon c85f42c51c Merge pull request #279 from Codename-11/dev
release(server): server-v1.5.0
2026-08-02 18:58:20 -04:00
Bailey Dixon 15e0106648 fix(android): preserve legacy collection compatibility 2026-08-02 18:55:21 -04:00
Bailey Dixon 58d7e8581b chore(release): reconcile dev before Android 1.6.0
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
2026-08-02 18:51:59 -04:00
Bailey Dixon 8c570b214d Merge pull request #281 from Codename-11/fix/mobile-plugin-path-containment
fix(plugins): contain generated page paths
2026-08-02 18:42:04 -04:00
Bailey Dixon 52f19ca028 fix(plugins): contain generated page paths 2026-08-02 18:40:47 -04:00
Bailey Dixon acc7daa6f2 fix(dev): restore UI preview compilation 2026-08-02 18:29:45 -04:00
Bailey Dixon 9ca42e3e6c Merge pull request #280 from Codename-11/chore/reconcile-main-before-server-1.5.0
chore: reconcile main release history into dev
2026-08-02 18:28:11 -04:00
Bailey Dixon 781969d782 chore(release): reconcile Server 1.5.0 into Android 1.6.0
# Conflicts:
#	CHANGELOG.md
2026-08-02 18:19:47 -04:00
Bailey Dixon 279b83a77c release(android): prepare android-v1.6.0 2026-08-02 18:19:17 -04:00
Bailey Dixon 2f949c7d15 chore: reconcile main release history into dev 2026-08-02 18:17:50 -04:00
Bailey Dixon c77fd057bb Merge pull request #278 from Codename-11/release/server-1.5.0
release(server): server-v1.5.0
2026-08-02 18:16:25 -04:00
Bailey Dixon ed1c47f47d release(server): server-v1.5.0 2026-08-02 18:14:51 -04:00
Bailey Dixon c1800a3ddd chore(release): reconcile dev before Android 1.6.0
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
#	app/src/main/kotlin/com/hermesandroid/relay/ui/components/ThinkingBlock.kt
#	app/src/main/kotlin/com/hermesandroid/relay/voice/VoiceOverlayHost.kt
#	app/src/main/res/values-b+pt+BR/strings.xml
#	app/src/main/res/values-b+zh+Hans/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-ja/strings.xml
#	app/src/main/res/values/strings.xml
#	docs/localization-status.json
2026-08-02 18:14:45 -04:00
Bailey Dixon f5c2a2b888 feat(plugins): add live Android plugin surfaces 2026-08-02 18:14:42 -04:00
Bailey Dixon 3ec5a09885 chore(release): reconcile main before Android 1.6.0
# Conflicts:
#	DEVLOG.md
#	app/src/main/kotlin/com/hermesandroid/relay/ui/components/VoiceModeOverlay.kt
#	app/src/test/kotlin/com/hermesandroid/relay/ui/components/VoiceModeOverlayStateTest.kt
2026-08-02 18:09:37 -04:00
Bailey Dixon 2a8038a1bd Merge fix/android-pet-mvp-hardening into dev 2026-08-02 14:08:37 -04:00
Bailey Dixon d0684dd252 fix(android): harden floating pet mvp 2026-08-02 14:08:18 -04:00
Bailey Dixon dae75ebe13 feat(android): improve pet route inspector 2026-08-02 13:36:20 -04:00
Bailey Dixon 82fb46b914 Merge fix/android-list-identity-dev into dev 2026-08-02 13:17:30 -04:00
Bailey Dixon 3ac74404bf fix(android): keep pet inspector below app header 2026-08-02 13:12:04 -04:00
Bailey Dixon 9187d8e77c Merge pull request #277 from Codename-11/feature/russian-localization-salvage
feat(android): add Russian localization
2026-08-02 13:11:28 -04:00
Bailey Dixon 9b7bf0f7fd fix(android): preserve streamed list identity 2026-08-02 13:09:20 -04:00
Bailey Dixon a59b54c600 feat(android): collapse pet path inspector by default 2026-08-02 13:07:54 -04:00
Bailey Dixon 9c56598438 feat(android): improve pet terrain inspector 2026-08-02 13:03:25 -04:00
Bailey DixonandDScoNOIZ 4efc52dd5b feat(android): add Russian localization
Salvaged from #276 by @DScoNOIZ.

Co-authored-by: DScoNOIZ <212546794+DScoNOIZ@users.noreply.github.com>
2026-08-02 13:02:57 -04:00
Bailey Dixon b274e6f2a6 feat(android): expand floating pet terrain roaming 2026-08-02 12:28:54 -04:00
Bailey Dixon 6f5c49be17 Merge feature/android-live-plugins into dev
# Conflicts:
#	docs/localization-status.json
2026-08-02 11:39:59 -04:00
Bailey Dixon b328370d32 feat(plugins): add live Android plugin surfaces 2026-08-02 11:38:18 -04:00
Bailey Dixon 0e1d70e8ff fix(android): recover pets from occupied terrain 2026-08-02 10:53:22 -04:00
Bailey Dixon 4ea41386ee fix(android): constrain pet terrain routes 2026-08-02 10:46:59 -04:00
Bailey Dixon 6ac7e5457f feat(android): distinguish possible pet routes 2026-08-02 10:12:08 -04:00
Bailey Dixon 46463f4b00 feat(android): use narrow bubbles as pet hop points 2026-08-02 09:54:12 -04:00
Bailey Dixon 2f72c5444c feat(android): visualize and explore pet terrain 2026-08-02 09:28:09 -04:00
Bailey Dixon ecc97416fc feat(android): add bounded pet terrain journeys 2026-08-02 08:40:27 -04:00
Bailey Dixon 88667eea89 fix(android): hop from measured bubble edges 2026-08-02 07:03:49 -04:00
Bailey Dixon febd938651 fix(android): escape pet from invalid bubble overlap 2026-08-01 22:41:41 -04:00
Bailey Dixon 9419615068 fix(android): recover pet from scrolling chat bubbles 2026-08-01 22:34:33 -04:00
Bailey Dixon 8e4fffab6d fix(android): observe settings pet scroll state 2026-08-01 22:23:32 -04:00
Bailey Dixon 1112a622a7 fix(android): recover pet roaming after scroll 2026-08-01 22:20:03 -04:00
Bailey Dixon 285342bf7e fix(android): keep pet clear of chat scroll control 2026-08-01 22:09:04 -04:00
Bailey Dixon 401acdda8e fix(android): keep floating pet interactive during input 2026-08-01 21:58:22 -04:00
Bailey Dixon a0299d62ca feat(android): preserve pet roaming after drag 2026-08-01 21:49:45 -04:00
Bailey Dixon 4f37b87a3d feat(android): refine floating pet experience 2026-08-01 21:36:34 -04:00
Bailey Dixon 7d3ad1c19f fix(android): make pet visit chat surfaces 2026-08-01 20:55:41 -04:00
Bailey Dixon 54e069888d Merge branch 'docs/pet-experience' into dev 2026-08-01 20:35:28 -04:00
Bailey Dixon 7d3ebfb842 fix(android): localize pet capability previews 2026-08-01 20:35:23 -04:00
Bailey Dixon 7d9552bf1e docs(android): document interactive pet behavior 2026-08-01 20:29:55 -04:00
Bailey Dixon 73c0b6c99d fix(android): label mirrored pet travel accurately 2026-08-01 20:26:09 -04:00
Bailey Dixon 9b68577c47 feat(android): apply pet temperament pacing 2026-08-01 20:23:16 -04:00
Bailey Dixon ec3ff1da02 Merge branch 'feature/android-pet-route-surfaces' into dev 2026-08-01 20:15:30 -04:00
Bailey Dixon 0d78077393 Merge branch 'feature/petdex-capability-preview' into dev 2026-08-01 20:15:29 -04:00
Bailey Dixon eaf345b9c7 Merge branch 'feature/android-pet-temperament' into dev 2026-08-01 20:15:28 -04:00
Bailey Dixon 2d4afd035c Merge branch 'feature/android-pet-core-motion' into dev 2026-08-01 20:15:28 -04:00
Bailey Dixon b5f3eba340 feat(android): make pet roaming intentional 2026-08-01 20:12:29 -04:00
Bailey Dixon e6c48d5fa9 feat(android): preview pet animation capabilities 2026-08-01 20:09:58 -04:00
Bailey Dixon eed739c3a3 feat(android): add pet temperament preferences 2026-08-01 20:09:23 -04:00
Bailey Dixon 91ddebde79 feat(android): add pet roaming to app status chrome 2026-08-01 20:06:19 -04:00
Bailey Dixon a1d99f390f fix(android): keep pet clear of chat content 2026-08-01 19:55:16 -04:00
Bailey Dixon af284952e7 Merge branch 'feature/android-pet-roaming' into dev 2026-08-01 19:49:27 -04:00
Bailey Dixon 4b872f3f54 Merge branch 'fix/android-route-resilience' into feature/android-pet-roaming 2026-08-01 19:46:57 -04:00
Bailey Dixon b725f2b295 feat(android): prefer pet bubble perches 2026-08-01 19:45:55 -04:00
Bailey Dixon 0359fb46ff fix(android): stabilize relay route failover 2026-08-01 19:44:45 -04:00
Bailey Dixon 0e8ab74685 feat(android): enrich pet overlay roaming 2026-08-01 19:38:30 -04:00
Bailey Dixon f49b8d8161 Merge branch 'feature/android-pet-roaming' into dev 2026-08-01 13:57:15 -04:00
Bailey Dixon 8361a059e8 fix(android): avoid stale pet visit targets 2026-08-01 13:46:12 -04:00
Bailey Dixon f6c222ced5 feat(android): animate post-response pet visits 2026-08-01 13:41:27 -04:00
Bailey Dixon 05cda21119 feat(android): schedule post-response pet visits 2026-08-01 13:36:26 -04:00
Bailey Dixon 310893a49a feat(android): add bubble visit routing 2026-08-01 13:36:02 -04:00
Bailey Dixon 09c48efecf fix(android): prioritize pet locomotion states 2026-08-01 13:31:55 -04:00
Bailey Dixon 748c3b1c07 feat(android): add assistant pet visit targets 2026-08-01 13:31:23 -04:00
Bailey Dixon 259d64fe30 fix(android): hold pet drop until state syncs 2026-08-01 13:26:25 -04:00
Bailey Dixon e2044a15ea Merge branch 'feature/android-pet-roaming' into dev 2026-08-01 11:38:13 -04:00
Bailey Dixon 33cc622643 fix(android): preserve manual pet placement 2026-08-01 11:25:01 -04:00
Bailey Dixon 2c388a4c73 fix(android): add obstacle-aware pet hops 2026-08-01 11:21:46 -04:00
Bailey Dixon a4323a6b04 fix(android): smooth pet movement states 2026-08-01 11:15:53 -04:00
Bailey Dixon af6680090f fix(android): refresh pet surface activity 2026-08-01 11:13:56 -04:00
Bailey Dixon 54362c7d31 Merge branch 'feature/android-pet-roaming' into dev 2026-08-01 10:36:02 -04:00
Bailey Dixon 40837013c7 feat(android): add element-aware pet roaming 2026-08-01 10:35:49 -04:00
Bailey Dixon 70edc5e73f Merge branch 'feature/android-pet-roaming' into dev 2026-08-01 09:48:55 -04:00
Bailey Dixon 55e8486f94 fix(android): animate pet roam locomotion 2026-08-01 09:48:47 -04:00
Bailey Dixon ab4519d4cf Merge branch 'feature/android-pet-roaming' into dev 2026-08-01 09:35:50 -04:00
Bailey Dixon d5cfa2bf5c fix(android): match desktop pet overlay behavior 2026-08-01 09:35:34 -04:00
Bailey Dixon c5376b2c25 Merge branch 'feature/android-pet-roaming' into dev 2026-08-01 09:14:19 -04:00
Bailey Dixon ee33666e1f fix(android): dock pet at chat end edge by default 2026-08-01 09:14:14 -04:00
Bailey Dixon c357f201c6 Merge branch 'feature/android-pet-roaming' into dev 2026-08-01 08:53:18 -04:00
Bailey Dixon 17a439cfaa feat(android): add roaming Petdex companions 2026-08-01 08:53:04 -04:00
Bailey Dixon 6b888e91d3 Merge Petdex preview cache follow-up into dev 2026-07-31 23:47:09 -04:00
Bailey Dixon 0793f9213c perf(android): prioritize cached pet previews 2026-07-31 23:47:04 -04:00
Bailey Dixon 146652e475 Merge Petdex preview retry follow-up into dev 2026-07-31 23:45:33 -04:00
Bailey Dixon 9bdaf3a02f fix(android): retry Petdex preview loading 2026-07-31 23:45:27 -04:00
Bailey Dixon e08d0e13a4 Merge branch 'feature/petdex-previews' into dev 2026-07-31 23:43:43 -04:00
Bailey Dixon 3c10c67075 feat(android): add Petdex gallery previews 2026-07-31 23:43:38 -04:00
Bailey Dixon e8e51d9ee4 Merge branch 'feature/android-floating-pet' into dev
# Conflicts:
#	CHANGELOG.md
#	app/src/main/kotlin/com/hermesandroid/relay/ui/components/MessageBubble.kt
2026-07-31 22:59:35 -04:00
Bailey Dixon dd5a80d03c fix(android): recover stalled voice output 2026-07-31 22:58:34 -04:00
Bailey Dixon 06a2f35144 feat(android): add floating pets and Petdex 2026-07-31 22:57:30 -04:00
Bailey Dixon 3d78e79c5d feat(android): refine voice mode and overlay 2026-07-31 22:35:18 -04:00
Bailey Dixon 50f745d9da Merge branch 'fix/android-standard-voice-oom' into dev
# Conflicts:
#	CHANGELOG.md
#	app/src/test/kotlin/com/hermesandroid/relay/network/upstream/StandardHermesVoiceClientTest.kt
2026-07-31 21:46:04 -04:00
Bailey Dixon 33e8a11615 fix(android): stream standard voice transcription uploads 2026-07-31 21:43:12 -04:00
Bailey Dixon 21bbad5f3b Merge branch 'fix/android-r8-resource-shrinking' into dev 2026-07-31 21:04:30 -04:00
Bailey Dixon 6524157549 fix(android): enable release resource shrinking 2026-07-31 21:04:17 -04:00
Bailey Dixon e8192b09dd docs(android): clarify voice stop dependency 2026-07-31 19:45:31 -04:00
Bailey Dixon ef3916a143 feat(android): align voice interruption with upstream 2026-07-31 19:30:53 -04:00
Bailey Dixon f2b92b2755 Merge pull request #274 from Codename-11/fix/android-1.5.3-voice-transcript-keys
release(android): Android 1.5.3 duplicate-key hotfix
2026-07-31 19:03:40 -04:00
Bailey Dixon 8651656899 release(android): android-v1.5.3 2026-07-31 18:50:21 -04:00
Bailey Dixon b458d83fcc fix(android): stabilize voice transcript keys 2026-07-31 18:27:59 -04:00
Bailey Dixon f4ae8d21ca fix(android): preserve passport shell in identity editor 2026-07-31 17:26:56 -04:00
Bailey Dixon b2f8070a1b feat(android): refine agent passport controls 2026-07-31 17:05:49 -04:00
Bailey Dixon 27f1393ea7 Merge branch 'feature/android-assistant-overlay' into dev 2026-07-31 15:44:26 -04:00
Bailey Dixon d8f8082639 feat(android): unify assistant voice surfaces 2026-07-31 15:44:20 -04:00
Bailey Dixon 5df42c1fda Merge branch 'feature/android-agent-passport-v2' into dev 2026-07-31 15:15:07 -04:00
Bailey Dixon 778c15de7f feat(android): deepen agent passport details 2026-07-31 15:14:58 -04:00
Bailey Dixon a606eb7c40 Merge branch 'fix/android-assistant-picker' into dev 2026-07-30 19:53:17 -04:00
Bailey Dixon a4df726bae fix(android): support voice-task assistant launch 2026-07-30 19:53:12 -04:00
Bailey Dixon f87de0f96a Merge branch 'fix/android-assistant-picker' into dev 2026-07-30 19:46:49 -04:00
Bailey Dixon 2471c3dd55 fix(android): activate selected assistant service 2026-07-30 19:46:42 -04:00
Bailey Dixon c53b0c6aa3 Merge branch 'fix/android-assistant-picker' into dev 2026-07-30 19:38:49 -04:00
Bailey Dixon 8ade8debf0 fix(android): expose assistant picker entry point 2026-07-30 19:38:42 -04:00
Bailey Dixon e84eeb8e4f Merge branch 'fix/android-wake-strictness-default' into dev 2026-07-30 19:23:52 -04:00
Bailey Dixon d28f0d5de7 fix(android): tune wake strictness defaults 2026-07-30 19:23:47 -04:00
Bailey Dixon c680f6f896 Merge branch 'feature/android-full-assistant-mode' into dev 2026-07-30 19:15:34 -04:00
Bailey Dixon 7df350365c feat(android): add opt-in digital assistant mode 2026-07-30 19:02:06 -04:00
Bailey Dixon 0795565a4d Merge branch 'feature/android-voice-wake-parity' into dev 2026-07-30 18:39:58 -04:00
Bailey Dixon fe4ef2441c fix(android): resume enabled wake listener visibly 2026-07-30 18:39:53 -04:00
Bailey Dixon 042f5c0927 Merge branch 'fix/android-voice-transcript-keys' into dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
2026-07-30 18:28:01 -04:00
Bailey Dixon 2998773e70 fix(android): stabilize voice transcript keys 2026-07-30 18:27:31 -04:00
Bailey Dixon 91ab611b54 Merge branch 'feature/android-voice-wake-parity' into dev 2026-07-30 18:23:20 -04:00
Bailey Dixon c76d0bf33e fix(android): stabilize local wake activation 2026-07-30 18:23:05 -04:00
Bailey Dixon ff965305d2 Merge branch 'feature/android-voice-wake-parity' into dev 2026-07-29 21:20:31 -04:00
Bailey Dixon a7d76d56e5 feat(android): absorb upstream voice and wake parity 2026-07-29 20:24:16 -04:00
Bailey Dixon 6e1e3d8b38 Merge branch 'feature/open-ledger-batches' into dev 2026-07-28 21:49:40 -04:00
Bailey Dixon 04944ffe8d feat(android): consume upstream profile and gateway contracts 2026-07-28 21:47:27 -04:00
Bailey Dixon 34cf109804 Merge origin/dev into dev 2026-07-28 21:05:11 -04:00
Bailey Dixon e459f24a1a Merge branch 'fix/android-agent-passport-drawer' into dev 2026-07-28 21:04:53 -04:00
Bailey Dixon 4346e33fd4 fix(android): repair agent passport drawer flow 2026-07-28 21:04:47 -04:00
Bailey Dixon ca0c5b2a54 Merge pull request #266 from Codename-11/fix/android-https-gateway-route
fix(android): preserve secure gateway routes
2026-07-28 21:04:31 -04:00
Bailey Dixon 77e34c2c02 fix(android): preserve secure gateway routes 2026-07-28 19:35:14 -04:00
Bailey Dixon f4ee409106 docs(devlog): record Android 1.5.2 release 2026-07-28 18:20:37 -04:00
Bailey Dixon aa26f7c9b6 Merge pull request #265 from Codename-11/dev
release(android): android-v1.5.2
2026-07-28 17:58:37 -04:00
Bailey Dixon bfb608bea6 release(android): android-v1.5.2 2026-07-28 17:30:59 -04:00
Bailey Dixon 95a95fe7d2 Merge pull request #264 from Codename-11/fix/android-nous-native-auth
fix(android): support Nous system-browser sign-in
2026-07-28 17:28:56 -04:00
Bailey Dixon 1bdf2ae71b fix(android): support Nous system-browser sign-in 2026-07-28 17:15:26 -04:00
Bailey Dixon f9e7a2f320 Merge pull request #263 from Codename-11/fix/android-duplicate-compose-keys
fix(android): coalesce replayed chat message ids
2026-07-27 11:38:06 -04:00
Bailey Dixon 988fac8522 Merge pull request #262 from Codename-11/fix/android-oidc-manage-callback
fix(android): keep dashboard OIDC on cookie flow
2026-07-27 11:37:43 -04:00
Bailey Dixon d4832a6a38 fix(android): coalesce replayed chat message ids 2026-07-27 09:30:39 -04:00
Bailey Dixon f9c8736e5b fix(android): keep dashboard OIDC on cookie flow 2026-07-27 08:57:17 -04:00
dependabot[bot] a815dd33fa build(deps): bump com.android.library from 9.3.0 to 9.3.1 (#261)
Bumps com.android.library from 9.3.0 to 9.3.1.

---
updated-dependencies:
- dependency-name: com.android.library
  dependency-version: 9.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:56:57 +00:00
dependabot[bot] cc9c75a636 build(deps): bump androidx.browser:browser from 1.9.0 to 1.10.0 (#260)
Bumps androidx.browser:browser from 1.9.0 to 1.10.0.

---
updated-dependencies:
- dependency-name: androidx.browser:browser
  dependency-version: 1.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:55:03 +00:00
dependabot[bot] 43179e03c0 build(deps): bump com.android.application from 9.3.0 to 9.3.1 (#259)
Bumps com.android.application from 9.3.0 to 9.3.1.

---
updated-dependencies:
- dependency-name: com.android.application
  dependency-version: 9.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:55:00 +00:00
Bailey Dixon 325b8e5670 Merge pull request #257 from Codename-11/dev
release(android): promote android-v1.5.1
2026-07-26 16:11:49 -04:00
Bailey Dixon 693ac4ed64 Merge pull request #256 from Codename-11/release/android-1.5.1-patch
release(android): android-v1.5.1
2026-07-26 15:44:19 -04:00
Bailey Dixon f94d663ac4 release(android): android-v1.5.1 2026-07-26 15:35:21 -04:00
Bailey Dixon d1a21bd42e fix(android): use Compose resources for sign-in copy 2026-07-26 15:35:20 -04:00
Bailey Dixon 7ee2d73010 fix(android): preserve formatted chat completion position 2026-07-26 15:10:48 -04:00
Bailey Dixon 682bde84fe fix(android): merge API 36 target 2026-07-26 09:38:22 -04:00
Bailey Dixon 16bdbe5f44 fix(android): target API 36 2026-07-26 09:38:00 -04:00
Bailey Dixon f43fba9fed feat(android): merge chat readability and final-only voice 2026-07-26 09:13:57 -04:00
Bailey Dixon d1745413fd fix(android): release realtime background foreground turns 2026-07-26 08:57:00 -04:00
Bailey Dixon 1b7a8025c3 fix(android): restore standard voice narration 2026-07-26 08:56:42 -04:00
Bailey Dixon d92a87483e feat(android): enhance voice conversation experience 2026-07-26 08:56:23 -04:00
Bailey Dixon 0e6d64f987 fix(android): restore standard voice narration 2026-07-26 08:42:04 -04:00
Bailey Dixon e9da59cf40 Merge pull request #254 from Codename-11/dev
fix(android): repair immutable release dispatch
2026-07-25 18:30:53 -04:00
Bailey Dixon 0bea626ed8 Merge pull request #253 from Codename-11/fix/android-release-dispatch
fix(android): repair immutable release dispatch
2026-07-25 18:30:27 -04:00
Bailey Dixon f453dd27f3 fix(android): repair immutable release dispatch 2026-07-25 18:29:32 -04:00
Bailey Dixon c9a03c9ed7 Merge pull request #252 from Codename-11/dev
release(android): android-v1.5.0
2026-07-25 18:26:34 -04:00
Bailey Dixon 36546c2712 Merge pull request #251 from Codename-11/release/android-1.5.1
release(android): android-v1.5.0
2026-07-25 17:54:01 -04:00
Bailey Dixon fbe3fc3e05 release(android): android-v1.5.0 2026-07-25 17:34:26 -04:00
Bailey Dixon a77cebec43 fix(android): refine agent passport interactions 2026-07-25 17:28:46 -04:00
Bailey Dixon 8167f93705 fix(android): repair developer options and data actions 2026-07-25 16:33:03 -04:00
Bailey Dixon 52dc46072e feat(android): redesign agent passport drawer 2026-07-25 16:32:15 -04:00
Bailey Dixon e3bc816624 fix(android): repair developer options and data actions 2026-07-25 16:29:36 -04:00
Bailey Dixon c18ab4cce8 fix(android): complete new localized strings 2026-07-25 14:39:03 -04:00
Bailey Dixon 52b28e5b48 Merge pull request #246 from Codename-11/fix/reconcile-dev-delivery
fix(android): reconcile completed dev work
2026-07-25 14:35:13 -04:00
Bailey Dixon 575fd82c59 Merge branch 'fix/android-cold-start-api-toast' into dev 2026-07-25 14:32:28 -04:00
Bailey Dixon ead3f5fd4f fix(android): suppress cold-start API fallback 2026-07-25 14:24:56 -04:00
Bailey Dixon 36a922be64 Merge branch 'feature/image-generation-progress' into dev
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/ui/components/MessageBubble.kt
2026-07-25 14:11:57 -04:00
Bailey Dixon 530a1c9591 feat(android): refine image generation progress 2026-07-25 14:10:28 -04:00
Bailey Dixon 353faa9da5 Merge branch 'chore/android-ci-feedback' into dev
# Conflicts:
#	DEVLOG.md
2026-07-25 14:07:10 -04:00
Bailey Dixon 76fdbcfd70 Merge branch 'fix/android-gateway-card-routing' into dev 2026-07-25 12:57:08 -04:00
Bailey Dixon 5365e22fff fix(android): keep gateway card actions on gateway 2026-07-25 12:56:42 -04:00
Bailey Dixon 1e3974fd88 feat(android): complete native dashboard sign-in 2026-07-25 12:33:01 -04:00
Bailey Dixon 72854d58b1 Merge origin/dev into dev before native sign-in completion 2026-07-25 11:45:47 -04:00
Bailey Dixon b63e0e726d Merge pull request #245 from Codename-11/fix/android-active-turn-notifications
feat(android): retain active turns in background
2026-07-25 11:42:44 -04:00
Bailey Dixon fff3ba8d91 feat(android): retain active turns in background 2026-07-25 11:41:41 -04:00
Bailey Dixon 7c155e3693 test(android): stabilize integrated UX fixtures 2026-07-25 11:38:45 -04:00
Bailey Dixon d15d3b594c fix(android): restore routing integration imports 2026-07-25 11:26:40 -04:00
Bailey Dixon 915b2ebe54 Merge branch 'feature/android-relay-ux-batch' into dev 2026-07-25 11:24:00 -04:00
Bailey Dixon cf3634ee2b Merge branch 'feature/hrui-059-072-routing' into feature/android-relay-ux-batch
# Conflicts:
#	TODO.md
2026-07-25 11:23:37 -04:00
Bailey Dixon 88b11856d3 test(android): fix recovery ack fixture 2026-07-25 11:23:07 -04:00
Bailey Dixon aede6c8cb3 fix(android): harden fallback model locks 2026-07-25 11:16:13 -04:00
Bailey Dixon c1187f0f2f Merge branch 'feature/hrui-030-approval' into feature/android-relay-ux-batch 2026-07-25 11:16:07 -04:00
Bailey Dixon 6ff473820c Merge branch 'feature/hrui-069-moa' into feature/android-relay-ux-batch
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/network/upstream/GatewayEventMapper.kt
2026-07-25 11:15:57 -04:00
Bailey Dixon 847a21cc0c Merge branch 'feature/hrui-063-065-voice' into feature/android-relay-ux-batch 2026-07-25 11:15:04 -04:00
Bailey Dixon 8ebd97643d Merge attachment test fix into feature/android-relay-ux-batch 2026-07-25 11:15:03 -04:00
Bailey Dixon 8aded16da9 test(android): fix attachment assertion imports 2026-07-25 11:14:28 -04:00
Bailey Dixon a35c0b34f8 Merge branch 'feature/hrui-074-040-recovery' into feature/android-relay-ux-batch 2026-07-25 11:14:27 -04:00
Bailey Dixon 877cfad88a Merge branch 'feature/hrui-060-native-pkce' into feature/android-relay-ux-batch 2026-07-25 11:14:06 -04:00
Bailey Dixon fb0b8deef7 feat(android): expose profile approval modes 2026-07-25 11:10:48 -04:00
Bailey Dixon 1a710f071c fix(android): serialize gateway resume events 2026-07-25 11:07:59 -04:00
Bailey Dixon 89b1461431 fix(android): scope and serialize dashboard auth 2026-07-25 11:07:00 -04:00
Bailey Dixon ce72f7790e Merge branch 'feature/android-collapsible-attachments' into feature/android-relay-ux-batch 2026-07-25 11:05:57 -04:00
Bailey Dixon 51f4d29ebb Merge branch 'feature/hrui-073-resume-media' into feature/android-relay-ux-batch 2026-07-25 11:05:56 -04:00
Bailey Dixon 97a2dac96d Merge branch 'feature/hrui-manage-ux' into feature/android-relay-ux-batch 2026-07-25 11:05:55 -04:00
Bailey Dixon a569361d43 fix(android): reconcile MoA advisor state 2026-07-25 11:02:34 -04:00
Bailey Dixon 35dfc8c051 fix(android): narrate complete assistant runs 2026-07-25 11:01:09 -04:00
Bailey Dixon 4a1ece7ad0 fix(android): restore persisted image attachments 2026-07-25 10:58:38 -04:00
Bailey Dixon 41b6fb4f84 feat(android): add collapsible attachment groups 2026-07-25 10:56:19 -04:00
Bailey Dixon a15b247d1a feat(android): improve Manage diagnostics and model discovery 2026-07-25 10:53:18 -04:00
Bailey Dixon 2d486dd395 fix(android): preserve API fallback model routing 2026-07-25 10:51:08 -04:00
Bailey Dixon 0fcb833c83 feat(android): surface MoA advisor progress 2026-07-25 10:49:40 -04:00
Bailey Dixon 4507b2270a fix(android): recover failed gateway turns 2026-07-25 10:49:10 -04:00
Bailey Dixon 1cd3da5ac6 feat(android): add native dashboard auth foundation 2026-07-25 10:46:16 -04:00
Bailey Dixon afe2be9304 Merge pull request #242 from Codename-11/fix/android-interaction-notifications
fix(android): notify for blocked gateway interactions
2026-07-25 09:47:58 -04:00
Bailey Dixon 60c14b5db1 Merge origin/dev into fix/android-interaction-notifications 2026-07-25 09:40:24 -04:00
Bailey Dixon e3d6dd9509 Merge pull request #244 from Codename-11/fix/onboarding-permissions-setup
fix(android): add onboarding permission setup
2026-07-24 22:59:24 -04:00
Bailey Dixon 91d05c982e Merge origin/dev into fix/onboarding-permissions-setup 2026-07-24 22:50:57 -04:00
Bailey Dixon 85bbbd004d Merge pull request #243 from Codename-11/fix/android-markdown-user-ca
fix(android): harden Markdown and private CA connections
2026-07-24 22:37:54 -04:00
Bailey Dixon a8f61f2aeb docs: restore route and localization checks 2026-07-24 22:04:46 -04:00
Bailey Dixon d554c1819a fix(android): preserve promoted background task rows 2026-07-24 22:04:42 -04:00
Bailey Dixon 920e7d58f0 fix(android): add onboarding permission setup 2026-07-24 17:34:20 -04:00
Bailey Dixon 0ce7c6c6b3 fix(android): trust user-installed certificate authorities 2026-07-24 16:44:36 -04:00
Bailey Dixon 46e8f90c39 fix(android): guard markdown highlight ranges 2026-07-24 16:44:35 -04:00
Bailey Dixon 2d28f171e0 fix(android): hide dashboard source badge 2026-07-24 16:29:44 -04:00
Bailey Dixon fdd8301796 fix(android): smooth image generation transition 2026-07-24 08:29:40 -04:00
Bailey Dixon c9ddc2ef8d fix(android): notify for blocked gateway interactions 2026-07-23 22:05:26 -04:00
Bailey Dixon b0d662b802 feat(relay): bridge image generation activity 2026-07-23 21:36:13 -04:00
Bailey Dixon 63ca0a1428 Merge feature/hrui-ledger-all-20260723 into dev 2026-07-23 21:11:00 -04:00
Bailey Dixon 8196856d76 docs: align upstream impact ledger guidance 2026-07-23 21:10:08 -04:00
Bailey Dixon 605ff00cc0 fix(android): consume upstream display metadata 2026-07-23 21:09:01 -04:00
Bailey Dixon c9b1e1b04e fix(android): keep active chat progress visible 2026-07-23 20:57:10 -04:00
Bailey Dixon a4466e4ca0 fix(android): show image animation when tools are hidden 2026-07-23 20:34:41 -04:00
Bailey Dixon e13e381e3a docs(android): clarify shared dashboard sessions 2026-07-23 20:20:31 -04:00
Bailey Dixon 19d33910d0 fix(android): share dashboard session across routes 2026-07-23 20:17:57 -04:00
Bailey Dixon 41480a3254 fix(android): refresh dashboard-only route changes 2026-07-23 19:33:13 -04:00
Bailey Dixon 50f151edba fix(android): derive dashboard for QR routes 2026-07-23 19:18:50 -04:00
Bailey Dixon 0f108fe971 fix(android): move dashboard with active route 2026-07-23 18:42:13 -04:00
Bailey Dixon c8d6119e1a fix(android): probe remote health with GET 2026-07-23 17:18:10 -04:00
Bailey Dixon b2b7a2572b fix(android): restore remote route surfaces 2026-07-23 09:03:20 -04:00
Bailey Dixon 1ccf87401a fix(android): rebind gateway turns and dedupe sessions 2026-07-22 23:23:34 -04:00
Bailey Dixon 50b1a17895 release(server): server-v1.4.3 2026-07-22 22:52:27 -04:00
Bailey Dixon d536923c55 release(android): android-v1.5.0 2026-07-22 22:50:30 -04:00
Bailey Dixon 14a2e01ac5 Merge feature/hrui-063-standard-voice-streaming into dev 2026-07-22 22:12:47 -04:00
Bailey Dixon a0d03f6947 feat(android): stream Standard Hermes voice replies 2026-07-22 22:12:39 -04:00
Bailey Dixon 478eeac3f7 Merge fix/hrui-061-correction-copy into dev 2026-07-22 22:01:35 -04:00
Bailey Dixon 799159457a fix(android): finish active-turn correction copy 2026-07-22 22:01:18 -04:00
Bailey Dixon f90650bdc9 Merge docs/hrui-056-ops-audit into dev 2026-07-22 21:06:06 -04:00
Bailey Dixon 6a41ec154c Merge feature/hrui-android-diagnostics-manage into dev 2026-07-22 21:05:57 -04:00
Bailey Dixon 53f4c8187b Merge feature/hrui-android-gateway-controls into dev 2026-07-22 21:05:49 -04:00
Bailey Dixon 675252090c Merge feature/hrui-plugin-compat-diagnostics into dev 2026-07-22 21:05:42 -04:00
Bailey Dixon e94db467e8 docs: add Hermes update restart audit 2026-07-22 21:05:25 -04:00
Bailey Dixon 65dae79c8c feat(android): consume dashboard health hints 2026-07-22 21:04:52 -04:00
Bailey Dixon e5d0334c8b feat(android): use gateway redirect and compress RPCs 2026-07-22 20:57:47 -04:00
Bailey Dixon 9cc7b25fd1 fix(plugin): align diagnostics and config route hygiene 2026-07-22 20:34:43 -04:00
Bailey Dixon 2ce352a320 fix(android): add voice settings translations 2026-07-20 20:40:37 -04:00
Bailey Dixon a6957268b9 Merge feature/upstream-ledger-next-batch into dev 2026-07-20 20:22:30 -04:00
Bailey Dixon 8f42b96be1 feat: consume upstream interim gateway events 2026-07-20 20:22:02 -04:00
Bailey Dixon 8a9c058ddb Merge feature/voice-settings-layout-v2 into dev 2026-07-20 20:08:59 -04:00
Bailey Dixon 5ef2c40f81 feat(android): expand voice settings discovery 2026-07-20 20:08:52 -04:00
Bailey Dixon 6b32fe7ddd Merge feature/voice-settings-preview into dev 2026-07-20 10:52:26 -04:00
Bailey Dixon 02f38322fa feat(android): improve voice settings previews 2026-07-20 10:52:11 -04:00
Bailey Dixon f40b7abaf0 Merge feature/open-ledger-batches into dev 2026-07-20 10:20:50 -04:00
Bailey Dixon 5fcbe5ff63 Merge origin/dev into dev 2026-07-20 10:20:37 -04:00
Bailey Dixon 6ce504b1c9 Merge OAuth dashboard binding fix 2026-07-20 10:07:17 -04:00
Bailey Dixon 31ebef825f fix(android): bind oauth flow to dashboard 2026-07-20 10:07:03 -04:00
Bailey Dixon 68abbf156d Merge Manage localization follow-up 2026-07-20 09:39:02 -04:00
Bailey Dixon 73a8af7c8f fix(android): localize manage parity strings 2026-07-20 09:38:44 -04:00
Bailey Dixon 2db00d4c59 Merge feature/hrui-direct-chat-compat 2026-07-20 09:10:47 -04:00
Bailey Dixon 847444d115 fix(android): cap inline image lifecycle memory 2026-07-20 09:09:27 -04:00
Bailey Dixon 798e8eef55 Merge feature/hrui-manage-parity 2026-07-20 09:05:14 -04:00
Bailey Dixon 35f791be50 Merge pull request #238 from Codename-11/feature/axi-129-image-generation-animation
feat(android): show diffusion animation during image generation
2026-07-20 09:03:45 -04:00
Bailey Dixon e727979897 fix(android): safely gate hosted oauth 2026-07-20 09:03:35 -04:00
Bailey Dixon 91025b733f fix(android): bound inline data image memory 2026-07-20 09:01:16 -04:00
Bailey Dixon 34da86a96a fix(android): refresh locale source hashes after image gen string
Translated catalogs already include image_generation_rendering; update
localization-status source_sha256 so check-android-locales stays green.

Forge: AXI-129
2026-07-20 08:56:50 -04:00
Bailey Dixon 30f9f51e2a fix(android): preserve hosted oauth scope 2026-07-20 08:54:33 -04:00
Bailey Dixon 713529f79f Merge feature/hrui-session-controls 2026-07-20 08:50:09 -04:00
Bailey Dixon 16d1728306 fix(android): honor session control reset semantics 2026-07-20 08:49:20 -04:00
Bailey Dixon 7a813995ba feat(android): show diffusion animation during image generation
Specialize pending image_generate tool parts with a theme-aware procedural
diffusion placeholder and accessibility announcement. Vanilla Hermes already
emits the generic tool lifecycle, so this stays client-only.

Forge: AXI-129
2026-07-20 08:49:12 -04:00
Bailey Dixon c86b2224ce feat(android): align direct chat with upstream contracts 2026-07-20 08:47:20 -04:00
Bailey Dixon 11a782bc44 feat(android): add hosted manage parity 2026-07-20 08:36:48 -04:00
Bailey Dixon 884a17ded7 Merge certification coverage fixes 2026-07-20 08:25:32 -04:00
Bailey Dixon 745904d468 test: cover background delivery ownership 2026-07-20 08:21:54 -04:00
Bailey Dixon 4258322acc test: run delegation ownership certification 2026-07-20 08:17:43 -04:00
Bailey Dixon 2c544b8ab0 Merge feature/hrui-live-certification 2026-07-20 08:11:43 -04:00
Bailey Dixon 5122ee69f6 test: automate upstream compatibility preflight 2026-07-20 08:11:18 -04:00
Bailey Dixon 512199448e Merge origin/dev into dev 2026-07-20 08:02:22 -04:00
dependabot[bot] 7a7b10f08a chore(deps): bump com.meta.spatial:spatial-gradle-plugin-impl (#237)
Bumps com.meta.spatial:spatial-gradle-plugin-impl from 0.13.1 to 0.13.2.

---
updated-dependencies:
- dependency-name: com.meta.spatial:spatial-gradle-plugin-impl
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 11:56:33 +00:00
dependabot[bot] f7845291e7 chore(deps): bump spatialsdk from 0.13.1 to 0.13.2 (#236)
Bumps `spatialsdk` from 0.13.1 to 0.13.2.

Updates `com.meta.spatial:meta-spatial-sdk` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-compose` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-ovrmetrics` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-toolkit` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-vr` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-isdk` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-castinputforward` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-hotreload` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-datamodelinspector` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-uiset` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-mruk` from 0.13.1 to 0.13.2

---
updated-dependencies:
- dependency-name: com.meta.spatial:meta-spatial-sdk
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-compose
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-ovrmetrics
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-toolkit
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-vr
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-isdk
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-castinputforward
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-hotreload
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-datamodelinspector
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-uiset
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-mruk
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 11:56:03 +00:00
dependabot[bot] 689219405b chore(deps): bump the testing group with 2 updates (#235)
Bumps the testing group with 2 updates: [io.github.takahirom.roborazzi:roborazzi](https://github.com/takahirom/roborazzi) and [io.github.takahirom.roborazzi:roborazzi-compose](https://github.com/takahirom/roborazzi).


Updates `io.github.takahirom.roborazzi:roborazzi` from 1.68.0 to 1.70.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.68.0...1.70.0)

Updates `io.github.takahirom.roborazzi:roborazzi-compose` from 1.68.0 to 1.70.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.68.0...1.70.0)

---
updated-dependencies:
- dependency-name: io.github.takahirom.roborazzi:roborazzi
  dependency-version: 1.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
- dependency-name: io.github.takahirom.roborazzi:roborazzi-compose
  dependency-version: 1.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 11:55:47 +00:00
Bailey Dixon f4e9de425a Merge feature/upstream-ledger-batch into dev
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/ui/screens/DiagnosticsScreen.kt
#	docs/upstream-surface-matrix.md
2026-07-20 07:41:48 -04:00
Bailey Dixon 68f8b58a0b Merge pull request #232 from Codename-11/dev
release(android): android-v1.4.9
2026-07-19 21:27:28 -04:00
Bailey Dixon 2bfe21eaff chore(ci): shorten Android validation feedback 2026-07-19 20:50:36 -04:00
Bailey Dixon d4cdb96bab fix: preserve running queued recovery handoff 2026-07-19 20:20:36 -04:00
Bailey Dixon 481ad48c57 chore(release): prepare Android 1.4.9 2026-07-19 19:55:35 -04:00
Bailey Dixon c5f35145b4 Merge queued recovery boundary fixes 2026-07-19 19:53:02 -04:00
Bailey Dixon 3283c9b601 fix: preserve resumed gateway turn boundaries 2026-07-19 19:52:46 -04:00
Bailey Dixon 74be630e05 Merge pull request #231 from Codename-11/feature/dashboard-primary-connections
feat(android): make dashboard the primary connection
2026-07-19 19:47:04 -04:00
Bailey Dixon 44f030f93b docs: block one-turn model automation on atomicity 2026-07-19 19:44:35 -04:00
Bailey Dixon abce00f49e Revert "Merge feature/hrui-046-model-once"
This reverts commit 61c4337807, reversing
changes made to 6e1338004c.
2026-07-19 19:44:19 -04:00
Bailey Dixon a61d27a92d feat(android): make dashboard the primary connection
Make the Hermes dashboard the standard connection path with API fallback and optional Relay pairing. Redesign onboarding and connection management, add startup preference and route/security details, reconcile pinned profile identity and hostname discovery, and update tests, docs, and localized resources.
2026-07-19 19:39:28 -04:00
Bailey Dixon 123f1d1263 docs: record upstream ledger follow-ups 2026-07-19 19:37:47 -04:00
Bailey Dixon 61c4337807 Merge feature/hrui-046-model-once 2026-07-19 19:26:42 -04:00
Bailey Dixon 6e1338004c Merge feature/hrui-047-queued-recovery 2026-07-19 19:25:59 -04:00
Bailey Dixon 348152a7cb Merge feature/hrui-044-windows-ca 2026-07-19 19:25:59 -04:00
Bailey Dixon 3ec34502ec feat(chat): add one-turn model selection 2026-07-19 19:25:55 -04:00
Bailey Dixon d30de8656d fix(android): scope diagnostics toolsets to profile 2026-07-19 19:25:52 -04:00
Bailey Dixon 9b9b8c7c06 Merge feature/hrui-038-profile-routing
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClient.kt
2026-07-19 19:25:21 -04:00
Bailey Dixon 93b82aa538 feat: surface queued recovery and project labels 2026-07-19 19:24:20 -04:00
Bailey Dixon 7f2049fa0a fix(desktop): trust Windows system certificate authorities 2026-07-19 19:24:16 -04:00
Bailey Dixon 92444a7039 feat(android): route multiplex profile API traffic 2026-07-19 19:23:31 -04:00
Bailey Dixon a02d7e10df Merge feature/hrui-diagnostics-bundle
# Conflicts:
#	docs/upstream-surface-matrix.md
2026-07-19 19:18:29 -04:00
Bailey Dixon 8a3935ffa1 feat: add upstream gateway diagnostics 2026-07-19 19:17:18 -04:00
Bailey Dixon e48935929a Merge bootstrap database initialization follow-up 2026-07-19 19:07:15 -04:00
Bailey Dixon 1e82347e7d fix(plugin): offload bootstrap database initialization 2026-07-19 19:06:59 -04:00
Bailey Dixon 75ed3c4226 Merge feature/hrui-004-012-bootstrap-async 2026-07-19 18:34:26 -04:00
Bailey Dixon bade61ac34 fix(plugin): offload bootstrap compatibility state 2026-07-19 18:28:14 -04:00
Bailey Dixon f88559f856 docs: define upstream compatibility gates 2026-07-19 18:25:57 -04:00
Bailey Dixon 22e4d24817 Merge pull request #229 from Codename-11/fix/windows-gateway-pid-probe
fix(plugin): avoid signalling gateway PID on Windows
2026-07-18 21:58:15 -04:00
Bailey Dixon 7edaa2df14 test(plugin): preserve PID identity coverage 2026-07-18 21:57:07 -04:00
Bailey Dixon 165feaa0d6 fix(plugin): avoid signalling gateway PID on Windows 2026-07-18 21:55:03 -04:00
Bailey Dixon 8c516c3c8d Merge pull request #228 from Codename-11/main
chore: back-merge Android 1.4.8 hotfix
2026-07-18 12:22:29 -04:00
Bailey Dixon 40bb0a4ef8 Merge pull request #227 from Codename-11/fix/privacy-policy-url-hotfix
fix(android): restore Play privacy policy URL
2026-07-18 11:42:37 -04:00
Bailey Dixon d96898a6aa fix(android): restore Play privacy policy URL 2026-07-18 11:30:17 -04:00
Bailey Dixon b4e595e320 Merge pull request #226 from Codename-11/dev
release: Android 1.4.7
2026-07-18 10:29:31 -04:00
Bailey Dixon 31c41fb2ff Merge pull request #225 from Codename-11/fix/android-1.4.7-release-prep
release(android): android-v1.4.7
2026-07-18 09:56:28 -04:00
Bailey Dixon ab0f7b726a release(android): android-v1.4.7 2026-07-18 09:49:04 -04:00
Bailey Dixon f72904ab53 Merge pull request #206 from Codename-11/docs/branch-release-contract
docs: reconcile branch and release contract
2026-07-18 09:41:46 -04:00
Bailey Dixon 4fc5f668de merge: refresh branch contract from dev 2026-07-18 09:34:49 -04:00
Bailey Dixon cedc340091 Merge pull request #224 from Codename-11/fix/reconcile-dev-release
fix: reconcile release history and Android localization
2026-07-18 09:33:48 -04:00
Bailey Dixon 97cb30c927 merge: back-merge main release history into dev 2026-07-18 09:23:01 -04:00
Bailey Dixon ed41be3390 merge: reconcile Android localization into dev 2026-07-18 09:22:39 -04:00
Bailey Dixon 08816cfe63 Merge pull request #222 from Codename-11/fix/smooth-stream-rendering
fix(android): smooth streamed reply rendering
2026-07-17 14:44:40 -04:00
Bailey Dixon 01a0cde589 fix(android): smooth streamed reply rendering 2026-07-17 14:35:12 -04:00
Bailey Dixon ed6742afe4 Merge pull request #219 from Codename-11/fix/smooth-stream-finalization
fix(android): smooth streamed reply finalization
2026-07-17 09:22:45 -04:00
Bailey Dixon a6264df910 fix(android): smooth streamed reply finalization 2026-07-17 09:14:34 -04:00
Bailey Dixon 64e2e2eca6 Merge pull request #218 from Codename-11/fix/post-stream-history-scroll
fix(android): preserve chat anchor across history reload
2026-07-17 07:42:46 -04:00
Bailey Dixon 1ccaf2c4f1 fix(android): preserve chat anchor across history reload 2026-07-17 07:33:40 -04:00
Bailey Dixon 7686bb41e7 Merge pull request #217 from Codename-11/fix/stream-final-scroll-anchor
fix(android): retain chat bottom after stream completion
2026-07-16 21:03:11 -04:00
Bailey Dixon a940b4b8ea fix(android): retain chat bottom after stream completion 2026-07-16 20:55:01 -04:00
Bailey Dixon 46afdeab59 Merge pull request #216 from Codename-11/fix/critical-relay-security
fix(security): enforce Relay privileged boundaries
2026-07-16 20:23:16 -04:00
Bailey Dixon d4a8aad050 fix(ci): classify PR paths from merge commit 2026-07-16 19:38:00 -04:00
Bailey Dixon 0a6e95ae74 fix(ci): retry transient path classification failures 2026-07-16 19:36:05 -04:00
Bailey Dixon a6fc53e5cf docs: record critical relay hardening 2026-07-16 19:33:11 -04:00
Bailey Dixon c013daacda fix(security): prevent relay session self-upgrade 2026-07-16 19:27:39 -04:00
Bailey Dixon f5b1d377a4 fix(security): enforce terminal session grants 2026-07-16 19:26:12 -04:00
Bailey Dixon bb1e406f3f fix(security): redact remote profile config 2026-07-16 19:26:06 -04:00
Bailey Dixon 10213ca8ed fix(security): authorize Android bridge HTTP routes 2026-07-16 19:25:52 -04:00
Bailey Dixon cbfccd8ccf fix(security): keep voice provider origins host-controlled 2026-07-16 19:21:17 -04:00
Bailey Dixon c3c98caa31 fix(security): require host-authorized pairing 2026-07-16 19:18:02 -04:00
Bailey Dixon ed60abd57c Merge pull request #215 from Codename-11/fix/docs-docker-assets
fix(website): restore production docs build context
2026-07-16 17:42:30 -04:00
Bailey Dixon cab0d90530 fix(website): restore production docs build context 2026-07-16 17:40:03 -04:00
Bailey Dixon d977600f9d Merge pull request #214 from Codename-11/dev
merge: promote localized public experience
2026-07-16 15:51:48 -04:00
Bailey Dixon c902c00101 Merge pull request #213 from Codename-11/feature/docs-home-hub
feat: modernize and localize public experience
2026-07-16 15:38:49 -04:00
Bailey Dixon aa6b48a068 merge: sync latest main into public experience work
# Conflicts:
#	DEVLOG.md
2026-07-16 15:31:18 -04:00
Bailey Dixon 50297d1496 feat: modernize and localize public experience 2026-07-16 15:29:39 -04:00
Bailey Dixon d80f36a087 merge: add Android German Portuguese and Japanese 2026-07-16 08:46:29 -04:00
Bailey Dixon b6117c2d41 Merge pull request #212 from Codename-11/fix/docs-clean-urls
fix(website): serve VitePress clean URLs
2026-07-16 08:04:39 -04:00
Bailey Dixon b0ee6935fe fix(website): serve VitePress clean URLs 2026-07-16 08:02:26 -04:00
Bailey Dixon 33538fde0c Merge pull request #211 from Codename-11/fix/legacy-docs-redirect
fix(docs): add temporary legacy redirects
2026-07-16 07:58:37 -04:00
Bailey Dixon 603919c8ff fix(docs): add temporary legacy redirects 2026-07-16 07:56:09 -04:00
Bailey Dixon 52df3adbf6 Merge pull request #210 from Codename-11/fix/retire-github-pages
fix(docs): retire GitHub Pages
2026-07-16 07:42:52 -04:00
Bailey Dixon 3eab11c639 fix(docs): retire GitHub Pages 2026-07-15 21:48:20 -04:00
Bailey Dixon 2673f228bb Merge pull request #209 from Codename-11/fix/website-coolify-deployment
fix(website): add Coolify root-context build
2026-07-15 20:37:28 -04:00
Bailey Dixon 53b8f6a418 fix(website): add Coolify root-context build 2026-07-15 20:35:51 -04:00
Bailey Dixon 87cd9e7b9d docs: keep main as GitHub default branch 2026-07-15 14:56:32 -04:00
Bailey Dixon 51a020bd22 merge: refresh branch contract from dev 2026-07-15 14:37:07 -04:00
Bailey Dixon 34ff4d0629 docs: reconcile branch and release contract 2026-07-15 14:37:01 -04:00
Bailey Dixon c452c25148 feat(android): integrate expanded language support 2026-07-15 11:24:54 -04:00
Bailey Dixon 0db5c02722 feat(android): add Japanese localization 2026-07-15 10:57:40 -04:00
Bailey Dixon 4630695c17 feat(android): add Brazilian Portuguese localization 2026-07-15 10:52:15 -04:00
Bailey Dixon f4ee440015 feat(android): add German localization 2026-07-15 10:52:15 -04:00
859 changed files with 136842 additions and 11116 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ contact_links:
url: https://github.com/Codename-11/hermes-relay/security/advisories/new
about: Report privately via GitHub Security Advisories — do not open a public issue. See SECURITY.md for the full policy.
- name: User documentation
url: https://codename-11.github.io/hermes-relay/
url: https://hermes-relay.dev/docs/
about: Read setup, pairing, remote access, and troubleshooting docs.
- name: Contributing guide
url: https://github.com/Codename-11/hermes-relay/blob/main/CONTRIBUTING.md
+1 -1
View File
@@ -25,7 +25,7 @@ otherwise use verified Co-authored-by trailers. Write "N/A" for original work.
## Checklist
- [ ] Target branch is `dev` unless this is a release PR
- [ ] Target branch is `dev`, unless this is a `dev` → `main` release PR or a focused production-tag hotfix PR to `main`
- [ ] Android changes: lint and focused unit tests ran, or rationale is listed above
- [ ] Translation changes: locale status/review references are accurate, `python scripts/check-android-locales.py` ran, and device/emulator review is documented, or N/A
- [ ] Server changes: focused `python -m unittest ...` checks ran, or rationale is listed above
@@ -89,7 +89,7 @@ jobs:
VERSION: ${{ steps.metadata.outputs.version }}
run: |
gh workflow run release-android.yml \
--ref="android-v${VERSION}" \
--ref=main \
-f version="$VERSION"
- name: Approval summary
@@ -97,4 +97,4 @@ jobs:
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY"
echo "The release workflow was dispatched at that tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
echo "The current release workflow was dispatched from main and will check out that immutable tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
+7 -6
View File
@@ -38,10 +38,11 @@ on:
- ".github/workflows/approve-release-android.yml"
- ".github/workflows/release-android.yml"
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
# Cancel superseded PR and dev runs. Never cancel main: every release-branch
# commit must finish its independent validation.
concurrency:
group: ci-android-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
# ──────────────────────────────────────────────
@@ -62,7 +63,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
@@ -94,7 +95,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
@@ -138,7 +139,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
@@ -202,7 +203,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
@@ -0,0 +1,79 @@
# Hermes-Relay - Desktop Vanilla-Upstream Baseline
#
# Manual/scheduled confidence gate for HRUI-055. This keeps the first CI shape
# intentionally small: check out a clean upstream hermes-agent beside Relay and
# run the desktop typed-stream/renderer tests that protect the gateway event
# contract. A later expansion can boot the upstream gateway with a mock provider
# once that harness is stable enough for CI.
name: CI - Desktop Upstream Baseline
on:
workflow_dispatch:
inputs:
upstream_ref:
description: "NousResearch/hermes-agent ref to check"
required: false
default: "main"
schedule:
- cron: "30 6 * * 1"
concurrency:
group: ci-desktop-upstream-baseline-${{ github.ref }}
cancel-in-progress: true
jobs:
desktop-baseline:
name: Desktop typed gateway baseline
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout hermes-relay
uses: actions/checkout@v7
- name: Resolve upstream ref
id: ref
run: |
if [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
REF="${{ github.event.inputs.upstream_ref }}"
else
REF="main"
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
- name: Checkout vanilla upstream
uses: actions/checkout@v7
with:
repository: NousResearch/hermes-agent
ref: ${{ steps.ref.outputs.ref }}
path: _upstream
fetch-depth: 1
- name: Assert upstream checkout is vanilla
run: |
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
echo "FAIL: upstream checkout contains a relay bootstrap."; exit 1
fi
git -C _upstream status --short --untracked-files=no
- name: Run desktop gateway baseline contract
run: python scripts/check-desktop-upstream-baseline.py "_upstream"
- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: "22"
cache: "npm"
cache-dependency-path: desktop/package-lock.json
- name: Install desktop dependencies
working-directory: desktop
run: npm ci
- name: Run desktop gateway baseline tests
working-directory: desktop
env:
HERMES_UPSTREAM_BASELINE: ${{ github.workspace }}/_upstream
run: npx tsx --test tests/gatewayTypes.test.ts tests/renderer.test.ts tests/typedStreamRenderer.test.ts
+4 -2
View File
@@ -100,13 +100,15 @@ jobs:
with:
node-version: '22'
cache: npm
cache-dependency-path: desktop/package-lock.json
cache-dependency-path: |
desktop/package-lock.json
desktop/tray/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install deps
run: npm ci
run: npm ci && npm --prefix tray ci
- name: Check tray formatting
run: npm run tray:fmt
+4 -1
View File
@@ -50,6 +50,7 @@ jobs:
- name: Syntax check (plugin relay — canonical location)
run: |
python -m py_compile plugin/relay/config.py
python -m py_compile plugin/relay/server.py
python -m py_compile plugin/relay/channels/terminal.py
python -m py_compile plugin/relay/channels/chat.py
@@ -103,4 +104,6 @@ jobs:
plugin/tests/test_relay_security.py \
plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py \
plugin/tests/test_native_layout_imports.py
plugin/tests/test_native_layout_imports.py \
plugin/tests/test_profile_discovery.py \
plugin/tests/test_profiles_updated_broadcast.py
+7 -7
View File
@@ -33,6 +33,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 2
- name: Test path classifier
run: node .github/scripts/classify-ci-paths.test.cjs
@@ -42,13 +44,11 @@ jobs:
uses: actions/github-script@v8
with:
script: |
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});
const paths = files.map((file) => file.filename);
const { stdout } = await exec.getExecOutput(
'git',
['diff', '--name-only', 'HEAD^1', 'HEAD^2'],
);
const paths = stdout.split(/\r?\n/).filter(Boolean);
const { classifyCiPaths } = require(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/classify-ci-paths.cjs`,
);
-75
View File
@@ -1,75 +0,0 @@
# Hermes-Relay — Docs Deployment
#
# Builds VitePress docs and deploys to GitHub Pages.
# Triggers on pushes to main that change user-docs/ content,
# or manually via workflow_dispatch.
name: Deploy Docs
on:
push:
branches: [main]
paths:
- 'user-docs/**'
- '.github/workflows/docs.yml'
workflow_dispatch:
# Allow only one concurrent deployment
concurrency:
group: pages
cancel-in-progress: false
# Sets permissions for GITHUB_TOKEN to enable Pages deployment
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
name: Build Docs
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0 # Full history for lastUpdated timestamps
- name: Setup Node.js
uses: actions/setup-node@v7
with:
# Node 24 ships npm 11, matching the npm that generates
# user-docs/package-lock.json. On npm 10 (Node 20), `npm ci` rejects
# the lock over the optional `search-insights` peer dep of bundled
# docsearch. Keep this aligned with the npm used to write the lock.
node-version: 24
cache: npm
cache-dependency-path: user-docs/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: user-docs
- name: Build VitePress site
run: npm run build
working-directory: user-docs
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: user-docs/.vitepress/dist
deploy:
name: Deploy to GitHub Pages
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
-65
View File
@@ -1,65 +0,0 @@
name: Issue Triage
on:
issues:
types: [opened]
workflow_dispatch:
inputs:
issue_number:
description: "Issue number to label again"
required: true
type: string
concurrency:
group: issue-triage-${{ github.event.issue.number || github.event.inputs.issue_number }}
cancel-in-progress: false
permissions:
contents: read
issues: write
jobs:
auto-label:
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issues' && github.event.issue.user.type != 'Bot')
runs-on: ubuntu-latest
steps:
- name: Label from title prefix and issue area
uses: actions/github-script@v8
env:
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue_number }}
with:
script: |
const issue_number = Number(process.env.ISSUE_NUMBER);
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner, repo: context.repo.repo, issue_number,
});
const title = (issue.title || '').toLowerCase();
const body = (issue.body || '').toLowerCase();
const haystack = `${title}\n${body}`;
const labels = [];
if (title.startsWith('[bug]')) labels.push('bug');
else if (title.startsWith('[feature]') || title.startsWith('[feat]')) labels.push('enhancement');
else if (title.startsWith('[docs]')) labels.push('documentation');
if (/\b(cli|desktop|terminal|daemon|pty|hermes-relay (install|binary|tray))\b/.test(haystack)) labels.push('area:cli');
else if (/\b(dashboard|plugin ui|react)\b/.test(haystack)) labels.push('area:dashboard');
else if (/\b(relay|plugin|aiohttp|python|pairing|voice (transcribe|synthesize)|bridge (endpoint|route))\b/.test(haystack)) labels.push('area:plugin');
else if (/\b(readme|user-?docs|documentation)\b/.test(haystack)) labels.push('area:docs');
else if (/\b(android|app|compose|apk|phone|samsung|gradle|chat|voice|notification|sphere|keystore)\b/.test(haystack)) labels.push('area:android');
if (!labels.length) {
core.info('No deterministic label matched; leaving the issue for maintainer triage.');
return;
}
try {
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo, issue_number, labels,
});
core.info(`Applied labels: ${labels.join(', ')}`);
} catch (error) {
core.warning(`Could not apply ${labels.join(', ')}: ${error.message}`);
}
@@ -0,0 +1,89 @@
name: Deploy legacy docs redirects
on:
pull_request:
paths:
- "legacy-pages-redirect/**"
- "website/public/privacy.html"
- ".github/workflows/legacy-docs-redirect.yml"
push:
branches: [main]
paths:
- "legacy-pages-redirect/**"
- "website/public/privacy.html"
- ".github/workflows/legacy-docs-redirect.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: legacy-docs-pages
cancel-in-progress: true
jobs:
build:
name: Build redirect artifact
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Build redirect-only site
shell: bash
run: |
set -euo pipefail
source_file="legacy-pages-redirect/redirect.html"
privacy_file="website/public/privacy.html"
output_dir="legacy-pages-redirect/_site"
rm -rf "$output_dir"
mkdir -p \
"$output_dir/guide/getting-started" \
"$output_dir/privacy" \
"$output_dir/reference/relay-server" \
"$output_dir/architecture"
for target in \
index.html \
404.html \
guide/getting-started.html \
guide/getting-started/index.html \
reference/relay-server.html \
reference/relay-server/index.html \
architecture/connection-security.html; do
cp "$source_file" "$output_dir/$target"
done
cp "$privacy_file" "$output_dir/privacy.html"
cp "$privacy_file" "$output_dir/privacy/index.html"
touch "$output_dir/.nojekyll"
test "$(find "$output_dir" -type f | wc -l)" -eq 10
grep -Fq '<h1>Privacy Policy</h1>' "$output_dir/privacy.html"
grep -Fq 'https://hermes-relay.dev/privacy.html' "$output_dir/privacy.html"
if grep -R -E '<title>VitePress|<div id="app">' "$output_dir"; then
echo "Full documentation content must not be deployed by this workflow." >&2
exit 1
fi
- name: Configure Pages
if: github.event_name != 'pull_request'
uses: actions/configure-pages@v6
- name: Upload redirect artifact
if: github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@v5
with:
path: legacy-pages-redirect/_site
deploy:
name: Deploy redirect shim
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
+1 -1
View File
@@ -76,7 +76,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: false
+2 -1
View File
@@ -74,13 +74,14 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: false
- name: Validate release metadata and source compatibility
run: |
python3 scripts/check-version-tracks.py
python3 scripts/check-privacy-policy.py --live
python3 scripts/check-android-locales.py
python3 scripts/check-android-collection-apis.py
python3 -m json.tool app/src/main/assets/changelog.json >/dev/null
+52 -11
View File
@@ -3,7 +3,7 @@
# Triggered when an Android release tag (android-v*) is pushed.
# Validates the tag matches the app version in libs.versions.toml,
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
# GitHub Release. Plugin/Python package releases use plugin-v* tags.
# GitHub Release. Server/Python package releases use server-v* tags.
name: Release Android
@@ -12,8 +12,9 @@ on:
tags:
- "android-v*"
# Approve Android Release creates its tag with GITHUB_TOKEN, whose tag event
# does not recursively start workflows. It explicitly dispatches this file
# at that tag instead. Manual tag pushes continue to use the push trigger.
# does not recursively start workflows. It dispatches the current workflow
# definition from main, while every job checks out the immutable tag. Manual
# tag pushes continue to use the push trigger.
workflow_dispatch:
inputs:
version:
@@ -35,19 +36,24 @@ jobs:
version_code: ${{ steps.version.outputs.version_code }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
- name: Extract version from tag
id: version
env:
DISPATCHED_VERSION: ${{ inputs.version }}
run: |
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
if [ "$GITHUB_REF" = "$REF_VERSION" ]; then
if [ -n "$DISPATCHED_VERSION" ]; then
REF_VERSION="$DISPATCHED_VERSION"
fi
if [ -n "$DISPATCHED_VERSION" ] && [ "$DISPATCHED_VERSION" != "$REF_VERSION" ]; then
echo "::error::Dispatched version $DISPATCHED_VERSION does not match ref version $REF_VERSION"
exit 1
TAG_COMMIT=$(git rev-list -n 1 "android-v${REF_VERSION}")
if [ -z "$TAG_COMMIT" ] || [ "$TAG_COMMIT" != "$(git rev-parse HEAD)" ]; then
echo "::error::Checked-out commit does not match immutable tag android-v${REF_VERSION}"
exit 1
fi
else
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
fi
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
@@ -65,9 +71,26 @@ jobs:
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
exit 1
fi
if ! grep -Eq "^## \\[(Android )?${TAG_VERSION}\\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no Android release heading for $TAG_VERSION"
exit 1
fi
echo "Version validated: $TAG_VERSION"
- name: Verify public privacy policy URLs
run: python3 scripts/check-privacy-policy.py --live
- name: Verify tagged commit belongs to main
run: |
set -euo pipefail
git fetch origin main --no-tags
tag_commit="$(git rev-parse HEAD)"
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
echo "Android releases must be tagged from main; $tag_commit is not in origin/main" >&2
exit 1
fi
- name: Require successful Play preflight for this exact release tree
if: ${{ !contains(steps.version.outputs.version, '-') }}
env:
@@ -92,6 +115,8 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
- name: Set up JDK 17
uses: actions/setup-java@v5
@@ -100,13 +125,14 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: false
- name: Validate release metadata and Android API compatibility
run: |
python3 scripts/check-version-tracks.py
python3 scripts/check-privacy-policy.py
python3 scripts/check-android-locales.py
python3 scripts/check-android-collection-apis.py
@@ -127,6 +153,8 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
- name: Set up JDK 17
uses: actions/setup-java@v5
@@ -135,7 +163,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: false
@@ -163,6 +191,19 @@ jobs:
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
run: ./gradlew bundleRelease assembleRelease
# The Play AAB carries its mapping for Play Console deobfuscation, but
# sideload issue reports need the exact mapping from this immutable build.
# Keep both variants as a workflow artifact (not a public release asset).
- name: Retain R8 mappings for retrace
uses: actions/upload-artifact@v7
with:
name: android-r8-mappings-${{ needs.validate.outputs.version }}-${{ github.sha }}
path: |
app/build/outputs/mapping/googlePlayRelease/mapping.txt
app/build/outputs/mapping/sideloadRelease/mapping.txt
if-no-files-found: error
retention-days: 90
- name: Scan release DEX for unsupported collection APIs
run: |
python3 scripts/check-android-collection-apis.py \
+162 -15
View File
@@ -1,8 +1,8 @@
name: Release CLI
name: Release Desktop
on:
push:
tags: ['cli-v*']
tags: ['desktop-v*']
permissions:
contents: write
@@ -36,13 +36,17 @@ jobs:
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#cli-v}"
version="${GITHUB_REF_NAME#desktop-v}"
if [[ -z "$version" || "$version" == "$GITHUB_REF_NAME" ]]; then
echo "Expected a cli-v* tag, got $GITHUB_REF_NAME" >&2
echo "Expected a desktop-v* tag, got $GITHUB_REF_NAME" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
npm run check:version-sync -- --expect "$version"
if ! grep -Fq "## [$version]" ../CHANGELOG.md; then
echo "CHANGELOG.md has no release heading for $version" >&2
exit 1
fi
- name: Verify tagged commit belongs to main
shell: bash
@@ -52,7 +56,7 @@ jobs:
git fetch origin main --no-tags
tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")"
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
echo "CLI releases must be tagged from main; $tag_commit is not in origin/main" >&2
echo "Desktop releases must be tagged from main; $tag_commit is not in origin/main" >&2
exit 1
fi
@@ -164,7 +168,9 @@ jobs:
with:
node-version: '22'
cache: npm
cache-dependency-path: desktop/package-lock.json
cache-dependency-path: |
desktop/package-lock.json
desktop/tray/package-lock.json
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -175,7 +181,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Install deps
run: npm ci
run: npm ci && npm --prefix tray ci
- name: Type-check
run: npm run type-check
@@ -209,12 +215,153 @@ jobs:
$proc = Start-Process -FilePath tray/target/release/hermes-relay-tray.exe -WindowStyle Hidden -PassThru
Start-Sleep -Seconds 5
if ($proc.HasExited) { throw "tray app exited early with code $($proc.ExitCode)" }
$proc.Refresh()
if ($proc.MainWindowHandle -ne 0) { throw 'menu-only systray created an application window' }
$traySize = (Get-Item tray/target/release/hermes-relay-tray.exe).Length
if ($traySize -gt 5242880) { throw "tray executable exceeds 5 MiB: $traySize bytes" }
if ($traySize -le 0) { throw 'tray executable is empty' }
Stop-Process -Id $proc.Id -Force
Write-Host "menu-only tray launch smoke OK pid=$($proc.Id) bytes=$traySize"
Write-Host "management tray launch smoke OK pid=$($proc.Id) bytes=$traySize"
- name: Smoke-test packaged installer lifecycle
shell: pwsh
env:
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
run: |
$ErrorActionPreference = 'Stop'
function Normalize-UserPath([string]$Value) {
return (@($Value -split ';' | Where-Object { $_ }) -join ';')
}
function Get-RawUserPath {
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment')
if ($null -eq $environmentKey) { return '' }
try {
return [string]$environmentKey.GetValue(
'Path',
'',
[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames
)
} finally {
$environmentKey.Dispose()
}
}
$setup = (Resolve-Path 'dist/tray/hermes-relay-windows-x64-setup.exe').Path
$smokeRoot = Join-Path $env:RUNNER_TEMP 'hermes-installer-lifecycle-smoke'
$smokeProfile = Join-Path $smokeRoot 'profile'
$installDir = Join-Path $smokeRoot 'installed files'
$sessionDir = Join-Path $smokeProfile '.hermes'
$sessionSentinel = Join-Path $sessionDir 'remote-sessions.json'
$uninstaller = Join-Path $installDir 'uninstall-hermes-relay.exe'
$uninstallKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesRelay'
$productKey = 'HKCU:\Software\HermesRelay'
$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
$startMenuDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Hermes-Relay CLI'
$oldUserProfile = $env:USERPROFILE
$oldHomeEnv = $env:HOME
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
$hadUserPath = $environmentKey.GetValueNames() -contains 'Path'
$originalUserPath = Get-RawUserPath
$originalUserPathKind = if ($hadUserPath) { $environmentKey.GetValueKind('Path') } else { $null }
$userPathBefore = 'C:\Windows\System32'
$environmentKey.Dispose()
$startupBefore = (Get-ItemProperty -Path $startupKey -Name HermesRelayTray -ErrorAction SilentlyContinue).HermesRelayTray
if (Test-Path $uninstallKey) { throw 'installer smoke requires a clean HermesRelay uninstall registry key' }
if (Test-Path $productKey) { throw 'installer smoke requires a clean HermesRelay product registry key' }
if (Test-Path $smokeRoot) { Remove-Item -LiteralPath $smokeRoot -Recurse -Force }
New-Item -ItemType Directory -Force -Path $sessionDir | Out-Null
Set-Content -LiteralPath $sessionSentinel -Value '{"sentinel":"preserve-me"}' -Encoding UTF8
$env:USERPROFILE = $smokeProfile
$env:HOME = $smokeProfile
try {
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
$environmentKey.SetValue('Path', $userPathBefore, [Microsoft.Win32.RegistryValueKind]::String)
$environmentKey.Dispose()
$installProcess = Start-Process -FilePath $setup -ArgumentList @('/S', "/D=$installDir") -Wait -PassThru
if ($installProcess.ExitCode -ne 0) { throw "installer exited with code $($installProcess.ExitCode)" }
$expectedFiles = @(
'hermes-relay.exe',
'hermes-relay-tray.exe',
'hermes-relay-ui.cmd',
'hermes-relay-path.ps1',
'uninstall-hermes-relay.exe'
)
foreach ($name in $expectedFiles) {
$path = Join-Path $installDir $name
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "packaged installer did not create $path"
}
}
$cli = Join-Path $installDir 'hermes-relay.exe'
$versionOutput = (& $cli --version | Out-String).Trim()
if ($LASTEXITCODE -ne 0) { throw "installed CLI --version exited with code $LASTEXITCODE" }
if ($versionOutput -ne "hermes-relay $env:EXPECTED_DESKTOP_VERSION") {
throw "installed CLI version mismatch: expected $env:EXPECTED_DESKTOP_VERSION, got '$versionOutput'"
}
$helpOutput = (& $cli --help | Out-String)
if ($LASTEXITCODE -ne 0 -or $helpOutput -notmatch 'Usage:') {
throw 'installed CLI --help smoke failed'
}
if (-not (Test-Path -LiteralPath $sessionSentinel -PathType Leaf)) {
throw 'installer removed profile session data'
}
$uninstallProcess = Start-Process -FilePath $uninstaller -ArgumentList '/S' -Wait -PassThru
if ($uninstallProcess.ExitCode -ne 0) { throw "uninstaller exited with code $($uninstallProcess.ExitCode)" }
$deadline = [DateTime]::UtcNow.AddSeconds(20)
while ((Test-Path -LiteralPath $uninstaller) -and [DateTime]::UtcNow -lt $deadline) {
Start-Sleep -Milliseconds 250
}
foreach ($name in $expectedFiles) {
$path = Join-Path $installDir $name
if (Test-Path -LiteralPath $path) { throw "uninstaller left owned artifact $path" }
}
if (Test-Path $uninstallKey) { throw 'uninstaller left the Installed Apps registry key' }
if (Test-Path $productKey) { throw 'uninstaller left the HermesRelay product registry key' }
if (Test-Path -LiteralPath $startMenuDir) { throw "uninstaller left Start-menu artifacts at $startMenuDir" }
if (-not (Test-Path -LiteralPath $sessionSentinel -PathType Leaf)) {
throw 'uninstaller removed preserved profile session data'
}
if ((Get-Content -LiteralPath $sessionSentinel -Raw) -notmatch 'preserve-me') {
throw 'installer lifecycle modified preserved profile session data'
}
# Compare the raw registry value so expandable entries such as
# %USERPROFILE% are not resolved against the isolated smoke profile.
$userPathAfter = Normalize-UserPath (Get-RawUserPath)
if ($userPathAfter -ne $userPathBefore) {
throw "uninstaller did not restore user PATH (before='$userPathBefore', after='$userPathAfter')"
}
$startupAfter = (Get-ItemProperty -Path $startupKey -Name HermesRelayTray -ErrorAction SilentlyContinue).HermesRelayTray
if ($startupAfter -ne $startupBefore) {
throw "installer lifecycle changed the pre-existing tray startup preference"
}
Write-Host "packaged installer lifecycle smoke OK version=$versionOutput install=$installDir"
} finally {
Get-Process -Name 'hermes-relay-tray' -ErrorAction SilentlyContinue |
Stop-Process -Force -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $uninstaller) {
Start-Process -FilePath $uninstaller -ArgumentList '/S' -Wait | Out-Null
}
$env:USERPROFILE = $oldUserProfile
$env:HOME = $oldHomeEnv
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
if ($hadUserPath) {
$environmentKey.SetValue('Path', $originalUserPath, $originalUserPathKind)
} else {
$environmentKey.DeleteValue('Path', $false)
}
$environmentKey.Dispose()
if (Test-Path -LiteralPath $smokeRoot) {
Remove-Item -LiteralPath $smokeRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
- name: Upload Windows tray release asset
uses: actions/upload-artifact@v4
@@ -234,9 +381,9 @@ jobs:
# (the other publish-release steps only consume downloaded build artifacts).
- uses: actions/checkout@v7
- name: Extract CLI version
- name: Extract Desktop version
id: version
run: echo "version=${GITHUB_REF_NAME#cli-v}" >> "$GITHUB_OUTPUT"
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v8
with:
@@ -253,7 +400,7 @@ jobs:
# Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub
# Release body. __VERSION__ = bare version (0.3.0), __TAG__ = full tag
# (cli-v0.3.0) so the install/pin commands stay accurate without manual edits.
# (desktop-v0.3.0) so install/pin commands stay accurate without manual edits.
- name: Render release notes
env:
VERSION: ${{ steps.version.outputs.version }}
@@ -266,7 +413,7 @@ jobs:
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
name: Hermes-Relay-CLI v${{ steps.version.outputs.version }}
name: Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
tag_name: ${{ github.ref_name }}
draft: false
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
+25 -8
View File
@@ -1,32 +1,49 @@
name: Release Plugin
name: Release Server
on:
push:
tags:
- "plugin-v*"
- "server-v*"
permissions:
contents: write
jobs:
validate:
name: Validate Plugin release
name: Validate Server release
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/plugin-v}" >> "$GITHUB_OUTPUT"
run: echo "version=${GITHUB_REF#refs/tags/server-v}" >> "$GITHUB_OUTPUT"
- name: Verify Plugin version sync
run: python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
- name: Verify Server version sync and changelog
run: |
python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
exit 1
fi
env:
TAG_VERSION: ${{ steps.version.outputs.version }}
- name: Verify tagged commit belongs to main
run: |
set -euo pipefail
git fetch origin main --no-tags
tag_commit="$(git rev-parse HEAD)"
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
echo "Server releases must be tagged from main; $tag_commit is not in origin/main" >&2
exit 1
fi
test:
name: Test Plugin package
needs: validate
@@ -100,8 +117,8 @@ jobs:
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
name: Hermes-Relay-Plugin v${{ needs.validate.outputs.version }}
tag_name: plugin-v${{ needs.validate.outputs.version }}
name: Hermes-Relay-Server v${{ needs.validate.outputs.version }}
tag_name: server-v${{ needs.validate.outputs.version }}
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
fail_on_unmatched_files: true
body_path: release_notes_rendered.md
+2
View File
@@ -93,3 +93,5 @@ keystore.properties
# Legacy generated desktop tray assets may remain after upgrading a worktree.
desktop/tray/ui/vendor/
# Generated from assets/screenshots/02_chat.png before docs dev/build.
/user-docs/public/chat-demo.png
+33 -10
View File
@@ -5,26 +5,49 @@ coding agent (Claude Code, Codex, Cursor, etc.).
## Read this first
The detailed, authoritative context lives in **[CLAUDE.md](CLAUDE.md)** —
architecture, the upstream Hermes API reference, repository layout, per-language
code style, the dev loop, and the Key Files map. Read it before touching code,
then `docs/spec.md` and `docs/decisions.md`.
This file is the provider-neutral canonical agent context. Read it before
touching code, then `docs/spec.md` and `docs/decisions.md`. Provider adapters
such as **[CLAUDE.md](CLAUDE.md)** may add tool-specific guidance, but they do
not redefine the branch, release, or hotfix policy here and in `RELEASE.md`.
- Release process → **[RELEASE.md](RELEASE.md)**
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
- Follow-ups / deferred work / known gaps → **[TODO.md](TODO.md)** (the single home for "what's next" — never DEVLOG, never scattered code comments)
## Branch contract
| Contract item | Canonical source or target |
|---|---|
| Integration branch | `dev`; normal feature, fix, docs, and chore PRs target `dev` |
| Release branch | `main`; release history and hotfix integration only |
| Tag source | The new `main` tip after an approved `dev` → `main` release PR, or after an approved hotfix PR to `main` |
| Staging source | An exact tested `dev` SHA or release-candidate tag; staging is an environment, never a branch |
| Production source | Immutable `android-v*`, `server-v*`, or `desktop-v*` tags, selected by surface |
| Hotfix base | The immutable production tag for the affected surface |
| Back-merge target | `dev`; merge `main` back immediately after every hotfix |
Feature completion means merged and verified on `dev`; it does not mean
released. A release train is separate work owned by a Forge release
issue/session: reconcile only the affected surface version and notes on `dev`,
open the `dev` → `main` release PR, tag the resulting `main` tip, publish the
surface artifacts, deploy or roll out, and verify the live result. Never create
a staging branch.
## Non-negotiables (the short list)
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection —
chat via the API server, Vanilla Hermes voice via the Hermes dashboard — must work
against unmodified upstream hermes-agent. Server-side needs go through upstream
PRs or the optional relay plugin, never fork patches.
- **Vanilla Hermes path = upstream-only.** The standard (no-plugin) connection
uses the upstream Dashboard/Gateway for chat, authentication, Manage, sessions,
and Vanilla Hermes voice. The API server is an optional automatic fallback and
advanced headless-compatibility surface; Relay adds optional extensions. This
path must work against unmodified upstream hermes-agent. Server-side needs go
through upstream PRs or the optional relay plugin, never fork patches.
- **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
- **Conventional Commits + `main`/`dev` branching.** Feature branches off `dev`,
`--no-ff` merges, version bumps at release-prep on `dev`, tags cut from `main`.
- **Conventional Commits + `main`/`dev` branching.** Normal branches start at
`dev` and PR back to `dev`; merge commits/no-ff are the repository policy.
Version bumps happen only during release preparation on `dev`, and production
tags are cut only from `main`.
- **Android:** Jetpack Compose only (no XML), kotlinx.serialization (no Gson),
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
- **Plugin (Python 3.11+):** aiohttp + asyncio (no threading), type hints
+342 -1
View File
@@ -6,6 +6,347 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Added
- **Android can edit current Hermes profiles through the standard Gateway.** The Profile Inspector capability-gates `profiles.describe` and `profiles.configure`, keeps Relay-only memory editing and older-Hermes fallback intact, and reports partial section saves without discarding failed drafts.
- **Android sessions show their coding context when Hermes supplies it.** Session rows can display repository, Git branch, and the current state of the pull request created by that session while older hosts remain unchanged.
- Android Manage can now finish host-owned backup workflows, edit or remove learning nodes with explicit recovery guidance, configure and activate memory providers, and complete profile-scoped WhatsApp QR onboarding through the authenticated upstream Dashboard contracts.
### Fixed
- **Android preserves authoritative Gateway outcomes.** Protected-file cards cannot offer forbidden persistent scopes, compression no-ops show the server result, bounded resume failures do not create context-free replacement sessions, and edit/regenerate retains durable row identities across consecutive rewinds.
- **Android routes and uploads against live upstream truth.** Multiplex API fallback trusts `served_profiles` instead of installed profiles, and generic documents carry the Gateway-issued `@file:` reference into ordinary and queued prompts.
- **Android clarify cards preserve upstream decision semantics.** Multi-select prompts keep independent selections and submit one exact list, while server expiry events—not an invented local deadline—retire unanswered cards.
- **Android keeps profile management and retained automation truthful.** Custom Endpoint list and mutation routes now follow the selected Hermes profile, while completed one-shot cron jobs show their retained outcome and expose only valid Runs/Delete actions.
- **Android and Relay recover more generated media reliably.** Android accepts upstream-valid wrapped, punctuated, adjacent, spaced, and Windows `MEDIA:` markers without consuming fenced examples, and Relay translates Docker-visible workspace, home, cache, and configured-mount paths before applying its existing credential, sandbox, and size checks.
## [1.7.0] - 2026-08-13
### Added
- **Hermes Secure Link provides self-hosted pinned TLS ingress.** Relay, API, and Dashboard namespaces share one operator-owned TLS endpoint while retaining their native authentication boundaries, QR-carried certificate continuity, explicit rotation, and fail-closed route validation.
- **Hermes Reach is available for explicit experimentation.** The optional self-hosted rendezvous broker carries opaque Secure Link TLS records over outbound-only connections with bounded multiplexing, hashed credentials, replay protection, persistence, revocation, and no access to Hermes payloads.
- **Remote-access management exposes supported reachability clearly.** Dashboard status and pairing metadata distinguish Tailscale reachability, Secure Link transport protection, direct routes, and experimental Reach without presenting the broker as a replacement for authentication.
### Changed
- **Tailscale is the recommended remote route.** Pairing, Dashboard, documentation, and public site guidance present Tailscale as the easiest supported remote-access path; Reach remains disabled by default, advanced, and lower priority than supported routes.
- **Relay voice custom transports follow upstream provider security options.** Relay-owned OpenAI/xAI realtime and TTS clients honor custom headers, custom CA bundles, standard CA environment precedence, and an explicitly warned development-only verification override.
- **Voice Lab xAI sign-in uses device authorization.** The standalone login shows a verification URL and user code and polls for approval without requiring a loopback callback.
### Fixed
- **Phone delivery remains compatible with strict Hermes targets.** Version-tolerant parser and validator hooks retain older-host registration and exactly-once standalone delivery.
- **Profile-owned Relay registrations stay isolated.** Current Hermes uses profile-scoped ownership and context-local profile homes while legacy hosts retain a guarded compatibility path.
- **Phone is discoverable before its first historical session.** The Relay phone adapter publishes its configured home destination through Hermes' standard channel directory.
## [0.4.0-alpha.8] - 2026-08-13
### Added
- **Windows management separates each Relay host from this PC.** Host detail owns identity, pairing, access, capabilities, authorized clients, re-pairing, and guarded removal; Settings owns local daemon lifecycle, startup, privilege, terminal, logs, diagnostics, updates, and Help & About.
- **Desktop access uses clear host-scoped presets and capabilities.** Restricted, Ask Every Time, Standard, Full Access, and Custom remain explicit across commands, files, screen/input, USB, microphone, and camera controls.
- **Activity drilldown preserves bounded execution evidence.** Overview shows the latest three events and detail views expose request, output, result, exit, duration, and truncation metadata without copying sensitive inputs.
- **Connection presentation shows the live Agent-to-PC path.** Host selection, bidirectional packet motion, transition feedback, route details, and connection testing stay compact, responsive, and reduced-motion aware.
### Changed
- **Connect and disconnect remain responsive during daemon work.** Lifecycle calls and snapshot collection run outside the UI thread, transition status polls quickly without overlapping probes, and progress remains visible until authoritative daemon state arrives.
- **Tailscale is recommended for remote access.** Secure Link and direct TLS routes remain supported, while Hermes Reach is visibly experimental and lower priority.
### Fixed
- **Connection tests classify legacy private routes correctly.** A saved generic role is inferred from its actual endpoint, so LAN and Tailscale routes no longer appear as Custom VPN; results include reachability, latency, security, endpoint, and route count.
- **Ask-mode approval cards show the requested action.** A bounded preview appears in the compact card with full context and an Open in UI action.
- **Mixed capability policies are labeled Custom.** Overview no longer claims a preset when individual capability controls differ.
- **Tray placement follows the notification-area monitor and DPI.** Responsive popup geometry stays anchored above the tray icon across compact and high-DPI desktops.
- **PowerShell success output is complete and self-describing.** Scalar, pipeline, JSON, native stdout/stderr, exit status, and truncation metadata survive the desktop RPC response.
## [1.6.4] - 2026-08-12
### Added
- **Desktop tools support explicit host targeting.** Every client-routed desktop tool accepts a stable device ID or unambiguous computer name, and `/desktop/health` enumerates connected targets and their advertised tools.
- **USB operations retain both routing scopes.** Raw USB and ADB tools use `device` to select the desktop PC, while ADB operations continue to use `serial` to select hardware attached to that PC.
### Fixed
- **Multiple desktop clients remain connected simultaneously.** The Relay no longer replaces the previous desktop when another heartbeat arrives; concurrent requests are bound to their selected WebSockets, responses from another PC are ignored, and an untargeted call fails closed when several desktops are online.
- **Pairing another desktop preserves existing credentials.** Legacy placeholder device identifiers are treated as absent instead of shared ownership, preventing an unrelated PC from revoking the first desktop's session.
## [1.6.3] - 2026-08-11
### Fixed
- **Relay diagnostics distinguish a prior clean stop from a crash.** Doctor and `/relay/info` expose only bounded clean, unclean, or unknown gateway-exit state with an optional suspected out-of-memory hint, without returning raw log evidence.
- **Relay reconnects spread out after shared gateway restarts.** Ordinary exponential reconnect delays use full jitter while explicit reconnects and server-directed retry timing retain their exact behavior.
## [0.4.0-alpha.7] - 2026-08-11
### Fixed
- **Installer lifecycle validation uses an isolated Windows PATH fixture.** Release smoke tests now verify add/remove cleanup against a fixed registry value and restore the runner's original value afterward, independently of the temporary profile used for session-preservation checks.
## [0.4.0-alpha.6] - 2026-08-11
### Fixed
- **Installer cleanup validation compares the unexpanded Windows PATH.** Release smoke tests now read the raw user registry value, ensuring `%USERPROFILE%` entries are verified without temporary-profile expansion changing their apparent value.
## [0.4.0-alpha.5] - 2026-08-11
### Fixed
- **Installer cleanup validation handles expandable Windows PATH entries.** Release smoke tests restore the original profile environment before comparing user PATH, avoiding false failures when unchanged `%USERPROFILE%` entries are expanded inside an isolated test profile.
## [0.4.0-alpha.4] - 2026-08-11
### Fixed
- **Windows release validation waits for installer processes.** The packaged install/uninstall lifecycle smoke now captures GUI-subsystem process exit codes reliably before validating installed files, preserved sessions, registry state, and cleanup.
## [0.4.0-alpha.3] - 2026-08-11
### Added
- **Windows tray provides focused remote-access management.** The compact host-aware popup covers connection state, per-host Ask/Trusted/Full Access, pending grant dialogs, authorized-client revocation, activity, daemon controls, and settings without adding chat, terminal, plugin, voice, or session surfaces.
- **Desktop access policy is isolated per Hermes host.** `hermes-relay hosts` lists and selects local pairings and stores fail-closed access modes independently for each canonical relay URL.
- **Windows CLI installations can add or open the management UI directly.** `hermes-relay ui install|open|status` and the installed UI shim provide a supported lifecycle for optional UI setup, discovery, and activation.
### Changed
- **Daemon connectivity no longer requires a tool grant.** Ask mode can keep an authenticated daemon connected with zero desktop tools attached; Trusted enables command/file tools with task-scoped screen/input grants, while Full Access removes those task prompts only for the selected host.
- **Windows bundle updates preserve the desktop lifecycle.** The CLI and tray coordinate one verified installer launch, restore the daemon and UI after setup, and permit same-version UI add or repair without silently downgrading a newer CLI.
### Fixed
- **Background daemon start reports real readiness.** Detached startup now waits for the spawned process to authenticate and connect, and returns actionable log evidence for configuration, authentication, early-exit, and timeout failures.
- **Local and release tray builds embed the packaged UI.** Development installs use Tauri's production protocol instead of attempting to load a missing localhost development server, and release CI exercises a silent install/uninstall lifecycle.
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
## [1.6.2] - 2026-08-11
### Fixed
- **Paired sessions use recognizable device identities.** Relay sessions preserve a client-provided hostname as the primary name, retain model and platform details, and enrich valid reconnects without requiring users to pair again.
- **Long-lived session expiry is readable.** The Dashboard presents paired-session lifetime in days or weeks with the exact local deadline available in the detail view instead of accumulating hundreds of hours.
## [Android 1.8.1] - 2026-08-09
### Fixed
- **Android preserves complete long-session transcripts.** API-server and profile-scoped Dashboard history reads now use explicit bounded pagination, retain compatibility with older unpaginated responses, and keep edit, retry, sharing, and recovery anchors stable beyond Hermes' latest-500 default window.
- **Android follows authoritative Gateway turn contracts.** Submit rejections retain the server's message without silently falling through to SSE, event envelopes reconcile consistently, and edit-and-regenerate requests send the required truncation confirmation.
## [Android 1.8.0] - 2026-08-09
### Added
- **Android chat keeps work in context and makes live turns easier to read.** Draft text, edits, quotes, and attachments stay with their connection, profile, and session; conversation search and prompt-turn navigation jump by stable message identity; message actions reveal smoothly on tap; quoted replies use linked previews without placing markup in the composer; assistant replies retain their compact high-contrast bubbles; and pending attachments support preview, removal, and accessible reordering.
- **Android reasoning and tool activity use a quieter transcript.** Live thinking opens as an inline disclosure and settles to a collapsed Thought row, while consecutive routine reads, searches, commands, browser actions, and device actions share one live activity ticker or concise completed summary. Approvals, failures, generated media, file changes, output risks, and delegated work keep their own visible lifecycle surfaces even when ordinary tool progress is hidden.
- **Android Profile Shelf makes agent switching immediate without mixing conversations.** The Chat header expands a compact, accessible shelf with ordered profile avatars, a subtle Server-default home badge on the resolved identity, last-session restoration, display hiding, lock controls, and one full switcher shared with Agent Passport.
- **Android accepts shared text as a new Chat draft.** Hermes Relay now appears in the system sharesheet for text, opens the active profile in a fresh conversation, and fills the composer for review without sending automatically.
### Changed
- **Android appearance controls are more expressive and easier to preview.** Theme presets, accent and shape customization, imported Sphere skins, and custom pet creation share one live-preview workflow while preserving separate agent, background, and companion identities.
### Fixed
- **Android restores complete Gateway activity and makes settled replies speakable.** Successful Gateway turns reconcile structured persisted tool calls even when an upstream server omits live tool lifecycle events, and a configured voice can read a completed assistant reply from its message actions without requiring Voice Mode. While that narration is active, the same message actions expose Stop without cancelling an unrelated chat turn.
- **Android chat matches standard keyboard, scrolling, and photo behavior.** Sentence capitalization is enabled, physical Enter can send or insert a newline according to a device-level setting, Ctrl/Command+Enter always submits, directional keys stay with the text caret, expanded thinking and tool content retains bottom-follow until the user scrolls away, and portrait attachments honor their EXIF orientation in previews and message viewers.
- **Android distinguishes live-turn corrections from queued follow-ups.** The composer names its current action with visible text and accessible state, successful gateway redirects show a correction lifecycle marker, and attachment-bearing follow-ups always enter the session-owned queue because the upstream redirect operation is text-only.
- **Android visibly explains quiet startup work without an empty chat bubble.** The full-size thinking animation now sits directly in the conversation lane with a stable reviewable status until the first answer text arrives, while recovery keeps its explicit reconnecting state.
- **Android keeps pets and screen chrome inside safe interaction bounds.** Floating companions avoid agent identity rows and controls during scrolling, remain touchable for their menu, and settings headers respect edge-to-edge system insets.
## [Android 1.7.1] - 2026-08-08
### Fixed
- **Android chat follows a growing live reply.** Bottom-owned conversations now observe each replacement of the streaming message list, keeping newly added lines visible while preserving the reader's position after a manual scroll away.
- **Hosted Hermes onboarding completes through the official Dashboard sign-in path.** Android recognizes hosted account addresses, uses the system-browser native PKCE flow, and resumes the verified Dashboard session after its loopback callback.
- **Live Android tool cards remain expandable while a run is active.** Streaming Gateway updates preserve stable card identity and merge tool arguments and result previews into the existing row, so details can be opened before the session finishes.
- **Completed Android replies format Markdown immediately.** Live assistant text keeps its stable plain renderer only while incomplete, then the same owned row transitions to rich code blocks, lists, emphasis, and links without leaving or reopening the session.
- **Android approval cards require an explicit labeled decision.** Reading or scrolling a guarded command, navigating away, backgrounding, recomposition, later turn activity, and card dismissal cannot submit or locally resolve it; pending requests remain bound to their owning profile and session until an explicit response or authoritative upstream expiry.
- **Android Agent Passport controls are readable and easy to dismiss.** Safety and speed choices use full-width accessible targets with plain-language selected-state explanations, while a persistent close action and boundary-aware downward swipe make the sheet reliably dismissible without stealing nested content scrolling.
- **Android queued messages stay with their originating chat.** Follow-ups now retain their exact connection, profile, session, run, route, attachments, and voice context across concurrent Gateway session switches instead of following whichever session is visible when a run finishes.
- **Android model pickers reject duplicate catalog identities before rendering.** Repeated provider/model rows from cached or refreshed inventories are merged at the provider boundary, while identical model IDs under different providers remain distinct choices with provider-aware reasoning capabilities.
- **Android session pins and archives survive app restarts.** The session drawer now reads and updates the owning Hermes profile's durable session metadata, rolls failed changes back, and makes unpinned stars clearly distinct in light theme.
## [1.6.1] - 2026-08-08
### Fixed
- **The Dashboard plugin hands hosted Hermes connections to Android reliably.** Mobile setup exposes the canonical Dashboard address and keeps dialog focus handling contained, so system-browser authentication can return to the correct connection without disrupting the Dashboard.
## [Android 1.7.0] - 2026-08-06
### Added
- **Android exposes provider-aware reasoning controls.** The effort drawer consumes exact upstream or optional Relay capability metadata for each provider/model identity, while unmodified or older Hermes installations retain a fail-soft standard fallback including `max` and `ultra`.
- **Android support information is local, redacted, and reviewable.** Fatal crashes and handled failures share a bounded on-device record, Diagnostics can copy or share the exact reviewed text, and nothing is uploaded automatically.
### Fixed
- **Android chat chrome follows its active interaction state.** Opening the session drawer dismisses the composer keyboard, refreshed sessions keep their newest row visible, and floating pets wait for measured chat terrain, sit flush on supported rails, and treat the complete scroll-to-bottom control as forbidden space.
- **Android pets and optional model discovery initialize quietly.** Floating companions wait for a measured overlay before taking their home position, and background API model-inventory failures retain actionable local diagnostics without interrupting chat with a generic notice.
- **Android chat and Voice stay precisely bottom-pinned through replies, restores, and layout changes.** The active tail keeps its stable live renderer until another row takes ownership, restored sessions follow late composer and message measurement without overriding a reader, and bottom-owned transcripts settle to the exact list boundary after replies and keyboard animations instead of leaving a small hidden remainder.
- **Android Focus voice controls remain responsive.** The modal click-through guard now sits behind the voice UI instead of consuming pointer events from the mic, close, expansion, and panel controls.
- **Android diagnostics explain what failed and what to try next.** Relay, route, WebSocket, and API checks distinguish the saved route from the redacted request they actually attempted, name the operation, and provide targeted guidance for connection, DNS, timeout, TLS, authentication, rate-limit, and server failures.
- **Android chat and Voice keep one render identity through recovery.** Checkpoint restore, streamed callbacks, server-ID adoption, and replay now resolve the same owned transcript row before publication, preventing recurring Compose duplicate-key crashes.
- **Android crash reports retain actionable release context.** Reports identify the Android surface, avoid exposing hosts and credentials, migrate earlier local crash records, and release automation retains exact Play and sideload R8 mappings for retrace.
## [1.6.0] - 2026-08-06
### Added
- **Relay supplies exact provider/model reasoning capabilities when providers expose them.** The bounded, profile-aware overlay resolves dynamic catalogs for OpenAI Codex, Copilot, LM Studio, and Ollama Cloud, keeps provider credentials on the host, and leaves unknown or unavailable catalogs on the advisory fallback.
## [Android 1.6.1] - 2026-08-03
### Fixed
- **Voice capture waits for the microphone to be released.** Manual recording no longer races barge-in teardown, and AudioRecord startup failures now explain how to free or permit the microphone before retrying.
- **Android text selection stays stable as streamed replies finish.** Chat resets an active selection when live text becomes rich Markdown, preventing selection-handle drags from retaining removed text nodes.
- **Android session history follows the upstream page-size contract.** The drawer keeps its 200-session window through bounded 100-row requests, avoiding HTTP 422 errors from current dashboard servers while preserving active-profile isolation.
- **Android no longer mistakes optional-surface auth failures for expired Relay pairing.** Background session refreshes stay out of the global snackbar, Dashboard and API authorization errors name their owning credential, and Relay-only surfaces use consistent Optional, Ready, Reconnecting, Unavailable, and Needs re-pair states. Foreground recovery retries ordinary Relay backoff immediately while preserving server rate limits, and recovery prioritizes Dashboard or host session management while retained credentials are labeled as stored details instead of active pairing.
- **Voice controls no longer collide with new-chat coaching.** The clean-view hint yields while Voice owns the composer so it cannot cover the expanding Voice drawer.
## [1.5.1] - 2026-08-03
### Fixed
- **Re-pairing repairs one device instead of accumulating duplicate sessions.** An explicit host-approved pair replaces older sessions and refresh credentials for the same device, while the Dashboard and `/relay revoke <token-prefix>` remain available for operator cleanup.
## [Android 1.6.0] - 2026-08-02
### Added
- **Hermes can be selected as Android’s default Digital Assistant.** The opt-in system role supports background and locked-screen invocation, while the separate experimental “Hey Hermes” listener keeps pre-activation audio on the phone and exposes an ongoing Stop control.
- **Installed Hermes plugins can contribute native Android pages.** Android renders a bounded declarative schema instead of plugin code, keeps write access off until the user grants it, and supports approval-gated agent-created previews through Relay 1.5.0.
- **Pets can stay with you across the Android app without replacing the agent.** Petdex and imported companions live in an app-level overlay, can be held and dragged, and optionally roam across live-measured chat and settings surfaces without reserving message space. (#267)
- **Petdex browsing and one-tap installation are built into Appearance.** Search results use lightweight previews, full atlases download only after Install, creator attribution remains visible, and installed pets stay available offline. (#267)
- **Android can be used in Russian.** Both product flavors include an AI-assisted Russian catalog, language picker support, localized plurals, and refreshed translations for the 1.6 feature set.
### Changed
- **Assistant and floating Voice surfaces use compact, expandable controls.** Opening full Voice continues the same turn and microphone owner instead of restarting the session.
- **Voice interruption covers generation and playback.** Barge-in follows upstream RMS calibration and timing, exact stop phrases can end an active voice chat, and interrupted spoken context remains private to the next Standard turn.
- **Profile identity, the Sphere, and pets are separate appearance choices.** Agent avatars identify messages, background visualization controls ambient art, and Floating pet controls the companion independently. (#267)
- **The Agent Passport exposes more profile state and safer controls.** Profile configuration, skills, routing, reasoning, and scoped API access remain visibly distinct from the active session identity.
### Fixed
- **Voice output recovers when a streaming renderer produces no audio.** Android falls back to basic synthesis after a bounded first-audio timeout, and long Standard Voice uploads no longer retain duplicate encoded audio buffers.
- **Relay route failover avoids competing reconnect loops.** Route changes settle through one generation-aware reconnect owner instead of rapidly switching between LAN and remote candidates.
- **Live chat rows keep stable UI identity while upstream state reconciles.** Streamed messages and process rows no longer collide or restart merely because a server identity arrives later.
- **Floating pets recover from invalid or scrolling terrain.** Roaming uses measured bubble edges, avoids the jump-to-latest control and text overlap, resumes after drag or scrolling, and preserves locomotion, held, drop, and fallback animation states.
- **Hermes appears and activates in OEM Android assistant pickers.** Required Assist, Voice, recognition-service, and single-microphone lifecycle metadata now agree.
- **Experimental wake detection handles completed sherpa results and empty speech cleanly.** Tests use the real local microphone/model path, and no-speech activation returns to ready state instead of surfacing a fatal server error.
## [Android 1.5.3] - 2026-07-31
### Fixed
- **Voice transcripts retain stable rows after chat-history reconciliation.** Focus mode uses the same stable Compose identity as the main conversation, preventing duplicate-key crashes when live rows adopt persisted server IDs.
## [1.5.0] - 2026-08-02
### Added
- **Realtime Agent sessions can speak only settled answers.** Clients may enable an optional per-session `final_answer_only` policy that suppresses routine acknowledgements, progress narration, and intermediate commentary while preserving spoken approvals, confirmation questions, blocking failures, and the final Hermes answer.
- **Agents can draft native Android plugin pages through Relay.** New tools store bounded declarative JSON pages under the authenticated Relay plugin namespace, while Android retains control of enablement, publication, write grants, and persistent removal. Generated pages cannot include executable code, arbitrary network calls, Android intents, or backend action requests.
## [Android 1.5.2] - 2026-07-28
### Fixed
- **Dashboard sign-in completes across supported providers and network routes.** Self-hosted OIDC stays on the dashboard cookie flow, while Nous Portal opens in the system browser and completes standards-compatible PKCE through HTTPS, private-LAN, or Tailscale dashboard routes.
- **Replayed chat updates no longer destabilize the conversation list.** Duplicate upstream message identifiers are coalesced before Compose renders them.
## [Android 1.5.1] - 2026-07-26
### Added
- **Voice supports focused and conversational layouts.** Focus keeps spoken turns, Markdown, tools, media, and actions in a compact voice surface, while Conversation opens the full Chat renderer without leaving the active voice session.
- **Voice can speak only settled answers.** A global Voice setting keeps tool progress, service updates, and intermediate commentary visual while supported voice paths wait to speak the final Hermes answer.
### Changed
- **Chat answers are easier to read in every theme.** Primary assistant text now uses the theme's full-contrast foreground, and chat prose uses a 15sp size with 21sp line height.
- **Google Play builds target Android 16.** The app now targets API level 36 while retaining its existing minimum-device support.
### Fixed
- **Completed streamed answers render their formatting without losing the reading position.** Markdown headings, lists, emphasis, and code blocks replace the live text renderer only after completion, then the measured trailing edge remains anchored at the bottom.
- **Standard Voice speaks completed assistant replies again.** Session and message fences no longer suppress a valid final answer during the handoff from generation to narration.
- **Realtime background work no longer blocks the active voice controls.** A promoted task releases the foreground spinner and microphone while its progress, tools, cancellation, and final result remain available in the owning chat.
## [Server 1.4.3] - 2026-07-22
### Added
- **Relay diagnostics describe upstream Gateway compatibility.** Doctor and `/relay/info` report optional Gateway health, configuration-route, and capability signals so clients can distinguish an older upstream install from a Relay failure.
### Fixed
- **Relay trust boundaries are enforced across privileged interfaces.** Pairing policy is host-authorized, Android bridge and terminal dispatch require active grants, ordinary sessions can only reduce their own policy, remote profile config is restricted to a public schema, and voice callers cannot redirect host provider credentials.
- **Plugin bootstrap work no longer blocks the Gateway event loop.** Database initialization and compatibility-state inspection run off the async request path while preserving older upstream bootstrap behavior.
- **Starting Relay no longer terminates a running Hermes gateway on Windows.** Profile discovery now checks gateway PIDs through non-signalling process APIs, including during periodic rescans.
## [Android 1.5.0] - 2026-07-25
### Added
- **Voice settings are organized around Standard and Realtime paths.** Provider, model, and voice choices use a cleaner card layout with upstream-aware discovery, useful descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
- **Standard Hermes speech streams while replies are generated.** Android plays completed speech segments as they arrive, interrupts prior playback before starting another preview or reply, and stops audio when leaving voice mode.
- **Manage and diagnostics expose more upstream Gateway controls.** Android consumes health hints, follows canonical redirects, compresses larger RPC payloads, scopes diagnostics by profile, and surfaces compatibility information without requiring Relay-only behavior.
- **Chat shows richer upstream state and media.** One-turn model selection, approval policies, advisor progress, queued-recovery and project labels, collapsible attachments, persisted images, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
- **The Agent Passport makes the active agent controllable.** The chat drawer now combines live connection and session context with profile switching, personality, model, reasoning, approval, and speed controls in one focused surface.
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; native Gateway lifecycle events remain authoritative and Relay remains optional.
- **Background work stays actionable.** User-started turns remain protected until every active session settles, while privacy-safe notifications reopen the correct conversation for approvals, questions, elevated permissions, and secure responses.
### Fixed
- **Voice settings and active-turn correction remain usable across supported languages.** New voice controls are localized and correction copy accurately describes the turn being replaced.
- **Chat reconnects preserve the running Gateway turn without duplicating it.** Android reactivates the original live session after a socket loss, avoids resubmitting a prompt when its acknowledgement was lost, and de-duplicates session rows before they reach the drawer.
- **Relay pairing preserves Tailscale and other fallback routes.** Adding Relay to an existing Standard connection now keeps every signed QR route, restores older per-device endpoints hidden by the connection upgrade, and gives remote Dashboard routes their API fallback. When a host-scoped Dashboard sign-in is still required, Chat shows the route-specific sign-in action instead of loading indefinitely.
- **Remote routes move every Hermes surface together.** Android uses `GET /health` instead of misclassifying the API server's `405 Method Not Allowed` response to `HEAD`, and the selected Tailscale route now carries Dashboard/Gateway, sessions, Manage, and Standard Voice with API and Relay instead of leaving them pinned to the saved LAN host. Manage also distinguishes host-side Nous provider authentication from Dashboard sign-in.
- **Hosted Manage and direct-chat compatibility stay bounded and secure.** OAuth state remains tied to the selected dashboard, inline image memory is capped, and session reset and queued-recovery boundaries follow upstream contracts.
- **Dashboard sign-in is secure and route-aware.** Browser-based authorization is scoped and serialized to the selected host, while cold start no longer activates a temporary localhost API fallback or reports a missing key before stored connection state is ready.
- **Background and promoted voice work retain their owning chat rows.** Completing an initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running task, and concurrent turns remain reachable without requiring an always-on idle connection.
- **Self-hosted rendering is safer.** Android accepts deliberately installed user certificate authorities without bypassing chain, hostname, or Relay-pin verification, and malformed syntax-highlighting ranges no longer crash Markdown rendering.
- **Developer Options reflect current product behavior.** The obsolete Relay feature toggle is removed, version-tap unlock and explicit relock persist correctly, and backup, import, reset, and completion messages now report their actual results.
## [1.4.9] - 2026-07-19
### Changed
- **Hermes connections now use the Dashboard/Gateway as their standard surface.** Chat, sessions, Manage, and voice share one upstream sign-in; the API server is an optional automatic fallback or headless compatibility path, while Relay remains optional for power features.
- **Connection management and onboarding now explain each path clearly.** Nearby and remote dashboard setup, Tailscale and custom ports, Relay pairing, startup preference, route details, and security posture are presented in dedicated flows.
### Fixed
- **Server default consistently displays Hermes' pinned active profile.** Chat, session drawers, agent details, settings, voice, diagnostics, and profile inspection now use the active profile identity while preserving server-default routing semantics.
- **Discovered connections show useful host identity.** Successful local dashboard probes resolve and retain a hostname without overwriting a user-supplied connection label.
## [1.4.8] - 2026-07-18
### Fixed
- **The Google Play privacy-policy URL is permanently available.** The canonical policy now lives on hermes-relay.dev, the historical GitHub Pages URL serves the complete policy for compatibility, and Android release automation blocks publication if either public page is unavailable.
- **Android opens the hosted privacy policy directly.** The About screen no longer sends users to a repository source file.
## [1.4.7] - 2026-07-18
### Added
- **Android adds German, Brazilian Portuguese, and Japanese.** Complete AI-assisted catalogs cover both product flavors, with language-picker integration and freshness validation against the canonical English resources.
### Fixed
- **Long streamed replies grow smoothly and remain at the latest text.** Android frame-paces bursty token delivery, expands the active bubble within clipped bounds, preserves bottom-following through completion, and avoids replacing the visible live transcript while readers who intentionally scroll up remain undisturbed.
## [Android 1.4.6] - 2026-07-15
### Added
@@ -303,7 +644,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Spoken-turn badges (chat).** Voice-mode replies now carry a "Voice" chip and realtime replies a "Realtime Agent" chip — both with a speaker glyph — so spoken turns are distinguishable from typed ones in the scrollback.
- **App themes.** A new theme picker in Settings → Appearance ships eight looks: the signature Hermes Relay brand (with full light/dark) plus ports of the Nous Hermes baselines — Hermes Teal, Nous Blue (light), Midnight, Ember, Mono, Cyberpunk, and Rosé. The whole app — brand chrome, accents, and chat background — follows the chosen theme. Light/Dark/Auto applies to themes that ship both modes; fixed-mode themes show their own complete look.
- **Hot-swappable agent sphere.** The orb is now a pluggable "skin": an Adaptive skin that recolors to match your theme, built-in Classic / Aurora / Solar / Mono looks, and support for **user-authored skins** loaded from a small JSON spec. Each skin declares which live signals it reacts to (voice, tool bursts, activity), shown as capability badges in the picker. See `docs/sphere-spec.md`.
- **Connections separate features from routes (Android).** Connection settings now distinguish what a connection can *do* (a **Features** section) from how this phone *reaches* Hermes (a **Route** section), so you can enable Relay features over whichever transport you prefer. A plugin-provided **Secure proxy** route is surfaced alongside LAN, Tailscale, public, and custom routes. The standard direct-to-upstream path is unchanged and still needs no plugin. See `docs/plans/2026-06-18-native-secure-routes.md`.
- **Connections separate features from routes (Android).** Connection settings now distinguish what a connection can *do* (a **Features** section) from how this phone *reaches* Hermes (a **Route** section), so you can enable Relay features over whichever transport you prefer. The optional plugin-provided **Hermes Secure Link** route is surfaced alongside LAN, Tailscale, public, and custom routes. The standard direct-to-upstream path is unchanged and still needs no plugin. See `docs/plans/2026-06-18-native-secure-routes.md`.
- **Enhanced voice control (Gemini & xAI).** When the relay uses a Gemini or xAI voice provider, Voice Settings can now steer it: pick a Gemini voice and model and turn on expressive tone tags (with optional natural-language voice direction), or set an xAI voice with expressive speech tags. Expressive tags also apply to xAI on the streaming voice-output renderer. Standard (no-plugin) voice stays configured server-side.
- **Voice render-path visibility.** Voice Settings shows which path is rendering speech (streaming vs. basic), and Diagnostics records it each session, making voice issues easier to troubleshoot.
- **Agent pets — a living, swappable avatar.** The orb can be replaced with an animated "pet" that reacts to what the agent is doing: idle / thinking / writing / speaking / listening states, a distinct **working** pose during tool calls, one-shot **greet** / **celebrate** reactions, and a loop that quickens as output streams. Add or remove pets right in Settings → Appearance (no `adb` needed), with a live state preview, a playback-speed slider, and optional frame auto-stabilization; capability badges (Voice · Tools · Activity) show honestly what each pet actually reacts to. Pets are pure data — an AI authoring kit and a JSON schema let you generate one from sprite art. See `docs/pet-spec.md` and the custom-avatars guide.
+15 -19
View File
@@ -1,6 +1,9 @@
# Hermes-Relay — Claude Code Context
# Hermes-Relay — Claude Code Adapter
> Read this before touching code. Then read docs/spec.md and docs/decisions.md.
> Read [AGENTS.md](AGENTS.md) first. It is the provider-neutral canonical agent
> context. Branch, release, staging, and hotfix rules live in `AGENTS.md` and
> [RELEASE.md](RELEASE.md); this file only adds Claude-specific project and tool
> guidance. Then read `docs/spec.md` and `docs/decisions.md`.
## What This Is
@@ -183,18 +186,16 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
### Git
- **Conventional Commits:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
- **Branching model (as of 2026-04-19):** `main` + `dev`. Feature branches target `dev`, not `main`. `main` receives only release merges (and tags). No straight-to-main exemption — even single-file typos go through `dev`.
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches on every merge in the chain (feature → dev → main).
- **Merging ≠ releasing.** Feature branches land on `dev` continuously as CI goes green; each PR appends to `[Unreleased]` in `CHANGELOG.md` on `dev`. Releases are a separate act — cut when accumulated state is worth shipping, not per-feature. See `RELEASE.md` "When to cut a release."
- **Version bumps happen on `dev`, then release-merge to `main`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-plugin-version.sh` for `plugin-vX.Y.Z`, and `desktop/package.json` for `cli-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
- **Server tracks `dev` for staging.** The hermes-host deployment pulls `dev` so merged features are exercised before they reach a tag. Released state lives on tags cut from `main`.
- **Branch protection** on `main` — direct push blocked; only release-merge PRs from `dev` land here. `dev` also requires CI to pass on PRs but accepts feature-branch merges freely.
- **Branch/release policy:** follow the branch-contract table in `AGENTS.md` and
the executable release and hotfix procedures in `RELEASE.md`. Do not maintain
a Claude-specific parallel policy here.
### Testing
- **Android:** JUnit + Compose testing for UI, MockK for mocks
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-plugin.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
- **CI and release gates:** follow the repository-wide requirements in
`AGENTS.md` and `RELEASE.md`; Claude-specific guidance does not redefine them.
## Key Files
@@ -406,7 +407,7 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
2. **Python syntax check** — `python -m py_compile plugin/<file>.py`. Full tests run on the server.
3. **Kotlin changes** — do NOT run `gradle build`. Bailey builds via Android Studio's ▶ button. Never `adb install` from Claude.
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Android CI runs lint alongside build/test for faster feedback, but a local lint run still surfaces issues before the workflow spends runner time compiling and packaging.
5. **Commit + push** — feature branch off `dev`, merged back to `dev` via PR. `main` is reserved for release merges.
5. **Commit + push** — follow `AGENTS.md` and `RELEASE.md`; normal work PRs to `dev`.
6. **Pull + restart on server** — see Server Deployment below.
7. **Test on phone** — Bailey builds from Studio, installs to Samsung device, pairs via `/hermes-relay-pair`.
@@ -455,15 +456,10 @@ must not depend on this hook.
### Release Process
See [RELEASE.md](RELEASE.md) for the full recipe.
- **Android version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`); bump with `scripts/bump-android-version.sh`
- **Relay plugin version source:** `pyproject.toml`; keep plugin/dashboard metadata synced with `scripts/check-plugin-version-sync.py`; bump with `scripts/bump-plugin-version.sh`
- **Desktop CLI version source:** `desktop/package.json`; regenerate `desktop/src/version.ts` with `npm run gen:version`
- **Track audit:** `python scripts/check-version-tracks.py` reports Android, plugin, and CLI versions without forcing them to match
- `**appVersionCode` is monotonic** — always increment across Android prereleases
- **Cut a release:** bump the target surface → commit → merge `dev` to `main` → tag with `android-v*`, `plugin-v*`, or `cli-v*` → push tag → CI builds + GitHub Release
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
See [AGENTS.md](AGENTS.md) for the canonical branch contract and
[RELEASE.md](RELEASE.md) for version sources, release trains, surface tags,
hotfixes, secrets, publishing, and verification. Claude-specific automation
must not infer release authority from feature completion.
## Integration Points
+18 -19
View File
@@ -1,35 +1,34 @@
# Hermes-Relay-CLI v__VERSION__
# Hermes-Relay CLI v__VERSION__
**Release Date:** 2026-07-13
**Release Date:** 2026-08-13
This alpha makes the desktop direction explicit: Hermes-Relay is a real CLI/TUI with an optional Windows right-click systray—not a second desktop application. The old Tauri/WebView dashboard and its embedded windows are gone. The installed CLI remains the single source of behavior for pairing, TUI, daemon management, grants, audit, diagnostics, chat, voice, and tools.
This alpha makes the compact **Hermes-Relay CLI UI** responsive during connection changes, expands host and capability management, and presents live route security and diagnostics without turning the tray into a full desktop client.
**Experimental phase.** Assets are unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64; the optional native systray is Windows-only.
**Experimental phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64; the management tray is Windows-only.
## What's changed
### Added
- **Persistent desktop-use control.** `hermes-relay computer-use status|enable|disable|cancel` stores one local preference, reports daemon privilege and active/pending grants, and can end an active task-scoped grant without relying on a GUI.
- **Headless grant review.** `hermes-relay grants` lists pending local computer-use requests and supports interactive review plus explicit `approve`, `reject`, and JSON forms for scripts.
- **Typed Relay chat option.** `chat --relay-chat` sends `chat.send` over WSS and renders typed `stream.event` v1 assistant, tool, artifact, memory, skill, and error lifecycles while preserving the existing gateway path as the default.
- **Release-parity verification.** One version contract now keeps the npm package, compiled CLI, Rust tray, lockfile, and installer metadata aligned. The Windows verification target covers TypeScript, compiled-binary smoke tests, Rust formatting/lint/check/tests, and installer packaging.
- **Host management hub.** Each paired Hermes host has local identity, pairing facts, access and capability controls, authorized-client revocation, re-pairing, and guarded removal.
- **Evidence-first activity.** Overview shows the latest three events and detailed views retain bounded command, output, exit, duration, and truncation evidence.
- **Live route details.** The Agent-to-PC path shows route type, encryption state, endpoint, packet motion, and a connection test with reachability and latency.
### Changed
- **Menu-only Windows systray.** The optional tray is a small native Rust process with no application window, WebView, overlay, embedded terminal, chat view, voice view, or settings dashboard. Interactive actions open the installed CLI in a normal terminal.
- **State- and privilege-aware daemon control.** The menu reports PID-backed daemon state and User/Administrator privilege, disables invalid lifecycle actions, and requests UAC only when **Start/Restart daemon as Administrator…** is explicitly chosen. The tray itself remains unprivileged.
- **Visible desktop-use safety.** The tray shows enablement, active grant mode and expiry, warns when an Administrator control grant is active, raises a native alert for pending approvals, opens CLI grant review, and provides immediate cancellation and emergency stop.
- **Per-user Windows installation.** The default PowerShell installer downloads the checksum-verified NSIS package, installs the CLI and optional tray under `~/.hermes/bin`, adds Start-menu shortcuts and user PATH, and can start the tray at sign-in. CLI-only installation remains available with `HERMES_RELAY_INSTALL_SURFACE=cli`.
- **Connection lifecycle stays responsive.** Connect, disconnect, and snapshot work run outside the UI thread with immediate transition feedback and single-flight live polling.
- **Access presets are explicit.** Restricted, Ask Every Time, Standard, Full Access, and Custom map visibly onto individual command, file, screen/input, and hardware capabilities.
- **Tailscale is the recommended remote route.** Direct TLS and Hermes Secure Link remain supported; Hermes Reach is marked experimental and stays below supported routes.
### Fixed
- **Installed-binary diagnostics.** `hermes-relay doctor` reports the physical Bun-compiled executable instead of a virtual embedded-module path, so PATH and install-directory checks describe the binary that actually launched.
- **Release guardrails.** CLI tag automation rejects version drift, tags not contained in `main`, oversized tray binaries, or a tray process that creates an application window.
- **Legacy LAN and Tailscale routes no longer appear as Custom VPN.** Route testing infers generic saved roles from the endpoint and reports the correct network path.
- **PowerShell output remains complete.** Scalar, pipeline, JSON, native output, errors, exit status, and truncation metadata return reliably through desktop RPC.
- **Tray placement follows the real notification area.** Responsive geometry uses the tray monitor and DPI and remains anchored above the icon.
## Install
**Windows CLI + optional systray (PowerShell):**
**Windows CLI + management tray (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
@@ -53,11 +52,11 @@ Pin this release with `HERMES_RELAY_VERSION=__TAG__`.
```text
hermes-relay --version
hermes-relay pair --remote ws://<host>:8767 --grant-tools
hermes-relay hosts list --json
hermes-relay daemon start
hermes-relay daemon status
hermes-relay daemon status --json
```
On Windows, open **Hermes Relay Systray** from the Start menu and right-click its notification-area icon. No separate desktop window is installed.
On Windows, click the Hermes-Relay CLI UI notification-area icon to open the management popup directly above it.
See the [CLI and systray guide](https://codename-11.github.io/hermes-relay/desktop/) for installation, commands, desktop-use safety, and troubleshooting.
See the [CLI and tray guide](https://hermes-relay.dev/docs/desktop/) for installation, access modes, grants, and troubleshooting.
+53 -9
View File
@@ -17,17 +17,41 @@ That's it — no extra setup or credentials required for a debug build.
Helper scripts for common development tasks:
```bash
scripts/dev.bat build # Build debug APK
scripts/dev.bat build # Build the sideload debug APK
scripts/dev.bat compile # Compile sideload Kotlin only
scripts/dev.bat test-one "com.hermesandroid.relay.SomeTest" # Run one test class
scripts/dev.bat install-fast # Build arm64 only + install + launch
scripts/dev.bat release # Build signed release APK
scripts/dev.bat bundle # Build release AAB for Google Play
scripts/dev.bat run # Build + install + launch + logcat
scripts/dev.bat test # Run unit tests
scripts/dev.bat run # Build sideload + install + launch + logcat
scripts/dev.bat test # Run sideload debug unit tests
scripts/dev.bat version # Show current version
scripts/dev.bat relay # Start relay server (dev, no TLS)
```
Linux/macOS equivalent lives at `scripts/dev.sh`.
### Fast Android iteration
Gradle's daemon, local build cache, configuration cache, and parallel task
execution are enabled for repeat local builds. Keep the same Gradle JVM
configuration between invocations and do not add `--no-daemon` to normal dev
commands; a different heap or Java home starts a separate daemon and discards
the warm-process benefit.
Use the narrowest command that proves the change:
1. `scripts/dev.bat compile` for a Kotlin compile check.
2. `scripts/dev.bat test-one "<fully-qualified-class-or-pattern>"` for a focused regression.
3. `scripts/dev.bat install-fast` when the result must run on the connected
arm64 phone. This passes `-Phermes.devAbi=arm64-v8a`, avoiding the x86,
x86_64, and armeabi-v7a native libraries in the local APK.
4. `scripts/dev.bat prepush` before pushing Android work.
`install-fast` is intentionally phone-specific. Use `install` for a universal
sideload debug APK or when the target ABI is not arm64. Release builds remain
universal and are unaffected unless `-Phermes.devAbi` is explicitly supplied.
## Repository Structure
```
@@ -51,12 +75,12 @@ The legacy `relay_server/` directory is a thin compatibility shim around `plugin
| Component | Stack |
|-----------|-------|
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
| **Android App** | Kotlin 2.4, Jetpack Compose, Material 3, OkHttp |
| **Relay Server** | Python 3.11+, aiohttp |
| **Serialization** | kotlinx.serialization |
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 |
| **Build** | AGP 9.3.1, Gradle 9.6.1, JVM toolchain 17 |
| **CI/CD** | GitHub Actions (lint, build, test, signed APK artifacts) |
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
| **Min SDK** | 26 (Android 8.0) / Target SDK 36 |
## Running the Relay Locally
@@ -92,9 +116,19 @@ After the plugin is in place, restart hermes and verify pairing with `hermes-pai
We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`.
**Branching model (as of 2026-04-19): `main` + `dev`.** Feature branches — `feature/<name>`, `fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back into `dev` via `--no-ff` PRs. `main` is released state only; it receives release merges from `dev` and nothing else. There is no straight-to-main exemption — even single-file typos go through `dev`.
**Branching model: `main` + `dev`.** Feature branches — `feature/<name>`,
`fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back
into `dev` via merge-commit/no-ff PRs. This includes small documentation fixes.
`main` is release history, not the normal contribution target; it receives
approved release PRs from `dev` and focused hotfix PRs based on production tags.
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`, `plugin-vX.Y.Z`, or `cli-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
Feature completion means merged and verified on `dev`; it does not mean the
change has been released. A separate Forge release issue/session owns release
preparation, the `dev` → `main` release PR, tagging, artifacts, rollout or
deployment, and live verification. Release-prep commits land on `dev`; tags are
cut from the resulting `main` tip as `android-vX.Y.Z`, `server-vX.Y.Z`, or
`desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release and hotfix
procedures.
## Stale PR salvage and contributor credit
@@ -158,10 +192,20 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
## Testing
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
- **Android pre-push gate:** `scripts\dev.bat prepush` on Windows or
`./scripts/dev.sh prepush` on macOS/Linux. This runs the Android repository
checks, Google Play debug lint, and the same focused unit-test shard used by
CI in one cached Gradle invocation. Run it before pushing Android PR updates
to catch common hosted failures without waiting for another full Actions
cycle; hosted CI remains the exhaustive all-variant gate.
- **Focused Android unit test:** `scripts/dev.bat test-one "<fully-qualified-class-or-pattern>"`
- **Android unit tests:** `scripts/dev.bat test` (runs the sideload debug JUnit + MockK + Compose suite)
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
Superseded Android runs on `dev` and PR refs are canceled automatically; `main`
runs are never canceled because each release-branch commit must complete its
independent validation.
## Questions?
+976
View File
@@ -1,5 +1,914 @@
# Hermes-Relay — Dev Log
## 2026-08-09 — Gateway activity recovery and chat speech
Successful Android Gateway turns now reconcile against their profile-owned,
structured session history. This recovers persisted tool calls when an upstream
Gateway completes a turn without emitting live tool lifecycle events; assistant
prose is never inspected for inferred activity, and the recovered calls continue
through the existing Off, Compact, and Detailed display policy.
The Speak message action now follows configured voice readiness and idle output
state instead of active Voice Mode or presentation style. A settled assistant
reply can therefore be read aloud directly from chat, while live or provider
playback still prevents overlapping output. One-shot message narration owns its
completion state outside Voice Mode and replaces Speak with Stop while active;
stopping drains only that narration pipeline and does not cancel a chat turn
started while the response was playing.
## 2026-08-09 — Quiet reasoning and grouped tool activity
Android Chat now treats reasoning and routine tools as transcript scaffolding.
Visible live reasoning opens automatically without a tinted card, then collapses
to a quiet Thought disclosure when it settles unless the reader has explicitly
chosen its state. Empty reasoning remains absent and the existing first-token
status continues to own the waiting state.
Top-level routine calls retain their source order but render as consecutive
activity runs. A live run keeps one summary and one latest-activity ticker in a
stable footprint; a settled run becomes one collapsed summary that can disclose
the original identity-preserving tool rows. File edits, approval/question tools,
generated media, failures, output-risk findings, and delegated work split runs
and retain independent surfaces. Off hides only ordinary activity runs, Compact
uses compact disclosed rows, and Detailed preserves the full per-tool detail
surface on demand. Expansion still yields bottom-follow ownership, and each run
registers its measured bounds as Chat pet terrain.
## 2026-08-09 — Android chat experience and attachment polish
The Android composer now declares sentence capitalization and a Send IME
action. A device-level setting chooses whether unmodified physical Enter sends
or inserts a newline; Shift+Enter remains a newline and Ctrl/Command+Enter
always submits. Every submit route converges on the existing live-turn owner,
so the current gateway state still decides whether content steers the active
turn or queues behind it. Directional focus traversal is cancelled while the
editor owns focus so hardware arrow keys continue to move the caret and
selection.
Conversation bottom-follow now remains owned when thinking or tool details
expand. Only an actual drag ending above the bottom yields that ownership, and
a chat first measured while the IME is already visible now captures the same
resize-follow state as a keyboard opened after composition.
Bitmap-backed attachment surfaces now apply EXIF rotation and reflection before
display. Attachment reads and Base64 conversion also leave the UI thread, so
selecting a larger photo no longer performs the full ingestion path inside the
activity-result callback.
Composer drafts now belong to the stable connection, profile, and session
identity. Text, edit context, and pending attachments restore when returning to
a chat without persisting attachment bytes. Pending attachments expose bounded,
orientation-aware previews plus explicit remove and reorder controls.
Conversation overflow now opens transcript search with previous/next matches
and a prompt-turn rail, both keyed to the same stable UI identity as the message
list. Assistant prose retains the compact bubble and subtle edge treatment that
keeps it legible above the animated chat background. Tapping a message reveals
the existing copy, quote, speak, and edit actions with reduced-motion-aware
expansion and accessible targets.
Quotes are composer-owned structured references instead of raw blockquote text.
The composer and sent message render a linked, highlighted author preview; the
transport remains ordinary Markdown so unmodified Desktop and TUI clients show
a readable quoted reply. Thinking and top-level tools continue to use their
independent compact thought bubbles and configured compact or full tool cards,
without an aggregate completion card. The composer also names Correction and
Queue states with visible labels. Gateway redirects remain text-only:
follow-ups with attachments are forced through the existing destination-owned
queue so files cannot be left behind by a correction request.
## 2026-08-08 — Standalone Android thinking status
Blank streaming assistant rows now present the full-size working animation
directly in the conversation lane above the visible `Still working…` label,
without painting an empty assistant bubble around the status. The first answer
token replaces that standalone state with the normal response bubble, while
recovery retains the distinct `Reconnecting to your answer…` wording. The
status owns one stable TalkBack description and suppresses animated child
nodes, avoiding repeated announcements without claiming measurable progress.
## 2026-08-08 — Android text-share draft handoff
The shared Android manifest now advertises a `text/*` `ACTION_SEND` target for
both app flavors. `MainActivity` accepts only non-blank single-item text shares
and places them in a process-local, identity-fenced handoff that survives cold
Compose initialization. Once the configured chat context settles, the app root
navigates to Chat and delegates draft creation and composer prefill to
`ChatViewModel`.
The ViewModel reuses the existing new-chat lifecycle, preserving Gateway
background-turn reconciliation and the active connection/profile/transport
namespace. Composer prefills use a one-consumer conflated channel so an intent
received before Chat composition is delivered once. Shared text is never
routed through message sending; the user must review and submit it explicitly.
## 2026-08-08 — Android Profile Shelf and profile-context identity
Chat profile selection now lives in a collapsible shelf directly below the top
app bar. The header toggles the shelf, the active capsule opens Agent Passport,
inactive 48 dp avatars switch context, and a pinned overflow opens the same full
switcher used by Passport. Saved ordering and hidden state drive both surfaces;
the selected hidden profile remains disclosed, while a one-identity shelf stays
out of the layout. Long-press actions expose inspection, Passport, profile lock,
and hiding without adding activity or presence claims.
The shelf uses the chat surface instead of a second elevated toolbar. A neutral
active capsule, 36 dp avatar artwork inside 48 dp targets, compact spacing, and
a contained overflow affordance keep the row visually subordinate to Chat. When
Server default resolves to a concrete profile, that profile's avatar remains
the identity and a small home badge discloses its default routing role.
Local avatar lookup remains keyed to the Server-default presentation identity,
so an image customized while that row is selected appears consistently in both
the Chat header and shelf rather than being re-keyed to whichever explicit
profile is currently active.
The Server-default sentinel is now distinct from a profile literally named
`default`. Profile selection restores the last compatible connection/profile/
transport session, otherwise leaves a fresh draft. Gateway turns detach and
reconcile in their original session, live SSE turns keep switching disabled,
and every profile transition clears session-scoped model, provider, personality,
reasoning, approval, Fast, and YOLO state before the destination session seeds
its own values. Server sticky-default state is never written.
## 2026-08-08 — Streaming reply tail follow
Android's conversation-bottom follower now reads the current immutable message
list from Compose state inside its long-lived layout observer. Each streamed
replacement can therefore advance the viewport as the active bubble gains
lines, while dragging or scrolling away still releases bottom ownership.
## 2026-08-08 — Settled live replies transition to Markdown
Android now releases the live plain-text renderer immediately after an
assistant row settles. The transition retains the row's stable UI identity,
commits the final live frame before replacing its selectable text topology,
and anchors the same bottom-owned row during the Markdown remeasure. Readers
who scrolled away retain their viewport, while completed code fences, lists,
emphasis, links, and interrupted partial replies no longer require session
navigation before rich rendering appears.
## 2026-08-08 — Explicit-consent ownership for Android approvals
Android no longer treats unrelated Gateway activity or a terminal display event
as proof that an approval was resolved. Approval cards remain pending through
scrolling, recomposition, navigation, and background restoration, and retain
their exact connection, profile, and session ownership. Only a labeled action
that successfully reaches `approval.respond`, an authoritative upstream expiry,
or an explicit interrupt can retire the local request; the interrupt path stays
visibly denied because upstream force-denies it.
## 2026-08-08 — Agent Passport control and dismissal accessibility
The Android Agent Passport keeps its title and explicit close action outside
the nested content scroller. Material bottom-sheet gestures are enabled again,
so a downward gesture scrolls long content toward its top boundary before the
sheet receives the gesture and dismisses; backdrop and Back dismissal retain
the same callback.
Safety and speed choices now sit below their labels instead of competing for a
narrow horizontal column. Every segment provides at least a 48 dp target,
allows two-line labels, exposes radio-selection semantics, and states the
meaning of the current approval, chat override, or processing-tier choice in
plain language. The layout remains scrollable on compact heights and at larger
font scales without moving the close action off-screen.
## 2026-08-08 — Session-owned Android send queues
Android follow-up queues now capture the composing connection/profile context,
stored session, configured transport, originating run, attachments, and voice
context as one immutable destination. Queue presentation is filtered to the
visible session, while completion eligibility is tied to the exact run and live
Gateway generation that owned the queue. A completion from another session or
an older live generation cannot dispatch through the current composer route.
In-flight checkpoints retain bounded queued text across process restoration.
Attachment bytes are not copied into Preferences DataStore; an attachment queue
that cannot be restored is rejected with a visible review-and-resend notice.
Connection replacement and session deletion cancel their owned queues, while
switching among concurrent Gateway sessions preserves each session's queue.
## 2026-08-07 — Provider-owned model inventory identity
Android now normalizes Gateway and API model inventories before publishing
them to picker consumers. Repeated provider rows merge by canonical provider
slug, repeated exact model IDs collapse within that provider, and capability
metadata follows the same provider/model identity. Models intentionally offered
by different providers remain distinct choices, even when their display labels
and model IDs match.
The searchable picker groups and keys rows by provider slug plus exact model ID
instead of provider display text. Cached loads, dynamic refreshes, API aliases,
and Manage inventory use the same idempotent identity rule, preventing duplicate
catalog data from reaching keyed Compose lists without hiding valid routes.
## 2026-08-05 — Restored chat bottom ownership and effort fallback clarity
Opening an existing Android session now retains exact bottom ownership through
late, non-streaming layout changes. Composer capability controls, status rows,
and restored message content can finish measuring after history first reaches
the footer; a session-scoped geometry observer corrects those changes without
using a fixed delay. New-message following remains governed by the smooth
auto-scroll setting, while a real drag, IME ownership, and the Voice dock keep
their existing anchors.
The advisory effort drawer now states that Hermes does not advertise exact
levels for the selected model before explaining why standard options are shown.
The wording is consistent across all shipped Android locales.
## 2026-08-05 — Provider-aware reasoning effort discovery
The optional Relay plugin now exposes a bearer-protected, profile-aware model
capability overlay without requiring changes to upstream Hermes. Android merges
that overlay with the standard `model.options` inventory using exact provider
and model identities, while older or unpaired Relay installations continue with
the canonical advisory fallback.
Dynamic LM Studio, Ollama Cloud, and Copilot discovery is bounded by a shared
network limiter, cached by profile, endpoint, model, and credential fingerprint,
and fenced across refresh generations. Neither credentials nor internal cache
scope are returned to clients. Composer controls, Agent Passport, session
creation, and asynchronous server reconciliation share the same capability
resolver so a displayed effort cannot silently differ from the value sent.
## 2026-08-05 — Chat drawer and companion terrain ownership
The Chat screen now clears composer focus when the session drawer commits to
opening, dismissing the IME without continuously clearing focus from drawer
search or rename fields. Drawer refreshes override keyed list anchoring only
when the leading session identity changes, keeping the newest row visible after
activity-based reordering.
Floating companions wait for Chat's measured composer rail before publishing
their first roaming position. Their collision footprint contains both the
pointer target and rendered sprite, and the complete scroll-to-bottom control
envelope is an obstacle rather than a landing perch. Supported rails add no
visual lift, and the floating-only renderer aligns each frame's opaque bottom
edge to its canvas baseline so transparent atlas padding cannot make pets hover;
centered previews and message avatars remain unchanged.
## 2026-08-05 — Measured pet placement and passive model sync
The floating pet now remains unpublished until the app-level overlay has a
positive measured viewport. Its initial home coordinate is therefore derived
from the real safe bounds instead of the zero-size pre-measure bounds that
collapsed to the top-left corner.
API provider inventory remains an optional background catalog on Gateway-led
connections. A timeout, refusal, or unavailable optional route no longer emits
a global chat notice during initialization, reconnection, or connection-sheet
refresh. The failure is retained as a contextual warning in local Diagnostics,
including the operation, endpoint role, redacted stack trace, preserved network
cause, and targeted troubleshooting guidance. Cached and Gateway-owned model
options remain unchanged.
## 2026-08-05 — Stable chat-tail completion
Chat and Voice now treat the active streamed reply as the owner of its live
renderer until a different row becomes the conversation tail. Stream
completion retains the existing Compose subtree and list anchor; the full
Markdown renderer is deferred until the row is no longer active or the session
is revisited.
The last-in-group timestamp occupies its final geometry from the first
streaming frame and is only revealed at completion. Measured positive growth
during an active stream continues to follow the bottom without replacing the
logical anchor. Once completion layout stabilizes, a bottom-owned transcript
settles to the exact LazyColumn boundary; proximity slop is reserved for
retaining follow intent during motion and cannot define the final position. The
visible footer supplies the exact remaining distance so rounding or adjacent
layout changes cannot leave a residual forward range.
IME expansion participates in that same viewport owner. A transcript already
at the bottom advances by the measured viewport-height loss throughout the
keyboard animation, then settles exactly after inset updates stop on both open
and close. A transcript being read above the bottom preserves its existing
anchor, and a real drag cancels keyboard follow immediately. Host-side coverage
verifies renderer ownership, unchanged bubble height, exact footer settling,
keyboard arming, viewport loss, completion/IME settlement ownership, and
history-reading behavior.
## 2026-08-05 — Focus voice input boundary repair
The Focus voice presentation remains modal without installing a consuming
pointer handler on the full overlay ancestor. Its click-through guard is now a
behind-content sibling: empty-space gestures cannot reach the chat or drawer,
while the mic, close, expand/collapse, and panel controls receive their full
pointer sequence.
Host-side Compose coverage injects real touch events instead of invoking
semantic click actions. It verifies both child callback delivery and the modal
background boundary so the two requirements cannot regress independently.
## 2026-08-05 — Actionable Android connection diagnostics
Android diagnostic entries now separate the configured route from the exact
request operation and path used to test it. Relay health checks identify the
HTTP `/health` probe that precedes a WebSocket connection, route selection
records its Dashboard, API, or Relay probe, and WebSocket and API checks name
their handshake or authentication stage.
Known network and HTTP failure classes attach a bounded next step for refused
listeners, DNS, routing, timeouts, TLS, credentials, rate limits, missing
routes, and server failures. The activity list, status timeline, detail dialog,
copy text, and GitHub issue prefill all carry the same context. Public issue
text preserves protocol and request paths while redacting hosts, credentials,
queries, and user information.
## 2026-08-04 — Android transcript identity ownership
ChatHandler now owns one render identity for every published transcript row.
Checkpoint recovery and all streamed message mutations resolve both the mutable
server/domain ID and the stable UI identity, so history adoption cannot leave a
stale client reference that appends a second row. The publication boundary also
coalesces repeated render identities before Chat or Voice can observe them,
while keeping the first transcript position and latest state.
Focused coverage composes history reconciliation with checkpoint restore,
exercises stale post-adoption callbacks, and runs deterministic transition
sequences across restore, replay, deltas, thinking, and usage updates. Voice's
temporary transcript row now occupies an auxiliary key namespace disjoint from
real message rows.
## 2026-08-04 — Android reliability and support foundation
Android fatal capture and centrally classified handled failures now converge on
a versioned, allowlisted reliability record. Reports are redacted before local
persistence, capped at 20 records with 14-day retention, written atomically,
and correlated only with random app/report identifiers. Expected cancellation
and permission denial remain non-reportable. The pre-existing one-file crash
format migrates locally on first launch.
Crash recovery leads with the recovery outcome and no-upload guarantee, then
requires an explicit review before copy, share, or GitHub actions. Diagnostics
adds an offline support-information review using the same exact redacted text.
Android issue prefills now request the Android area while repository-wide issue
ownership remains maintainer-reviewed, and the release workflow retains both
variant R8 mappings for deterministic retrace.
The architecture audit defers an ANR watchdog, richer allowlisted breadcrumbs,
hashed product correlation, and OOM emergency writing until their lifecycle,
privacy, and false-positive behavior can be validated on devices.
## 2026-08-02 — Android Russian localization
Android now ships complete Russian catalogs for the main and sideload builds.
The in-app language picker, Android locale configuration, chat and voice labels,
tool and status presentation, diagnostics, onboarding, and plural resources are
registered against the canonical English catalog. Existing non-English catalogs
were refreshed to retain exact resource and format-argument parity.
The integration preserves PR #276 as the source contribution while excluding
unrelated recovery, routing, and test-stability changes from the localization
scope. The localization registry records Android coverage only; Russian public
documentation and marketing pages continue to use the canonical English
fallback until those surfaces are translated separately.
## 2026-07-31 — Upstream-compatible voice interruption semantics
Android full-turn barge-in now follows upstream Hermes' RMS behavior: roughly
450 ms of quiet-room calibration, a 90th-percentile floor, 3× default
multiplier, separate generation/playback minimums, a bounded ceiling, 500 ms
playback grace, and an 80%-majority decision window. Calibration remains frozen
against speaker output and cannot itself trigger. Renderer-driven phase tracking
returns to generation thresholds in quiet output gaps and rearms playback grace
only after a gap of at least one second. Opt-in Logcat diagnostics expose the
inputs used for device tuning. Barge-in is enabled by default while retaining its master,
Silero sensitivity, RMS multiplier, playback grace, and resume controls.
Voice stop phrases now use an editable exact-match list that defaults to
`stop`; clearing the list disables the behavior. A match ends the active voice
chat in generation or playback, but the same word outside voice chat and longer
requests continue through normal Hermes input. Continuous-mode pause/resume and
explicit background-task cancellation retain their narrower state gates.
Interrupting spoken playback arms the upstream one-shot interruption note for
the next Standard model-bound message. The latch expires after 120 seconds and
travels only in API-local voice interface context, never in visible or
persisted user text. Generation and pre-audio synthesis interruption do not mark
an unspoken reply, Realtime keeps its provider-session context, and silencing remains independent
from cancellation of promoted background work.
## 2026-07-30 — Expandable Android assistant surface
Android Digital Assistant sessions now open as a compact bottom bar over the
current app, expand in place for transcript and response detail, and collapse
without changing the active turn. Open full voice disables only the
system-owned session UI and reveals the existing app Voice surface, preserving
the same session, response stream, and microphone owner.
Connection, chat, and voice state machines now have one main-process,
application-lifetime owner. Assistant activation can initialize and run a cold
voice turn without constructing or foregrounding `MainActivity`; opening full
Voice binds the Activity to those same ViewModels and audio resources.
The optional `SYSTEM_ALERT_WINDOW` Voice surface now follows the same
wide-bar-to-expanded-sheet progression while retaining its minimized bubble.
It remains a separately user-invoked control for turns that began in the app;
the Assistant-role session does not require display-over-other-apps permission.
The assistant window is transparent outside the bar or sheet, leaves the
underlying app unresized, and restricts touch interception to the measured
surface. Back collapses an expanded surface first; Back from compact, Stop, and
ordinary dismissal remain terminal. A hidden full-Voice handoff instead follows
the app-owned turn through its final Closed state.
Package-scoped lifecycle reconciliation also clears assistant state if Android
reclaims the separately processed UI while full Voice remains active.
Assistant activation is ID-aware and single-flight. Duplicate delivery cannot
re-arm capture, Retry replaces a pending readiness attempt, and Stop invalidates
the attempt before chat, Voice, or microphone mutations. Scoped voice settings
must hydrate from DataStore before route readiness, and process extraction
preserves connection-catalog isolation plus the existing gateway route-flip
settle window.
Physical-device validation on a Samsung SM-S938U confirmed cold invocation over
a non-Hermes foreground app, compact and expanded presentation without
foregrounding `MainActivity`, and one main-process microphone owner. Locked
invocation reached a shown system assistant session without runtime or recorder
errors; Samsung's secure lock screen prevented screenshot-based visual review.
## 2026-07-30 — Foreground wake-word diagnostics and recovery
The Android-local sherpa listener now treats each non-empty keyword result as a
completed KWS event, resets the stream immediately, and maps the stored
confirmation setting to sherpa's native trailing-blank confirmation instead of
requiring an already-completed result to recur across application frames. Voice
settings can arm a ten-second test against the same foreground service,
microphone owner, installed model, and current tuning; it displays live input
level and reports detection without opening voice or transmitting audio.
Expected empty-transcript responses after activation now record a no-speech
diagnostic and return voice to its ready state with a retry hint rather than
surfacing the provider's HTTP error. Other transcription failures retain the
existing error path. Foreground-service behavior is documented explicitly:
background detections remain pending behind the notification until Hermes is
visible; Android default-assistant integration is a separate mode. Opening the
visible Voice settings screen also reconciles an enabled listener after package
replacement or process death without adding boot/background auto-start.
Focused wake preferences/core and no-speech classification tests pass.
Sideload lint, sideload debug packaging, and Google Play debug Kotlin
compilation pass. This batch adds no model, native library, ABI, permission, or
network dependency; the existing approximately 6 MB downloaded model and
packaged sherpa ABI footprint are unchanged.
## 2026-07-30 — Voice transcript identity alignment
Android voice Focus mode now keys transcript rows with the same stable UI
identity as the main Chat list. A live row may adopt its persisted server
message ID during history reconciliation while retaining its original Compose
identity; using the mutable domain ID in the voice overlay could otherwise
collide during that transition and close the app.
Focused JVM coverage recreates two visible rows with a shared reconciled server
ID and verifies distinct stable transcript keys. Sideload production and
Android-test Kotlin compilation pass. The existing full-overlay instrumentation
fixture remains blocked by its continuously animating surface never reaching
Compose idleness.
## 2026-07-30 — Opt-in Android Digital Assistant mode
Android now declares an explicit `VoiceInteractionService` and separately
processed `VoiceInteractionSessionService`. Only Android's user-confirmed
Assistant role activates the integration. Optional background “Hey Hermes”
detection reuses the local sherpa model and tuning, releases its recorder before
the system session opens the existing voice flow, and resumes after session
exit. Package-scoped lifecycle messages reconcile prompt/listen state,
transcript/response presentation, cancellation, errors, and process recreation.
The Digital Assistant listener and the existing experimental microphone
foreground service are separate, mutually exclusive opt-ins. Both retain local
pre-activation privacy and the shared one-microphone contract. Standard voice
continues through the upstream Dashboard audio surface. Voice settings include
role status, setup, removal, runtime status, and the limitation that third-party
assistants do not receive Google's low-power hotword hardware.
## 2026-07-29 — Full-turn voice interruption and local wake-word preview
Android barge-in now owns one microphone/VAD listener from response generation
through playback drain for both Standard and Realtime voice. Quiet-room RMS
calibration freezes before output begins, playback receives a grace interval,
and model-confirmed majority filtering separates actual interruption from raw
ducking hints. Turn epochs, stream cancellation, late-delta suppression, and
an awaited microphone handoff keep an interrupted response from speaking again
or racing the replacement recording. Exact stop/pause intent is phase-aware,
while explicit background-task cancellation remains separate from silencing.
An opt-in Android-local “Hey Hermes” preview uses sherpa-onnx in a user-started
microphone foreground service. Its approximately 6 MB English model is
downloaded and hash-verified on first enable rather than bundled. The service
keeps pre-activation audio local, exposes an ongoing Stop notification, pauses
for active voice, and shares a process-wide single-microphone ownership
contract with voice recording, barge-in, and realtime diagnostics. The stored
configuration includes strictness, confirmation frames, new-session behavior,
and a deliberately inactive future profile-routing shape.
Focused JVM coverage exercises calibration, grace, listener teardown,
Thinking-to-Speaking ownership, generation/playback interruption, command
gating, wake preferences, activation, and microphone exclusion. Android
compilation for both distribution flavors, sideload lint, and sideload APK
packaging pass with all four supported ABIs. On-device acoustic, foreground
service, and lifecycle checks remain the corresponding validation gates.
## 2026-07-28 — Android 1.5.2 production release
Android 1.5.2 shipped from the approved `dev` to `main` release tree as
versionCode 35. The release adds provider-aware Dashboard sign-in: Nous uses
the advertised native PKCE system-browser flow, while compatible self-hosted
providers retain cookie-backed full-page Dashboard authentication. Callback
origin discovery remains server-driven, private-network HTTP compatibility is
preserved, and arbitrary public HTTP redirects remain rejected.
The private Play preflight validated the exact application tree before release
PR #265 merged. The immutable `android-v1.5.2` tag resolves to the resulting
`main` tip, the production workflow promoted versionCode 35 to the completed
Google Play production track, and the public GitHub release contains the
signed AAB, sideload APK, and SHA-256 manifest. The published sideload APK
checksum was independently verified; replacing the debug-signed phone build
with the release-signed artifact requires an uninstall because Android
correctly rejects cross-signature in-place updates.
## 2026-07-27 — Android replayed-message identity reconciliation
Android history reconciliation now collapses reconnect/rejoin replays of the
same persisted message ID before publishing the transcript to Compose. The
latest repeated snapshot replaces the value at the message's first transcript
position, preserving stable ordering, distinct messages, and the LazyColumn
identity contract without index- or random-key fallbacks.
Focused coverage reproduces the duplicate UUID condition and verifies that the
authoritative final content wins while every rendered message keeps a unique
stable UI key.
## 2026-07-26 — Android 1.5.1 patch reconciliation
Android 1.5.1 reconciles the post-1.5.0 voice and chat fixes into versionCode
34. Voice now offers compact Focus and full Conversation presentation,
Standard narration preserves valid completed replies, and promoted Realtime
tasks release foreground voice controls while retaining progress and results.
Completed streamed answers promote from the stable live text node to full
Markdown only after completion. The measured Markdown row is then positioned
by its trailing edge until deferred code and attachment measurement settles,
preventing the LazyColumn from restoring the start of a tall response.
The release also targets Android API level 36. Release notes, in-app What's
New assets, localized Play notes, and the Play listing reference were updated
for Android 1.5.1.
## 2026-07-25 — Immutable Android release dispatch repair
Android approval now dispatches the current release workflow definition from
`main`, while every release job explicitly checks out the immutable
`android-v*` tag. Validation confirms the dispatched version resolves to that
checked-out commit and accepts the repository's surface-qualified
`[Android x.y.z]` changelog heading. Existing tags remain unchanged, and a
workflow-only correction can resume a failed publication without rebuilding
from a different application tree.
Audited `.github/workflows/approve-release-android.yml`,
`.github/workflows/release-android.yml`, `RELEASE.md`, and `DEVLOG.md`.
## 2026-07-25 — Android 1.5.0 final release reconciliation
The final Android 1.5.0 release tree reconciles the accumulated Dashboard-first
connection, Gateway recovery, background delivery, Agent Passport, onboarding,
voice, attachment, image-generation, security, localization, and Developer
Options work into one public release narrative. Android remains version 1.5.0
with monotonic versionCode 33 because that prepared version was not previously
tagged or uploaded to production.
Release notes, the in-app What's New assets, localized Play release notes, and
the Play listing reference now describe the final tree rather than the earlier
voice-focused candidate. The release train is gated by the exact-tree private
Play preflight before the `dev` to `main` release merge and public tag.
## 2026-07-25 — Active-turn retention and actionable interaction alerts
Android now promotes user-started chat work to foreground execution until every
connection/profile/session-scoped turn settles. Independent leases preserve
concurrent detached Gateway sessions, track sessions paused for input, and
prevent one completion from stopping protection for siblings. The existing
Persistent connection switch now extends retention only to idle periods.
The foreground notification reports active and waiting session counts.
Interaction alerts add privacy-safe expanded profile/session context and an
explicit review, answer, or secure-response action that deep-links to the exact
conversation; commands, questions, passwords, secrets, and environment-variable
names remain confined to the authenticated chat surface.
Audited `ChatViewModel`, `ConnectionViewModel`, `GatewayChatClient`,
`GatewayKeepAliveService`, `InteractionRequestNotifier`, the shared Android
manifest, Android settings copy, chat user documentation, and Play foreground
service declaration guidance.
## 2026-07-24 — Post-connect permission setup
Android onboarding now finishes with a layered permission step after a
successful Hermes connection. Standard Chat and Manage are explicitly ready
without a phone grant; Android notifications are recommended through one
user-triggered runtime prompt; camera, microphone, notification companion, and
flavor-supported device tools remain individually optional on the centralized
Permissions screen. Existing just-in-time permission prompts remain available
when users skip setup.
Coverage separates the Android-version notification policy from the Compose
presentation and checks the recommended, granted, optional-review, and skip
states. English and all shipped Android locale catalogs were updated together.
## 2026-07-24 — Realtime background-task delivery continuity
Chat stream completion now preserves an otherwise empty assistant row when it
owns a promoted background task. This keeps the task identity available after
the provider's initial spoken handoff, so later progress, completion, and
forced-summary events update and settle the initiating row even when a newer
persistent Voice command has started. Existing empty-response cleanup remains
unchanged for assistant rows without background work.
## 2026-07-23 — Background interaction notifications
Android Gateway chat now treats approval, clarification, elevated-permission,
and secret requests as actionable background events. Privacy-safe notifications
use stable per-session identities, reopen the exact conversation, replace
replayed requests, and clear on answer, expiry, or resumed turn activity.
Detached active turns retain and replay their pending interaction when the
conversation is reopened.
The audit classified `terminal.read.request` as renderer plumbing rather than a
user decision. Android now answers it with the upstream no-terminal empty
response instead of showing an interaction or waiting for the server timeout.
The shared main manifest continues to provide notification and persistent
connection support to both Google Play and sideload builds.
## 2026-07-23 — Android user-CA trust for self-hosted Hermes
The shared Android network security configuration now accepts CA certificates
that the device owner deliberately installed in Android's user credential store,
in addition to system roots. Because the policy is attached to the common
application manifest, it covers both product flavors and every platform-backed
Hermes transport: endpoint probes, Dashboard requests and authentication
WebView, redirects, Gateway WebSockets, API streaming, Standard Voice, and
Relay HTTPS/WSS. Default OkHttp and WebView certificate-chain and hostname
checks remain active, and Relay's independent certificate pinner is not
overridden.
An app-wide policy is required for arbitrary operator-supplied server names and
for consistent WebView behavior. A runtime opt-in would require parallel custom
trust implementations for each client and could not safely reconfigure WebView;
per-connection CA import would add private trust-material lifecycle without
covering all transports. The tradeoff is that Android disables public
Certificate Transparency verification when user trust anchors are enabled.
User documentation records the deliberate-installation boundary and a device or
emulator validation procedure with positive chain and negative hostname checks.
JVM coverage locks the accepted anchor sources and rejects pin overrides or
debug-only trust additions.
## 2026-07-23 — Bounded Android code-highlighting ranges
Android Markdown code rendering now validates every syntax-highlighting span
before applying it to Compose text. The bundled highlighting dependency can
emit a reversed multiline-comment range when malformed or incomplete code
contains a closing delimiter before its opening delimiter; the Markdown
renderer previously passed that range directly to `AnnotatedString` and
crashed. Both fenced and indented code use the guarded renderer, valid spans
remain highlighted, and malformed ranges are clipped or ignored. Focused JVM
coverage reproduces the dependency output and verifies the safe conversion.
## 2026-07-20 — Image generation placeholder during turns
Android chat now specializes the generic tool lifecycle for active
`image_generate` calls. While the tool is pending, the message shows a
theme-aware procedural diffusion canvas with a polite live-region announcement
instead of a generic tool card. The completed tool result still replaces the
placeholder through the existing tool completion path. Coverage includes pure
JVM selection/denoise tests and a Compose accessibility snapshot test.
## 2026-07-20 — Faster Android validation feedback
Android contributors now have one cross-platform pre-push command for locale,
documentation, collection-API, and version checks plus primary Play-variant
lint and the focused CI unit-test shard. It uses daemon and configuration-cache
reuse, supplies a conservative Gradle heap, and discovers the standard Windows
Android SDK without writing worktree-local configuration. Hosted CI retains
the exhaustive all-variant lint gate.
Android CI now cancels a superseded run on `dev` or a pull-request ref while
preserving every `main` run. A newer integration commit therefore stops paying
for an older release smoke that can no longer become the tested release tip.
## 2026-07-19 — Android 1.4.9 release preparation
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
connection and onboarding work merged to `dev`. The public changelog, GitHub
release notes, in-app What's New surfaces, English and Simplified Chinese Play
notes, and store-listing copy now describe the Android-only patch. Unreleased
Relay security hardening and the Windows gateway PID fix remain assigned to the
independent server release track.
## 2026-07-19 — Connection management detail pass
The Connections list now uses compact capability chips and exposes a cold-start
preference when more than one server is saved. Last used remains the default;
pinning a startup connection does not change the active connection during the
current run. Dashboard-only connections now render their configured Dashboard
and authentication status in Routes instead of an empty endpoint list, while
optional API fallback routes remain explicitly separate.
Advanced settings render directly in their dedicated tab without a redundant
expander. Security adds Dashboard authentication, credential-storage, Relay
session, sign-out, and transport posture facts sourced from current runtime
state. Android catalogs and localization source hashes were refreshed alongside
focused startup-persistence coverage.
## 2026-07-18 — Dashboard-primary connection contract
The canonical Android connection model now treats one Hermes installation as a
stable identity with independently optional endpoints. Dashboard/Gateway owns
the standard upstream chat, authentication, sessions, Manage, and voice path;
the API server is an automatic fallback and advanced headless compatibility
surface; Relay remains an additive extension for terminal, bridge, media, and
enhanced voice capabilities. Existing API-first records and pairing payloads
remain compatible without defining normal onboarding.
The audit reconciled `AGENTS.md`, `docs/spec.md`, `docs/decisions.md`,
`docs/upstream-surface-matrix.md`, `README.md`,
`user-docs/features/connections.md`, `user-docs/features/dashboard.md`,
`CHANGELOG.md`, and `DEVLOG.md`. `RELEASE.md` was reviewed and required no
change because it contains no connection-routing contract.
## 2026-07-18 — Non-signalling Windows gateway PID probes
Relay profile discovery now prefers Hermes' supported psutil liveness check and
falls back to native Windows process handles when psutil is unavailable. The
POSIX signal-zero probe is restricted to POSIX hosts, preventing relay startup
and periodic profile rescans from signalling or terminating the gateway named
by `gateway.pid`. Regression coverage uses disposable child processes instead
of pointing PID fixtures at the test runner.
## 2026-07-18 — Android privacy-policy URL hotfix
The canonical Android privacy policy moved to a stable public page on
hermes-relay.dev. The historical GitHub Pages path now serves the complete
policy for compatibility with existing store metadata instead of depending on
a client-side redirect. Android's About screen, public privacy references, and
Play submission documentation use the canonical site URL.
The legacy Pages workflow publishes both file and directory policy paths from
the same canonical HTML source. Repository validation checks the policy's
required disclosures and URL wiring, while Play preflight and stable tag release
jobs additionally require both deployed policy URLs to return complete policy
content before an artifact can be uploaded or promoted. Android advanced to
1.4.8 with versionCode 31 for the replacement Play submission.
## 2026-07-18 — Android 1.4.7 release preparation
Android advanced to 1.4.7 with versionCode 30 after the localization, streaming,
release-history, and branch-contract reconciliation landed on `dev`. The public
changelog, GitHub release body, in-app What's New surfaces, Play metadata, and
store-listing copy now describe the Android-only patch while the unreleased Relay
security work remains assigned to its independent server release track.
## 2026-07-17 — Smooth streamed-reply rendering and finalization
Uninterrupted Gateway turns treat their structured live assistant, reasoning, and
tool events as authoritative instead of immediately republishing the transcript
through a full history read. Rejoined sockets, Sessions SSE, detached turns,
profile-aware resume, errors, and missing-data recovery retain their required
authoritative reconciliation paths.
Bursty provider deltas now enter a main-thread frame pacer that publishes adaptive
UTF-16-safe slices at a display-sized cadence. The visible live tail uses one stable
plain-text node, ignores leading blank transport lines, and expands inside a short
clipped size animation. Tool, thinking, completion, cancellation, and error
boundaries still flush buffered content immediately and preserve event order.
Bottom-following is driven by stable row identity, real drag interactions, measured
positive tail growth, and structural anchors. The active response retains its live
renderer through completion, settles the exact footer for two frames, and releases
to full Markdown after another row becomes the tail or the session is revisited.
This prevents both the completion-time top snap and the transient scroll-to-bottom
button without interrupting readers who intentionally move into history.
Focused stream-pacing, Unicode-boundary, leading-whitespace, Gateway reconnect,
completion-policy, and scroll regressions passed. Repeated sideload builds and live
phone tests verified smooth following, stable completion, exact-bottom settling,
frame-paced text insertion, and clipped bubble growth.
## 2026-07-17 — Stable chat rows across post-turn history reconciliation
Android chat now separates the stable Compose identity of a visible message row
from its authoritative server message ID. The post-turn history reconcile can
adopt persisted IDs and rebuild message boundaries without making LazyColumn
remove and reinsert the long answer currently anchoring the viewport.
Regression coverage exercises both the same-count user/assistant ID adoption
and a list-expansion reconcile that inserts persisted rows around a matched live
tail. The focused ChatHandler and scroll-snapshot unit tests passed.
## 2026-07-16 — Stable chat position after stream completion
Android chat now observes the assistant message identity and the streaming-to-final
transition as conversation-tail changes. When a reader is already following the
response, completion performs an instant multi-frame bottom settle after the
streaming renderer is replaced by the final Markdown layout. The existing
user-scroll gate remains authoritative, so reading older messages is not
interrupted.
Focused snapshot regression coverage verifies completion detection, ordinary
stream growth, stream startup, and server message-ID reconciliation.
## 2026-07-16 — Critical Relay authorization hardening
Relay privileged interfaces now enforce host-authorized policy at every shared
dispatch boundary. Anonymous pairing-code minting was removed; pairing clients
can no longer choose session lifetime or grants; Android bridge HTTP routes and
terminal messages require live sessions with active route grants; ordinary
session bearers can only reduce their own lifetime and existing grants; remote
profile config reads expose an explicit public schema without host paths; and
voice requests cannot override credential-bearing provider origins.
Six bounded exploit harnesses stopped at the restored boundaries. The combined
security regression set passed 96 tests, Python compilation passed, Ruff passed
for changed modules and tests apart from the pre-existing unused `signal`
import in `server.py`, and `git diff --check` passed. The broad plugin
discovery run progressed through unrelated suites but was interrupted by the
existing Windows async-suite `KeyboardInterrupt` behavior, so the focused
security and neighboring route suites remain the authoritative local result.
The required-check path classifier now reads changed paths from the checked-out
PR merge commit instead of GitHub's PR-files API, so an API outage cannot skip
every surface check.
## 2026-07-16 — Fix production docs asset context
Updated the production docs Docker stage to build from the same full repository
checkout used by CI rather than a hand-maintained file allowlist. This supplies
the canonical screenshot manifest, localization registry and validators, route
contract source, version metadata, and preview renderer without creating
Docker-only `ENOENT` failures when those build inputs grow. Both Node build
stages now install Python 3 so the localized website and docs validators run
inside the production image build as they do in CI.
## 2026-07-16 — Localized marketing site
The Astro product site now publishes German, Spanish, Japanese, Brazilian
Portuguese, and Simplified Chinese routes from one typed copy contract. Each
route localizes marketing copy, navigation, accessibility labels, metadata, and
links into the matching first-run documentation while retaining canonical
screenshots, command examples, and UI recreations as shipped-product evidence.
Locale-aware canonical URLs, alternate-language links, Open Graph locale data,
structured-data language, sitemap entries, and a responsive language selector
were added. The localization registry records English-source freshness, and the
website development and build commands reject missing or stale translations.
Astro diagnostics, deterministic asset checks, the six-page production build,
built-site validation, desktop/mobile browser checks, and `git diff --check`
passed.
## 2026-07-16 — Temporary redirect shim for pre-migration Android builds
Restored GitHub Pages only as a redirect-only compatibility endpoint for app
versions that still open `https://codename-11.github.io/hermes-relay/`. The
shim preserves known paths, query strings, and fragments while forwarding to
`https://hermes-relay.dev/docs/`; it does not publish the VitePress site.
The production Nginx configuration resolves VitePress clean URLs to their
`.html` artifacts so both legacy and corrected in-app links reach real pages.
Removal criteria and the operator review date are tracked in `TODO.md`.
## 2026-07-15 — German, Brazilian Portuguese, and Japanese localization
Android now includes complete German, Brazilian Portuguese, and Japanese
catalogs across the Google Play and sideload flavors. German and Brazilian
Portuguese were recovered from unfinished translation drafts and refreshed
against the current English resource contract; Japanese was generated through
the same deterministic translation harness. The in-app picker, Android locale
configuration, localization registry, contributor references, and user-facing
language lists now describe the expanded set consistently.
The catalogs remain marked as AI-translated until fluent review is recorded.
Structural validation covers resource parity, placeholders, plurals, arrays,
formatting flags, XML parsing, and canonical source hashes. The 10-catalog
validator, five focused `AppLanguageTest` cases, both flavor Kotlin/resource
compilations, sideload debug lint, and `git diff --check` passed.
## 2026-07-15 — Retire GitHub Pages and move docs to hermes-relay.dev
Disabled and removed the GitHub Pages deployment path, changed the repository
homepage to `https://hermes-relay.dev`, and moved the existing VitePress guide
to `https://hermes-relay.dev/docs/` inside the production Coolify image. Active
README, website, Android, release-note, and pet-schema links now target the new
docs origin while historical DEVLOG entries remain unchanged.
## 2026-07-15 — Coolify root-context website deployment hotfix
Added a repository-owned multi-stage Dockerfile for the Astro marketing site
and corrected its Coolify instructions. Production builds now keep the
repository root as Docker context, run the existing `build:production` gate
from `website/`, and serve the generated static output with Nginx. This keeps
the site's canonical screenshot comparison against `docs/media/` intact while
avoiding Nixpacks' incorrect Android/Gradle provider selection at monorepo root.
Verification: local website checks, production build, link validation, Docker
image build, and Nginx-served smoke checks passed before deployment.
## 2026-07-15 — Android 1.4.6 and Plugin 1.4.2 release preparation
The profile-continuity and profile-image work was prepared as a two-surface
@@ -52,6 +961,60 @@ Verification: seven profile-avatar endpoint tests and the focused Android host
avatar client plus profile-controller suites passed. Android lint and final diff
checks are recorded with the completed work.
## 2026-07-15 — Repository branch, release, and hotfix contract reconciliation
Repository guidance now has one provider-neutral branch contract in `AGENTS.md`:
normal work, including documentation, branches from and returns to `dev`; release
preparation happens on `dev`; approved release PRs merge `dev` to `main`; and
immutable surface tags are cut from the new `main` tip. Staging is an environment
sourced from an exact tested SHA or release-candidate tag. Production uses
`android-v*`, `server-v*`, or `desktop-v*`. Hotfixes branch from the affected
production tag, change and patch-bump only that surface, merge to `main`, tag,
verify, and immediately merge `main` back into `dev`.
Stable release workflows now require the tagged commit to be contained in
`main`, require the tag to match the authoritative surface version source, and
require a matching `CHANGELOG.md` release heading before any build or publish
job. Server and Desktop use the canonical `server-v*` and `desktop-v*` prefixes;
runtime update discovery retains fallback support for immutable historical
`plugin-v*` and `cli-v*` releases. No historical tag was moved or rewritten.
Audit inventory:
- Canonical/root guidance: `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`,
`RELEASE.md`, `README.md`, `DEVLOG.md`, `PLUGIN_RELEASE_NOTES.md`, and
`CLI_RELEASE_NOTES.md`.
- Contributor metadata: `.github/PULL_REQUEST_TEMPLATE.md`; every file in
`.github/ISSUE_TEMPLATE/` (`bug_report.yml`, `config.yml`, `docs.yml`,
`feature_request.yml`, and `translation.yml`); `.github/copilot-instructions.md`;
and `.github/dependabot.yml`.
- GitHub workflows: `approve-release-android.yml`, `ci-android.yml`,
`ci-contract.yml`, `ci-dashboard.yml`, `ci-desktop.yml`, `ci-plugin.yml`,
`ci-required.yml`, `dependabot-auto-merge.yml`, `docs.yml`, `issue-triage.yml`,
`play-listing.yml`, `play-preflight-android.yml`, `release-android.yml`,
`release-cli.yml`, and `release-plugin.yml`.
- Developer documentation: every Markdown file directly under `docs/`, plus
`docs/audits/`, `docs/diagrams/`, and `docs/mockups/`. Historical files under
`docs/plans/` were inspected for classification but not rewritten as current
instructions. Current contradictions were corrected in `docs/decisions.md`
and `docs/worktree-workflow.md`.
- Public documentation: every Markdown file under `user-docs/`, including the
architecture, desktop, features, guide, reference, and `zh-CN` trees. Current
tag guidance was corrected in `user-docs/desktop/index.md` and
`user-docs/desktop/installation.md`.
- Release/runtime seams: all three release workflows; `scripts/bump-version.sh`,
`scripts/bump-plugin-version.sh`, `scripts/check-version-tracks.py`, and
`scripts/check-plugin-version-sync.py`; Desktop install/update sources under
`desktop/scripts/` and `desktop/src/`; and Server update-discovery sources and
focused tests under `plugin/`.
The repository audit also confirmed that GitHub-owned settings cannot be
reconciled through repository files. The default branch correctly remained
`main`, the release-history branch. At audit time, `dev` was unprotected, squash
and rebase merges were enabled, and `main` protection did not apply to
administrators. An operator must align those remaining settings with the
documented contract.
## 2026-07-15 — Android 1.4.5 release and automated Play gate
Android 1.4.5 shipped as versionCode 28 after the signed release build, final
@@ -6290,3 +7253,16 @@ After: Phone (HTTP/SSE) → API Server (:8642) [chat — direct]
- Deploy docs site (GitHub Pages or similar)
- Phase 2: Terminal channel (xterm.js in WebView, tmux integration)
- Phase 3: Bridge channel migration
# 2026-07-30 — Wake-word strictness tuning
- Lowered the unset Android wake-word strictness default from `0.6` to `0.3` after physical-device testing showed reliable activation only at the lower slider positions.
- Added the live numeric strictness value to Voice settings so tuning is observable.
- Preserved saved user values and the existing three-frame confirmation default; this adjustment does not migrate working installations or broaden the detector acceptance window beyond the selected threshold.
# 2026-07-30 — OEM assistant-picker compatibility
- Added the standard `android.intent.action.ASSIST` activity filter because some OEM assistant pickers enumerate Assist activities even when a valid `VoiceInteractionService` is present.
- Routed activity-based Assist invocations through the existing system-assistant activation protocol so both Android entry points share microphone ownership and session lifecycle behavior.
- Added the required `recognitionService` metadata and a bounded recognition component after device validation showed Samsung could grant the package role while leaving the active voice-interaction service empty. The component does not open the microphone; Hermes assistant sessions continue to use the established transcription pipeline.
- Declared `CATEGORY_VOICE` on the Assist activity and retained `ACTION_ASSIST` on the explicit activation intent. `VoiceInteractionSession.startVoiceActivity()` adds the voice category, and Android rejects the launch as `START_NOT_VOICE_COMPATIBLE` unless the target filter matches both.
+17 -8
View File
@@ -1,20 +1,28 @@
# Hermes-Relay-Plugin v__VERSION__
# Hermes-Relay-Server v__VERSION__
**Release Date:** July 15, 2026
**Release Date:** August 13, 2026
This patch aligns Server default with Hermes' sticky active profile and lets paired clients import conventional profile avatar files without exposing host paths.
This release adds an operator-owned secure ingress path, promotes Tailscale as the easiest supported remote route, and introduces Hermes Reach as a disabled-by-default experimental broker for outbound-only environments.
Pairs with Hermes-Relay-Android v1.4.6 for profile image import. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
## What's changed
### Added
- **Paired clients can import profile avatars.** Relay discovers conventional direct-child images such as `avatar.png` and `profile.jpg`, validates their media type, size, and profile boundary, and serves the bytes through an authenticated route.
- **Hermes Secure Link.** A pinned-TLS ingress can expose Relay, API, and Dashboard namespaces through one self-hosted endpoint while retaining each service's native authentication.
- **Experimental Hermes Reach.** An optional self-hosted rendezvous broker forwards opaque inner Secure Link TLS records, with hashed credentials, replay protection, bounded streams, persistence, and revocation.
- **Remote-access status.** Dashboard and pairing metadata distinguish reachability from transport protection and show supported services without exposing certificate pins.
### Changed
- **Tailscale is recommended for remote access.** Tailscale Serve remains the simplest supported path; direct TLS and Secure Link are self-hosted alternatives, while Reach stays advanced and experimental.
- **Pairing carries reviewed transport trust.** QR payloads include the Secure Link endpoint and pin before the first network request; rotation requires explicit re-pairing.
### Fixed
- **Server default follows Hermes' active profile.** Advertised identity, model, SOUL, profile metadata, and avatar resolve through the sticky `active_profile` marker instead of always using the root profile.
- **Route credentials remain scoped and revocable.** Reach credentials are issued only through trusted Secure Link ingress, replaced atomically per Relay session, bounded by session expiry, and removed on revocation.
- **Proxy namespaces preserve credential isolation.** API and Dashboard headers, cookies, redirects, methods, sizes, timeouts, and loopback authority are constrained independently.
## Install / update
@@ -22,15 +30,16 @@ Pairs with Hermes-Relay-Android v1.4.6 for profile image import. Standard chat a
hermes plugins install Codename-11/hermes-relay/plugin --enable
# Classic install / update on a systemd host:
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/server-v__VERSION__/install.sh | bash
# or, if already installed:
hermes-relay-update
## Verify
hermes relay doctor
# Agent/tool callers can use desktop_health to list desktop targets.
python scripts/check-plugin-version-sync.py --expect __VERSION__
---
Tag prefixes: Android releases use android-v*, plugin releases use plugin-v*, and CLI releases use cli-v*.
Tag prefixes: Android releases use android-v*, Server releases use server-v*, and Desktop releases use desktop-v*.
+77 -60
View File
@@ -22,7 +22,7 @@
<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://hermes-relay.dev/docs/">Documentation</a> ·
<a href="https://github.com/Codename-11/hermes-relay/releases">Releases</a> ·
<a href="CHANGELOG.md">Changelog</a> ·
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
@@ -34,10 +34,10 @@
Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-agent) on the devices you actually carry. The brain stays on your own machine — Hermes-Relay is how you reach it.
- **📱 Android app** — streaming chat, hands-free voice, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. On sideload builds, the agent can read your screen and act on it.
- **📱 Android app** — streaming chat, hands-free voice, native plugin pages, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. Add a floating Petdex companion or optionally make Hermes your Android assistant; sideload builds can also let the agent read and act on your screen.
- **⌨️ Hermes-Relay CLI** *(alpha)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**. Add the optional relay only when you want terminal, phone control, or the CLI's tools. **Pair once from either surface; both work.**
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, voice, Petdex, and ordinary installed-plugin pages need **no Relay plugin**. Add the optional Relay only when you want terminal, phone control, agent-created page drafts, or the CLI's tools. **Pair once from either surface; both work.**
<p align="center">
<img src="docs/diagrams/architecture-homepage.png" alt="How Hermes-Relay connects — Vanilla Hermes (Chat, Manage, Voice) runs with no plugin; the optional Relay plugin adds Terminal, Bridge, relay voice and desktop tools to the app and CLI; Device Control needs the sideload build." width="900">
@@ -50,60 +50,71 @@ Install → connect → talk, in about two minutes.
### 1 · Install the app
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, Manage, terminal/TUI, media, notifications, and relay sessions.
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://hermes-relay.dev/docs/guide/getting-started.html#sideload-apk).
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks) for the capability matrix.
### 2 · Have Hermes running
### 2 · Have the Hermes Dashboard running
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is vanilla Hermes setup; the [full walkthrough](https://codename-11.github.io/hermes-relay/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
The normal Android connection uses the upstream Hermes Dashboard/Gateway for
chat, sign-in, sessions, Manage, and voice. Installing Hermes and choosing a
provider is vanilla Hermes setup:
```bash
hermes setup --portal # install / log in / pick a provider — skip if already done
mkdir -p ~/.hermes
API_SERVER_KEY="$(openssl rand -hex 32)" # strong random key — or substitute your own memorable value
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://<this-computer-ip>:8642 key: $API_SERVER_KEY"
hermes gateway
hermes setup --portal # install / log in / pick a provider — skip if already done
hermes dashboard # start the standard Dashboard/Gateway surface
```
`API_SERVER_ENABLED` turns the API server on; `API_SERVER_HOST=0.0.0.0` makes it reachable on your LAN (the default is localhost-only); `API_SERVER_KEY` is the bearer token the app sends — **your choice of value**.
Make the dashboard reachable from your phone over a trusted LAN, Tailscale, or
an HTTPS reverse proxy. The [full walkthrough](https://hermes-relay.dev/docs/guide/getting-started)
covers Windows, remote access, and dashboard authentication. You do not need to
enable the separate API server or invent an API key for the standard path.
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
For plugin-enabled setups, optional **Hermes Secure Link** presents Relay, API,
and Dashboard routes through one pairing-pinned TLS origin. It protects traffic
to the paired endpoint while each service keeps its own authentication; it does
not provide reachability or independently identify the physical host. You still
use LAN routing, Tailscale or another VPN, or an operator-managed public route
to reach the listener. Secure Link is off by default and requires a fresh QR
pairing after it is enabled. See the
[remote-access guide](https://hermes-relay.dev/docs/guide/remote-access/).
**Hermes Reach** is an experimental, advanced outbound-broker route. It remains
available for development and self-hosted evaluation, but it is disabled by
default, ordered after supported routes, and not recommended for normal remote
access. Use Tailscale for the easiest supported remote setup, or a public TLS
domain / Direct Secure Link when you want to own the complete network path.
### 3 · Connect and talk
Open the app and pick how to connect — any of:
Open the app, choose **Connect to Hermes**, and enter or discover the dashboard
address (conventionally `http://<host>:9119`). Sign in through the dashboard's
configured provider when prompted. The app probes the available upstream
capabilities and finishes with a connection summary.
- **Vanilla Hermes** → tap **Scan for Hermes on LAN** to auto-find the server, then enter your key.
- **Vanilla Hermes** → type the address (`http://<host>:8642`) and key by hand.
- **Scan setup QR** → ask your Hermes agent to generate a QR with your URL + key (e.g. `{"api_url":"http://<host>:8642","api_key":"<key>","dashboard_url":"http://<host>:9119"}`) and scan it. `dashboard_url` is optional when the dashboard uses the conventional same-host `:9119` URL.
The separate API server can be discovered automatically or added later under
**Advanced** as a chat fallback or for a headless compatibility setup. Its API
key is requested only when that optional endpoint is configured. Existing
API-first setup QRs remain importable.
The wizard probes everything and finishes with a capability card:
| Line | What it means |
|------|---------------|
| **Chat** | API server reachable — you can talk |
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
| **Chat** | Dashboard/Gateway ready — you can talk |
| **Manage** | Models, keys, skills, and profiles are available from the phone |
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
| **Remote** | Fallback route configured — keeps working away from home |
| **Relay** | Optional power tools — fine to leave unpaired |
| **API fallback** | Optional API route available/unavailable |
| **Relay** | Optional extensions — fine to leave unpaired |
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session unlocks voice. That's the whole Vanilla Hermes setup.
One dashboard sign-in unlocks Chat, Manage, sessions, and standard voice. That's
the whole Vanilla Hermes setup.
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
> **Going places?** Add the Dashboard's Tailscale address — for example `http://100.x.y.z:9119` or a separately published `https://host.ts.net` URL — under **Settings → Connections → Routes**. Android tests it as a Dashboard route; no API server or API key is required. The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://hermes-relay.dev/docs/guide/remote-access).
### 4 · Optional: install Relay for power tools
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, or the realtime voice engine:
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, the realtime voice engine, or approval-gated agent-created plugin-page drafts:
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
@@ -119,8 +130,11 @@ shell shims, and the full clone/update workflow:
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
```
The plugin-manager install owns the plugin code, dashboard tab, CLI commands,
and agent tools. `hermes relay compat status/install/remove` manages only the
Installed Hermes plugins can expose bounded, host-rendered pages to Android
through the authenticated Dashboard without running plugin code on the phone.
Relay 1.5.0 additionally supports approval-gated agent-created page drafts. The
plugin-manager install owns the plugin code, dashboard tab, CLI commands, and
agent tools. `hermes relay compat status/install/remove` manages only the
optional legacy API compatibility hook when an older Hermes build needs it. Scan
the QR from the phone's Connections screen — or use
`hermes pair --register-code ABCD12` with the manual code from Android
@@ -133,13 +147,13 @@ the QR from the phone's Connections screen — or use
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the API server and dashboard enabled · Python 3.11+ on the server.
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the Dashboard/Gateway enabled · Python 3.11+ on the server. The API server and Relay are optional.
## Screenshots
<table>
<tr>
<td align="center" width="25%"><img src="assets/screenshots/01_startup.png" alt="Cold start" width="100%"><br><sub><b>Cold start</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/01_voice_conversation.png" alt="Voice controls in chat" width="100%"><br><sub><b>Voice in chat</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/02_chat.png" alt="Streaming chat" width="100%"><br><sub><b>Streaming chat</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/03_voice.png" alt="Hands-free voice" width="100%"><br><sub><b>Hands-free voice</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/04_sessions.png" alt="Session history" width="100%"><br><sub><b>Session history</b></sub></td>
@@ -162,12 +176,12 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
</tr>
</table>
The Android app also ships a complete AI-assisted Spanish catalog. Choose
**Español** from **Settings → Appearance → Language**; translation status and
fluent review are tracked independently so community corrections remain easy
to contribute.
The Android app ships complete AI-assisted catalogs for **Deutsch**, **Español**,
**日本語**, **Português (Brasil)**, **Русский**, and **简体中文**. Choose a language from
**Settings → Appearance → Language**; translation status and fluent review are
tracked independently so community corrections remain easy to contribute.
<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>
<p align="center"><sub>▶ <a href="https://hermes-relay.dev/docs/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
## Features
@@ -183,11 +197,11 @@ to contribute.
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL.
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts.
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks).
## Hands on any machine — the Hermes-Relay CLI&nbsp;<sub>(alpha)</sub>
> **Alpha.** Self-contained CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64 — no Node required. Windows also has an optional native, menu-only systray. Assets are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
> **Alpha.** Self-contained CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64 — no Node required. Windows also has an optional compact management tray. Assets are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
The agent's brain stays on the host; the CLI lets it call tools **on your machine** over the same WSS relay — `read_file`, `write_file`, `terminal`, `search_files`, `screenshot`, `clipboard`, `open_in_editor`, and more — behind a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch.
@@ -201,11 +215,11 @@ hermes-relay daemon start # background tool router — agen
hermes-relay update # self-update via GitHub Releases
```
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on a separate `cli-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=cli), with old alpha prereleases still visible under `desktop-v*`.
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on the `desktop-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=desktop), with historical releases still visible under `cli-v*`.
On Windows, the default installer adds the optional right-click-only systray: no dashboard or app window, just TUI launch, User/Administrator-aware daemon controls, pairing, local grant review, audit, diagnostics, logs, desktop-use status/cancellation, sign-in startup, and emergency stop.
On Windows, the default installer adds the optional compact **Hermes-Relay CLI UI** tray popup for host selection and pairing, connection and daemon state, per-host Ask/Trusted/Full Access, local grant dialogs, authorized-client revocation, activity, settings, and emergency stop. It is a management surface only—chat, TUI, plugins, voice, and agent sessions remain CLI/upstream concerns.
- **Docs:** [CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
- **Docs:** [CLI guide](https://hermes-relay.dev/docs/desktop/) · [`desktop/README.md`](desktop/README.md)
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
## How It Works
@@ -229,14 +243,14 @@ configure API, dashboard, and relay routes without merging their auth models.
| | |
|---|---|
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, features, configuration — start here** |
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
| [Hermes-Relay CLI](https://codename-11.github.io/hermes-relay/desktop/) | Pairing, subcommands, local tool routing |
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
| **[User Guide](https://hermes-relay.dev/docs/)** | **Quick start, features, configuration — start here** |
| [Android](https://hermes-relay.dev/docs/guide/) | Android install + setup + features |
| [Hermes-Relay CLI](https://hermes-relay.dev/docs/desktop/) | Pairing, subcommands, local tool routing |
| [Architecture](https://hermes-relay.dev/docs/architecture/) | How the system works under the hood |
| [API Reference](https://hermes-relay.dev/docs/reference/api.html) | Hermes API endpoints used by both surfaces |
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `plugin-v*`, `cli-v*`) |
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `server-v*`, `desktop-v*`; historical prefixes remain immutable) |
<details>
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
@@ -268,11 +282,14 @@ Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `
```bash
# Android: open the repo root in Android Studio, wait for Gradle sync, Run (Shift+F10).
scripts/dev.bat build # Build debug APK
scripts/dev.bat build # Build sideload debug APK
scripts/dev.bat compile # Compile sideload Kotlin only
scripts/dev.bat test-one "com.hermesandroid.relay.SomeTest" # Focused unit test
scripts/dev.bat install-fast # arm64 phone build + install + launch
scripts/dev.bat release # Build signed release APK
scripts/dev.bat bundle # Build release AAB for Google Play
scripts/dev.bat run # Build + install + launch + logcat
scripts/dev.bat test # Run unit tests
scripts/dev.bat run # Build sideload + install + launch + logcat
scripts/dev.bat test # Run sideload debug unit tests
scripts/dev.bat version # Show current version
scripts/dev.bat relay # Start the relay server (dev, no TLS)
```
@@ -281,13 +298,13 @@ scripts/dev.bat relay # Start the relay server (dev, no TLS)
| Component | Stack |
|-----------|-------|
| **Android app** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
| **Android app** | Kotlin 2.4, Jetpack Compose, Material 3, OkHttp |
| **Hermes-Relay CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
| **Server / plugin** | Python 3.11+, aiohttp |
| **Serialization** | kotlinx.serialization (Android) |
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
| **Build** | AGP 9.3.1, Gradle 9.6.1, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
| **CI/CD** | GitHub Actions — lint, build, test, APK artifact, CLI binaries per platform |
| **Min SDK** | 26 (Android 8.0) · Target SDK 35 |
| **Min SDK** | 26 (Android 8.0) · Target SDK 36 |
<details>
<summary><b>Repository structure</b></summary>
+7 -5
View File
@@ -9,7 +9,7 @@
<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://hermes-relay.dev/docs/zh-CN/">中文文档</a> ·
<a href="https://github.com/Codename-11/hermes-relay/releases">版本下载</a> ·
<a href="CHANGELOG.md">更新日志</a>
</p>
@@ -18,9 +18,9 @@
## 功能简介
- **Android 应用**:流式聊天、会话历史、文件附件、Hermes 管理、语音模式、多连接和配置文件。
- **Android 应用**:流式聊天、会话历史、文件附件、Hermes 管理、语音模式、原生插件页面、Petdex 悬浮宠物、多连接和配置文件;也可将 Hermes 设为 Android 助手。
- **无需插件的标准路径**:聊天、管理和标准语音可直接连接未修改的上游 Hermes Agent。
- **可选 Relay 插件**:增加终端、手机控制、媒体传输、通知助手、Relay 语音和电脑工具。
- **可选 Relay 插件**:增加终端、手机控制、媒体传输、通知助手、Relay 语音、电脑工具,以及需确认的代理创建插件页面草稿。
- **安全连接**:二维码配对、Android Keystore、证书固定、按通道授权和可配置会话有效期。
- **远程使用**:可配置 Tailscale 或 HTTPS 地址,在家庭局域网和远程路由之间自动切换。
- **两种 Android 发行渠道**:Google Play 版本适合日常使用;sideload 版本包含完整手机控制能力。
@@ -67,7 +67,7 @@ hermes gateway
### 4. 可选:安装 Relay
仅在需要终端、手机控制、媒体路由、Relay 会话、实时语音或电脑工具时安装:
仅在需要终端、手机控制、媒体路由、Relay 会话、实时语音、电脑工具或代理创建插件页面草稿时安装:
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
@@ -76,7 +76,9 @@ hermes relay start --no-ssl
hermes pair
```
完整说明请阅读[中文快速开始](https://codename-11.github.io/hermes-relay/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
已安装的 Hermes 插件可通过已认证的 Dashboard 向 Android 提供由应用安全渲染的原生页面,无需在手机上运行插件代码。Relay 1.5.0 另支持需用户确认的代理创建页面草稿。
完整说明请阅读[中文快速开始](https://hermes-relay.dev/docs/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
## 中文界面
+149 -71
View File
@@ -13,23 +13,24 @@ with optional prerelease identifiers.
- `PATCH` — bug fixes, backwards compatible
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
Hermes-Relay now ships three independently versioned surfaces. Public GitHub
Release titles use product names (`Hermes-Relay-Android`,
`Hermes-Relay-Plugin`, `Hermes-Relay-CLI`); tag prefixes stay short and stable
for automation.
Hermes-Relay ships three independently versioned production surfaces. Public
GitHub Release titles use product names (`Hermes-Relay-Android`,
`Hermes-Relay-Server`, `Hermes-Relay-Desktop`); immutable tag prefixes select
the corresponding build and deployment lane.
| Surface | Tag prefix | Version source | Bump script | Release workflow |
|---|---|---|---|---|
| Hermes-Relay-Android | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
| Hermes-Relay-Plugin | `plugin-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
| Hermes-Relay-CLI | `cli-v*` | `desktop/package.json` | `cd desktop && npm version --no-git-tag-version <version>` | `.github/workflows/release-cli.yml` |
| Hermes-Relay-Server | `server-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
| Hermes-Relay-Desktop | `desktop-v*` | `desktop/package.json` | `cd desktop && npm version --no-git-tag-version <version>` | `.github/workflows/release-cli.yml` |
This split is intentional. The plugin carries relay features for both Android
and CLI clients, so plugin fixes can ship without forcing an Android app
`versionCode` bump, and CLI alphas can continue on their own cadence. Historical
Android releases before this naming split used bare `v*` tags. Historical
plugin/server releases used `relay-v*` tags, and historical CLI prereleases used
`desktop-v*` tags. New releases use the explicit tag prefixes above.
plugin/server releases used `relay-v*` and `plugin-v*` tags. Historical
desktop/CLI releases also include `cli-v*` tags. Those tags remain immutable;
new releases use the canonical prefixes above.
### Android app versioning
@@ -87,7 +88,7 @@ lockstep:
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
Always bump Plugin releases via:
Always bump Server releases via:
```bash
bash scripts/bump-plugin-version.sh 0.6.2
@@ -105,23 +106,23 @@ Check all release tracks at once with:
python scripts/check-version-tracks.py
```
This aggregate check reports Android, plugin, and CLI versions
This aggregate check reports Android, Server, and Desktop versions
side by side and validates that each track's own source files are internally
consistent. It deliberately does not require all three tracks to share the same
SemVer.
The `plugin-v*` release workflow validates the tag against the same metadata,
The `server-v*` release workflow validates the tag against the same metadata,
runs plugin tests, builds a wheel and sdist, generates checksums, and
publishes a `Hermes-Relay-Plugin vX.Y.Z` GitHub Release with the package
publishes a `Hermes-Relay-Server vX.Y.Z` GitHub Release with the package
artifacts.
### CLI / tray versioning
`desktop/package.json` is the CLI release track's source of truth. Its version
must match the generated CLI and native Windows systray metadata. The systray is
a menu-only controller for the installed CLI; it has no application window,
WebView, embedded terminal, or separate desktop product surface. The public
release remains one `Hermes-Relay-CLI` track containing CLI binaries plus the
`desktop/package.json` is the Desktop/CLI release track's source of truth. Its version
must match the generated CLI and Windows tray metadata. The tray is a compact
management popup over the installed CLI and shared state; it has no chat,
embedded terminal, plugins, voice, or separate desktop product surface. The public
release remains one `Hermes-Relay-Desktop` track containing CLI binaries plus the
optional Windows installer.
| File | Purpose |
@@ -131,6 +132,9 @@ optional Windows installer.
| `desktop/src/version.ts` | compiled CLI runtime version |
| `desktop/tray/Cargo.toml` | native systray package version |
| `desktop/tray/Cargo.lock` | locked systray package version |
| `desktop/tray/tauri.conf.json` | tray application and bundle version |
| `desktop/tray/package.json` | tray UI package version |
| `desktop/tray/package-lock.json` | locked tray UI package version |
Prepare a new CLI version on `dev` without creating a tag or npm-generated
commit:
@@ -166,12 +170,28 @@ the accumulator: every merged PR appends bullets there. A release is a
separate act, taken when the accumulated state on `dev` is worth shipping
(see "When to cut a release" below). Cutting a release means opening a
surface-specific release PR from `dev` into `main`, merging it `--no-ff`,
then tagging `main`.
then tagging `main`. Feature completion means merged and verified on `dev`; it
does not mean released.
**Server tracks `dev` for staging.** The hermes-host deployment pulls
`dev` so merged features get exercised against real data before they
reach a tag. Users (Play Store, sideload, `hermes-relay-update`) only
see state that lives on `main` and on release tags.
**Staging is an environment, not a branch.** Deploy an exact tested `dev` SHA or
an immutable release-candidate tag to staging. Record that source in the Forge
release issue/session. Never deploy a moving branch name as the source of record
and never create a staging branch. Production deploys only immutable
`android-v*`, `server-v*`, or `desktop-v*` tags cut from `main`.
### Normal contribution and release flow
1. Branch `feature/*`, `fix/*`, `docs/*`, or `chore/*` from `dev`.
2. Open the PR into `dev` and require CI to pass.
3. Merge with a merge commit/no-ff according to repository policy.
4. Accumulate user-facing work under `CHANGELOG.md` `[Unreleased]`.
5. Treat the feature as complete when it is merged and verified on `dev`.
6. Start a separate Forge release issue/session when a release train is approved.
7. Prepare the affected surface release on `dev`, including its version and notes.
8. Open and approve the release PR from `dev` into `main`.
9. Tag the new `main` tip with the affected surface prefix.
10. Build and publish that surface's artifacts, roll out or deploy from the
immutable tag, and verify the release and live environment.
### Branch names
@@ -211,23 +231,35 @@ version files and, for Android, on `appVersionCode` (which must be
monotonic).
Version-bump commits live on `dev` as the last commit of release-prep
work. Android commits use `release(android): android-vX.Y.Z`; plugin commits
use `release(plugin): plugin-vX.Y.Z`; CLI commits use
`release(cli): cli-vX.Y.Z`. A release PR then merges `dev` →
work. Android commits use `release(android): android-vX.Y.Z`; server commits
use `release(server): server-vX.Y.Z`; desktop commits use
`release(desktop): desktop-vX.Y.Z`. A release PR then merges `dev` →
`main` with `--no-ff`, and the matching tag is cut from the resulting
`main` tip.
### Branch protection
Light branch protection is enabled:
Repository files define the contract and CI, but GitHub owns the default branch,
branch protection, rulesets, allowed merge methods, and required-check settings.
Those settings require an operator or infrastructure automation.
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
here. PR must pass CI (Android + Plugin) before merge. Force push and
branch deletion blocked.
- **`dev`** — direct pushes blocked for non-trivial work; feature
branches PR in. PR must pass CI. Force push and branch deletion
blocked.
- Signed commits + review approval NOT required (solo-dev overhead).
The intended settings are:
- **`main`** — PRs required; `Required checks` required and current; force push
and deletion blocked. Normal work does not target this branch.
- **`dev`** — PRs and `Required checks` required; force push and deletion
blocked. This is the normal contribution target.
- **Merge policy** — merge commits allowed; squash and rebase merges disabled so
the no-ff contract cannot be bypassed in the GitHub UI.
- **Default branch** — `main`, which remains the release-history branch and the
repository's canonical landing page. Normal contribution PRs must explicitly
target `dev`.
As of the 2026-07-15 repository audit, the default branch was correctly `main`.
The remaining GitHub-owned gaps were that `dev` had no protection, squash and
rebase merges were enabled, and `main` protection did not apply to
administrators. Those settings must be reconciled separately; this documentation
PR does not mutate them.
## One-time Setup
@@ -388,6 +420,15 @@ tag a **pre-release** (`android-vX.Y.Z-rc.N`). Users can opt in via
`hermes-relay-update --branch rc/vX.Y.Z-rc.N` without being auto-pushed
the unstable build.
## Release train ownership
Every release train gets its own Forge release issue/session. That owner records
the exact tested staging source, reconciles the affected surface version and
notes on `dev`, owns the `dev` → `main` PR, tags the new `main` tip, observes the
artifact workflow, performs the rollout or deployment, and captures live
verification. Feature implementation sessions stop at merged and verified on
`dev`; they do not inherit release authority.
## Release Process
### 1. Bump the Android app version
@@ -430,7 +471,7 @@ the new app version and a higher `appVersionCode`.
three* surfaces (Android + CLI + plugin), but releases are
per-surface. Move only the entries for the surface you're cutting into
the new versioned block, and leave the other surfaces' entries under
the fresh `[Unreleased]` for their own `cli-v*` / `plugin-v*` cut.
the fresh `[Unreleased]` for their own `desktop-v*` / `server-v*` cut.
(Those tracks' GitHub-Release bodies come from `CLI_RELEASE_NOTES.md` /
`PLUGIN_RELEASE_NOTES.md`, so the split here only governs this file's
historical record.)
@@ -456,6 +497,14 @@ the new app version and a higher `appVersionCode`.
in `app/build.gradle.kts`. Never rename the sideload APK — the
in-app update checker matches assets by `.apk` + `sideload` in the
name, and user-docs verify steps cite the filename.
The release workflow also retains
`app/build/outputs/mapping/{googlePlayRelease,sideloadRelease}/mapping.txt`
for 90 days in the `android-r8-mappings-<version>-<sha>` workflow
artifact. It is intentionally not a GitHub Release asset. To symbolicate an
in-app or sideload report, download the artifact for the exact version/SHA and
run Android's retrace tool with the matching flavor mapping:
`retrace <mapping.txt> <obfuscated-trace.txt>`. Play reports can additionally
use the mapping bundled into the uploaded AAB through Play Console.
- `app/src/main/assets/whats_new.txt` — in-app "What's New" content
shown in the settings/about screen. Update with the version number
and a brief feature summary. Gets stale silently if forgotten
@@ -471,7 +520,11 @@ the new app version and a higher `appVersionCode`.
the version reference and the "Release Notes" section that gets
pasted into the Play Console "What's new" field. Keep the Play
"What's new" within **500 characters** and framed around the
release's themes, not a feature dump.
release's themes, not a feature dump. Compare its **Foreground service
permissions** section with the merged `googlePlayRelease` manifest and
complete Play Console declarations for every declared service type before
approval; the Publisher API can upload a draft and still reject promotion
when an App content declaration is missing.
#### Scrub for public distribution
@@ -572,8 +625,12 @@ git push origin dev
Then open **Actions → Approve Android Release**, choose **Run workflow**, select
`main`, and enter the version. Starting the workflow is the release approval. It
verifies that `main` has the exact preflighted tree and creates the
`android-v<version>` tag. Manual stable tags are still guarded by the same
preflight proof in the tag workflow.
`android-v<version>` tag. Because tags created with `GITHUB_TOKEN` do not trigger
another workflow, approval dispatches the current release workflow definition
from `main`; every release job explicitly checks out and verifies the immutable
`android-v<version>` tag. This lets release-workflow fixes apply without moving
an existing tag or changing its artifact tree. Manual stable tags are still
guarded by the same preflight proof in the tag workflow.
The tag-triggered `.github/workflows/release-android.yml` rebuilds and scans the
artifacts, changes the existing Play Production draft to `completed` (submitting
@@ -585,14 +642,14 @@ publication.
Plugin/Python version files are intentionally not part of an Android app
release unless the plugin package itself is also being released.
### Plugin / Python package release
### Server / Python package release
Use this when plugin or relay behavior changes independently of Android app
delivery, for example CLI channel support, bridge routes, pairing server fixes,
voice auth, dashboard plugin UI, or packaging changes.
First **rewrite `PLUGIN_RELEASE_NOTES.md`** — it is the GitHub Release body for
`plugin-v*` tags (the same role `RELEASE_NOTES.md` plays for Android). Fill the
`server-v*` tags (the same role `RELEASE_NOTES.md` plays for Android). Fill the
Summary and the Added/Changed/Fixed groups from the plugin-relevant bullets in the
promoted `CHANGELOG.md` block, keep the `__VERSION__` token in the Install command
(the workflow substitutes it), and apply the same public-distribution scrub as §2.
@@ -603,31 +660,31 @@ git pull --ff-only origin dev
bash scripts/bump-plugin-version.sh 0.6.2
git add pyproject.toml plugin/relay/__init__.py plugin/plugin.yaml plugin/dashboard/manifest.json plugin/dashboard/package.json plugin/dashboard/package-lock.json CHANGELOG.md PLUGIN_RELEASE_NOTES.md
git commit -m "release(plugin): plugin-v0.6.2"
git commit -m "release(server): server-v0.6.2"
git push origin dev
# Open the release PR (dev -> main) and merge with --no-ff.
# After merge, tag from the new main tip:
git checkout main
git pull --ff-only origin main
git tag plugin-v0.6.2
git push origin plugin-v0.6.2
git tag server-v0.6.2
git push origin server-v0.6.2
```
Pushing `plugin-v*` triggers `.github/workflows/release-plugin.yml`, which
Pushing `server-v*` triggers `.github/workflows/release-plugin.yml`, which
validates all plugin-owned version metadata with
`scripts/check-plugin-version-sync.py`. Run
`python scripts/check-version-tracks.py` locally before tagging when a change
touches more than one release surface. The workflow also runs plugin tests,
builds a wheel and sdist, generates `SHA256SUMS.txt`, and creates a GitHub
Release named `Hermes-Relay-Plugin v<version>` for the plugin package.
Release named `Hermes-Relay-Server v<version>` for the server/plugin package.
### CLI / Windows systray release
Use this when the standalone CLI, daemon, desktop tools, or Windows tray changes.
Android and plugin versions do not need to move with it.
First rewrite `CLI_RELEASE_NOTES.md` for the new CLI release and promote only
First rewrite `CLI_RELEASE_NOTES.md` for the new Desktop release and promote only
CLI/tray-relevant changelog bullets into the release block. Then:
```powershell
@@ -641,7 +698,7 @@ cd ..
git add desktop/package.json desktop/package-lock.json desktop/src/version.ts `
desktop/tray/Cargo.toml desktop/tray/Cargo.lock CHANGELOG.md CLI_RELEASE_NOTES.md
git commit -m "release(cli): cli-v0.4.0-alpha.2"
git commit -m "release(desktop): desktop-v0.4.0-alpha.2"
git push origin dev
# Open the release PR (dev -> main) and merge with --no-ff.
@@ -651,8 +708,8 @@ git pull --ff-only origin main
cd desktop
npm run check:version-sync -- --expect 0.4.0-alpha.2
cd ..
git tag cli-v0.4.0-alpha.2
git push origin cli-v0.4.0-alpha.2
git tag desktop-v0.4.0-alpha.2
git push origin desktop-v0.4.0-alpha.2
```
The tag workflow rejects version drift and tags whose commit is not in
@@ -768,7 +825,8 @@ plugin changes from forcing an Android app `versionCode` bump.
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
1. Validates the tag matches `appVersionName` in
1. Verifies the stable tag resolves to a commit contained in `main` and that the
tag matches `appVersionName` in
`gradle/libs.versions.toml` (mismatches fail the workflow).
2. Runs the Android debug build and the stable sideload pairing/connection
regression slice with explicit timeouts.
@@ -778,30 +836,35 @@ On every push of a tag matching `android-v*`, `.github/workflows/release-android
(`./gradlew bundleRelease assembleRelease`); only the sideload APK and
googlePlay AAB are attached (see §Release assets).
5. Generates `SHA256SUMS.txt` covering the two attached files.
6. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
6. Promotes the exact preflighted Production draft to `completed`; a missing
credential or rejected Play edit fails before public GitHub publication.
7. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
7. Prints a `$GITHUB_STEP_SUMMARY` showing whether release signing
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
that the artifacts are debug-signed and unsuitable for Play Store.
8. Prints a `$GITHUB_STEP_SUMMARY` with the release and Play result.
On every push of a tag matching `plugin-v*`,
On every push of a tag matching `server-v*`,
`.github/workflows/release-plugin.yml`:
1. Validates the tag matches all plugin-owned version metadata checked by
`scripts/check-plugin-version-sync.py`.
1. Verifies the tag commit is contained in `main`, validates the tag against
all server/plugin-owned version metadata checked by
`scripts/check-plugin-version-sync.py`, and requires the matching release
heading in `CHANGELOG.md`.
2. Runs plugin syntax checks and the focused route/auth/session test slice.
3. Builds the Python wheel and sdist with `python -m build`.
4. Generates `dist/SHA256SUMS.txt`.
5. Creates a GitHub Release named `Hermes-Relay-Plugin v<version>` with the wheel,
5. Creates a GitHub Release named `Hermes-Relay-Server v<version>` with the wheel,
sdist, and checksum file attached.
On every push of a tag matching `cli-v*`,
On every push of a tag matching `desktop-v*`,
`.github/workflows/release-cli.yml` builds and publishes the CLI binaries and
Windows tray installer. Its GitHub Release body comes from `CLI_RELEASE_NOTES.md`
(rewritten per release — the CLI counterpart of `RELEASE_NOTES.md`); the workflow
substitutes `__VERSION__` (bare, e.g. `0.3.0`) and `__TAG__` (full, e.g.
`cli-v0.3.0`) so the install/pin commands stay accurate. Fill its Summary and
`desktop-v0.3.0`) so the install/pin commands stay accurate. It rejects tags
whose commit is not contained in `main`, whose version differs from
`desktop/package.json`, or whose version has no `CHANGELOG.md` release heading.
Fill its Summary and
Added/Changed/Fixed groups at CLI release-prep and apply the §2 public scrub.
Dashboard-only changes are covered by
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
@@ -816,19 +879,28 @@ in the built bundle.
| `HERMES_KEYSTORE_PASSWORD` | Store password | Password set during `keytool -genkey` |
| `HERMES_KEY_ALIAS` | Key alias | Alias set during `keytool -genkey` |
| `HERMES_KEY_PASSWORD` | Key password | Usually the same as the store password |
| `PLAY_SERVICE_ACCOUNT_JSON` | **Optional** — Play auto-upload | Paste the full Play Developer API service-account JSON (step 3) |
| `PLAY_SERVICE_ACCOUNT_JSON` | Stable Play submission | Paste the full Play Developer API service-account JSON (step 3) |
If `PLAY_SERVICE_ACCOUNT_JSON` is set, the `android-v*` release workflow uploads
the `googlePlay` AAB to the **Production track as a DRAFT** automatically (stable
tags only — prereleases are skipped). CI does the upload; you still click **Start
rollout** in Play Console. If the secret is unset, the workflow skips the upload
and you upload manually (§5) — nothing else changes.
Stable Android releases require `PLAY_SERVICE_ACCOUNT_JSON`. Preflight uploads
the Production draft and the tag workflow promotes that exact version code to
`completed`. The workflow does not fall back to manual upload or publish GitHub
first. With Play Managed Publishing off, an approved release publishes
automatically; with it on, Play holds the approved change for an operator action
that the Developer API does not expose.
## Hotfix Recipe
When production has a bug and you need to ship a fix without picking up
unreleased work from `dev`, branch from the affected release tag and only
bump the version source for the surface you are shipping.
When production has a bug, use the same invariant for every surface:
1. Branch from the affected immutable `android-v*`, `server-v*`, or `desktop-v*`
production tag, never from the moving `main` or `dev` branch.
2. Make the smallest safe fix and add focused verification.
3. Bump only the affected surface's patch version and release notes.
4. Open the focused hotfix PR into `main` and merge with a merge commit/no-ff.
5. Tag the new `main` tip with the affected surface's patch tag.
6. Verify the artifacts and production rollout or deployment.
7. Merge `main` back into `dev` immediately so integration inherits the fix and
version history.
For an Android app hotfix:
@@ -842,17 +914,23 @@ For an Android app hotfix:
5. Open a PR from `fix/short-name` into `main`, merge with `--no-ff`.
6. `git tag android-v0.6.2` from the new `main` tip and `git push origin android-v0.6.2`
so Android release CI builds and publishes.
7. Upload to Play Console as normal.
7. Verify the automated Play submission, GitHub artifacts, and rollout.
8. Merge `main` back into `dev` (`git checkout dev && git merge --no-ff main`)
so `dev` picks up the hotfix and the versionCode bump. Without this,
`dev`'s `appVersionCode` lags behind `main` and the next app release
bump collides.
For a Plugin hotfix, branch from the affected `plugin-v*` tag, apply
For a Server hotfix, branch from the affected `server-v*` tag, apply
the fix, run `bash scripts/bump-plugin-version.sh <next-version>`, merge to
`main`, and tag `plugin-v<next-version>`. Do not touch
`main`, tag `server-v<next-version>`, verify the package/deployment, and merge
`main` back to `dev`. Do not touch
`gradle/libs.versions.toml` unless an Android app release is also shipping.
For a Desktop hotfix, branch from the affected `desktop-v*` tag, update only
`desktop/package.json` and its generated lock/runtime/tray metadata, merge to
`main`, tag `desktop-v<next-version>`, verify all binaries and the installer,
then merge `main` back to `dev`.
## Troubleshooting
**`Tag version (X) does not match appVersionName (Y)` in CI validate step**
+18 -14
View File
@@ -1,30 +1,34 @@
# Hermes-Relay-Android v1.4.6
# Hermes-Relay-Android v1.8.1
**Release Date:** July 15, 2026
**Release Date:** August 9, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.4.6-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.8.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://codename-11.github.io/hermes-relay/guide/sideload) for installation help.
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://hermes-relay.dev/docs/guide/sideload) for installation help.
## Summary
This patch keeps Server default aligned with Hermes' active profile, adds per-profile icon import and organization controls, and prevents the session drawer from mixing profile databases.
## Added
- Reorder or hide profiles per connection without changing server configuration.
- Choose a profile icon from the Android file picker or import `avatar.png`/`profile.jpg` from a paired Relay host.
This patch keeps long Hermes conversations complete and aligns Android's
Gateway behavior with current upstream turn contracts.
## Fixed
- Server default resolves Hermes' sticky active profile before Gateway session create/resume and dashboard session operations, keeping the agent, drawer, transcript, and writes in one profile database.
- Host image import distinguishes an outdated Relay from a genuinely missing avatar and presents **Choose file** as a reliable fallback.
- Complete transcript reads page explicitly across both API-server and
profile-scoped Dashboard routes, so sessions beyond Hermes' latest-500
default retain stable history, sharing, retry, edit, and recovery anchors.
- Gateway submit rejections preserve the authoritative server message without
silently falling through to SSE.
- Gateway event envelopes reconcile consistently, and edit-and-regenerate
requests send the required truncation confirmation.
## Install / Verify
- App version: **1.4.6** (versionCode **29**).
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- App version: **1.8.1** (versionCode **42**).
- Standard Chat, sessions, Manage, and Vanilla Hermes voice continue to work
against unmodified upstream Hermes.
- The optional Relay plugin is not required for standard Android chat or hosted
Dashboard authentication.
+212 -37
View File
@@ -6,6 +6,163 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Structured desktop hardware capabilities
Structured access and per-host USB policy now ship with typed, serial-bound ADB
list, shell, push, pull, install, and bounded logcat operations. Remaining work:
- Add microphone and camera only with backend readiness detection, bounded local
grants, active-use indicators, audit events, and immediate cancellation.
- Reconcile legacy `desktop_screenshot` with the task-granted computer screenshot
path so screen capture follows one policy.
- Extend capability policy beyond hardware only where a typed broker provides a
meaningfully stronger boundary than Structured mode already provides.
---
## Android Plugin Studio protocol follow-ups
The first live declarative Plugin lane is host-local: Relay tools create bounded
draft JSON, Android previews it through the authenticated Dashboard namespace,
and exact-digest Keep/Remove actions require an Android user tap. Complete the
multi-session protocol before treating `lifecycle=session` as an isolation claim:
- Derive draft ownership from trusted Hermes task context and store only an HMAC
of that identifier; never accept a model-supplied session owner.
- Filter draft discovery by the Android app's active Hermes session while keeping
profile and connection publications separate with explicit precedence.
- Replace foreground five-second catalog polling with authenticated catalog
invalidation events plus ETag polling fallback.
- Expire abandoned drafts and pending approvals, and add revision-bound profile
versus connection promotion targets.
---
## Split fast Android unit tests from resource and screenshot tests
The quick-loop commands now narrow execution to the sideload debug variant and
support one-class filtering, but all `:app` unit tests still share one Android
test variant. That variant includes merged Android resources, gives every test
worker a 2 GiB heap, runs on JDK 21, and enables Roborazzi recording because a
small subset of Robolectric/screenshot tests requires those settings.
Create a separate resource/screenshot test lane so pure state, parser, routing,
and formatting tests can run as ordinary JVM tests without Android resource
packaging. Keep golden-image recording explicit rather than applying it to all
unit tests, preserve a CI task that runs both lanes, and benchmark cold plus
warm focused-test latency before adopting the split.
---
## Verify Android native dashboard sign-in on device
Android now selects Custom Tab + PKCE for HTTPS gateways that advertise
`native_pkce`. The lifecycle-owned callback binds only `127.0.0.1` on an
OS-assigned port, keeps verifier/state inside the sign-in coroutine, rejects
untrusted callback noise, and closes on completion, cancellation, navigation,
or timeout. Encrypted bearer/refresh tokens authenticate Gateway chat, Manage,
prewarm, and standard voice; sign-out clears both cookie and native sessions.
Older gateways retain the identified WebView cookie fallback.
Before release, device-test the real Custom Tab → provider → loopback return,
configuration/background transitions, Manage reload, Gateway chat ticket,
standard voice, sign-out, and process relaunch. Native bearer exchange remains
disabled for non-loopback HTTP dashboard addresses; configure HTTPS before
using the native flow.
---
## Active — Remove temporary GitHub Pages docs redirects
PR #210 moved current source and production documentation to
`https://hermes-relay.dev/docs/`, but Android 1.4.0 and earlier releases still
contain hardcoded `https://codename-11.github.io/hermes-relay/` links. GitHub
Pages therefore serves a redirect-only compatibility shim from
`legacy-pages-redirect/`; it must never regain full documentation content.
Retire the shim only after the first Android release containing merge commit
`52df3adbf6d61d0ddbfb69671546f7c4953f956a` has been available for at least
90 days **and** at least two Android releases containing the corrected links
have shipped. If either condition is unmet at review time, retain it and set a
new review date.
Removal checklist:
- Remove `.github/workflows/legacy-docs-redirect.yml` and
`legacy-pages-redirect/` through a reviewed PR.
- Delete/disable the repository Pages site after that PR merges.
- Verify `https://codename-11.github.io/hermes-relay/` no longer serves the
shim and `https://hermes-relay.dev/docs/` plus representative deep links
still return HTTP 200.
- Update `DEVLOG.md` and the canonical Obsidian Hermes-Relay project note.
A one-shot operator reminder is scheduled for **2026-10-15 at 09:00 ET** to
review these gates; it is a review trigger, not authorization for automatic
removal.
---
## Upstream impact certification follow-ups (2026-07-19)
The client/plugin implementation batch for queued recovery,
multiplex-profile fallback routing, gateway diagnostics, Windows system-CA
trust, and retained bootstrap async safety is implemented. The following gates
intentionally remain outside that code batch:
- **Image-generation lifecycle while tool progress is hidden.** The upstream
TUI gateway suppresses every `tool.start` / `tool.complete` event when
`display.tool_progress` is off, so a client cannot distinguish an active
`image_generate` turn from generic model work. Propose a narrow upstream
exception that always emits the lifecycle for `image_generate` while leaving
unrelated tool diagnostics hidden. Android already treats that lifecycle as
presentation state rather than a generic tool card and keeps the diffusion
canvas visible when its local tool display is off.
- Run `docs/upstream-compatibility-certification.md` against an approved test
gateway with real provider calls and an Android device. Include concurrent
model/image routing, turn isolation off/on, queued reconnect, same-profile
background-completion ownership, compression lineage, and the explicitly
approved restart case. Static upstream fixtures are necessary but do not
prove device or restart behavior.
- Upstream the atomic one-turn model arm/submit contract proposed in
`docs/upstream-contributions.md`. Until then, document the narrow race where a
disconnect or Stop after `/model --once` succeeds but before prompt submission
can leave the override armed for a later prompt.
- Keep HRUI-052 (`/new` session-control reset parity) blocked until upstream
exposes a reset on the active gateway session or an authoritative reset event.
`slash.exec` runs the command in a separate worker today, and the mirrored
slash side effects do not reset the active TUI session's agent. Relay must not
clear local model, reasoning, or Fast pins from a successful command response
that did not mutate the agent those controls describe.
- Keep profile-scoped cron execution attempts blocked on the public upstream API
proposed in `docs/upstream-contributions.md`. The first-class interim
assistant event is no longer blocked: Relay Android and desktop consume
upstream `message.interim` / `response_previewed`.
- Keep Standard voice labeled host-global until upstream exposes a stable
profile/per-request audio contract; do not emulate it through Relay on the
vanilla path.
- Keep provider exclusion/disable filtering out of Android Manage until the
public model-options payload identifies excluded and disabled providers.
`include_unconfigured=1` currently re-adds indistinguishable setup rows, so
empty models are not authoritative evidence that a provider should be hidden.
- Keep persistent approval-mode writes for multiplexed non-launch profiles
read-only until upstream `config.get` / `config.set` bind an explicit
`profile` to that profile's `HERMES_HOME`. Gateway contract v3 currently
accepts `approvals.mode` but resolves it against the gateway process home;
Android may reconcile a selected profile's `session.info.approval_mode`, but
must not claim a profile-scoped write that upstream ignores.
- Keep gateway `model.options` profile scoping blocked until the supported
upstream RPC accepts an explicit `profile` and documents that the returned
provider inventory was built inside that profile's runtime scope. Android
now keys picker results to its active profile context and rejects late
responses after a profile switch, but it deliberately does not send an
invented `profile` parameter. API-server fallback can use the separate,
authenticated `/p/<profile>/api/model/options` surface when multiplexed.
- Expand the desktop upstream-baseline workflow into a live mock-provider E2E
once the harness can boot a credential-free upstream gateway deterministically.
The initial `ci-desktop-upstream-baseline` gate only checks a clean vanilla
checkout and the desktop typed gateway renderer/tests.
---
## Multi-profile Phone/Threads routing — deferred (2026-07-12)
Android profile hot-swap and concurrent Gateway turns are separate from proactive
@@ -607,25 +764,21 @@ Deferred:
A 5-agent audit compared the chat surface to Discord/Telegram/Messenger/iMessage/
GitHub-mobile. **Shipped this pass (pending on-device verification):** a chat-tuned
`markdownTypography()` ramp (headings were falling through to M3 display roles —
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
scaling down, list/paragraph unified to 14sp, inline+fenced code 13sp, `textLink`
`markdownTypography()` ramp (headings were falling through to M3 display roles —
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
scaling down, list/paragraph unified to 15sp/21sp, primary assistant prose moved
to the theme's full-contrast `onSurface`, inline+fenced code 13sp, `textLink`
accent+underline) in `MarkdownContent.kt`; timestamp gated to `isLastInGroup` (was on
every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
conversation gets its own beat; long-press haptic on the action menu; streaming dots
gated to pre-first-token. Deferred:
- **Streaming↔final render parity — conservative 1.4.1 slice implemented; live
reflow check remains.** Blank-terminated, unambiguous top-level prose/headings use
the final Markdown renderer during generation while the active tail stays raw.
Lists, quotes, tables, HTML, and fences intentionally remain lightweight until the
final parse because partial CommonMark containers can re-parent earlier blocks.
Verify that the chosen boundary removes the common heading/prose pop without
introducing partial-fence or list flicker.
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
stays ~36–38 chars/line. Debatable/broad — left out of the certain heading win.
- **Streaming↔final render parity — live reflow check remains.** Blank-terminated,
unambiguous top-level prose/headings use the final Markdown renderer during
generation while the active tail stays lightweight. Completion intentionally
parses one full CommonMark document so global link references, indentation, and
nested containers remain correct; the viewport now anchors that same remeasure.
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
- **Tail-corner on last-in-group only (design decision).** The audit flagged the
per-bubble bottom tail as "half-implemented," but it's a deliberate aesthetic
(every bubble tails). Switching to iMessage-style "tail on the last bubble only"
@@ -643,13 +796,12 @@ gated to pre-first-token. Deferred:
kebab). Needs on-device confirmation of the current conflict first.
- **Drop the no-op tap ripple on bubbles.** The 1.4.1 jump-to-bottom unread badge is
code-complete; `combinedClickable(onClick={})` still ripples on a normal bubble tap.
- **Sessions-transport `animateItem` flash.** Stream-complete rebuilds the list with
new ids → every visible bubble replays its enter animation (gateway transport,
stable id, is unaffected). Reuse the streaming bubble's id for the final message.
- **Viewport re-pin on the `isStreaming` true→false height growth** (gateway
transport): `ChatScreen` early-returns on `onlyStreamingFlagChanged`; issue one
`withFrameNanos{}` + instant `scrollToItem(last)` when the flag flips and the user
isn't scrolled away. Largely neutralized once render parity removes the height delta.
- **Sessions per-turn reconciliation.** Current upstream includes assistant/tool
rows in `run.completed.messages`, but Android still uses a full profile-aware
history read for successful Sessions turns so older servers and persisted message
boundaries remain safe. Replace it only with a bounded partial-turn merge that
preserves the prior transcript and client-only fields, with a full-history fallback
when the completion payload is absent or incomplete.
- **Full 15-role `Typography` + metadata contrast.** Type.kt declares only 7 roles at
0 tracking; the rest inherit M3 defaults with 0.1–0.5sp tracking (ChatScreen uses
several) — declare all 15 for one coherent scale. Separately, floor muted-metadata
@@ -770,7 +922,7 @@ Phase 1 (end-to-end spine) shipped on `Codename-11/phone-platform` — `send_mes
- Live gateway must discover the plugin (`~/.hermes/plugins/hermes-relay` → `plugin/`) and `plugins.enabled` must include `hermes-relay` for the `phone` platform to register. Confirm `phone` appears in `hermes gateway status` with `PHONE_ENABLED=1`.
- End-to-end: with the app paired + "Let Hermes message me" on, run `send_message target=phone text=...` (and a cron `deliver=phone`) and confirm a notification on the device. Verify 503 (no phone) and the off-by-default gates.
- **Phase 2c reply round-trip — ✅ DONE (verified on-device 2026-06-29).** Confirmed: agent → phone notification → inline reply → drained through the relay's loopback `GET /phone/replies` (different process) → `handle_message` (`role_authorized=True`, no `PHONE_ALLOW_ALL_USERS`) → agent answer back in the *same* thread. Both fixes required (see DEVLOG / the Phase 2c bullet above).
- **FIX: cron `deliver=phone` / standalone send is broken.** Live testing: `hermes send --to phone` returns `{"error": "Unknown platform: phone"}`. The standalone (non-gateway) send path doesn't run a `kind=standalone` plugin's programmatic `ctx.register_platform`, so it never learns `phone` — only the running gateway (which loads `register()` at startup) does. The agent path (`send_message target=phone` in the gateway) works and was verified end-to-end on-device; the standalone/cron path needs the platform discoverable there too (declare it so the standalone loader picks it up, or route cron through the gateway). Until then `cron deliver=phone` won't work.
- **Cron `deliver=phone` live certification pending.** The plugin now registers its standalone sender and enumerates the canonical phone home through the upstream adapter channel-directory hook. Re-run the device scenario above on the deployed plugin to certify scheduled delivery, including the offline queue and opt-in gates.
- **FIX SHIPPED (2026-07-07) — installer + doctor guard against stale duplicate plugin copies; live-host verify pending.** Root cause of the 2026-06-29 round-trip failure: the gateway loader dedups discovered plugins by manifest `name`, so a second directory declaring `name: hermes-relay` (an old-installer backup copy, or a stray native install) could win the dedup and make the gateway load stale code — silently ignoring every later deploy. `plugin/doctor.py` now emits a `plugin-name-unique` warning when more than one directory under `~/.hermes/plugins/` declares the same plugin name (distinct real targets only — two links to the same target are deduped), and `install.sh` sweeps any such duplicate so only the canonical `hermes-relay` symlink survives. (Current `install.sh` already `rm -rf`s the old link rather than backing it up inside the plugins dir, so the original "back up outside the plugins dir" half is moot.) **Verify on the live host:** `hermes relay doctor` reports the `plugin-name-unique` check, and a reinstall leaves exactly one `hermes-relay` entry under `~/.hermes/plugins/`.
## Phone platform — usability roadmap (post device-verification, 2026-06-29)
@@ -803,7 +955,7 @@ Phase 1 (end-to-end spine) shipped on `Codename-11/phone-platform` — `send_mes
The gateway-platform model is the *correct + sufficient architecture* (the phone is a registered platform peer, so anything that routes to a platform — `send_message`, cron `deliver=`, channel directory, background jobs — can reach the phone). These are the concrete gaps between "architecturally a peer" and "I never open Discord":
- **Guaranteed background delivery (the biggest gap; no push today).** Delivery is **live-WSS-only** + a 24 h relay buffer; there is **no FCM/UnifiedPush** wake-up. If the app process is dead AND not holding a socket, a message waits for the next reconnect, and the relay buffer is ephemeral (lost on relay restart). Discord/Telegram feel instant because they wake the device via push even when the app is dead. Decide a **push transport**: **UnifiedPush/ntfy** (recommended — self-hostable, no Google dependency, upstream *already* ships an `ntfy` platform, on-brand for self-hosted) vs **FCM** (simplest UX but adds Play Services + a push relay; clashes with self-hosted ethos — at most the `googlePlay` flavor) vs **persistent foreground keep-alive service** holding the relay WSS (zero new infra, like `GatewayKeepAliveService`, but battery cost + Doze-fragile). Likely: UnifiedPush primary + foreground-keepalive fallback.
- **Cron / background-job delivery is BROKEN** (already tracked above): `deliver=phone` standalone path → `Unknown platform: phone`. This is load-bearing for "receiver of crons/background jobs" — fix is required, not optional, for the replacement goal.
- **Cron / background-job delivery needs live certification.** The standalone sender and channel-directory enumeration are implemented; certify `deliver=phone` against a deployed Relay and paired device, including reconnect delivery from the bounded offline queue.
- **Agent-initiated multi-thread creation remains.** The app already renders N
`source=phone` sessions, user-created Threads vary `chat_id`, and replies route
by `chat_id` + `reply_to`. The missing parity is letting the agent open/name a
@@ -937,12 +1089,14 @@ to tool state, safety prompts, or the current task.
playback-synchronized amplitude through `shouldMarkRealtimeOutputActive`,
matching the basic-TTS path. Confirm visually on-device with the 1.4.1 batch.
- **Voice command layer — initial 1.4.1 subset code-complete; live verify and
navigation residuals remain.** Exact final transcripts can stop speech,
- **Voice command layer — upstream stop phrases and phase-aware pause are
code-complete; live verify and navigation residuals remain.** Exact final transcripts can end the active voice chat,
explicitly cancel the active background task, pause/resume Continuous mode,
repeat a settled background answer, and start a new Standard chat. Bare `stop`
and `cancel`, partial transcripts, and command-like ordinary prompts stay on the
normal Hermes route. Realtime `new chat` remains gated on a clean websocket
repeat a settled background answer, and start a new Standard chat. Bare
`stop` is configurable and exact-only while voice chat is active; bare
`pause` remains phase-gated to Continuous mode. `cancel`, partial transcripts,
and command-like ordinary prompts stay on the normal Hermes route. Realtime
`new chat` remains gated on a clean websocket
session-rebind boundary; `open overlay` and `return to Hermes` remain future
navigation commands. Verify barge-in Stop, pause during a background run, local
command Chat cleanup, and Continuous rearm on device.
@@ -977,11 +1131,34 @@ and whether the agent is waiting on the user.
experimental barge-in choice. Relay update is server-first; local Voice/barge-in
values share one DataStore transaction, with relay rollback on local failure.
- **Barge-in hardening** — keep barge-in experimental until echo/self-recording
- **Barge-in hardening — code complete; on-device matrix remains.** Full-turn
listener ownership, AEC/noise suppression, upstream-compatible RMS
calibration and thresholds, configurable playback grace, duck/cut behavior,
late-delta fencing, next-turn interruption context, and single-microphone
handoff are implemented. Phone testing still needs to cover speakerphone/headphones, quiet/noisy rooms, Standard/Realtime
generation and playback, stop/pause, and resume-after-interruption.
is solved. The target path is proper AEC, playback-ducking, and a rule that
- **Experimental wake word — on-device validation.** Verify first-enable model
installation and integrity failure recovery, all supported ABIs, Android
notification/microphone permission variants, background-start restrictions,
task recreation from the detection notification, acoustic false-positive and
false-negative rates, battery impact, stop action, and wake→voice→wake
microphone handoff. Voice settings now provide a bounded real-microphone/model
test with an input meter; use it to distinguish audio capture from KWS tuning
before testing the full activation flow. The first release remains fixed to
“Hey Hermes”; do not
expose profile-specific phrases until routing and acoustic behavior are
implemented and validated.
output audio can never become a user turn.
- **Android Digital Assistant — on-device validation.** On a physical device,
select and remove Hermes through the system Assistant role; verify gesture,
power-button, screen-off, credential-lock, and unlocked “Hey Hermes”
invocation; confirm the system session appears without overlay/full-screen
permissions; exercise compact, expanded, collapsed, and full-Voice handoff
states, background tap-through, rotation and insets, cancel/back, microphone
denial, network failure, process kill/recreation, and wake→voice→wake
resumption. Measure idle battery drain because third-party assistants do not
receive Google's dedicated low-power hotword hardware.
- **Audio quality guardrails** — normalize output volume across realtime and
@@ -1042,7 +1219,7 @@ Things to look into:
- **Update discovery (shipped 2026-06-30 — CLI + dashboard + app).** `hermes relay update-check`, a dashboard "Plugin version" card, and an app **About → "Relay"** row all compare the installed plugin against the latest `plugin-v*` release and surface the right update command (`hermes plugins update hermes-relay` vs `hermes-relay-update`). The app polls the relay's `GET /relay/update-check` (`:8767`, bearer) on each `auth.ok`; the relay is the single source of truth (the app never hits GitHub). Possible polish (deferred): a more prominent dismissible "relay is behind" banner outside About (today it's capability-first + the About row), and showing the app's own version alongside the relay's in the same readout (the app-Version row already exists separately just above it).
- **Per-profile enablement (shipped 2026-06-30).** `hermes relay profiles list|enable [--all|NAME]` + `plugin/profiles.py` resolve the install-once/enable-per-profile papercut; docs now cover the pair-once/one-relay model. Possible follow-up: an `install.sh` / `hermes plugins install` prompt offering "enable for all existing profiles" so new installs don't need the manual `profiles enable --all`.
- `**hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Done (2026-07-19, HRUI-004/012):** retained session search now uses upstream `AsyncSessionDB` when available and `asyncio.to_thread` on older Hermes, and every compatibility memory mutation resets the upstream consolidation-failure budget when that API exists. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to the native session-control baseline (#33134). Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
- **Gateway slash-command preprocessor — bootstrap middleware (Stage 1 equivalent).** Sibling shim in `hermes_relay_bootstrap/_command_middleware.py` that mirrors the upstream Stage 1 PR as an aiohttp middleware injected at bootstrap time. Ships the hallucination fix to vanilla-upstream installs before the upstream PR lands. Planned for v0.4.1, after the current bridge feature branch wraps. See `ROADMAP.md` v0.4.1 entry.
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Unblocked now that session primitives shipped upstream (#33134 / `f7527b0`). Add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
@@ -1093,6 +1270,7 @@ Follow-ups:
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`)
- **Collapsible message groups (shipped 2026-07-25).** Android wraps rendered galleries and generic/LOADING/FAILED cards in a localized, accessible attachment disclosure. It defaults open, remembers the user's fold state by stable message identity, and leaves a compact count/name/type summary available to restore all attachment actions.
- **B3 — download progress + cancel.** Inbound fetch is un-cancelable; the previews work scaffolded an indeterminate bar + nullable `onCancel`. Live wiring needs the fetch-path owner (`ChatViewModel`/`Attachment`) to expose determinate progress (Content-Length) + a cancel hook.
- **C5 — agent-side sensitivity config gate.** `RELAY_MEDIA_SENSITIVITY_HINTS` (env or per-profile) instructing the agent to annotate sensitive media via the prompt-builder. Transport (relay `X-Media-Sensitive` header + client blur) already ships; the agent isn't asked to set the bit yet.
- **Relay thumbnails (D6).** Server-side thumbnail generation to avoid full-size download for cards/galleries. Needs an image lib (Pillow not currently a dep) — evaluate before adding.
@@ -1109,14 +1287,11 @@ Follow-ups:
profile/skill-aware empty-state chips and the ~40-flow recomposition hotspot at
the top of `ChatScreen`.
- **Pet hot-load + in-app add/remove (shipped 2026-06-20).** Pets now live-refresh: an `avatarsRefreshTick` keys the avatar `produceState` in `RelayApp`, and Appearance re-scans `pets/` on open and after in-app import/delete — no app restart. Appearance gained "Add a pet" (SAF `.zip` import via `PetImporter`, zip-slip/zip-bomb guarded + validated through `toAvatar`) and an "Installed pets" list with per-pet remove (`PetLoader.deletePet`, confirm dialog, Sphere fallback). Remaining:
- **Sphere-skin parity.** Skins are still process-scoped + `adb push` only — the live tick and the importer cover pets, not skins. Extend the tick to `loadUserSkins` and add a `.json` skin import if hot-loading/adding skins in-app is wanted.
- **Sphere-skin parity (shipped 2026-08-09).** Appearance now imports a bounded, validated declarative `.json` skin through the system picker, hot-refreshes the shared sphere registry, and selects the imported skin without an app restart.
- `**adb push` into `Android/data` hangs on Samsung scoped storage.** Confirmed: pushing a pet pack to `/sdcard/Android/data/<pkg>/files/pets/` stalls (no bytes written) although `adb shell ls` of the dir works. In-app `.zip` import is the supported path; `/sdcard/Download` pushes fine. Consider softening `docs/pet-spec.md` + user-docs to lead with in-app import over adb.
- **On-device import/delete smoke.** Import `/sdcard/Download/lucy.zip` via Add a pet → confirm Lucy appears, selects, and animates all states; then remove it and confirm the avatar falls back to the Sphere.
- **Pet state-change re-decode can flash one blank frame.** When the agent state switches clips, the first frame of the new clip may briefly be blank during decode; prewarm/hold-last-frame to smooth it. Root cause is the same as the next item: `PetAvatar.Render` re-decodes from disk on every clip change.
- **Pet frame-sequence memory: no cap or downsample (audit 2026-06-19).** `decodeClip` decodes every frame of the selected clip into `List<ImageBitmap>` at full resolution with no `inSampleSize` downscale to the display size and no frame-count/dimension ceiling — a long sequence of large PNGs can use a lot of RAM and a single very large image can OOM `BitmapFactory`. Add `inSampleSize` downsampling to the avatar's draw size and/or a documented hard cap. Spec now warns authors (prefer sprite sheets), but the renderer doesn't enforce it.
- **Pet decoded-clip cache (audit 2026-06-19).** `PetAvatar.Render` keys `produceState` on `clip`, so idle→thinking→speaking→idle within one turn re-runs `BitmapFactory.decodeFile` from disk each transition (repeated I/O + GC churn, and the blank-frame flash above). Add a small per-avatar `Map<SphereState, PetFrames>` decode cache.
- **Pet behavior model — richer state association (spec'd 2026-06-19, `docs/pet-spec.md` "Agent states &amp; pet behavior").** Shipped: the honesty clamp (declared reactivity ∩ `PET_RENDERER_CAPABILITIES`), the friendly `writing` alias, the `**working`/tool-use overlay** (pet-local sub-state from `toolCallBurst`; opt-in `working` clip drives both the swap and the Tools badge), the **one-shot reaction layer** (`greet`/`wake` on appear, `done`/`celebrate` on turn-finish — opt-in, play-once-then-revert, transition-derived; `ONE_SHOT_MAX_MS` backstop), and `**intensity` modulation** (opt-in `reactive.intensity` → live playback speedup ≤1.6× via `rememberUpdatedState`; un-clamps the Activity badge). Voice · Tools · Activity reactivity is now complete. Remaining:
- `**attention` one-shot (only deferred behavior).** A reaction on notification arrival — needs a host event the avatar doesn't yet receive (unlike `greet`/`done`, which ride state transitions). Would plumb a notification edge into `AvatarRenderState` (or a side channel) + a `PetOneShot.Attention`. Low priority: the avatar is rarely on-screen when notifications land (backgrounded) — see the value analysis; revisit only if the avatar becomes an always-on surface (persistent overlay / Quest port).
- **On-device verification (working + one-shots + intensity).** Best seen in clean mode (`AgentTextFlow` feeds `toolCallBurst` + `streamingIntensity` + state transitions). Confirm: a `working` clip swaps in during a tool run and releases ~600ms after (`WORKING_BURST_THRESHOLD` 0.5); a `done` clip plays once on reply completion then returns to idle; a `greet` clip plays once when the avatar appears; with `intensity:true`, a writing/working loop visibly quickens while streaming. Watch for the known clip re-decode flash on each swap (separate TODO — decoded-clip cache).
- **On-device verification (working + one-shots + intensity).** Best seen in clean mode (`AgentTextFlow` feeds `toolCallBurst` + `streamingIntensity` + state transitions). Confirm: a `working` clip swaps in during a tool run and releases ~600ms after (`WORKING_BURST_THRESHOLD` 0.5); a `done` clip plays once on reply completion then returns to idle; a `greet` clip plays once when the avatar appears; with `intensity:true`, a writing/working loop visibly quickens while streaming. Confirm each decoded clip swap holds the previous complete visual until the new state is ready.
- **Undecodable-but-present image appears valid (audit 2026-06-19).** A file that exists but isn't a decodable image passes the loader's `isFile` check, so the pet shows in the picker but renders blank. Documented as a caveat; consider a cheap header sniff at load time if false-valid pets become a support issue.
+48 -3
View File
@@ -7,6 +7,15 @@ plugins {
alias(libs.plugins.play.publisher)
}
val supportedHermesDevAbis = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
val hermesDevAbi = providers.gradleProperty("hermes.devAbi").orNull
hermesDevAbi?.let { requestedAbi ->
require(requestedAbi in supportedHermesDevAbis) {
"Unsupported hermes.devAbi '$requestedAbi'. Expected one of: " +
supportedHermesDevAbis.sorted().joinToString()
}
}
// Rename output artifacts to include the app version. AGP respects
// `archivesName` for both APK (assemble*) and AAB (bundle*) outputs, so
// this single line produces `hermes-relay-<version>-<flavor>-<buildType>`
@@ -37,12 +46,23 @@ android {
// exempt from Play's 14-day closed-testing rule. See RELEASE.md.
applicationId = "com.axiomlabs.hermesrelay"
minSdk = 26
targetSdk = 35
targetSdk = 36
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Optional local-only fast path for device iteration. Native voice/VAD
// dependencies make the universal sideload APK very large, while a
// connected phone needs only its own ABI. Release and normal debug
// builds remain universal unless the developer explicitly supplies
// -Phermes.devAbi=<abi>.
hermesDevAbi?.let { requestedAbi ->
ndk {
abiFilters += requestedAbi
}
}
// Feature flags — DEV_MODE enables all experimental features in debug builds
buildConfigField("boolean", "DEV_MODE", "false")
}
@@ -125,6 +145,7 @@ android {
}
release {
isMinifyEnabled = true
isShrinkResources = true
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
@@ -164,6 +185,21 @@ android {
}
}
packaging {
jniLibs {
// sherpa-onnx v1.13.4 and the Silero VAD both use ONNX Runtime.
// Keep them on sherpa's 1.27.0 baseline and package one shared core.
pickFirsts += "**/libonnxruntime.so"
// The Android app calls only sherpa's JNI facade. These native C/C++
// API facades are development surfaces and are not loaded by the app.
excludes += setOf(
"**/libsherpa-onnx-c-api.so",
"**/libsherpa-onnx-cxx-api.so",
)
}
}
// JVM unit tests run against the stubbed Android SDK jar, where every
// platform API method throws RuntimeException("... not mocked") by
// default. With returnDefaultValues = true, those stubs instead
@@ -245,6 +281,7 @@ dependencies {
// Activity
implementation(libs.activity.compose)
implementation(libs.browser)
implementation(libs.appcompat)
// Core
@@ -261,6 +298,12 @@ dependencies {
// Bundled ONNX Silero model (~2.2 MB); pulled from JitPack.
implementation(libs.android.vad.silero)
// Experimental, opt-in local keyword spotting. Models are downloaded only
// after the user enables the feature; no model binary is bundled in APKs.
// Keep the shared runtime aligned with sherpa-onnx v1.13.4.
implementation(libs.onnxruntime.android)
implementation(libs.sherpa.onnx)
// Google Play In-App Update — googlePlay flavor ONLY (FLEXIBLE flow).
// Scoped via the `googlePlayImplementation` configuration so it never
// ships in the sideload APK, which updates via the GitHub-releases
@@ -277,9 +320,11 @@ dependencies {
// Coil 3 — async image loading for generated images in chat
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.exifinterface)
// QR Code scanning (ML Kit + CameraX)
implementation(libs.mlkit.barcode)
implementation(libs.zxing.core)
implementation(libs.camera.core)
implementation(libs.camera.camera2)
implementation(libs.camera.lifecycle)
@@ -326,8 +371,8 @@ dependencies {
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
// Renders real composables on the JVM at an exact canvas — no device, no
// status bar, no clipping. See StoreScreenshotTest.
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.68.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.68.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.71.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.71.0")
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.ui.test.manifest)
testImplementation("androidx.test.ext:junit:1.3.0")
@@ -0,0 +1,48 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
/** Local device-review helper. Never runs in or ships with the application APK. */
@RunWith(AndroidJUnit4::class)
class ConnectionReviewSeedTest {
@Test
fun seedOfflineSecondaryConnection() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = ConnectionStore(context)
store.isHydrated.first { it }
if (store.connections.value.none { it.id == REVIEW_ID }) {
store.addConnection(
Connection(
id = REVIEW_ID,
label = "Lab NAS",
apiServerUrl = "",
relayUrl = "",
tokenStoreKey = Connection.buildTokenStoreKey(REVIEW_ID),
dashboardUrl = "http://192.0.2.10:9119",
lastUsedAt = System.currentTimeMillis() - 2L * 24L * 60L * 60L * 1_000L,
),
)
}
}
@Test
fun removeOfflineSecondaryConnection() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = ConnectionStore(context)
store.isHydrated.first { it }
if (store.connections.value.any { it.id == REVIEW_ID }) {
store.removeConnection(REVIEW_ID)
}
}
private companion object {
const val REVIEW_ID = "00000000-0000-4000-8000-000000000220"
}
}
@@ -0,0 +1,70 @@
package com.hermesandroid.relay.plugins.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.hermesandroid.relay.plugins.document.PluginDocumentState
import com.hermesandroid.relay.plugins.document.PluginElement
import com.hermesandroid.relay.plugins.document.PluginPage
import com.hermesandroid.relay.plugins.document.PluginText
import com.hermesandroid.relay.plugins.document.PluginValue
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class PluginDocumentRendererTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun pageRendersBindingsAndEmitsControlledStateChanges() {
var interaction: PluginInteraction? = null
val page = PluginPage(
id = "home",
title = PluginText.Binding("title", "Fallback"),
content = PluginElement.Group(
id = "root",
children = listOf(
PluginElement.Text(
id = "message",
text = PluginText.Binding("message"),
),
PluginElement.Toggle(
id = "enabled-toggle",
label = PluginText.Literal("Enabled"),
binding = "enabled",
),
),
),
)
val state = PluginDocumentState(
mapOf(
"title" to PluginValue.StringValue("Status plugin"),
"message" to PluginValue.StringValue("Everything is healthy"),
"enabled" to PluginValue.BooleanValue(false),
),
)
composeTestRule.setContent {
MaterialTheme {
PluginPageRenderer(page, state, { interaction = it })
}
}
composeTestRule.onNodeWithText("Status plugin").assertIsDisplayed()
composeTestRule.onNodeWithText("Everything is healthy").assertIsDisplayed()
composeTestRule.onNode(isToggleable()).performClick()
assertEquals(
PluginInteraction.ValueChanged(
elementId = "enabled-toggle",
key = "enabled",
value = PluginValue.BooleanValue(true),
),
interaction,
)
}
}
@@ -0,0 +1,147 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertDoesNotExist
import androidx.compose.ui.test.assertExists
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.test.platform.app.InstrumentationRegistry
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.avatar.AgentAvatar
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.AvatarSource
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
import com.hermesandroid.relay.ui.components.avatar.LocalBackgroundVisualizationEnabled
import com.hermesandroid.relay.viewmodel.InteractionMode
import com.hermesandroid.relay.viewmodel.VoiceState
import com.hermesandroid.relay.viewmodel.VoiceUiState
import org.junit.Rule
import org.junit.Test
class AmbientVisualizationVisibilityTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun cleanMode_backgroundOff_hidesSphereAndKeepsComposer() {
composeTestRule.setContent {
AmbientTestProviders(enabled = false) {
CleanChatMode(
messages = emptyList(),
isStreaming = false,
sphereState = SphereState.Idle,
streamingIntensity = 0f,
toolCallBurst = 0f,
animationEnabled = true,
enabled = true,
onSend = {},
onExit = {},
)
}
}
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertDoesNotExist()
composeTestRule.onNodeWithContentDescription(targetString(R.string.agent_text_send_cd))
.assertExists()
}
@Test
fun cleanMode_backgroundOn_rendersSphere() {
composeTestRule.setContent {
AmbientTestProviders(enabled = true) {
CleanChatMode(
messages = emptyList(),
isStreaming = false,
sphereState = SphereState.Idle,
streamingIntensity = 0f,
toolCallBurst = 0f,
animationEnabled = false,
enabled = true,
onSend = {},
onExit = {},
)
}
}
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertExists()
}
@Test
fun voiceMode_backgroundOff_hidesSphereAndKeepsVoiceUi() {
composeTestRule.setContent {
AmbientTestProviders(enabled = false) {
TestVoiceOverlay()
}
}
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertDoesNotExist()
composeTestRule.onNodeWithText(targetString(R.string.voice_overlay_tap_mic)).assertExists()
}
@Test
fun voiceMode_backgroundOn_rendersSphere() {
composeTestRule.setContent {
AmbientTestProviders(enabled = true) {
TestVoiceOverlay()
}
}
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertExists()
}
@Composable
private fun AmbientTestProviders(enabled: Boolean, content: @Composable () -> Unit) {
MaterialTheme {
CompositionLocalProvider(
LocalAgentAvatar provides TaggedAmbientRenderer,
LocalBackgroundVisualizationEnabled provides enabled,
content = content,
)
}
}
@Composable
private fun TestVoiceOverlay() {
VoiceModeOverlay(
uiState = VoiceUiState(
voiceMode = true,
state = VoiceState.Idle,
interactionMode = InteractionMode.TapToTalk,
),
onMicTap = {},
onMicRelease = {},
onInterrupt = {},
onDismiss = {},
onModeChange = {},
onClearError = {},
)
}
private fun targetString(id: Int): String =
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
private object TaggedAmbientRenderer : AgentAvatar {
override val id = "ambient-test"
override val label = "Ambient test"
override val description = "Test renderer"
override val source = AvatarSource.BUILT_IN
override val reactivity = SphereReactivity()
@Composable
override fun Render(state: AvatarRenderState, modifier: Modifier) {
Box(modifier = modifier.testTag(AMBIENT_RENDERER_TAG))
}
}
private companion object {
const val AMBIENT_RENDERER_TAG = "ambientVisualizationRenderer"
}
}
@@ -121,42 +121,58 @@ class OnboardingFlowTest {
}
@Test
fun connectPage_showsStandardChoiceFirst() {
fun connectPage_recommendsGeneralSetupQr() {
setOnboardingContent()
navigateToPage(4)
composeTestRule
.onNodeWithText("Vanilla Hermes")
.onNodeWithText("Scan Hermes setup QR")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Recommended")
.assertIsDisplayed()
}
@Test
fun standardSetup_showsApiFields() {
fun manualSetup_showsHermesAddressWithoutApiCredentials() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
composeTestRule.onNodeWithText("Server or VPS").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("API server URL")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("API key")
.onNodeWithText("Hermes address")
.assertIsDisplayed()
}
@Test
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
fun manualSetup_findButton_isShown() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
composeTestRule.onNodeWithText("Server or VPS").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Connect")
.assertIsEnabled()
.onNodeWithText("Find Hermes")
.assertIsDisplayed()
}
@Test
fun cloudSetup_requestsTheHostedDashboardAddress() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Nous-hosted Hermes").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Connect to Nous-hosted Hermes")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Use the complete HTTPS address shown for your hosted agent.")
.assertIsDisplayed()
}
@Test
@@ -164,6 +180,9 @@ class OnboardingFlowTest {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Advanced").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Pair Relay by code")
.assertIsDisplayed()
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
android:exported="true"
android:screenOrientation="portrait" />
<activity
android:name="com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity"
android:exported="true"
android:screenOrientation="portrait" />
</application>
</manifest>
@@ -0,0 +1,196 @@
package com.hermesandroid.relay.ui.screens
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.ImageGenerationPlaceholder
import com.hermesandroid.relay.ui.components.ImageGenerationResultTransition
import com.hermesandroid.relay.ui.components.ImageGenerationVisualStyle
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
/**
* Debug-build-only live host for fast image-generation motion tuning.
*
* Launch directly:
* adb shell am start -n <applicationId>/
* com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity
*/
class ImageGenerationDesignQaActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val themePreference = intent.getStringExtra("theme") ?: "auto"
setContent {
HermesRelayTheme(themePreference = themePreference) {
ImageGenerationDesignQaScene(onBack = ::finish)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ImageGenerationDesignQaScene(onBack: () -> Unit) {
var restartKey by remember { mutableIntStateOf(0) }
var durationMillis by remember { mutableIntStateOf(4_800) }
var visualStyle by remember { androidx.compose.runtime.mutableStateOf(ImageGenerationVisualStyle.LatentGrid) }
var showResult by remember { androidx.compose.runtime.mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Image generation lab") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Live debug preview · no generation request",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(
ImageGenerationVisualStyle.LatentGrid to "Grid",
ImageGenerationVisualStyle.ParticleOrb to "Orb",
ImageGenerationVisualStyle.Constellation to "Nodes",
).forEach { (style, label) ->
FilterChip(
selected = visualStyle == style,
onClick = { visualStyle = style },
label = { Text(label) },
)
}
}
key(restartKey, durationMillis, visualStyle) {
val startedAtMillis = remember { System.currentTimeMillis() }
ImageGenerationResultTransition(
generating = !showResult,
startedAtMillis = startedAtMillis,
animationDurationMillis = durationMillis,
visualStyle = visualStyle,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
Image(
painter = painterResource(R.drawable.image_generation_transition_preview),
contentDescription = "Generated landscape preview",
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "Generated image",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "12.4s",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Text(
text = "Cycle speed",
style = MaterialTheme.typography.labelMedium,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(
7_200 to "Slow",
4_800 to "Normal",
3_200 to "Fast",
).forEach { (duration, label) ->
FilterChip(
selected = durationMillis == duration,
onClick = { durationMillis = duration },
label = { Text(label) },
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
showResult = true
},
enabled = !showResult,
) {
Text("Reveal result")
}
Button(
onClick = {
showResult = false
restartKey++
},
) {
Text("Restart")
}
}
}
}
}
@@ -0,0 +1,128 @@
package com.hermesandroid.relay.ui.screens
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.network.relay.RealtimeProviderInfo
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.VoicePreviewUiState
/** Debug-build-only deterministic host for design QA screenshots. */
class VoiceSettingsDesignQaActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val themePreference = intent.getStringExtra("theme") ?: "auto"
setContent { HermesRelayTheme(themePreference = themePreference) { VoiceSettingsDesignQaScene() } }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun VoiceSettingsDesignQaScene() {
val provider = remember {
RealtimeProviderInfo(
id = "xai_tts",
name = "xAI Grok TTS",
status = "ready",
models = listOf("grok-tts", "grok-tts-fast"),
voices = listOf("eve", "ara", "sal", "rex", "leo"),
model_labels = mapOf("grok-tts" to "Grok TTS"),
voice_labels = mapOf("eve" to "Eve", "ara" to "Ara", "sal" to "Sal"),
recommended_voices = listOf("eve", "ara"),
supports_tts = true,
)
}
var selectedSection by remember { mutableStateOf(VoiceSettingsSection.Output) }
var selectedVoice by remember { mutableStateOf("eve") }
var expanded by remember { mutableStateOf(false) }
val allVoices = remember {
listOf(
VoiceChoice("eve", "Eve", "Warm · expressive", recommended = true),
VoiceChoice("ara", "Ara", "Clear · balanced", recommended = true),
VoiceChoice("sal", "Sal", "Calm · grounded"),
VoiceChoice("rex", "Rex", "Direct · confident"),
VoiceChoice("leo", "Leo", "Bright · conversational"),
)
}
Scaffold(topBar = { TopAppBar(title = { Text("Voice") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f),
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Hermes Chat + Voice Output", style = MaterialTheme.typography.titleMedium)
Text("Default profile · Profile voice", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
VoiceSettingsTabs(selectedSection) { selectedSection = it }
VoiceProviderGroupCard(
provider = provider,
providerValue = provider.id,
enabled = true,
providerChoices = listOf(VoiceChoice(provider.id, provider.name.orEmpty())),
onEnabledChange = {},
onProviderChange = {},
controlsEnabled = true,
)
ModelAndVoiceGroupCard(
modelValue = "grok-tts",
modelChoices = listOf(VoiceChoice("grok-tts", "Grok TTS")),
voices = previewVoiceChoices(allVoices, selectedVoice),
allVoices = allVoices,
selectedVoice = selectedVoice,
previewState = VoicePreviewUiState(
selectionKey = "voice:eve",
isPlaying = true,
amplitude = 0.42f,
),
onModelChange = {},
onVoiceChange = { selectedVoice = it },
onPreviewVoice = {},
enabled = true,
)
LanguageQualityCard(
expanded = expanded,
onExpandedChange = { expanded = it },
language = "English",
languages = listOf(VoiceChoice("en", "English")),
onLanguageChange = {},
sampleRate = "24000",
sampleRates = listOf(VoiceChoice("24000", "24 kHz")),
onSampleRateChange = {},
enabled = true,
)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 180 KiB

@@ -1 +1 @@
Server default now keeps its agent, chats, drawer, and transcript in the active Hermes profile. Reorder or hide profiles per connection, choose an icon from your phone, or import avatar.png/profile.jpg from an updated paired Relay. Image import now clearly distinguishes an outdated Relay from a missing avatar.
Long sessions now retain complete history beyond Hermes' latest-500 default, keeping edit, retry, sharing, and recovery anchors stable. Gateway submit rejections preserve the server's message without unintended SSE fallback, while event envelopes and edit-and-regenerate requests follow current upstream contracts.
@@ -1 +1 @@
“服务器默认”现在会让代理、聊天、会话抽屉和记录保持在 Hermes 当前活动配置文件中。可按连接重新排序或隐藏配置文件,从手机选择图标,或从已更新且配对的 Relay 导入 avatar.png/profile.jpg。图像导入也会明确区分 Relay 版本过旧与头像文件缺失。
新增可在界面中漫游的 Petdex 浮动宠物、由已安装 Hermes 插件提供的安全原生页面,以及支持本地“Hey Hermes”的可选 Android 数字助理。本次更新还新增俄语,并改进语音恢复、路线切换、实时聊天稳定性和宠物移动。
+97 -8
View File
@@ -20,6 +20,7 @@
for the device-control bridge service; the merger dedups.) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
@@ -47,6 +48,32 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- User-mediated text handoff. The app opens a fresh Chat draft
and fills the composer; it never sends from an external intent. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<!-- The loopback native-PKCE result page uses this fixed, tokenless
link only to bring the installed flavor back to the foreground.
MainActivity intentionally does not interpret the URI as an auth
callback or navigation command. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="${applicationId}"
android:host="return" />
</intent-filter>
<!-- Some Android OEM assistant pickers enumerate ACTION_ASSIST
activities in addition to VoiceInteractionService providers. -->
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.VOICE" />
</intent-filter>
</activity>
<!-- AppCompat persists in-app language choices on Android 12 and lower.
@@ -90,22 +117,84 @@
android:name=".notifications.ProactiveReplyReceiver"
android:exported="false" />
<!-- Opt-in "Persistent connection" — holds the user's connection to
Hermes open while backgrounded so messages and live features stay
responsive (relay-paired setups also keep device control +
notification mirroring reachable). In main so BOTH flavors ship it
(Home-Assistant-class persistent connection). Off by default; only
runs while the user has explicitly enabled the toggle. specialUse
needs a Play Console foreground-service declaration at submission. -->
<!-- Protects user-started active turns automatically; the optional
"Persistent connection" setting extends the same foreground
protection to idle/background connectivity (and relay-paired
device features). In main so BOTH flavors ship it. Every Play
foreground-service type needs its matching App content declaration. -->
<service
android:name=".network.upstream.GatewayKeepAliveService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Keeps the user's connection to their Hermes agent open in the background so messages and live features stay responsive, only when the user has explicitly enabled 'Persistent connection'." />
android:value="Keeps user-started Hermes turns connected until they finish or need input, and optionally keeps idle connections responsive when the user enables Persistent connection." />
</service>
<!-- Experimental, explicitly user-started on-device wake-word listener.
Audio remains local and the service is never boot/restart started.
The Play build's microphone type needs an App content declaration. -->
<service
android:name=".wake.WakeWordForegroundService"
android:exported="false"
android:foregroundServiceType="microphone"
android:stopWithTask="false" />
<!-- User-started protection for voice capture from the system overlay.
The service does not own AudioRecord; it keeps foreground-only
microphone app-ops available while Hermes is behind another app.
Include this use case in the Play microphone declaration. -->
<service
android:name=".voice.VoiceOverlayForegroundService"
android:exported="false"
android:foregroundServiceType="microphone"
android:stopWithTask="false" />
<!-- Explicitly opt-in Android Digital Assistant integration. Android
binds this only after the user selects Hermes for ROLE_ASSISTANT. -->
<service
android:name=".assistant.HermesVoiceInteractionService"
android:exported="true"
android:label="@string/assistant_service_label"
android:permission="android.permission.BIND_VOICE_INTERACTION">
<intent-filter>
<action android:name="android.service.voice.VoiceInteractionService" />
</intent-filter>
<meta-data
android:name="android.voice_interaction"
android:resource="@xml/voice_interaction_service" />
</service>
<!-- Heavy assistant UI is isolated from the always-running interaction
service, matching the platform lifecycle guidance. -->
<service
android:name=".assistant.HermesVoiceInteractionSessionService"
android:exported="true"
android:permission="android.permission.BIND_VOICE_INTERACTION"
android:process=":assistant_session" />
<!-- Required companion component for VoiceInteractionService metadata.
Hermes session transcription remains owned by the existing voice
pipeline; this service does not open a second microphone stream. -->
<service
android:name=".assistant.HermesRecognitionService"
android:exported="true"
android:permission="android.permission.BIND_VOICE_INTERACTION">
<intent-filter>
<action android:name="android.speech.RecognitionService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
<receiver
android:name=".assistant.AssistantSessionStateReceiver"
android:exported="false"
android:process=":assistant_session" />
<receiver
android:name=".assistant.AssistantSessionLifecycleReceiver"
android:exported="false" />
</application>
</manifest>
+328
View File
@@ -1,5 +1,333 @@
{
"versions": [
{
"version": "1.8.1",
"title": "Complete, reliable transcripts",
"date": "2026-08-09",
"sections": [
{
"header": "Keep long sessions complete",
"bullets": [
"Android pages explicitly through complete API-server and profile-scoped Dashboard history instead of silently stopping at Hermes' latest-500 default.",
"Sharing, retry, edit, and recovery retain stable transcript anchors while bounded safety limits keep unusually large reads controlled."
]
},
{
"header": "Follow Gateway truth",
"bullets": [
"Authoritative submit rejections preserve the server's message without an unintended SSE fallback.",
"Gateway event envelopes and edit-and-regenerate truncation confirmation now follow current upstream contracts."
]
}
]
},
{
"version": "1.8.0",
"title": "Conversations with more context",
"date": "2026-08-09",
"sections": [
{
"header": "Keep the whole turn together",
"bullets": [
"Quote, edit, search, and attach or reorder files without losing the active connection, profile, or session.",
"Share text from another Android app into a fresh Chat draft for review before sending."
]
},
{
"header": "See the work without the clutter",
"bullets": [
"Live thinking settles into a compact Thought disclosure, while routine tool activity groups into concise runs.",
"Approvals, failures, generated media, file changes, risks, and delegated work remain clearly distinct."
]
},
{
"header": "Switch agents, not identities",
"bullets": [
"The Profile Shelf switches agents from Chat while restoring each profile's last session.",
"Agent Passport model and reasoning controls remain scoped to the active session instead of rewriting server defaults."
]
},
{
"header": "Make it yours",
"bullets": [
"Preview theme accents and shapes, Sphere skins, and pets in one Appearance workflow.",
"Message speech controls, pet touch targets, scrolling terrain, image rotation, and edge-to-edge settings layout are more reliable."
]
}
]
},
{
"version": "1.7.1",
"title": "Safer, steadier conversations",
"date": "2026-08-08",
"sections": [
{
"header": "Chat stays with you",
"bullets": [
"Growing streamed replies stay visible while you are at the bottom, and intentional scrollback remains undisturbed.",
"Completed replies render Markdown immediately while live tool details remain expandable."
]
},
{
"header": "Sessions keep their ownership",
"bullets": [
"Queued follow-ups retain their originating connection, profile, session, route, attachments, and voice context.",
"Session pins and archives persist across restarts, and duplicate model rows are reconciled before rendering."
]
},
{
"header": "Safer controls and setup",
"bullets": [
"Approval cards require an explicit labeled decision, and Agent Passport safety controls are easier to read and dismiss.",
"Hosted Hermes setup completes through the official Dashboard system-browser sign-in flow."
]
}
]
},
{
"version": "1.7.0",
"title": "Smarter controls, steadier sessions",
"date": "2026-08-06",
"sections": [
{
"header": "Model controls fit the model",
"bullets": [
"Reasoning effort choices follow the selected provider and model when an exact supported list is available.",
"Unmodified Hermes and setups without the optional Relay capability overlay keep a fail-soft standard choice list."
]
},
{
"header": "Active chats stay easy to follow",
"bullets": [
"The searchable session drawer shows which conversations are working or waiting for input.",
"Restored and completed chats remain bottom-pinned through late layout changes without overriding intentional scrollback.",
"Chat and Voice keep stable rows through recovery, and Focus Voice controls receive taps normally."
]
},
{
"header": "Support stays private and useful",
"bullets": [
"Review locally redacted support information before choosing to copy, share, or open GitHub; nothing uploads automatically.",
"Connection diagnostics identify the failed operation and offer targeted guidance without exposing hosts or credentials."
]
}
]
},
{
"version": "1.6.1",
"title": "Clearer recovery, steadier chat",
"date": "2026-08-03",
"sections": [
{
"header": "Relay stays optional",
"bullets": [
"Relay-only surfaces now use consistent Optional, Ready, Reconnecting, Unavailable, and Needs re-pair states without nagging from background session refreshes.",
"Foreground recovery retries ordinary reconnect backoff immediately and explains whether Relay credentials are merely stored or actually need re-pairing."
]
},
{
"header": "Sessions and chat stay stable",
"bullets": [
"The session drawer restores its 200-row window through upstream-compatible 100-row pages.",
"Selecting streamed text stays stable when a completed response changes to rendered Markdown."
]
},
{
"header": "Voice controls stay reachable",
"bullets": [
"Manual recording waits for the previous microphone owner to release it and gives a useful recovery message if capture cannot start.",
"New-chat coaching yields while Voice owns the composer so it cannot cover the expanding Voice drawer."
]
}
]
},
{
"version": "1.6.0",
"title": "Pets, plugins, and voice",
"date": "2026-08-02",
"sections": [
{
"header": "A companion with personality",
"bullets": [
"Browse and install Petdex companions, or import your own pet without replacing the agent avatar or background Sphere.",
"Drag a pet anywhere or let it roam across measured chat bubbles, settings cards, controls, and other safe UI ledges."
]
},
{
"header": "Native plugin pages",
"bullets": [
"Installed Hermes plugins can contribute host-rendered native pages without loading executable plugin code on the phone.",
"Scoped writes stay off until granted, while Relay 1.5.0 adds approval-gated agent-created page previews."
]
},
{
"header": "Hermes as your assistant",
"bullets": [
"Optionally select Hermes as Android’s Digital Assistant and use a local “Hey Hermes” listener for background or locked-screen sessions.",
"Compact assistant and floating Voice controls expand for detail and continue the same turn when full Voice opens."
]
},
{
"header": "More reliable everywhere",
"bullets": [
"Voice output recovery, long recordings, route failover, streamed chat identity, and pet terrain recovery are more resilient.",
"Android now includes a complete AI-assisted Russian catalog refreshed for the 1.6 feature set."
]
}
]
},
{
"version": "1.5.3",
"title": "Voice stays open",
"date": "2026-07-31",
"sections": [
{
"header": "Stable voice transcripts",
"bullets": [
"Voice Focus keeps stable transcript rows while live messages reconcile with persisted chat history, preventing duplicate-key crashes that could close the app."
]
}
]
},
{
"version": "1.5.2",
"title": "Sign in without detours",
"date": "2026-07-28",
"sections": [
{
"header": "Provider-compatible sign-in",
"bullets": [
"Self-hosted OIDC returns through the dashboard callback, while Nous Portal opens securely in the system browser.",
"Private-LAN and Tailscale dashboard routes preserve the configured HTTPS callback and keep credentials scoped to the active connection."
]
},
{
"header": "Stable conversation updates",
"bullets": [
"Replayed upstream chat events are coalesced before rendering so duplicate message identifiers do not destabilize the conversation list."
]
}
]
},
{
"version": "1.5.1",
"title": "Voice and chat stay in place",
"date": "2026-07-26",
"sections": [
{
"header": "Voice at the right depth",
"bullets": [
"Use Voice Focus for a compact spoken-turn view or Conversation for the complete Chat renderer without leaving the active voice session.",
"Keep intermediate work visual while supported voice paths wait to speak the settled final response."
]
},
{
"header": "Reliable narration and background work",
"bullets": [
"Standard Voice now speaks valid completed replies after generation hands off to narration.",
"Realtime background tasks release foreground voice controls while their progress and results remain reachable."
]
},
{
"header": "Formatted answers stay readable",
"bullets": [
"Completed streams render headings, lists, emphasis, and code blocks without returning to the beginning of the answer.",
"Assistant text uses stronger theme contrast and a more comfortable chat reading scale."
]
}
]
},
{
"version": "1.5.0",
"title": "Hermes, always in reach",
"date": "2026-07-25",
"sections": [
{
"header": "One secure Hermes connection",
"bullets": [
"Connect through secure Dashboard sign-in while Chat, sessions, Manage, and Standard Voice follow the same active route.",
"Switch profiles and control personality, model, reasoning, approvals, and processing speed from the new Agent Passport."
]
},
{
"header": "Active work stays reachable",
"bullets": [
"Multiple user-started chats remain active in the background until every session settles.",
"Approval, question, elevated-permission, and secure-response alerts reopen the correct conversation."
]
},
{
"header": "Richer chat and voice",
"bullets": [
"Attachments, image generation, model routing, recovery, advisor progress, and upstream events are clearer and more reliable.",
"Browse and preview Standard and Realtime voices, and hear Standard replies begin speaking as completed segments arrive."
]
},
{
"header": "Setup without surprises",
"bullets": [
"Onboarding explains optional notification, camera, microphone, companion, and device permissions without blocking standard chat.",
"Tailscale, QR, and remote routes now move all Hermes surfaces together and recover the original session after connection loss."
]
}
]
},
{
"version": "1.4.9",
"title": "Clearer Hermes connections",
"date": "2026-07-19",
"sections": [
{
"header": "Dashboard-first setup",
"bullets": [
"Connect through the Hermes dashboard with one sign-in for Chat, sessions, Manage, and voice; API fallback and optional Relay remain available.",
"Onboarding and connection management now explain nearby, remote, Tailscale, custom-port, startup, route, and security choices."
]
},
{
"header": "Consistent identity",
"bullets": [
"Server default now displays Hermes' pinned active profile consistently across the app.",
"Successful local discovery adds useful hostname identity without replacing a custom connection label."
]
}
]
},
{
"version": "1.4.8",
"title": "Privacy policy restored",
"date": "2026-07-18",
"sections": [
{
"header": "Google Play compliance",
"bullets": [
"The privacy policy now lives at hermes-relay.dev and the historical store URL remains valid for compatibility.",
"The About screen opens the hosted policy directly, and releases verify it is publicly available before publishing."
]
}
]
},
{
"version": "1.4.7",
"title": "Smoother replies, more languages",
"date": "2026-07-18",
"sections": [
{
"header": "Smooth streaming",
"bullets": [
"Long replies grow at a display-paced cadence and stay anchored at the newest text through completion.",
"Scrolling into history preserves your reading position instead of forcing the conversation back to the bottom."
]
},
{
"header": "More languages",
"bullets": [
"Use German, Brazilian Portuguese, or Japanese throughout both Android product flavors.",
"Catalog freshness validation keeps every shipped translation aligned with the canonical English resources."
]
}
]
},
{
"version": "1.4.6",
"title": "Profiles stay together",
+5 -7
View File
@@ -1,8 +1,6 @@
v1.4.6 - Profiles stay together
v1.8.1 - Complete, reliable transcripts
Profile continuity
* Server default now keeps its agent, chats, drawer, and transcript in the active Hermes profile.
* Reorder or hide profiles per connection without changing the server.
Profile icons
* Choose an image file on your phone or import avatar.png/profile.jpg from an updated paired Relay.
* Keep complete history in long sessions beyond Hermes' latest-500 default.
* Preserve stable edit, retry, sharing, and recovery anchors while paging history.
* Show authoritative Gateway rejection messages without an unintended fallback.
* Reconcile Gateway events and edit-and-regenerate requests with current upstream contracts.
@@ -1,6 +1,9 @@
package com.hermesandroid.relay
import android.app.ActivityManager
import android.app.Application
import android.content.Context
import android.os.Build
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
@@ -9,11 +12,24 @@ import coil3.request.crossfade
import com.hermesandroid.relay.bridge.UnattendedAccessManager
import com.hermesandroid.relay.data.AppAnalytics
import com.hermesandroid.relay.power.WakeLockManager
import com.hermesandroid.relay.runtime.HermesProcessRuntime
import com.hermesandroid.relay.util.AppForegroundTracker
import com.hermesandroid.relay.util.CrashReporter
class HermesRelayApp : Application(), SingletonImageLoader.Factory {
/**
* Shared chat/voice runtime for the main application process. It is lazy so
* the always-available assistant session UI process stays lightweight and
* cannot accidentally become a second microphone/session owner.
*/
val runtime: HermesProcessRuntime by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
check(isMainApplicationProcess()) {
"HermesProcessRuntime may only be created in the main application process"
}
HermesProcessRuntime(this)
}
/**
* Coil's singleton image loader for the whole app. Registering the OkHttp
* network fetcher EXPLICITLY guarantees `http(s)` image URLs (e.g. a
@@ -51,6 +67,19 @@ class HermesRelayApp : Application(), SingletonImageLoader.Factory {
AppForegroundTracker.initialize()
}
private fun isMainApplicationProcess(): Boolean {
val processName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
getProcessName()
} else {
val pid = android.os.Process.myPid()
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
activityManager.runningAppProcesses
?.firstOrNull { process -> process.pid == pid }
?.processName
}
return processName == packageName
}
companion object {
lateinit var instance: HermesRelayApp
private set
@@ -5,28 +5,36 @@ import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.os.Build
import android.util.Log
import android.view.View
import android.view.WindowManager
import android.view.animation.DecelerateInterpolator
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.animation.doOnEnd
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
import com.hermesandroid.relay.bridge.BridgeForegroundService
import com.hermesandroid.relay.bridge.UnattendedAccessManager
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
import com.hermesandroid.relay.notifications.InteractionRequestNotifier
import com.hermesandroid.relay.ui.RelayApp
import com.hermesandroid.relay.util.NavRouteRequest
import com.hermesandroid.relay.util.SharedTextRequest
import com.hermesandroid.relay.util.extractSharedText
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
class MainActivity : AppCompatActivity() {
private val connectionViewModel: ConnectionViewModel by viewModels()
private val connectionViewModel: ConnectionViewModel
get() = (applicationContext as HermesRelayApp).runtime.connectionViewModel
// === PHASE3-bridge-ui-followup: MediaProjection consent flow ===
// ActivityResultLauncher for the system screen-capture consent dialog.
@@ -66,6 +74,8 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
com.hermesandroid.relay.assistant.AssistantSessionProtocol
.prepareAssistActivation(intent)
// Hold splash until DataStore is loaded and onboarding status is known
splashScreen.setKeepOnScreenCondition {
@@ -87,6 +97,12 @@ class MainActivity : AppCompatActivity() {
}
super.onCreate(savedInstanceState)
configureAssistantWindow(intent)
lifecycleScope.launch {
com.hermesandroid.relay.assistant.AssistantAppSessionState.active.collect { active ->
if (!active) clearAssistantWindow()
}
}
enableEdgeToEdge()
// === PHASE3-bridge-ui-followup: install MediaProjection requester ===
@@ -113,6 +129,15 @@ class MainActivity : AppCompatActivity() {
// in RelayApp's NavRouteRequest collector — we just pump the request
// into the SharedFlow here.
consumeNavRouteIntent(intent)
consumeSharedTextIntent(intent)
val consumedAssistantActivation =
com.hermesandroid.relay.assistant.AssistantSessionProtocol.consumeActivation(
this,
intent,
)
if (!consumedAssistantActivation) {
com.hermesandroid.relay.assistant.AssistantSessionProtocol.restoreActivation(this)
}
// === END PHASE3-safety-rails-followup ===
setContent {
RelayApp()
@@ -121,6 +146,9 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
com.hermesandroid.relay.assistant.AssistantSessionProtocol
.prepareAssistActivation(intent)
configureAssistantWindow(intent)
// === PHASE3-safety-rails-followup: deep-link nav route on re-launch ===
// Same as onCreate but for the singleTask / FLAG_ACTIVITY_CLEAR_TOP
// path: when the app is already running and the foreground service's
@@ -128,6 +156,8 @@ class MainActivity : AppCompatActivity() {
// instead of onCreate. RelayApp's collector handles both cases.
setIntent(intent)
consumeNavRouteIntent(intent)
consumeSharedTextIntent(intent)
com.hermesandroid.relay.assistant.AssistantSessionProtocol.consumeActivation(this, intent)
// === END PHASE3-safety-rails-followup ===
}
@@ -137,11 +167,58 @@ class MainActivity : AppCompatActivity() {
NavRouteRequest.tryRequest(route)
}
private fun consumeSharedTextIntent(intent: Intent?) {
val sharedText = extractSharedText(
action = intent?.action,
mimeType = intent?.type,
text = intent?.getCharSequenceExtra(Intent.EXTRA_TEXT),
) ?: return
SharedTextRequest.tryRequest(sharedText)
}
private fun configureAssistantWindow(intent: Intent?) {
if (
intent?.getBooleanExtra(
com.hermesandroid.relay.assistant.AssistantSessionProtocol.EXTRA_ASSISTANT_SESSION,
false,
) == true ||
com.hermesandroid.relay.assistant.AssistantSessionPersistence.isActive(this)
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
}
}
private fun clearAssistantWindow() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(false)
setTurnScreenOn(false)
} else {
@Suppress("DEPRECATION")
window.clearFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
}
override fun onResume() {
super.onResume()
// Returning to the app clears the one-slot "Hermes finished
// responding" notification — the chat surface is the answer.
TurnCompleteNotifier.cancel(this)
// Action-required notifications are durable across process death.
// Once the authenticated chat surface is visible it owns presentation;
// unresolved asks are re-posted if the app returns to the background.
InteractionRequestNotifier.cancelAll(this)
// v0.4.1 — register this activity as the host for
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
// we don't leak the Activity past its lifecycle. The unattended-
@@ -0,0 +1,426 @@
package com.hermesandroid.relay.assistant
import android.app.role.RoleManager
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import android.service.voice.VoiceInteractionService
import androidx.core.content.edit
import com.hermesandroid.relay.viewmodel.VoiceState
import com.hermesandroid.relay.viewmodel.VoiceUiState
import com.hermesandroid.relay.HermesRelayApp
import com.hermesandroid.relay.wake.WakeWordActivation
import com.hermesandroid.relay.wake.WakeWordActivationCoordinator
import com.hermesandroid.relay.wake.WakeWordActivationSource
import com.hermesandroid.relay.wake.WakeWordProfileRouting
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
enum class AssistantRoleStatus {
Unavailable,
NotSelected,
Selected,
}
enum class AssistantSessionPhase {
Launching,
Listening,
Transcribing,
Thinking,
Speaking,
Idle,
Error,
Closed,
}
data class AssistantSessionSnapshot(
val phase: AssistantSessionPhase = AssistantSessionPhase.Launching,
val transcript: String? = null,
val response: String = "",
val error: String? = null,
)
object AssistantRole {
fun status(context: Context): AssistantRoleStatus {
val component = ComponentName(context, HermesVoiceInteractionService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val roles = context.getSystemService(RoleManager::class.java)
?: return AssistantRoleStatus.Unavailable
if (!roles.isRoleAvailable(RoleManager.ROLE_ASSISTANT)) {
return AssistantRoleStatus.Unavailable
}
return if (roles.isRoleHeld(RoleManager.ROLE_ASSISTANT) &&
VoiceInteractionService.isActiveService(context, component)
) {
AssistantRoleStatus.Selected
} else {
AssistantRoleStatus.NotSelected
}
}
return if (VoiceInteractionService.isActiveService(context, component)) {
AssistantRoleStatus.Selected
} else {
AssistantRoleStatus.NotSelected
}
}
fun selectionIntent(context: Context): Intent? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val roles = context.getSystemService(RoleManager::class.java)
if (roles?.isRoleAvailable(RoleManager.ROLE_ASSISTANT) == true) {
return roles.createRequestRoleIntent(RoleManager.ROLE_ASSISTANT)
}
}
return Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)
.takeIf { it.resolveActivity(context.packageManager) != null }
}
fun managementIntent(context: Context): Intent? =
Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)
.takeIf { it.resolveActivity(context.packageManager) != null }
?: selectionIntent(context)
}
/**
* Cross-process protocol between the system-owned assistant session process
* and the normal app process that owns the established voice pipeline.
*/
object AssistantSessionProtocol {
const val EXTRA_ASSISTANT_SESSION = "com.hermesandroid.relay.assistant.SESSION"
const val EXTRA_ACTIVATION_ID = "com.hermesandroid.relay.assistant.ACTIVATION_ID"
const val EXTRA_START_NEW_SESSION =
"com.hermesandroid.relay.assistant.START_NEW_SESSION"
const val EXTRA_HANDOFF_ONLY = "com.hermesandroid.relay.assistant.HANDOFF_ONLY"
private const val ACTION_STATUS = "com.hermesandroid.relay.assistant.STATUS"
private const val ACTION_FINISH = "com.hermesandroid.relay.assistant.FINISH"
private const val ACTION_START = "com.hermesandroid.relay.assistant.START"
private const val ACTION_ACTIVATE = "com.hermesandroid.relay.assistant.ACTIVATE"
private const val EXTRA_PHASE = "phase"
private const val EXTRA_TRANSCRIPT = "transcript"
private const val EXTRA_RESPONSE = "response"
private const val EXTRA_ERROR = "error"
private const val EXTRA_CANCEL_VOICE = "cancel_voice"
fun prepareAssistActivation(intent: Intent?) {
val assistIntent = intent ?: return
if (!isAssistAction(assistIntent.action)) return
if (assistIntent.getBooleanExtra(EXTRA_HANDOFF_ONLY, false)) return
assistIntent.putExtra(EXTRA_ASSISTANT_SESSION, true)
}
internal fun isAssistAction(action: String?): Boolean = action == Intent.ACTION_ASSIST
fun activationIntent(
context: Context,
activationId: String = UUID.randomUUID().toString(),
startNewSession: Boolean = true,
) =
Intent(context, com.hermesandroid.relay.MainActivity::class.java).apply {
action = Intent.ACTION_ASSIST
putExtra(EXTRA_ASSISTANT_SESSION, true)
putExtra(EXTRA_ACTIVATION_ID, activationId)
putExtra(EXTRA_START_NEW_SESSION, startNewSession)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
fun fullVoiceIntent(context: Context) =
Intent(context, com.hermesandroid.relay.MainActivity::class.java).apply {
action = Intent.ACTION_ASSIST
addCategory(Intent.CATEGORY_VOICE)
putExtra(EXTRA_HANDOFF_ONLY, true)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
fun activate(
context: Context,
activationId: String = UUID.randomUUID().toString(),
startNewSession: Boolean = true,
) {
context.sendBroadcast(
Intent(context, AssistantSessionLifecycleReceiver::class.java).apply {
action = ACTION_ACTIVATE
putExtra(EXTRA_ACTIVATION_ID, activationId)
putExtra(EXTRA_START_NEW_SESSION, startNewSession)
}
)
}
fun consumeActivation(context: Context, intent: Intent?): Boolean {
if (intent?.getBooleanExtra(EXTRA_HANDOFF_ONLY, false) == true) {
intent.removeExtra(EXTRA_HANDOFF_ONLY)
com.hermesandroid.relay.util.NavRouteRequest.tryRequest("chat")
return true
}
if (intent?.getBooleanExtra(EXTRA_ASSISTANT_SESSION, false) != true) return false
val id = intent.getStringExtra(EXTRA_ACTIVATION_ID) ?: UUID.randomUUID().toString()
val startNewSession = intent.getBooleanExtra(EXTRA_START_NEW_SESSION, true)
AssistantSessionPersistence.setActivation(context, id, startNewSession)
WakeWordActivationCoordinator.request(
WakeWordActivation(
id = id,
startNewSession = startNewSession,
profileRouting = WakeWordProfileRouting(),
source = WakeWordActivationSource.SystemAssistant,
)
)
AssistantAppSessionState.setActive(true)
intent.removeExtra(EXTRA_ASSISTANT_SESSION)
intent.removeExtra(EXTRA_ACTIVATION_ID)
intent.removeExtra(EXTRA_START_NEW_SESSION)
return true
}
fun restoreActivation(context: Context): Boolean {
if (AssistantAppSessionState.active.value) return false
val activation = AssistantSessionPersistence.restoreActivation(context) ?: return false
AssistantAppSessionState.setActive(true)
HermesVoiceInteractionService.setVoiceSessionActive(true)
val application = context.applicationContext as HermesRelayApp
application.runtime.requestVoiceActivation(
activationId = activation.id,
startNewSession = activation.startNewSession,
onFailure = { failure ->
publish(
application,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = failure.message ?: "Hermes voice could not start",
),
)
},
)
return true
}
fun publish(context: Context, snapshot: AssistantSessionSnapshot) {
context.sendBroadcast(
Intent(context, AssistantSessionStateReceiver::class.java).apply {
action = ACTION_STATUS
putExtra(EXTRA_PHASE, snapshot.phase.name)
putExtra(EXTRA_TRANSCRIPT, snapshot.transcript)
putExtra(EXTRA_RESPONSE, snapshot.response)
putExtra(EXTRA_ERROR, snapshot.error)
}
)
if (shouldFinishLifecycleOnSnapshot(snapshot)) {
// The session UI runs in a separate process. Reconcile the app-owned
// lifecycle directly as well so a reclaimed hidden UI process cannot
// leave wake listening paused after full Voice closes.
finish(context, cancelVoice = false)
}
}
fun publish(context: Context, state: VoiceUiState) {
publish(context, snapshotFromVoiceState(state))
}
internal fun snapshotFromVoiceState(state: VoiceUiState): AssistantSessionSnapshot {
val phase = when {
!state.voiceMode -> AssistantSessionPhase.Closed
state.state == VoiceState.Listening -> AssistantSessionPhase.Listening
state.state == VoiceState.Transcribing -> AssistantSessionPhase.Transcribing
state.state == VoiceState.Thinking -> AssistantSessionPhase.Thinking
state.state == VoiceState.Speaking -> AssistantSessionPhase.Speaking
state.state == VoiceState.Error -> AssistantSessionPhase.Error
else -> AssistantSessionPhase.Idle
}
return AssistantSessionSnapshot(
phase = phase,
transcript = state.transcribedText?.take(MAX_SESSION_TEXT_CHARS),
response = state.responseText.take(MAX_SESSION_TEXT_CHARS),
error = state.error?.take(MAX_SESSION_ERROR_CHARS),
)
}
internal fun shouldFinishLifecycleOnSnapshot(snapshot: AssistantSessionSnapshot): Boolean =
snapshot.phase == AssistantSessionPhase.Closed
fun finish(context: Context, cancelVoice: Boolean) {
context.sendBroadcast(
Intent(context, AssistantSessionLifecycleReceiver::class.java).apply {
action = ACTION_FINISH
putExtra(EXTRA_CANCEL_VOICE, cancelVoice)
}
)
}
fun started(context: Context) {
context.sendBroadcast(
Intent(context, AssistantSessionLifecycleReceiver::class.java).setAction(ACTION_START)
)
}
internal fun isFinishAction(action: String?): Boolean = action == ACTION_FINISH
internal fun isStartAction(action: String?): Boolean = action == ACTION_START
internal fun isActivateAction(action: String?): Boolean = action == ACTION_ACTIVATE
internal fun shouldCancelVoice(intent: Intent): Boolean =
intent.getBooleanExtra(EXTRA_CANCEL_VOICE, false)
internal fun readSnapshot(intent: Intent): AssistantSessionSnapshot {
val phase = runCatching {
AssistantSessionPhase.valueOf(
intent.getStringExtra(EXTRA_PHASE) ?: AssistantSessionPhase.Launching.name
)
}.getOrDefault(AssistantSessionPhase.Error)
return AssistantSessionSnapshot(
phase = phase,
transcript = intent.getStringExtra(EXTRA_TRANSCRIPT),
response = intent.getStringExtra(EXTRA_RESPONSE).orEmpty(),
error = intent.getStringExtra(EXTRA_ERROR),
)
}
private const val MAX_SESSION_TEXT_CHARS = 4_000
private const val MAX_SESSION_ERROR_CHARS = 1_000
}
object AssistantSessionState {
private val _snapshot = MutableStateFlow(AssistantSessionSnapshot())
val snapshot: StateFlow<AssistantSessionSnapshot> = _snapshot.asStateFlow()
internal fun update(snapshot: AssistantSessionSnapshot) {
_snapshot.value = snapshot
}
internal fun reset() {
_snapshot.value = AssistantSessionSnapshot()
}
}
class AssistantSessionStateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
AssistantSessionState.update(AssistantSessionProtocol.readSnapshot(intent))
}
}
class AssistantSessionLifecycleReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (AssistantSessionProtocol.isActivateAction(intent.action)) {
val id = intent.getStringExtra(AssistantSessionProtocol.EXTRA_ACTIVATION_ID)
?: UUID.randomUUID().toString()
val startNewSession = intent.getBooleanExtra(
AssistantSessionProtocol.EXTRA_START_NEW_SESSION,
true,
)
AssistantSessionPersistence.setActive(context, true)
AssistantSessionPersistence.setActivation(context, id, startNewSession)
AssistantAppSessionState.setActive(true)
HermesVoiceInteractionService.setVoiceSessionActive(true)
val application = context.applicationContext as HermesRelayApp
// Dispatch into the process-owned scope and return from the receiver
// immediately. Cold readiness can take longer than a broadcast's
// execution budget.
application.runtime.requestVoiceActivation(
activationId = id,
startNewSession = startNewSession,
onFailure = { failure ->
AssistantSessionProtocol.publish(
application,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = failure.message ?: "Hermes voice could not start",
),
)
},
)
return
}
if (AssistantSessionProtocol.isStartAction(intent.action)) {
AssistantSessionPersistence.setActive(context, true)
HermesVoiceInteractionService.setVoiceSessionActive(true)
return
}
if (!AssistantSessionProtocol.isFinishAction(intent.action)) return
AssistantSessionPersistence.setActive(context, false)
if (AssistantSessionProtocol.shouldCancelVoice(intent)) {
val application = context.applicationContext as HermesRelayApp
application.runtime.cancelVoice()
}
AssistantAppSessionState.setActive(false)
HermesVoiceInteractionService.setVoiceSessionActive(false)
}
}
object AssistantSessionPersistence {
private const val STORE = "assistant_session_lifecycle"
private const val KEY_ACTIVE_SINCE = "active_since"
private const val KEY_ACTIVATION_ID = "activation_id"
private const val KEY_START_NEW_SESSION = "start_new_session"
private const val STALE_AFTER_MS = 30 * 60 * 1_000L
fun setActive(context: Context, active: Boolean) {
context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit(commit = true) {
putLong(KEY_ACTIVE_SINCE, if (active) System.currentTimeMillis() else 0L)
if (!active) {
remove(KEY_ACTIVATION_ID)
}
}
}
fun setActivation(context: Context, id: String, startNewSession: Boolean) {
context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit(commit = true) {
putString(KEY_ACTIVATION_ID, id)
putBoolean(KEY_START_NEW_SESSION, startNewSession)
}
}
fun restoreActivation(context: Context): WakeWordActivation? {
if (!isActive(context)) return null
val store = context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
val id = store.getString(KEY_ACTIVATION_ID, null) ?: return null
return WakeWordActivation(
id = id,
startNewSession = store.getBoolean(KEY_START_NEW_SESSION, true),
profileRouting = WakeWordProfileRouting(),
source = WakeWordActivationSource.SystemAssistant,
)
}
fun isActive(context: Context, nowMs: Long = System.currentTimeMillis()): Boolean {
val since = context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
.getLong(KEY_ACTIVE_SINCE, 0L)
return isFresh(since, nowMs)
}
internal fun isFresh(sinceMs: Long, nowMs: Long): Boolean =
sinceMs > 0L && nowMs - sinceMs in 0..STALE_AFTER_MS
}
object AssistantAppSessionState {
private val _active = MutableStateFlow(false)
val active: StateFlow<Boolean> = _active.asStateFlow()
@Volatile private var voiceStarted = false
internal fun setActive(active: Boolean) {
if (active && !_active.value) voiceStarted = false
if (!active) voiceStarted = false
_active.value = active
}
fun markVoiceStarted() {
voiceStarted = true
}
fun hasVoiceStarted(): Boolean = voiceStarted
}
object AssistantVoiceCommandCoordinator {
private val _cancelRequest = MutableStateFlow<String?>(null)
val cancelRequest: StateFlow<String?> = _cancelRequest.asStateFlow()
fun requestCancel() {
_cancelRequest.value = UUID.randomUUID().toString()
}
fun consume(id: String): Boolean {
if (_cancelRequest.value != id) return false
_cancelRequest.value = null
return true
}
}
@@ -0,0 +1,26 @@
package com.hermesandroid.relay.assistant
import android.content.Intent
import android.speech.RecognitionService
import android.speech.SpeechRecognizer
/**
* Platform-required recognition component for the Hermes voice interactor.
*
* Assistant sessions deliberately use the existing Hermes transcription
* pipeline so wake detection, session capture, and active voice never compete
* for the microphone. Direct SpeechRecognizer clients are therefore rejected
* instead of opening a second recorder.
*/
class HermesRecognitionService : RecognitionService() {
override fun onStartListening(
recognizerIntent: Intent,
listener: Callback,
) {
listener.error(SpeechRecognizer.ERROR_CLIENT)
}
override fun onStopListening(listener: Callback) = Unit
override fun onCancel(listener: Callback) = Unit
}
@@ -0,0 +1,299 @@
package com.hermesandroid.relay.assistant
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.service.voice.VoiceInteractionService
import android.util.Log
import androidx.core.content.ContextCompat
import com.hermesandroid.relay.wake.MicrophoneLease
import com.hermesandroid.relay.wake.MicrophoneOwner
import com.hermesandroid.relay.wake.MicrophoneOwnershipCoordinator
import com.hermesandroid.relay.wake.SherpaWakeWordDetector
import com.hermesandroid.relay.wake.WakeWordModelInstaller
import com.hermesandroid.relay.wake.WakeWordPreferences
import com.hermesandroid.relay.wake.WakeWordPreferencesRepository
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
enum class AssistantWakeRuntimeState {
Stopped,
Starting,
Listening,
PausedForVoice,
AwaitingSession,
Error,
}
/**
* Opt-in Android Digital Assistant service. Android keeps the selected service
* available in the background; all pre-activation audio is evaluated locally.
*/
class HermesVoiceInteractionService : VoiceInteractionService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mainHandler = Handler(Looper.getMainLooper())
private val stopRequested = AtomicBoolean(false)
private val resourceLock = Any()
private var preferencesJob: Job? = null
private var recognitionJob: Job? = null
private var recorder: AudioRecord? = null
private var detector: SherpaWakeWordDetector? = null
private var microphoneLease: MicrophoneLease? = null
@Volatile private var latestPreferences = WakeWordPreferences()
@Volatile private var voiceSessionActive = false
override fun onCreate() {
super.onCreate()
runningInstance = this
}
override fun onReady() {
super.onReady()
if (runningInstance !== this) return
voiceSessionActive = AssistantSessionPersistence.isActive(this)
preferencesJob?.cancel()
preferencesJob = scope.launch {
WakeWordPreferencesRepository(applicationContext).flow.collectLatest { prefs ->
latestPreferences = prefs
if (prefs.assistantEnabled && !voiceSessionActive) {
restartRecognition(prefs)
} else {
stopRecognition()
setRuntimeState(
if (voiceSessionActive) {
AssistantWakeRuntimeState.PausedForVoice
} else {
AssistantWakeRuntimeState.Stopped
}
)
}
}
}
}
override fun onLaunchVoiceAssistFromKeyguard() {
val activationId = java.util.UUID.randomUUID().toString()
showAssistantSession(
fromKeyguard = true,
activationId = activationId,
)
}
override fun onShutdown() {
stopRecognition()
preferencesJob?.cancel()
setRuntimeState(AssistantWakeRuntimeState.Stopped)
super.onShutdown()
}
override fun onDestroy() {
stopRecognition()
preferencesJob?.cancel()
if (runningInstance === this) runningInstance = null
scope.cancel()
super.onDestroy()
}
private suspend fun restartRecognition(preferences: WakeWordPreferences) {
val previous = recognitionJob
stopRecognition()
previous?.join()
if (!voiceSessionActive && preferences.assistantEnabled) {
startRecognition(preferences)
}
}
@SuppressLint("MissingPermission")
private fun startRecognition(preferences: WakeWordPreferences) {
if (voiceSessionActive || recognitionJob?.isActive == true) return
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED
) {
setRuntimeState(AssistantWakeRuntimeState.Error)
return
}
val files = WakeWordModelInstaller(this).installedFiles()
if (files == null) {
setRuntimeState(AssistantWakeRuntimeState.Error)
return
}
val lease = MicrophoneOwnershipCoordinator.tryAcquire(MicrophoneOwner.WakeWord)
if (lease == null) {
setRuntimeState(AssistantWakeRuntimeState.PausedForVoice)
scheduleRetry()
return
}
microphoneLease = lease
stopRequested.set(false)
setRuntimeState(AssistantWakeRuntimeState.Starting)
recognitionJob = scope.launch {
var detected = false
var unattachedDetector: SherpaWakeWordDetector? = null
try {
val createdDetector = SherpaWakeWordDetector(
files,
preferences.sensitivity,
preferences.confirmationFrames,
)
unattachedDetector = createdDetector
val minBuffer = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(SAMPLE_RATE / 5 * 2)
val createdRecorder = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build()
)
.setBufferSizeInBytes(minBuffer * 2)
.build()
if (createdRecorder.state != AudioRecord.STATE_INITIALIZED) {
createdRecorder.release()
error("Assistant wake microphone failed to initialize")
}
synchronized(resourceLock) {
if (stopRequested.get()) {
createdRecorder.release()
return@launch
}
recorder = createdRecorder
detector = createdDetector
unattachedDetector = null
}
createdRecorder.startRecording()
setRuntimeState(AssistantWakeRuntimeState.Listening)
val samples = ShortArray(FRAME_SAMPLES)
while (!stopRequested.get()) {
val count = createdRecorder.read(samples, 0, samples.size)
if (count < 0) error("Assistant wake microphone read failed: $count")
if (count > 0 && createdDetector.accept(samples, count)) {
detected = true
break
}
}
} catch (t: Throwable) {
if (!stopRequested.get()) {
Log.w(TAG, "Assistant wake listening failed", t)
setRuntimeState(AssistantWakeRuntimeState.Error)
}
} finally {
runCatching { unattachedDetector?.close() }
releaseResources()
recognitionJob = null
}
if (detected && !stopRequested.get()) {
setRuntimeState(AssistantWakeRuntimeState.AwaitingSession)
val keyguard = getSystemService(android.app.KeyguardManager::class.java)
mainHandler.post {
showAssistantSession(fromKeyguard = keyguard?.isKeyguardLocked == true)
}
}
}
}
private fun showAssistantSession(fromKeyguard: Boolean, activationId: String? = null) {
voiceSessionActive = true
stopRecognition()
setRuntimeState(AssistantWakeRuntimeState.AwaitingSession)
showSession(
Bundle().apply {
putBoolean(EXTRA_FROM_KEYGUARD, fromKeyguard)
activationId?.let { putString(AssistantSessionProtocol.EXTRA_ACTIVATION_ID, it) }
putBoolean(
AssistantSessionProtocol.EXTRA_START_NEW_SESSION,
latestPreferences.startNewSession,
)
},
0,
)
}
private fun setVoiceSessionActiveInternal(active: Boolean) {
voiceSessionActive = active
if (active) {
stopRecognition()
setRuntimeState(AssistantWakeRuntimeState.PausedForVoice)
} else if (latestPreferences.assistantEnabled) {
scheduleRetry()
} else {
setRuntimeState(AssistantWakeRuntimeState.Stopped)
}
}
private fun scheduleRetry() {
if (recognitionJob?.isActive == true || voiceSessionActive) return
recognitionJob = scope.launch {
delay(RETRY_DELAY_MS)
recognitionJob = null
if (!voiceSessionActive && latestPreferences.assistantEnabled) {
startRecognition(latestPreferences)
}
}
}
private fun stopRecognition() {
stopRequested.set(true)
synchronized(resourceLock) {
runCatching { recorder?.stop() }
runCatching { recorder?.release() }
recorder = null
microphoneLease?.let(MicrophoneOwnershipCoordinator::release)
microphoneLease = null
}
recognitionJob?.cancel()
}
private fun releaseResources() {
synchronized(resourceLock) {
runCatching { recorder?.stop() }
runCatching { recorder?.release() }
recorder = null
runCatching { detector?.close() }
detector = null
microphoneLease?.let(MicrophoneOwnershipCoordinator::release)
microphoneLease = null
}
}
private fun setRuntimeState(state: AssistantWakeRuntimeState) {
_runtimeState.value = state
}
companion object {
private const val TAG = "HermesAssistant"
private const val SAMPLE_RATE = 16_000
private const val FRAME_SAMPLES = 1_600
private const val RETRY_DELAY_MS = 500L
const val EXTRA_FROM_KEYGUARD = "from_keyguard"
private val _runtimeState = kotlinx.coroutines.flow.MutableStateFlow(
AssistantWakeRuntimeState.Stopped
)
val runtimeState = _runtimeState.asStateFlow()
@Volatile private var runningInstance: HermesVoiceInteractionService? = null
fun setVoiceSessionActive(active: Boolean) {
runningInstance?.setVoiceSessionActiveInternal(active)
}
}
}
@@ -0,0 +1,629 @@
package com.hermesandroid.relay.assistant
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.service.voice.VoiceInteractionSession
import android.service.voice.VoiceInteractionSessionService
import android.view.View
import android.view.WindowManager
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.ComposeView
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.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import java.util.UUID
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class HermesVoiceInteractionSessionService : VoiceInteractionSessionService() {
override fun onNewSession(args: Bundle?): VoiceInteractionSession =
HermesVoiceInteractionSession(this)
}
internal enum class AssistantSessionPresentation {
Inactive,
Overlay,
FullVoice,
}
internal fun shouldCancelVoiceWhenSessionUiEnds(
presentation: AssistantSessionPresentation,
): Boolean = presentation == AssistantSessionPresentation.Overlay
private class HermesVoiceInteractionSession(
private val service: HermesVoiceInteractionSessionService,
) : VoiceInteractionSession(service) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val viewOwner = AssistantSessionViewOwner().also { it.start() }
private var presentation = AssistantSessionPresentation.Inactive
private val assistantSurfaceBounds = android.graphics.Rect()
private var surfaceExpanded by mutableStateOf(false)
init {
scope.launch {
AssistantSessionState.snapshot.collect { snapshot ->
if (presentation != AssistantSessionPresentation.Inactive &&
snapshot.phase == AssistantSessionPhase.Closed
) {
finishSession(cancelVoice = false)
}
}
}
}
override fun onCreate() {
super.onCreate()
window.window?.apply {
setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
setDimAmount(0f)
}
}
override fun onCreateContentView(): View = ComposeView(service).apply {
setBackgroundColor(android.graphics.Color.TRANSPARENT)
setViewTreeLifecycleOwner(viewOwner)
setViewTreeViewModelStoreOwner(viewOwner)
setViewTreeSavedStateRegistryOwner(viewOwner)
setContent {
HermesRelayTheme {
AssistantSessionSurface(
expanded = surfaceExpanded,
onExpandedChange = { surfaceExpanded = it },
onCancel = { finishSession(cancelVoice = true) },
onRetry = { launchVoice(startNewSession = true) },
onOpenFullVoice = {
if (presentation == AssistantSessionPresentation.Overlay) {
openFullVoice()
}
},
onSurfaceBoundsChanged = { bounds ->
if (assistantSurfaceBounds != bounds) {
assistantSurfaceBounds.set(bounds)
window.window?.decorView?.requestLayout()
}
},
)
}
}
}
override fun onShow(args: Bundle?, showFlags: Int) {
super.onShow(args, showFlags)
if (args?.getBoolean(HermesVoiceInteractionService.EXTRA_FROM_KEYGUARD, false) == true) {
window.window?.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
setUiEnabled(true)
val startsNewLifecycle = presentation == AssistantSessionPresentation.Inactive
presentation = AssistantSessionPresentation.Overlay
if (!startsNewLifecycle) return
surfaceExpanded = false
AssistantSessionState.reset()
launchVoice(
activationId = args?.getString(AssistantSessionProtocol.EXTRA_ACTIVATION_ID)
?: UUID.randomUUID().toString(),
startNewSession = args?.getBoolean(
AssistantSessionProtocol.EXTRA_START_NEW_SESSION,
true,
) ?: true,
)
}
override fun onComputeInsets(outInsets: Insets) {
super.onComputeInsets(outInsets)
outInsets.touchableInsets = Insets.TOUCHABLE_INSETS_REGION
outInsets.touchableRegion.set(assistantSurfaceBounds)
}
override fun onBackPressed() {
if (presentation == AssistantSessionPresentation.Overlay && surfaceExpanded) {
surfaceExpanded = false
return
}
super.onBackPressed()
}
override fun onHide() {
if (shouldCancelVoiceWhenSessionUiEnds(presentation)) {
finishSession(cancelVoice = true)
}
super.onHide()
}
override fun onDestroy() {
if (shouldCancelVoiceWhenSessionUiEnds(presentation)) {
AssistantSessionProtocol.finish(service, cancelVoice = true)
}
presentation = AssistantSessionPresentation.Inactive
viewOwner.stop()
scope.cancel()
super.onDestroy()
}
private fun launchVoice(
activationId: String = UUID.randomUUID().toString(),
startNewSession: Boolean,
) {
runCatching {
AssistantSessionProtocol.activate(
service,
activationId = activationId,
startNewSession = startNewSession,
)
}.onFailure {
AssistantSessionState.update(
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = it.message ?: "Hermes could not open the voice session.",
)
)
}
}
private fun openFullVoice() {
runCatching {
startVoiceActivity(AssistantSessionProtocol.fullVoiceIntent(service))
presentation = AssistantSessionPresentation.FullVoice
setUiEnabled(false)
}.onFailure {
AssistantSessionState.update(
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = it.message ?: "Hermes could not open full voice.",
)
)
}
}
private fun finishSession(cancelVoice: Boolean) {
if (presentation == AssistantSessionPresentation.Inactive) return
presentation = AssistantSessionPresentation.Inactive
AssistantSessionProtocol.finish(service, cancelVoice)
finish()
}
}
private class AssistantSessionViewOwner :
LifecycleOwner,
ViewModelStoreOwner,
SavedStateRegistryOwner {
private val lifecycleRegistry = LifecycleRegistry(this)
private val store = ViewModelStore()
private val savedStateController = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle get() = lifecycleRegistry
override val viewModelStore: ViewModelStore get() = store
override val savedStateRegistry: SavedStateRegistry
get() = savedStateController.savedStateRegistry
fun start() {
savedStateController.performRestore(null)
lifecycleRegistry.currentState = Lifecycle.State.CREATED
lifecycleRegistry.currentState = Lifecycle.State.RESUMED
}
fun stop() {
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
store.clear()
}
}
@Composable
private fun AssistantSessionSurface(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onCancel: () -> Unit,
onRetry: () -> Unit,
onOpenFullVoice: () -> Unit,
onSurfaceBoundsChanged: (android.graphics.Rect) -> Unit,
) {
val snapshot by AssistantSessionState.snapshot.collectAsState()
val status = assistantStatus(snapshot.phase)
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 12.dp)
.navigationBarsPadding(),
contentAlignment = Alignment.BottomCenter,
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
.onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
onSurfaceBoundsChanged(
android.graphics.Rect(
bounds.left.roundToInt(),
bounds.top.roundToInt(),
bounds.right.roundToInt(),
bounds.bottom.roundToInt(),
)
)
},
shape = RoundedCornerShape(if (expanded) 30.dp else 28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.98f),
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 10.dp,
shadowElevation = 12.dp,
) {
if (expanded) {
ExpandedAssistantSurface(
snapshot = snapshot,
status = status,
onCollapse = { onExpandedChange(false) },
onCancel = onCancel,
onRetry = onRetry,
onOpenFullVoice = onOpenFullVoice,
)
} else {
CompactAssistantSurface(
snapshot = snapshot,
status = status,
onExpand = { onExpandedChange(true) },
onCancel = onCancel,
)
}
}
}
}
@Composable
private fun CompactAssistantSurface(
snapshot: AssistantSessionSnapshot,
status: String,
onExpand: () -> Unit,
onCancel: () -> Unit,
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AssistantOrb(snapshot.phase)
Column(modifier = Modifier.weight(1f)) {
Text(
text = status,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
Text(
text = compactAssistantText(snapshot),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(
onClick = onExpand,
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Filled.ExpandLess,
contentDescription = stringResource(R.string.assistant_session_expand),
)
}
AssistantStopButton(onClick = onCancel, compact = true)
}
}
@Composable
private fun ExpandedAssistantSurface(
snapshot: AssistantSessionSnapshot,
status: String,
onCollapse: () -> Unit,
onCancel: () -> Unit,
onRetry: () -> Unit,
onOpenFullVoice: () -> Unit,
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.width(38.dp)
.height(4.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
.align(Alignment.CenterHorizontally),
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
AssistantOrb(snapshot.phase, size = 38)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = status,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = onCollapse) {
Icon(
imageVector = Icons.Filled.ExpandMore,
contentDescription = stringResource(R.string.assistant_session_collapse),
)
}
}
AssistantWaveform(snapshot.phase)
snapshot.transcript?.takeIf { it.isNotBlank() }?.let { transcript ->
AssistantTextRow(
icon = Icons.Filled.Person,
text = transcript,
color = MaterialTheme.colorScheme.primary,
)
}
snapshot.response.takeIf { it.isNotBlank() }?.let { response ->
AssistantTextRow(
icon = Icons.Filled.AutoAwesome,
text = response,
color = MaterialTheme.colorScheme.onSurface,
)
}
snapshot.error?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
if (snapshot.phase == AssistantSessionPhase.Transcribing ||
snapshot.phase == AssistantSessionPhase.Thinking
) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AssistantStopButton(onClick = onCancel, compact = false)
Spacer(Modifier.weight(1f))
if (snapshot.phase == AssistantSessionPhase.Error) {
TextButton(onClick = onRetry) {
Text(stringResource(R.string.assistant_session_retry))
}
}
OutlinedButton(onClick = onOpenFullVoice) {
Text(stringResource(R.string.assistant_session_open_full_voice))
}
}
}
}
@Composable
private fun AssistantOrb(
phase: AssistantSessionPhase,
size: Int = 52,
) {
val active = phase == AssistantSessionPhase.Listening ||
phase == AssistantSessionPhase.Transcribing ||
phase == AssistantSessionPhase.Thinking ||
phase == AssistantSessionPhase.Speaking
Box(
modifier = Modifier
.size(size.dp)
.clip(CircleShape)
.background(
if (active) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
}
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.GraphicEq,
contentDescription = null,
tint = if (active) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size((size * 0.5f).dp),
)
}
}
@Composable
private fun AssistantWaveform(phase: AssistantSessionPhase) {
val active = phase == AssistantSessionPhase.Listening ||
phase == AssistantSessionPhase.Speaking
val primary = if (active) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
}
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(28.dp),
) {
val centerY = size.height / 2f
val bars = 33
val spacing = size.width / bars
repeat(bars) { index ->
val distance = kotlin.math.abs(index - bars / 2f) / (bars / 2f)
val envelope = max(0.18f, 1f - distance)
val pattern = 0.45f + ((index * 17) % 11) / 20f
val halfHeight = size.height * 0.46f * envelope * pattern
val x = spacing * (index + 0.5f)
drawLine(
color = primary,
start = Offset(x, centerY - halfHeight),
end = Offset(x, centerY + halfHeight),
strokeWidth = max(2f, spacing * 0.28f),
cap = StrokeCap.Round,
)
}
}
}
@Composable
private fun AssistantTextRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
text: String,
color: Color,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(20.dp),
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = color,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun AssistantStopButton(
onClick: () -> Unit,
compact: Boolean,
) {
if (compact) {
IconButton(
onClick = onClick,
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.errorContainer),
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = stringResource(R.string.assistant_session_cancel),
tint = MaterialTheme.colorScheme.error,
)
}
} else {
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.error,
),
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.assistant_session_stop))
}
}
}
@Composable
private fun assistantStatus(phase: AssistantSessionPhase): String = when (phase) {
AssistantSessionPhase.Launching -> stringResource(R.string.assistant_session_launching)
AssistantSessionPhase.Listening -> stringResource(R.string.assistant_session_listening)
AssistantSessionPhase.Transcribing -> stringResource(R.string.assistant_session_transcribing)
AssistantSessionPhase.Thinking -> stringResource(R.string.assistant_session_thinking)
AssistantSessionPhase.Speaking -> stringResource(R.string.assistant_session_speaking)
AssistantSessionPhase.Idle -> stringResource(R.string.assistant_session_ready)
AssistantSessionPhase.Error -> stringResource(R.string.assistant_session_error)
AssistantSessionPhase.Closed -> stringResource(R.string.assistant_session_closing)
}
@Composable
private fun compactAssistantText(snapshot: AssistantSessionSnapshot): String =
snapshot.transcript?.takeIf { it.isNotBlank() }
?: snapshot.response.takeIf { it.isNotBlank() }
?: snapshot.error?.takeIf { it.isNotBlank() }
?: assistantStatus(snapshot.phase)
@@ -8,11 +8,16 @@ import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.util.Log
import com.hermesandroid.relay.wake.MicrophoneLease
import com.hermesandroid.relay.wake.MicrophoneOwner
import com.hermesandroid.relay.wake.MicrophoneOwnershipCoordinator
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -22,23 +27,26 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import kotlin.math.max
/**
* Duplex audio capture for voice barge-in (plan unit B3).
*
* While TTS is playing, this listener continuously pulls 32 ms / 512-sample
* PCM frames off the microphone and feeds them to [VadEngine]. It emits two
* SharedFlows that B4 will wire into the voice state machine:
* During response generation and playback, this listener continuously pulls
* 32 ms / 512-sample PCM frames off the microphone and feeds them to
* [VadEngine]. One instance owns the full active turn. It emits two
* SharedFlows wired into the voice state machine:
*
* - [maybeSpeech] fires on the **first** positive raw-VAD frame — before the
* second-layer debouncer latches. B4 uses this to softly [VoicePlayer.duck]
* the TTS so the user's voice has acoustic headroom while we decide whether
* to cut off.
*
* - [bargeInDetected] fires when [VadEngine] confirms speech post-hysteresis.
* B4 uses this to call `interruptSpeaking()` and flip state to Listening.
* - [bargeInDetected] fires when [VadEngine] confirms speech post-hysteresis
* and the calibrated RMS majority gate accepts it. The owner uses this to
* interrupt generation/playback and flip state to Listening.
*
* ### Acoustic echo cancellation
*
@@ -140,8 +148,43 @@ class BargeInListener internal constructor(
private val frameBuffer: ShortArray = ShortArray(VadEngine.FRAME_SIZE_SAMPLES)
@Volatile private var readerJob: Job? = null
@Volatile private var microphoneLease: MicrophoneLease? = null
@Volatile private var aec: AcousticEchoCanceler? = null
@Volatile private var noiseSuppressor: NoiseSuppressor? = null
private val rmsGate = RmsBargeInGate()
@Volatile private var playbackGraceMs: Long = RmsBargeInGate.DEFAULT_PLAYBACK_GRACE_MS
@Volatile private var playbackActiveProvider: (() -> Boolean)? = null
@Volatile private var diagnosticsEnabled: Boolean = false
private var wasCalibrating: Boolean = false
/** Apply the user-facing barge-in sensitivity to the quiet-room RMS gate. */
fun setThresholdMultiplier(multiplier: Float) {
rmsGate.thresholdMultiplier = multiplier
}
fun setDiagnosticsEnabled(enabled: Boolean) {
diagnosticsEnabled = enabled
}
/** Supplies the renderer's current playback phase for upstream-style gaps. */
fun setPlaybackActiveProvider(provider: () -> Boolean) {
playbackActiveProvider = provider
}
/**
* Freeze quiet-room calibration and begin the playback-only grace window.
* Idempotent so every renderer may call it at its first audible chunk.
*/
fun markPlaybackStarted(
nowMs: Long = System.currentTimeMillis(),
graceMs: Long = RmsBargeInGate.DEFAULT_PLAYBACK_GRACE_MS,
) {
playbackGraceMs = graceMs.coerceAtLeast(0L)
rmsGate.markPlaybackStarted(nowMs)
if (diagnosticsEnabled) {
Log.d(TAG, "voice-vad playback started; grace=${playbackGraceMs}ms")
}
}
/**
* Allocate the audio pipeline and begin reading frames into [vadEngine].
@@ -163,6 +206,12 @@ class BargeInListener internal constructor(
return
}
val lease = MicrophoneOwnershipCoordinator.tryAcquire(MicrophoneOwner.BargeIn)
if (lease == null) {
Log.i(TAG, "Barge-in listener inactive — microphone is owned by another voice surface")
return
}
microphoneLease = lease
if (!audioSource.initialize()) {
Log.w(
TAG,
@@ -170,11 +219,16 @@ class BargeInListener internal constructor(
"(missing RECORD_AUDIO permission or mic busy) — listener inactive",
)
_aecAttached.value = false
MicrophoneOwnershipCoordinator.release(lease)
microphoneLease = null
return
}
_aecAttached.value = false
rmsGate.reset()
wasCalibrating = true
readerJob = scope.launch(readerDispatcher) {
var effectsJob: Job? = null
try {
try {
audioSource.start()
@@ -185,7 +239,10 @@ class BargeInListener internal constructor(
return@launch
}
Log.i(TAG, "Barge-in AudioRecord reader started")
maybeAttachEffects()
// Do not block generation-phase listening while waiting for an
// AudioTrack session that does not exist until playback. The
// effects attach races harmlessly beside the reader.
effectsJob = launch { maybeAttachEffects() }
while (isActive) {
val read = try {
@@ -222,10 +279,41 @@ class BargeInListener internal constructor(
Log.w(TAG, "VadEngine.analyze failed; stopping reader: ${t.message}")
break
}
if (result.probability > 0f) {
val gated = rmsGate.observe(
frame = frameBuffer,
rawSpeech = result.probability > 0f,
nowMs = System.currentTimeMillis(),
playbackGraceMs = playbackGraceMs,
confirmedSpeech = result.isSpeech,
playbackActiveOverride = playbackActiveProvider?.invoke(),
)
if (diagnosticsEnabled) {
if (wasCalibrating && !gated.calibrating) {
Log.d(
TAG,
"voice-vad calibrated quiet floor=${gated.floor.toInt()} " +
"mult=${rmsGate.thresholdMultiplier}",
)
}
wasCalibrating = gated.calibrating
if (
gated.detected || gated.playbackGrace ||
gated.rms >= gated.threshold * 0.5f
) {
Log.d(
TAG,
"voice-vad rms=${gated.rms.toInt()} floor=${gated.floor.toInt()} " +
"trigger=${gated.threshold.toInt()} raw=${result.probability > 0f} " +
"confirmed=${result.isSpeech} detected=${gated.detected} " +
"grace=${gated.playbackGrace} " +
"phase=${if (gated.playback) "playback" else "generation"}",
)
}
}
if (gated.maybeSpeech) {
_maybeSpeech.tryEmit(Unit)
}
if (result.isSpeech) {
if (gated.detected) {
_bargeInDetected.tryEmit(Unit)
}
// Give the dispatcher a chance to observe cancellation
@@ -237,11 +325,19 @@ class BargeInListener internal constructor(
yield()
}
} finally {
// The reader reaches this block with its Job cancelled.
// Teardown still has to wait for the sibling AEC poll before
// releasing the AudioRecord and microphone lease.
withContext(NonCancellable) {
effectsJob?.cancelAndJoin()
}
// Release effects + AudioRecord in the reverse of attach order
// so the AudioSessionId is still valid when AEC teardown runs.
releaseEffects()
runCatching { audioSource.stop() }
runCatching { audioSource.release() }
microphoneLease?.let(MicrophoneOwnershipCoordinator::release)
microphoneLease = null
_aecAttached.value = false
}
}
@@ -258,8 +354,16 @@ class BargeInListener internal constructor(
if (job?.isActive == true) {
Log.i(TAG, "Stopping barge-in AudioRecord reader")
}
// AudioRecord.read() may be blocked in native code, so stop the source
// before cancellation to make the reader observe shutdown promptly.
runCatching { audioSource.stop() }
job?.cancel()
readerJob = null
if (job == null) {
runCatching { audioSource.release() }
microphoneLease?.let(MicrophoneOwnershipCoordinator::release)
microphoneLease = null
}
return job
}
@@ -4,6 +4,8 @@ import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import com.hermesandroid.relay.wake.MicrophoneOwner
import com.hermesandroid.relay.wake.MicrophoneOwnershipCoordinator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
@@ -40,24 +42,37 @@ class RealtimePcmRecorder(
maxDurationMs: Long = 15_000,
onLevel: ((Float) -> Unit)? = null,
): ByteArray = withContext(Dispatchers.IO) {
val minBuffer = AudioRecord.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(sampleRate / 10 * 2)
val microphoneLease =
MicrophoneOwnershipCoordinator.tryAcquire(MicrophoneOwner.RealtimeDiagnostics)
?: error("Microphone is in use by another voice feature")
val minBuffer = try {
AudioRecord.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(sampleRate / 10 * 2)
} catch (t: Throwable) {
MicrophoneOwnershipCoordinator.release(microphoneLease)
throw t
}
val maxBytes = ((sampleRate * maxDurationMs) / 1000L * 2L).toInt()
val recorder = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build()
)
.setBufferSizeInBytes(minBuffer)
.build()
val recorder = try {
AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build()
)
.setBufferSizeInBytes(minBuffer)
.build()
} catch (t: Throwable) {
MicrophoneOwnershipCoordinator.release(microphoneLease)
throw t
}
val out = ByteArrayOutputStream(minBuffer * 4)
val buffer = ByteArray(minBuffer)
@@ -77,32 +92,46 @@ class RealtimePcmRecorder(
capturing = false
try { recorder.stop() } catch (_: Exception) { }
recorder.release()
MicrophoneOwnershipCoordinator.release(microphoneLease)
}
out.toByteArray()
}
@SuppressLint("MissingPermission")
suspend fun capture(durationMs: Long = 800): ByteArray = withContext(Dispatchers.IO) {
val minBuffer = AudioRecord.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(sampleRate / 10 * 2)
val microphoneLease =
MicrophoneOwnershipCoordinator.tryAcquire(MicrophoneOwner.RealtimeDiagnostics)
?: error("Microphone is in use by another voice feature")
val minBuffer = try {
AudioRecord.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(sampleRate / 10 * 2)
} catch (t: Throwable) {
MicrophoneOwnershipCoordinator.release(microphoneLease)
throw t
}
val targetBytes = ((sampleRate * durationMs) / 1000L * 2L)
.toInt()
.coerceAtLeast(minBuffer)
val recorder = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build()
)
.setBufferSizeInBytes(minBuffer)
.build()
val recorder = try {
AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build()
)
.setBufferSizeInBytes(minBuffer)
.build()
} catch (t: Throwable) {
MicrophoneOwnershipCoordinator.release(microphoneLease)
throw t
}
val out = ByteArrayOutputStream(targetBytes)
val buffer = ByteArray(minBuffer)
@@ -123,6 +152,7 @@ class RealtimePcmRecorder(
} finally {
try { recorder.stop() } catch (_: Exception) { }
recorder.release()
MicrophoneOwnershipCoordinator.release(microphoneLease)
}
out.toByteArray()
}
@@ -0,0 +1,216 @@
package com.hermesandroid.relay.audio
import kotlin.math.ceil
import kotlin.math.roundToInt
import kotlin.math.sqrt
/**
* Turn-scoped RMS gate layered in front of the model VAD.
*
* The first quiet frames establish a room floor before playback. That floor is
* frozen as soon as playback begins so speaker output can never teach the gate
* to ignore the user. Detection uses a majority window rather than requiring
* perfectly consecutive frames, which tolerates short consonant/syllable dips.
*/
internal class RmsBargeInGate(
private val calibrationFrames: Int = DEFAULT_CALIBRATION_FRAMES,
private val decisionWindowFrames: Int = DEFAULT_DECISION_WINDOW_FRAMES,
private val requiredWindowRatio: Float = DEFAULT_REQUIRED_WINDOW_RATIO,
) {
private val ambient = ArrayDeque<Float>(MAX_AMBIENT_FRAMES)
private val decisions = ArrayDeque<Boolean>(decisionWindowFrames)
private var quietFloor: Float = DEFAULT_QUIET_FLOOR_RMS
private var calibrated = false
private var playbackActive = false
private var playbackStartedAtMs: Long? = null
private var playbackStoppedAtMs: Long? = null
var thresholdMultiplier: Float = DEFAULT_THRESHOLD_MULTIPLIER
set(value) {
field = value.coerceIn(MIN_THRESHOLD_MULTIPLIER, MAX_THRESHOLD_MULTIPLIER)
}
fun reset() {
ambient.clear()
decisions.clear()
quietFloor = DEFAULT_QUIET_FLOOR_RMS
calibrated = false
playbackActive = false
playbackStartedAtMs = null
playbackStoppedAtMs = null
}
fun markPlaybackStarted(nowMs: Long) {
updatePlaybackPhase(active = true, nowMs = nowMs)
}
fun observe(
frame: ShortArray,
rawSpeech: Boolean,
nowMs: Long,
playbackGraceMs: Long,
confirmedSpeech: Boolean = rawSpeech,
playbackActiveOverride: Boolean? = null,
): RmsGateResult {
val rms = rms(frame)
playbackActiveOverride?.let { reportedActive ->
// A renderer marks playback just before its first write so speaker
// output cannot enter calibration. Do not let a provider that has
// not observed the first audible frame yet undo that protection
// during the configured grace window.
val withinStartupGrace = playbackActive && playbackStartedAtMs?.let {
nowMs - it < playbackGraceMs
} == true
if (reportedActive || !withinStartupGrace) {
updatePlaybackPhase(active = reportedActive, nowMs = nowMs)
}
}
val playback = playbackActive
var justCalibrated = false
if (!playback && !calibrated) {
addAmbient(rms)
if (ambient.size >= calibrationFrames) {
freezeCalibration()
justCalibrated = true
}
}
if (!playback && (!calibrated || justCalibrated)) {
return RmsGateResult(
maybeSpeech = false,
detected = false,
rms = rms,
floor = quietFloor,
threshold = (quietFloor * thresholdMultiplier).coerceIn(
MIN_GENERATION_THRESHOLD_RMS,
MAX_THRESHOLD_RMS,
),
calibrating = !calibrated,
playbackGrace = false,
playback = false,
)
}
var threshold = if (playback) {
(quietFloor * thresholdMultiplier).coerceIn(
MIN_PLAYBACK_THRESHOLD_RMS,
MAX_THRESHOLD_RMS,
)
} else {
(quietFloor * thresholdMultiplier).coerceIn(
MIN_GENERATION_THRESHOLD_RMS,
MAX_THRESHOLD_RMS,
)
}
// Match upstream ambient drift: after initial calibration, keep the
// 90th-percentile floor current only while the room is quiet and no
// playback can contaminate it.
if (!playback && calibrated && !justCalibrated && rms < threshold) {
addAmbient(rms)
quietFloor = robustFloor(ambient)
threshold = (quietFloor * thresholdMultiplier).coerceIn(
MIN_GENERATION_THRESHOLD_RMS,
MAX_THRESHOLD_RMS,
)
}
val inPlaybackGrace = playbackStartedAtMs?.let { nowMs - it < playbackGraceMs } == true
val aboveRaw = rawSpeech && rms >= threshold && !inPlaybackGrace
val aboveConfirmed = confirmedSpeech && rms >= threshold && !inPlaybackGrace
decisions.addLast(aboveConfirmed)
while (decisions.size > decisionWindowFrames) decisions.removeAt(0)
val required = (decisionWindowFrames * requiredWindowRatio).roundToInt().coerceAtLeast(1)
val detected = aboveConfirmed && decisions.count { it } >= required
return RmsGateResult(
maybeSpeech = aboveRaw,
detected = detected,
rms = rms,
floor = quietFloor,
threshold = threshold,
calibrating = !playback && !calibrated,
playbackGrace = inPlaybackGrace,
playback = playback,
)
}
private fun freezeCalibration() {
if (!calibrated) {
quietFloor = robustFloor(ambient)
calibrated = true
}
}
private fun updatePlaybackPhase(active: Boolean, nowMs: Long) {
if (active == playbackActive) return
if (active) {
freezeCalibration()
val gapMs = playbackStoppedAtMs?.let { nowMs - it }
playbackStartedAtMs = if (gapMs == null || gapMs >= PLAYBACK_GRACE_REARM_GAP_MS) {
nowMs
} else {
null
}
playbackActive = true
decisions.clear()
} else {
playbackActive = false
playbackStartedAtMs = null
playbackStoppedAtMs = nowMs
decisions.clear()
}
}
private fun addAmbient(rms: Float) {
ambient.addLast(rms)
while (ambient.size > MAX_AMBIENT_FRAMES) ambient.removeAt(0)
}
private fun robustFloor(values: Collection<Float>): Float {
if (values.isEmpty()) return DEFAULT_QUIET_FLOOR_RMS
val sorted = values.sorted()
val percentileIndex = (ceil(sorted.size * 0.9).toInt() - 1).coerceIn(sorted.indices)
return sorted[percentileIndex].coerceAtLeast(MIN_QUIET_FLOOR_RMS)
}
private fun rms(frame: ShortArray): Float {
if (frame.isEmpty()) return 0f
var sum = 0.0
frame.forEach { sample ->
val value = sample.toDouble()
sum += value * value
}
return sqrt(sum / frame.size).toFloat()
}
companion object {
const val DEFAULT_THRESHOLD_MULTIPLIER = 3f
const val DEFAULT_PLAYBACK_GRACE_MS = 500L
internal const val DEFAULT_CALIBRATION_FRAMES = 14
internal const val DEFAULT_DECISION_WINDOW_FRAMES = 10
internal const val DEFAULT_REQUIRED_WINDOW_RATIO = 0.8f
internal const val MIN_PLAYBACK_THRESHOLD_RMS = 1_500f
internal const val MAX_THRESHOLD_RMS = 4_000f
internal const val MIN_GENERATION_THRESHOLD_RMS = 400f
internal const val DEFAULT_QUIET_FLOOR_RMS = 200f
internal const val MIN_QUIET_FLOOR_RMS = 200f
internal const val MAX_AMBIENT_FRAMES = 100
internal const val PLAYBACK_GRACE_REARM_GAP_MS = 1_000L
internal const val MIN_THRESHOLD_MULTIPLIER = 1f
internal const val MAX_THRESHOLD_MULTIPLIER = 8f
}
}
internal data class RmsGateResult(
val maybeSpeech: Boolean,
val detected: Boolean,
val rms: Float,
val floor: Float,
val threshold: Float,
val calibrating: Boolean,
val playbackGrace: Boolean,
val playback: Boolean,
)
@@ -8,6 +8,9 @@ import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.util.Log
import com.hermesandroid.relay.wake.MicrophoneLease
import com.hermesandroid.relay.wake.MicrophoneOwner
import com.hermesandroid.relay.wake.MicrophoneOwnershipCoordinator
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -57,6 +60,7 @@ class VoiceRecorder(
private val bufferLock = Any()
private val stopRequested = AtomicBoolean(false)
private var audioRecord: AudioRecord? = null
private var microphoneLease: MicrophoneLease? = null
private var echoCanceler: AcousticEchoCanceler? = null
private var noiseSuppressor: NoiseSuppressor? = null
private var currentOutputFile: File? = null
@@ -79,12 +83,21 @@ class VoiceRecorder(
releaseRecorder()
}
}
val lease = MicrophoneOwnershipCoordinator.tryAcquire(MicrophoneOwner.VoiceCapture)
?: throw IllegalStateException("Microphone is in use by another voice feature")
microphoneLease = lease
val minBuffer = AudioRecord.getMinBufferSize(
val minBuffer = try {
AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(SAMPLE_RATE / 10 * BYTES_PER_SAMPLE)
).coerceAtLeast(SAMPLE_RATE / 10 * BYTES_PER_SAMPLE)
} catch (t: Throwable) {
MicrophoneOwnershipCoordinator.release(lease)
microphoneLease = null
throw t
}
val outFile = File(context.cacheDir, "voice_rec_${System.currentTimeMillis()}.wav")
currentOutputFile = outFile
@@ -95,21 +108,29 @@ class VoiceRecorder(
stopRequested.set(false)
_amplitude.value = 0f
val recorder = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build()
)
.setBufferSizeInBytes(minBuffer * 2)
.build()
val recorder = try {
AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build()
)
.setBufferSizeInBytes(minBuffer * 2)
.build()
} catch (t: Throwable) {
MicrophoneOwnershipCoordinator.release(lease)
microphoneLease = null
throw t
}
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
recorder.release()
currentOutputFile = null
MicrophoneOwnershipCoordinator.release(lease)
microphoneLease = null
throw IllegalStateException("AudioRecord failed to initialize")
}
@@ -118,6 +139,8 @@ class VoiceRecorder(
} catch (e: Exception) {
recorder.release()
currentOutputFile = null
MicrophoneOwnershipCoordinator.release(lease)
microphoneLease = null
throw e
}
@@ -281,6 +304,8 @@ class VoiceRecorder(
try { record.release() } catch (_: Exception) { }
}
audioRecord = null
microphoneLease?.let(MicrophoneOwnershipCoordinator::release)
microphoneLease = null
readThread = null
readDone = null
}
@@ -1,9 +1,14 @@
package com.hermesandroid.relay.auth
import android.content.Context
import android.provider.Settings
import android.util.Log
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.BrokerEndpoint
import com.hermesandroid.relay.data.hasHermesReach
import com.hermesandroid.relay.data.replaceHermesReachCredential
import com.hermesandroid.relay.data.sameBrokerAuthority
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
@@ -14,11 +19,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -48,6 +56,7 @@ data class ConnectionAuthSecrets(
val refreshToken: String? = null,
val deviceId: String? = null,
val apiKey: String? = null,
val profileApiKeys: Map<String, String> = emptyMap(),
val pairedSessionMetaJson: String? = null,
)
@@ -114,6 +123,7 @@ class AuthManager(
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_API_KEY = "api_server_key"
private const val KEY_PROFILE_API_KEYS = "profile_api_server_keys"
private const val HINT_API_KEY_PRESENT = "api_key_present"
private const val KEY_PAIRED_META = "paired_session_meta_json"
// Marker (in the connection-0 token store) recording that the one-shot
@@ -132,6 +142,29 @@ class AuthManager(
*/
const val CONNECTION_ID_LEGACY: String = "legacy"
internal fun encodeProfileApiKeys(keys: Map<String, String>): String =
Json.encodeToString(
keys.mapNotNull { (profile, key) ->
val normalizedProfile = profile.trim()
val normalizedKey = key.trim()
if (normalizedProfile.isBlank() || normalizedKey.isBlank()) null
else normalizedProfile to normalizedKey
}.toMap(),
)
internal fun decodeProfileApiKeys(raw: String?): Map<String, String> {
if (raw.isNullOrBlank()) return emptyMap()
return runCatching { Json.decodeFromString<Map<String, String>>(raw) }
.getOrDefault(emptyMap())
.mapNotNull { (profile, key) ->
val normalizedProfile = profile.trim()
val normalizedKey = key.trim()
if (normalizedProfile.isBlank() || normalizedKey.isBlank()) null
else normalizedProfile to normalizedKey
}
.toMap()
}
internal fun shouldPreservePairedSessionOnAuthFail(
currentState: AuthState,
rawReason: String,
@@ -173,6 +206,7 @@ class AuthManager(
refreshToken = store.getString(KEY_REFRESH_TOKEN),
deviceId = store.getString(KEY_DEVICE_ID),
apiKey = store.getString(KEY_API_KEY),
profileApiKeys = decodeProfileApiKeys(store.getString(KEY_PROFILE_API_KEYS)),
pairedSessionMetaJson = store.getString(KEY_PAIRED_META),
)
}
@@ -188,6 +222,11 @@ class AuthManager(
writeOrRemove(store, KEY_REFRESH_TOKEN, secrets.refreshToken)
writeOrRemove(store, KEY_DEVICE_ID, secrets.deviceId)
writeOrRemove(store, KEY_API_KEY, secrets.apiKey)
writeOrRemove(
store,
KEY_PROFILE_API_KEYS,
secrets.profileApiKeys.takeIf { it.isNotEmpty() }?.let(::encodeProfileApiKeys),
)
writeOrRemove(store, KEY_PAIRED_META, secrets.pairedSessionMetaJson)
}
}
@@ -290,6 +329,7 @@ class AuthManager(
private var _store: SessionTokenStore? = null
private val storeMutex = Mutex()
private val profileApiKeysMutex = Mutex()
/**
* The encrypted-store filename for this connection — shared by [store]
@@ -416,6 +456,7 @@ class AuthManager(
KEY_REFRESH_TOKEN,
KEY_DEVICE_ID,
KEY_API_KEY,
KEY_PROFILE_API_KEYS,
KEY_PAIRED_META,
)
var migrated = false
@@ -522,6 +563,12 @@ class AuthManager(
* Either way, we leave the previously-persisted list untouched.
*/
private var pendingEndpoints: List<EndpointCandidate>? = null
private var activeEndpointProvider: () -> EndpointCandidate? = { null }
/** Bind auth.ok route credentials to the transport that actually carried them. */
fun setActiveEndpointProvider(provider: () -> EndpointCandidate?) {
activeEndpointProvider = provider
}
/**
* Server-advertised agent profiles from the `auth.ok` payload's
@@ -596,7 +643,7 @@ class AuthManager(
val now = System.currentTimeMillis() / 1000L
val defaults = PairedSession(
token = token,
deviceName = android.os.Build.MODEL,
deviceName = relayDeviceName(),
expiresAt = null,
grants = emptyMap(),
transportHint = null,
@@ -616,7 +663,7 @@ class AuthManager(
val transportHint = obj["transport_hint"]?.jsonPrimitive?.contentOrNull
val firstSeen = obj["first_seen"]?.jsonPrimitive?.longOrNull ?: now
val deviceName = obj["device_name"]?.jsonPrimitive?.contentOrNull
?: android.os.Build.MODEL
?: relayDeviceName()
PairedSession(
token = token,
@@ -715,6 +762,26 @@ class AuthManager(
})
}
private fun JsonObjectBuilder.putRelayDeviceIdentity() {
val model = android.os.Build.MODEL.orEmpty().ifBlank { "Android device" }
val deviceName = relayDeviceName()
put("device_name", deviceName)
put("device_hostname", deviceName)
put("device_model", model)
put("device_platform", "Android ${android.os.Build.VERSION.RELEASE}")
put("client_surface", "android")
put("device_form_factor", "phone")
}
private fun relayDeviceName(): String {
val configured = runCatching {
Settings.Global.getString(context.contentResolver, "device_name")
}.getOrNull()?.trim().orEmpty()
return configured.ifBlank {
android.os.Build.MODEL.orEmpty().ifBlank { "Android device" }
}
}
/**
* Send auth envelope when connection is established.
*
@@ -749,7 +816,7 @@ class AuthManager(
put("refresh_token", refreshToken)
}
put("device_id", deviceId)
put("device_name", android.os.Build.MODEL)
putRelayDeviceIdentity()
putRelayClientSupports()
}
}
@@ -765,7 +832,7 @@ class AuthManager(
buildJsonObject {
put("pairing_code", codeToSend)
put("device_id", deviceId)
put("device_name", android.os.Build.MODEL)
putRelayDeviceIdentity()
putRelayClientSupports()
pendingTtlSeconds?.let { put("ttl_seconds", it) }
pendingGrants?.let { grants ->
@@ -947,6 +1014,27 @@ class AuthManager(
recordApiKeyHint(false)
}
suspend fun getProfileApiKey(profileName: String): String? =
decodeProfileApiKeys(store().getString(KEY_PROFILE_API_KEYS))[profileName.trim()]
suspend fun setProfileApiKey(profileName: String, key: String) {
val normalizedProfile = profileName.trim()
require(normalizedProfile.isNotBlank()) { "Profile name must not be blank" }
profileApiKeysMutex.withLock {
val tokenStore = store()
val keys = decodeProfileApiKeys(tokenStore.getString(KEY_PROFILE_API_KEYS)).toMutableMap()
val normalizedKey = key.trim()
if (normalizedKey.isBlank()) keys.remove(normalizedProfile)
else keys[normalizedProfile] = normalizedKey
if (keys.isEmpty()) tokenStore.remove(KEY_PROFILE_API_KEYS)
else tokenStore.putString(KEY_PROFILE_API_KEYS, encodeProfileApiKeys(keys))
}
}
suspend fun clearProfileApiKey(profileName: String) {
setProfileApiKey(profileName, "")
}
val isPaired: Boolean
get() = _authState.value is AuthState.Paired
@@ -965,6 +1053,7 @@ class AuthManager(
}
if (token != null) {
applyBrokerRouteCredential(payload)
val s = store()
s.putString(KEY_SESSION_TOKEN, token)
val refreshToken = payload["refresh_token"]
@@ -1010,7 +1099,7 @@ class AuthManager(
val paired = PairedSession(
token = token,
deviceName = android.os.Build.MODEL,
deviceName = relayDeviceName(),
expiresAt = expiresAt,
grants = grantsMap,
transportHint = transportHint,
@@ -1073,6 +1162,40 @@ class AuthManager(
}
}
private suspend fun applyBrokerRouteCredential(payload: JsonObject) {
val active = activeEndpointProvider()?.takeIf { it.hasHermesReach() } ?: return
val current = active.broker ?: return
// Fresh pairing is scoped by pendingEndpoints; reconnect rotation is
// accepted only by this connection-scoped AuthManager's live session.
if (pendingEndpoints == null && _authState.value !is AuthState.Paired) return
val credential = payload["route_credential"] as? JsonObject ?: return
if (credential["kind"]?.jsonPrimitive?.contentOrNull != "broker_route") return
val brokerUrl = credential["broker_url"]?.jsonPrimitive?.contentOrNull ?: return
val hostId = credential["host_id"]?.jsonPrimitive?.contentOrNull ?: return
if (!sameBrokerAuthority(brokerUrl, current.url) || hostId != current.hostId) {
Log.w(TAG, "Ignoring broker route credential that does not match the active paired route")
return
}
val replacement = BrokerEndpoint(
url = current.url,
protocolVersion = current.protocolVersion,
hostId = current.hostId,
credentialKind = "route",
token = credential["token"]?.jsonPrimitive?.contentOrNull ?: return,
expiresAt = credential["expires_at"]?.jsonPrimitive?.longOrNull,
)
val validated = active.copy(broker = replacement).takeIf { it.hasHermesReach() } ?: return
val deviceId = getDeviceId()
val source = pendingEndpoints
?: PairingPreferences.getDeviceEndpoints(context, deviceId).first()
val updated = replaceHermesReachCredential(source, current, validated)
if (updated == source) return
if (pendingEndpoints != null) pendingEndpoints = updated
else PairingPreferences.setDeviceEndpoints(context, deviceId, updated)
Log.i(TAG, "Accepted a durable Hermes Reach route credential for the active paired route")
}
private fun handleAuthFail(envelope: Envelope) {
try {
val rawReason = envelope.payload["reason"]?.jsonPrimitive?.contentOrNull
@@ -90,12 +90,28 @@ class CertPinStore(private val context: Context) {
if (pins.isEmpty()) return CertificatePinner.DEFAULT
val builder = CertificatePinner.Builder()
for ((hostPort, pin) in pins) {
val host = hostPort.substringBefore(':')
val host = hostPort.substringBeforeLast(':')
builder.add(host, pin)
}
return builder.build()
}
/**
* Build a pinner for one exact URL authority. CertificatePinner keys by
* hostname only, so adding every stored host:port entry to one client
* accidentally lets a pin learned on one port govern another port.
*/
fun buildPinnerSnapshotFor(url: String): CertificatePinner {
val hostPort = hostPortFromUrl(url) ?: return CertificatePinner.DEFAULT
val pin = getPinsBlocking()[hostPort] ?: return CertificatePinner.DEFAULT
val host = runCatching { URI(url.trim()).host }.getOrNull()
?.takeIf { it.isNotBlank() }
?: return CertificatePinner.DEFAULT
return CertificatePinner.Builder()
.add(host, pin)
.build()
}
/**
* Record a pin for a host. Called from the WebSocket listener's `onOpen`
* when we have a successful connection and can read the peer certs from
@@ -74,6 +74,14 @@ data class PairedDeviceInfo(
val deviceName: String = "",
@SerialName("device_id")
val deviceId: String = "",
@SerialName("device_model")
val deviceModel: String = "",
@SerialName("device_platform")
val devicePlatform: String = "",
@SerialName("client_surface")
val clientSurface: String = "",
@SerialName("device_form_factor")
val deviceFormFactor: String = "",
@SerialName("created_at")
val createdAt: Double? = null,
@SerialName("last_seen")
@@ -3,10 +3,10 @@ package com.hermesandroid.relay.data
/**
* Shared profile/personality display and request identity helpers.
*
* A null profile name is the app's explicit "Server default" state. The
* relay also advertises the root Hermes config as a synthetic profile named
* "default"; for request/session identity that row is an alias of server
* default so it does not split chat, voice, or session scope.
* A null profile name is the app's explicit "Server default" state. It is
* intentionally distinct from a real profile whose name is literally
* `default`: the former follows the server's sticky default, while the latter
* explicitly addresses the root profile.
*/
object AgentDisplay {
const val SERVER_DEFAULT_PROFILE_KEY: String = "__server_default__"
@@ -16,22 +16,30 @@ object AgentDisplay {
"hermes agent",
)
// Only an EXPLICIT pick drives request/session identity. The advertised
// "default" profile is an alias for server default, so falling back to it
// here would split chat, voice, or session scope.
// Only an explicit pick drives request identity. Server default is the null
// selection; a named `default` profile is an ordinary explicit pick.
@Suppress("UNUSED_PARAMETER")
fun effectiveProfile(
selectedProfile: Profile?,
profiles: List<Profile>,
): Profile? = selectedProfile
// Display can use the synthetic default profile's metadata without making
// it a request/session override. Verbose SOUL summaries are filtered by
// Display can use the root default profile's metadata without making it a
// request/session override. Verbose SOUL summaries are filtered by
// profileDisplayName below, so this is safe for headers/cards.
fun effectiveDisplayProfile(
selectedProfile: Profile?,
profiles: List<Profile>,
): Profile? = selectedProfile ?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
serverDefaultProfileName: String? = null,
): Profile? {
selectedProfile?.let { return it }
val resolvedServerDefault = profileRequestName(serverDefaultProfileName)
return resolvedServerDefault
?.let { activeName ->
profiles.firstOrNull { it.name.equals(activeName, ignoreCase = true) }
}
?: profiles.firstOrNull { it.name.equals("default", ignoreCase = true) }
}
// The NAME goes in the name slot. Non-default profiles use their profile
// name first. The synthetic default profile uses its description only when
@@ -39,7 +47,7 @@ object AgentDisplay {
// verbose SOUL summary.
fun profileDisplayName(profile: Profile?): String? {
if (profile == null) return null
if (isServerDefaultAlias(profile.name)) {
if (profile.name.equals("default", ignoreCase = true)) {
return defaultProfileDisplayName(profile)
}
return when {
@@ -126,22 +134,18 @@ object AgentDisplay {
?.takeIf { it.isNotEmpty() }
?.takeUnless { it.lowercase() in GENERIC_MODEL_ALIASES }
fun isServerDefaultAlias(profileName: String?): Boolean =
profileName?.trim()?.equals("default", ignoreCase = true) == true
fun normalizeSelection(profile: Profile?): Profile? =
if (isServerDefaultAlias(profile?.name)) null else profile
fun normalizeSelection(profile: Profile?): Profile? = profile
fun profileRequestName(profileName: String?): String? =
profileName
?.trim()
?.takeIf { it.isNotEmpty() && !isServerDefaultAlias(it) }
?.takeIf { it.isNotEmpty() && it != SERVER_DEFAULT_PROFILE_KEY }
/**
* The profile name that owns chat sessions for the current UI selection.
*
* [selectedProfileName] is null (or the synthetic `default` alias) for the
* "Server default" row. That UI sentinel must remain distinct from the
* [selectedProfileName] is null for the "Server default" row. That UI
* sentinel must remain distinct from the
* server's sticky active profile: a dashboard launched under the root home
* may still report `active=victor`, in which case upstream Gateway and
* dashboard session calls must explicitly target `victor`. The resolved
@@ -7,8 +7,12 @@ import java.util.Locale
enum class AppLanguage(val languageTag: String) {
SYSTEM_DEFAULT(""),
ENGLISH("en"),
GERMAN("de"),
BRAZILIAN_PORTUGUESE("pt-BR"),
JAPANESE("ja"),
SIMPLIFIED_CHINESE("zh-Hans"),
SPANISH("es"),
RUSSIAN("ru"),
;
fun toLocaleList(): LocaleListCompat = if (languageTag.isEmpty()) {
@@ -27,8 +31,12 @@ enum class AppLanguage(val languageTag: String) {
val locale = Locale.forLanguageTag(primaryTag)
return when (locale.language.lowercase(Locale.ROOT)) {
"de" -> GERMAN
"en" -> ENGLISH
"es" -> SPANISH
"ja" -> JAPANESE
"pt" -> BRAZILIAN_PORTUGUESE
"ru" -> RUSSIAN
"zh" -> {
val simplified = locale.script.equals("Hans", ignoreCase = true) ||
locale.script.isEmpty() ||
@@ -5,6 +5,8 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -15,15 +17,14 @@ import kotlinx.coroutines.flow.map
*
* Phase V follow-on — owned by the voice-barge-in plan (Wave 1 / unit B1).
*
* Barge-in lets the user interrupt TTS playback by speaking. The three knobs
* Barge-in lets the user interrupt generation or TTS playback by speaking.
* here back the Voice Settings "Interruption" section added by B5 and are
* consumed by [com.hermesandroid.relay.viewmodel.VoiceViewModel] (wired in
* B4):
*
* - [enabled] — master toggle for the whole barge-in path. When false, the
* listener never starts and TTS plays uninterrupted. Default off at launch
* on both flavors so existing users aren't surprised by mic activation
* during a speaking turn.
* listener never starts and TTS plays uninterrupted. Default on matches
* upstream Hermes full-duplex voice; users can opt out here.
*
* - [sensitivity] — maps to Silero VAD threshold + hysteresis tuning inside
* [com.hermesandroid.relay.audio.VadEngine]. [BargeInSensitivity.Off] is
@@ -36,6 +37,12 @@ import kotlinx.coroutines.flow.map
* barge-in behaves like a hard cancel, which is more abrupt than most
* conversational UX expects.
*
* - [thresholdMultiplier] / [playbackGraceMs] — upstream-compatible RMS
* tuning. Defaults are 3x over the calibrated quiet floor and 500 ms.
*
* - [debugDiagnostics] — opt-in per-block VAD decision logging for logcat,
* equivalent to upstream's HERMES_VOICE_DEBUG switch.
*
* Matches the [BridgePreferences] / [VoicePreferences] / [MediaSettings] style:
* single shared DataStore (`relayDataStore`), one key per scalar field, enum
* stored as its `name` (cheap + schema-evolvable via fall-back to default on
@@ -45,6 +52,9 @@ data class BargeInPreferences(
val enabled: Boolean = DEFAULT_ENABLED,
val sensitivity: BargeInSensitivity = DEFAULT_SENSITIVITY,
val resumeAfterInterruption: Boolean = DEFAULT_RESUME_AFTER_INTERRUPTION,
val thresholdMultiplier: Float = DEFAULT_THRESHOLD_MULTIPLIER,
val playbackGraceMs: Long = DEFAULT_PLAYBACK_GRACE_MS,
val debugDiagnostics: Boolean = DEFAULT_DEBUG_DIAGNOSTICS,
)
/**
@@ -62,9 +72,12 @@ enum class BargeInSensitivity {
High,
}
const val DEFAULT_ENABLED: Boolean = false
const val DEFAULT_ENABLED: Boolean = true
val DEFAULT_SENSITIVITY: BargeInSensitivity = BargeInSensitivity.Default
const val DEFAULT_RESUME_AFTER_INTERRUPTION: Boolean = true
const val DEFAULT_THRESHOLD_MULTIPLIER: Float = 3f
const val DEFAULT_PLAYBACK_GRACE_MS: Long = 500L
const val DEFAULT_DEBUG_DIAGNOSTICS: Boolean = false
/**
* DataStore-backed repository for [BargeInPreferences].
@@ -86,6 +99,10 @@ class BargeInPreferencesRepository(
internal val KEY_SENSITIVITY = stringPreferencesKey("barge_in_sensitivity")
internal val KEY_RESUME_AFTER_INTERRUPTION =
booleanPreferencesKey("barge_in_resume_after_interruption")
internal val KEY_THRESHOLD_MULTIPLIER =
floatPreferencesKey("barge_in_threshold_multiplier")
internal val KEY_PLAYBACK_GRACE_MS = longPreferencesKey("barge_in_playback_grace_ms")
internal val KEY_DEBUG_DIAGNOSTICS = booleanPreferencesKey("barge_in_debug_diagnostics")
}
val flow: Flow<BargeInPreferences> = dataStore.data
@@ -96,6 +113,14 @@ class BargeInPreferencesRepository(
?: DEFAULT_SENSITIVITY,
resumeAfterInterruption = prefs[KEY_RESUME_AFTER_INTERRUPTION]
?: DEFAULT_RESUME_AFTER_INTERRUPTION,
thresholdMultiplier = prefs[KEY_THRESHOLD_MULTIPLIER]
?.coerceIn(MIN_THRESHOLD_MULTIPLIER, MAX_THRESHOLD_MULTIPLIER)
?: DEFAULT_THRESHOLD_MULTIPLIER,
playbackGraceMs = prefs[KEY_PLAYBACK_GRACE_MS]
?.coerceIn(MIN_PLAYBACK_GRACE_MS, MAX_PLAYBACK_GRACE_MS)
?: DEFAULT_PLAYBACK_GRACE_MS,
debugDiagnostics = prefs[KEY_DEBUG_DIAGNOSTICS]
?: DEFAULT_DEBUG_DIAGNOSTICS,
)
}
.distinctUntilChanged()
@@ -112,6 +137,33 @@ class BargeInPreferencesRepository(
dataStore.edit { it[KEY_RESUME_AFTER_INTERRUPTION] = value }
}
suspend fun setThresholdMultiplier(value: Float) {
dataStore.edit {
it[KEY_THRESHOLD_MULTIPLIER] = value.coerceIn(
MIN_THRESHOLD_MULTIPLIER,
MAX_THRESHOLD_MULTIPLIER,
)
}
}
suspend fun setPlaybackGraceMs(value: Long) {
dataStore.edit {
it[KEY_PLAYBACK_GRACE_MS] = value.coerceIn(
MIN_PLAYBACK_GRACE_MS,
MAX_PLAYBACK_GRACE_MS,
)
}
}
suspend fun setDebugDiagnostics(value: Boolean) {
dataStore.edit { it[KEY_DEBUG_DIAGNOSTICS] = value }
}
private fun decodeSensitivity(raw: String): BargeInSensitivity =
runCatching { BargeInSensitivity.valueOf(raw) }.getOrDefault(DEFAULT_SENSITIVITY)
}
private const val MIN_THRESHOLD_MULTIPLIER = 1f
private const val MAX_THRESHOLD_MULTIPLIER = 8f
private const val MIN_PLAYBACK_GRACE_MS = 0L
private const val MAX_PLAYBACK_GRACE_MS = 3_000L
@@ -0,0 +1,147 @@
package com.hermesandroid.relay.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Immutable owner of one composer draft.
*
* Callers must supply stable ids rather than display labels. [sessionId] may be
* a server id or a stable client-generated id for a not-yet-created session.
* [draftId] separates the primary composer from any future named draft slot.
*/
data class ChatComposerDraftKey(
val connectionId: String,
val profileId: String,
val sessionId: String,
val draftId: String = PRIMARY_DRAFT_ID,
) {
init {
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
require(profileId.isNotBlank()) { "profileId must not be blank" }
require(sessionId.isNotBlank()) { "sessionId must not be blank" }
require(draftId.isNotBlank()) { "draftId must not be blank" }
}
companion object {
const val PRIMARY_DRAFT_ID = "primary"
const val DEFAULT_PROFILE_ID = "default"
}
}
/** Message references associated with composer content. */
data class ChatComposerDraftContext(
val quotedMessageId: String? = null,
val editingMessageId: String? = null,
) {
internal fun normalized(): ChatComposerDraftContext = copy(
quotedMessageId = quotedMessageId?.takeIf(String::isNotBlank),
editingMessageId = editingMessageId?.takeIf(String::isNotBlank),
)
}
/**
* Complete restorable state for one composer.
*
* Selection offsets use the same start-inclusive/end-exclusive convention as
* Compose text fields. The store clamps them whenever the text changes so a
* restored selection can never address outside the restored string.
*/
data class ChatComposerDraft(
val text: String = "",
val selectionStart: Int = text.length,
val selectionEnd: Int = selectionStart,
val context: ChatComposerDraftContext = ChatComposerDraftContext(),
val attachments: List<Attachment> = emptyList(),
) {
val isEmpty: Boolean
get() = text.isEmpty() &&
context.quotedMessageId == null &&
context.editingMessageId == null &&
attachments.isEmpty()
internal fun normalized(): ChatComposerDraft {
val normalizedStart = selectionStart.coerceIn(0, text.length)
val normalizedEnd = selectionEnd.coerceIn(0, text.length)
return copy(
selectionStart = minOf(normalizedStart, normalizedEnd),
selectionEnd = maxOf(normalizedStart, normalizedEnd),
context = context.normalized(),
attachments = attachments.toList(),
)
}
}
/**
* Session-owned composer state.
*
* This store is deliberately memory-only: outbound [Attachment.content] can
* contain large Base64 payloads and must not enter Preferences DataStore. Keep
* one instance in the chat owner (normally its ViewModel) so drafts survive
* navigation and Activity recreation. Process death starts with empty drafts;
* a future durable implementation should persist URI grants, not attachment
* bytes.
*/
interface ChatComposerDraftStore {
fun observe(key: ChatComposerDraftKey): Flow<ChatComposerDraft>
fun snapshot(key: ChatComposerDraftKey): ChatComposerDraft
fun save(key: ChatComposerDraftKey, draft: ChatComposerDraft)
fun update(
key: ChatComposerDraftKey,
transform: (ChatComposerDraft) -> ChatComposerDraft,
)
fun remove(key: ChatComposerDraftKey)
fun removeSession(connectionId: String, profileId: String, sessionId: String)
fun clear()
}
class InMemoryChatComposerDraftStore : ChatComposerDraftStore {
private val drafts = MutableStateFlow<Map<ChatComposerDraftKey, ChatComposerDraft>>(emptyMap())
override fun observe(key: ChatComposerDraftKey): Flow<ChatComposerDraft> =
drafts
.map { it[key] ?: ChatComposerDraft() }
.distinctUntilChanged()
override fun snapshot(key: ChatComposerDraftKey): ChatComposerDraft =
drafts.value[key] ?: ChatComposerDraft()
@Synchronized
override fun save(key: ChatComposerDraftKey, draft: ChatComposerDraft) {
val normalized = draft.normalized()
drafts.value = if (normalized.isEmpty) {
drafts.value - key
} else {
drafts.value + (key to normalized)
}
}
@Synchronized
override fun update(
key: ChatComposerDraftKey,
transform: (ChatComposerDraft) -> ChatComposerDraft,
) {
save(key, transform(snapshot(key)))
}
@Synchronized
override fun remove(key: ChatComposerDraftKey) {
drafts.value = drafts.value - key
}
@Synchronized
override fun removeSession(connectionId: String, profileId: String, sessionId: String) {
drafts.value = drafts.value.filterKeys { key ->
key.connectionId != connectionId ||
key.profileId != profileId ||
key.sessionId != sessionId
}
}
@Synchronized
override fun clear() {
drafts.value = emptyMap()
}
}
@@ -0,0 +1,48 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/** Phone-local behavior for a physical keyboard's unmodified Enter key. */
enum class PhysicalKeyboardEnterBehavior(val storedValue: String) {
SendMessage("send_message"),
InsertNewline("insert_newline"),
;
companion object {
fun fromStoredValue(value: String?): PhysicalKeyboardEnterBehavior =
entries.firstOrNull { it.storedValue == value } ?: SendMessage
}
}
/** Device-level chat input preferences shared by every Hermes profile. */
class ChatInputPreferencesRepository(
private val dataStore: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.relayDataStore)
companion object {
internal val KEY_PHYSICAL_KEYBOARD_ENTER =
stringPreferencesKey("physical_keyboard_enter_behavior")
}
val physicalKeyboardEnterBehavior: Flow<PhysicalKeyboardEnterBehavior> = dataStore.data
.map { preferences ->
PhysicalKeyboardEnterBehavior.fromStoredValue(
preferences[KEY_PHYSICAL_KEYBOARD_ENTER],
)
}
.distinctUntilChanged()
suspend fun setPhysicalKeyboardEnterBehavior(behavior: PhysicalKeyboardEnterBehavior) {
dataStore.edit { preferences ->
preferences[KEY_PHYSICAL_KEYBOARD_ENTER] = behavior.storedValue
}
}
}
@@ -112,12 +112,11 @@ data class ChatMessage(
*/
val clientOnly: Boolean = false,
/**
* Delivery state for a message the user sends into an agent **Thread** over
* the relay proactive channel ([com.hermesandroid.relay.viewmodel.ChatViewModel]
* routes `source=phone` sessions here instead of the normal chat send).
* `SENDING` until the relay acks (`proactive.reply.ack`) → `DELIVERED`;
* `FAILED` on a send error. Null for ordinary chat messages — those render
* no status affix.
* Delivery state for a user-authored message. Agent **Thread** replies use
* `SENDING` until the relay acks (`proactive.reply.ack`) → `DELIVERED`,
* with `FAILED` on a send error. Ordinary chat may additionally use
* `QUEUED` and `STEERED` to make active-turn routing visible. Null keeps the
* legacy behavior of rendering no status affix.
*/
val deliveryStatus: MessageDeliveryStatus? = null,
/**
@@ -129,6 +128,40 @@ data class ChatMessage(
* the live message can be matched to its server row.
*/
val backgroundTask: BackgroundTaskState? = null,
/**
* Stable identity for Compose list rendering.
*
* Gateway/user rows start with client UUIDs, then post-turn history
* reconciliation adopts the server message id into [id]. That server-id
* adoption must not make a visible bubble look removed and reinserted to
* LazyColumn: doing so discards its scroll anchor, which is especially
* disruptive when the row is a long answer occupying the viewport.
*
* New rows default to their current [id]. Reconciled rows retain this key
* through `copy`, while [id] remains the authoritative lookup/wire id.
*/
val uiKey: String = id,
/**
* Durable Gateway transcript row identity for rewind/edit-regenerate.
* This is server-owned and can change after a truncating rewrite; it is
* never used as a Compose key or synthesized client-side.
*/
val rowId: Long? = null,
/**
* Mixture-of-Agents advisor responses surfaced during the live turn.
* Unavailable advisors retain only neutral state, never their raw failure
* body. A sanitized bounded copy may enter the local in-flight checkpoint,
* but server history never owns these presentation blocks.
*/
val moaReferences: List<MoaReference> = emptyList(),
)
data class MoaReference(
val index: Int,
val count: Int?,
val label: String,
val text: String,
val available: Boolean = true,
)
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
@@ -339,12 +372,20 @@ data class ToolCall(
* header can render without a separate lane registry.
*/
val taskLabel: String? = null,
/** Live upstream child id used by subagent.steer while this lane runs. */
val subagentId: String? = null,
/** Deterministic non-low output risk reported by upstream for this call. */
val outputRisk: String? = null,
/** Human-readable deterministic findings; rendered as untrusted metadata. */
val outputRiskFindings: List<String> = emptyList(),
/** Upstream removed sensitive spans before emitting the findings. */
val outputRiskRedacted: Boolean = false,
/**
* Stable UI identity retained when a generating placeholder adopts its
* gateway tool ID. This keeps per-card interaction state attached to the
* logical call across streaming reconciliation and list updates.
*/
val uiKey: String = id ?: "$name:$startedAt",
)
enum class MessageRole {
@@ -354,15 +395,17 @@ enum class MessageRole {
}
/**
* Delivery state of a user reply sent into an agent Thread over the relay
* proactive channel. Only set on Thread replies; ordinary chat messages leave
* it null and show no status affix.
* Delivery state of a user-authored message. Thread replies use the relay ack
* lifecycle; ordinary chat can additionally expose queue and steer outcomes.
* Null preserves the legacy behavior of rendering no status affix.
*
* - [SENDING] handed to the relay; awaiting the per-reply ack.
* - [QUEUED] held client-side until the active turn completes.
* - [STEERED] accepted as a correction to the active turn.
* - [DELIVERED] the relay acked (`proactive.reply.ack`) — buffered for the agent.
* - [FAILED] the send errored (e.g. relay disconnected).
*/
enum class MessageDeliveryStatus { SENDING, DELIVERED, FAILED }
enum class MessageDeliveryStatus { SENDING, QUEUED, STEERED, DELIVERED, FAILED }
data class ChatSession(
val sessionId: String,
@@ -379,6 +422,19 @@ data class ChatSession(
* for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12).
*/
val source: String? = null,
/** Server reports a persisted session runtime/model binding. */
val hasModelConfig: Boolean = false,
/** Durable upstream session metadata, scoped by the owning connection/profile DB. */
val pinned: Boolean = false,
val archived: Boolean = false,
/** Optional newer-upstream workspace context; absent on legacy/API-only hosts. */
val workingDirectory: String? = null,
val gitBranch: String? = null,
val gitRepoRoot: String? = null,
val pullRequestNumber: Int? = null,
val pullRequestUrl: String? = null,
val pullRequestState: String? = null,
val pullRequestDraft: Boolean = false,
) {
val activityTimestamp: Long
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
@@ -0,0 +1,66 @@
package com.hermesandroid.relay.data
import java.nio.charset.StandardCharsets
import java.util.Base64
/** Structured identity and preview for a quoted chat message. */
data class ChatQuoteReference(
val messageId: String,
val authorLabel: String,
val excerpt: String,
)
/** Parsed transport envelope: Android renders [reference] separately from [body]. */
data class ChatQuoteEnvelope(
val reference: ChatQuoteReference,
val body: String,
)
/**
* Serialize a structured quote as ordinary Markdown for unmodified Hermes clients.
* Android parses the same envelope back into a quote chip, while Desktop/TUI see
* a readable linked attribution instead of an Android-only marker.
*/
fun buildChatQuotedPrompt(body: String, reference: ChatQuoteReference?): String {
if (reference == null) return body
val encodedId = Base64.getUrlEncoder().withoutPadding().encodeToString(
reference.messageId.toByteArray(StandardCharsets.UTF_8),
)
val author = reference.authorLabel.normalizedQuoteText(MAX_AUTHOR_CHARS)
val excerpt = reference.excerpt.normalizedQuoteText(MAX_EXCERPT_CHARS)
if (encodedId.isBlank() || author.isBlank() || excerpt.isBlank()) return body
return "> **Replying to [@$author](hermes-message://$encodedId):** $excerpt\n\n$body"
}
/** Parse only the exact bounded envelope emitted by [buildChatQuotedPrompt]. */
fun parseChatQuotedPrompt(content: String): ChatQuoteEnvelope? {
val match = QUOTE_ENVELOPE.matchEntire(content) ?: return null
val author = match.groupValues[1]
val encodedId = match.groupValues[2]
val excerpt = match.groupValues[3]
val body = match.groupValues[4]
val messageId = runCatching {
String(Base64.getUrlDecoder().decode(encodedId), StandardCharsets.UTF_8)
}.getOrNull()?.takeIf { it.isNotBlank() && it.length <= MAX_MESSAGE_ID_CHARS } ?: return null
return ChatQuoteEnvelope(
reference = ChatQuoteReference(messageId, author, excerpt),
body = body,
)
}
private fun String.normalizedQuoteText(maxChars: Int): String =
replace(Regex("[\\p{Cc}\\s]+"), " ")
.replace("\\", "")
.replace("]", "")
.trim()
.take(maxChars)
private val QUOTE_ENVELOPE = Regex(
pattern = "^> \\*\\*Replying to \\[@([^]\\r\\n]{1,$MAX_AUTHOR_CHARS})]" +
"\\(hermes-message://([A-Za-z0-9_-]{1,512})\\):\\*\\* " +
"([^\\r\\n]{1,$MAX_EXCERPT_CHARS})\\n\\n([\\s\\S]*)$",
)
private const val MAX_AUTHOR_CHARS = 40
private const val MAX_EXCERPT_CHARS = 240
private const val MAX_MESSAGE_ID_CHARS = 512
@@ -32,6 +32,7 @@ data class ChatTurnCheckpoint(
val priorUserMessageCount: Int,
val baselineAssistantCount: Int,
val pendingAsk: ChatTurnAskCheckpoint? = null,
val queuedMessages: List<ChatQueuedMessageCheckpoint> = emptyList(),
val startedAt: Long,
val updatedAt: Long,
) {
@@ -41,6 +42,17 @@ data class ChatTurnCheckpoint(
}
}
@Serializable
data class ChatQueuedMessageCheckpoint(
val id: String,
val text: String,
val transport: String,
val ownerRunId: String,
val interfaceContextPrompt: String? = null,
/** Attachment bytes are intentionally not copied into Preferences DataStore. */
val hadAttachments: Boolean = false,
)
@Serializable
data class ChatTurnUserCheckpoint(
val id: String,
@@ -66,6 +78,17 @@ data class ChatTurnAssistantCheckpoint(
val cardDispatches: List<HermesCardDispatch> = emptyList(),
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null,
/** Sanitized, bounded live-only MoA presentation state; never server transcript data. */
val moaReferences: List<ChatTurnMoaReferenceCheckpoint> = emptyList(),
)
@Serializable
data class ChatTurnMoaReferenceCheckpoint(
val index: Int,
val count: Int? = null,
val label: String,
val text: String = "",
val available: Boolean = true,
)
@Serializable
@@ -106,6 +129,7 @@ data class ChatTurnAskCheckpoint(
val requestId: String? = null,
val text: String,
val choices: List<String>? = null,
val multiSelect: Boolean = false,
val smartDenied: Boolean = false,
val envVar: String? = null,
val timeoutSeconds: Int,
@@ -0,0 +1,31 @@
package com.hermesandroid.relay.data
/**
* Pure, persisted-state-derived availability for a Hermes connection.
*
* This deliberately describes configured surfaces, not live reachability or
* authentication. Runtime layers can combine it with their probe/auth state
* without treating a missing optional API server or Relay as a broken Hermes
* connection.
*/
data class ConnectionCapabilities(
val dashboardGatewayConfigured: Boolean,
val apiServerConfigured: Boolean,
val relayConfigured: Boolean,
) {
val gatewayChatAvailable: Boolean get() = dashboardGatewayConfigured
val manageAvailable: Boolean get() = dashboardGatewayConfigured
val standardVoiceAvailable: Boolean get() = dashboardGatewayConfigured
val apiChatFallbackAvailable: Boolean get() = apiServerConfigured
val relayFeaturesAvailable: Boolean get() = relayConfigured
val chatConfigured: Boolean get() = gatewayChatAvailable || apiChatFallbackAvailable
val anySurfaceConfigured: Boolean
get() = dashboardGatewayConfigured || apiServerConfigured || relayConfigured
}
val Connection.capabilities: ConnectionCapabilities
get() = ConnectionCapabilities(
dashboardGatewayConfigured = resolvedDashboardUrl.isNotBlank(),
apiServerConfigured = apiServerUrl.isNotBlank(),
relayConfigured = relayUrl.isNotBlank(),
)
@@ -13,13 +13,19 @@ data class DashboardConnectionStatus(
val authProvider: String? = null,
val gatewayTicketAvailable: Boolean? = null,
val message: String? = null,
val gatewayMode: String? = null,
/** Profiles positively advertised by the live multiplex gateway. */
val servedProfiles: List<String> = emptyList(),
/** Installed profiles reported by the dashboard; never routing authority. */
val profiles: List<String> = emptyList(),
)
/**
* A "connection" = a distinct Hermes server connection the app can switch between.
*
* Each connection has its own:
* - API server URL + relay URL
* - One or more independently-configured Hermes surfaces. Dashboard/Gateway
* is the standard primary path; API server and Relay are optional.
* - EncryptedSharedPreferences file (keyed by [tokenStoreKey]) holding the
* session token, device ID, API key, and paired-session metadata.
* - Cert pin (already host-keyed in [com.hermesandroid.relay.auth.CertPinStore]
@@ -73,17 +79,34 @@ data class Connection(
val preferredRouteRole: String? = null,
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
val pairedAt: Long? = null,
/** Last time the user explicitly selected this connection. */
val lastUsedAt: Long? = null,
val lastActiveSessionId: String? = null,
val transportHint: String? = null,
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
val expiresAt: Long? = null,
) {
/**
* Effective Dashboard/Gateway endpoint. Legacy records did not persist a
* dashboard URL, so they retain the conventional same-host `:9119`
* derivation from the API server. Dashboard-only records persist an
* explicit URL and may leave [apiServerUrl] and [relayUrl] blank.
*/
val resolvedDashboardUrl: String
get() = dashboardUrl
?.trim()
?.takeIf { it.isNotBlank() }
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
/** Stable display/host identity that does not depend on the API surface. */
val primaryEndpointUrl: String
get() = resolvedDashboardUrl.takeIf { it.isNotBlank() }
?: apiServerUrl.trim().takeIf { it.isNotBlank() }
?: relayUrl.trim()
val primaryHost: String
get() = extractHost(primaryEndpointUrl).orEmpty()
companion object {
/**
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
@@ -94,6 +117,8 @@ data class Connection(
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
const val DEFAULT_DASHBOARD_PORT: Int = 9119
const val DEFAULT_API_PORT: Int = 8642
const val DEFAULT_RELAY_PORT: Int = 8767
/**
* Derive a stable per-connection EncryptedSharedPreferences filename
@@ -111,12 +136,58 @@ data class Connection(
* user typed a malformed value — better to show something recognizable
* than to crash).
*/
fun extractDefaultLabel(apiServerUrl: String): String {
return try {
URI(apiServerUrl).host ?: apiServerUrl
} catch (_: Exception) {
apiServerUrl
}
fun extractDefaultLabel(apiServerUrl: String): String =
extractHost(apiServerUrl)?.let(::defaultLabelFromHost) ?: apiServerUrl
/** Preserve explicit labels while upgrading an auto-generated IP label to a discovered host name. */
fun chooseDiscoveredLabel(
currentLabel: String,
primaryHost: String,
discoveredHostname: String?,
): String {
val current = currentLabel.trim()
val discovered = discoveredHostname?.trim()?.takeIf { it.isNotBlank() }
val isAutomatic = current.isBlank() || current.equals(primaryHost.trim(), ignoreCase = true)
return if (isAutomatic && discovered != null) discovered else currentLabel
}
/**
* Dashboard-first label for a connection whose surfaces are optional.
* The one-argument overload above remains for source compatibility.
*/
fun extractDefaultLabel(
dashboardUrl: String?,
apiServerUrl: String,
relayUrl: String,
): String {
val primary = dashboardUrl?.trim()?.takeIf { it.isNotBlank() }
?: apiServerUrl.trim().takeIf { it.isNotBlank() }
?: relayUrl.trim()
return extractHost(primary)?.let(::defaultLabelFromHost) ?: primary
}
/**
* Nous-hosted agent gateways use the stable
* `<slug>.agents.nousresearch.com` origin contract. The public Hermes
* status response deliberately carries no tenant/agent display name,
* so a URL-only connection uses that exact single-label slug as its
* least-surprising default. Portal's human-readable agent name is only
* available through its separately authenticated discovery API.
*
* Match the complete suffix and exactly one leading DNS label. This
* avoids shortening lookalike or operator-controlled hostnames.
*/
private fun defaultLabelFromHost(host: String): String {
val suffix = ".agents.nousresearch.com"
if (!host.endsWith(suffix, ignoreCase = true)) return host
val slug = host.dropLast(suffix.length)
return slug.takeIf { it.isNotBlank() && '.' !in it } ?: host
}
private fun extractHost(url: String): String? = try {
URI(url).host
} catch (_: Exception) {
null
}
fun deriveDefaultDashboardUrl(
@@ -141,6 +212,29 @@ data class Connection(
return "$scheme://$hostPart:$dashboardPort"
}
/** Derive the conventional same-host direct API fallback from a Dashboard URL. */
fun deriveDefaultApiUrl(
dashboardUrl: String,
apiPort: Int = DEFAULT_API_PORT,
): String? {
val trimmed = dashboardUrl.trim().trimEnd('/')
if (trimmed.isEmpty()) return null
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
val scheme = when (uri.scheme?.lowercase()) {
"http" -> "http"
"https" -> "https"
else -> return null
}
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
"[$host]"
} else {
host
}
return "$scheme://$hostPart:$apiPort"
}
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
if (trimmed.isEmpty()) return true
@@ -150,7 +244,7 @@ data class Connection(
fun deriveDefaultRelayUrl(
apiServerUrl: String,
relayPort: Int = 8767,
relayPort: Int = DEFAULT_RELAY_PORT,
): String? {
val trimmed = apiServerUrl.trim().trimEnd('/')
if (trimmed.isEmpty()) return null
@@ -174,6 +268,7 @@ data class Connection(
apiServerUrl: String,
relayUrl: String,
extraApiUrls: List<Pair<String, String>> = emptyList(),
dashboardUrl: String? = null,
): List<EndpointCandidate> {
val routes = buildList {
endpointCandidateFromApiUrl(
@@ -182,6 +277,7 @@ data class Connection(
apiServerUrl = apiServerUrl,
relayUrl = relayUrl.takeIf { it.isNotBlank() }
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
dashboardUrl = dashboardUrl,
)?.let(::add)
extraApiUrls
@@ -193,13 +289,14 @@ data class Connection(
priority = index + 1,
apiServerUrl = url,
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
dashboardUrl = dashboardUrl,
)?.let(::add)
}
}
return routes
.distinctBy {
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
"${it.role.lowercase()}|${it.routeAuthority()}"
}
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
}
@@ -222,13 +319,13 @@ data class Connection(
existing: List<EndpointCandidate>,
): List<EndpointCandidate> {
val rebuiltHostPorts = rebuilt
.map { "${it.api.host.lowercase()}:${it.api.port}" }
.mapNotNull { it.mergeAuthority() }
.toSet()
val preserved = existing
.filter { it.priority > 0 }
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
.filterNot { it.mergeAuthority() in rebuiltHostPorts }
return (rebuilt + preserved)
.distinctBy { "${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" }
.distinctBy { "${it.role.lowercase()}|${it.routeAuthority()}" }
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
}
@@ -267,6 +364,7 @@ data class Connection(
priority: Int,
apiServerUrl: String,
relayUrl: String,
dashboardUrl: String? = null,
): EndpointCandidate? {
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
?: return null
@@ -290,12 +388,141 @@ data class Connection(
role = role.ifBlank { inferRouteRole(apiServerUrl) },
priority = priority,
api = ApiEndpoint(host = host, port = port, tls = tls),
dashboard = deriveDefaultDashboardUrl(apiServerUrl)
dashboard = dashboardUrl
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() && urlsShareHost(it, apiServerUrl) }
?.let { DashboardEndpoint(url = it) }
?: deriveDefaultDashboardUrl(apiServerUrl)
?.let { DashboardEndpoint(url = it) },
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
)
}
/**
* Reconcile stored API-derived routes with the Dashboard origin that
* was actually verified during setup. Older app versions synthesized
* `:9119` for every API route, even when the same host was reached
* through an HTTPS reverse proxy on 443. Replace only that conventional
* synthesized value (or a missing value); preserve explicit and
* different-host LAN/Tailscale routes.
*/
fun reconcileDashboardRoutes(
dashboardUrl: String?,
candidates: List<EndpointCandidate>,
): List<EndpointCandidate> {
val explicitDashboard = dashboardUrl
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?: return candidates
return candidates.map { candidate ->
val apiUrl = candidate.api?.url ?: return@map candidate
if (!urlsShareHost(explicitDashboard, apiUrl)) return@map candidate
val currentDashboard = candidate.dashboard?.url
val derivedDashboard = deriveDefaultDashboardUrl(apiUrl)
val canReplace = currentDashboard.isNullOrBlank() ||
(
derivedDashboard != null &&
currentDashboard.trim().trimEnd('/')
.equals(derivedDashboard, ignoreCase = true)
)
if (canReplace) {
candidate.copy(dashboard = DashboardEndpoint(url = explicitDashboard))
} else {
candidate
}
}
}
fun urlsShareHost(leftUrl: String, rightUrl: String): Boolean {
val leftHost = runCatching { URI(leftUrl.trim()) }.getOrNull()?.host
val rightHost = runCatching { URI(rightUrl.trim()) }.getOrNull()?.host
return !leftHost.isNullOrBlank() &&
!rightHost.isNullOrBlank() &&
leftHost.equals(rightHost, ignoreCase = true)
}
/**
* De-duplication identity for rebuilding stored routes. Prefer the
* legacy API authority when present so an older API-only candidate and
* its dashboard-enriched replacement still collide. Dashboard-only
* candidates fall back to their primary route authority.
*/
private fun EndpointCandidate.mergeAuthority(): String? =
api?.let { endpoint -> "api|${endpoint.host.lowercase()}:${endpoint.port}" }
?: routeAuthority()?.let { authority -> "route|$authority" }
/**
* Build a Dashboard/Gateway-primary route from a remote host or URL.
* API and Relay are retained only when explicitly configured; callers
* no longer need to invent an API key or legacy surface URL.
*/
fun endpointCandidateFromDashboardUrl(
role: String,
priority: Int,
dashboardUrl: String,
apiServerUrl: String? = null,
relayUrl: String? = null,
): EndpointCandidate? {
val normalizedDashboard = normalizeDashboardUrlInput(dashboardUrl)
val dashboardUri = runCatching { URI(normalizedDashboard) }.getOrNull() ?: return null
if (dashboardUri.scheme?.lowercase() !in setOf("http", "https") ||
dashboardUri.host.isNullOrBlank()
) return null
val api = apiServerUrl
?.trim()
?.takeIf { it.isNotBlank() }
?.let { apiUrl ->
val apiUri = runCatching { URI(apiUrl.trimEnd('/')) }.getOrNull()
?: return@let null
val tls = when (apiUri.scheme?.lowercase()) {
"http" -> false
"https" -> true
else -> return@let null
}
val host = apiUri.host?.takeIf { it.isNotBlank() } ?: return@let null
ApiEndpoint(host, if (apiUri.port > 0) apiUri.port else 8642, tls)
}
val relay = relayUrl
?.trim()
?.takeIf { it.isNotBlank() }
?.let { url ->
val hint = when {
url.startsWith("wss://", ignoreCase = true) -> "wss"
url.startsWith("ws://", ignoreCase = true) -> "ws"
else -> null
}
RelayEndpoint(url, hint)
}
return EndpointCandidate(
role = role.ifBlank { inferRouteRole(normalizedDashboard) },
priority = priority,
dashboard = DashboardEndpoint(normalizedDashboard),
api = api,
relay = relay,
)
}
fun normalizeDashboardUrlInput(
raw: String,
defaultPort: Int = DEFAULT_DASHBOARD_PORT,
): String {
val trimmed = raw.trim().trimEnd('/')
if (trimmed.isEmpty()) return trimmed
if (SCHEME_REGEX.containsMatchIn(trimmed)) return trimmed
val withScheme = "http://$trimmed"
val uri = runCatching { URI(withScheme) }.getOrNull()
val canAppendPort = uri != null &&
!uri.host.isNullOrBlank() &&
uri.port <= 0 &&
uri.rawPath.isNullOrEmpty() &&
uri.rawQuery == null
return if (canAppendPort) "$withScheme:$defaultPort" else withScheme
}
fun inferRouteRole(apiServerUrl: String): String {
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
.getOrNull()
@@ -63,12 +63,8 @@ fun EndpointCandidate?.isEncryptedOverlayRoute(isTailscaleDetected: Boolean): Bo
val hint = security.orEmpty().lowercase()
return r == "tailscale" ||
(isTailscaleDetected && hint.contains("tailscale")) ||
r == "plugin_proxy" ||
r == "plugin-proxy" ||
hasSecureProxy() ||
hint.contains("wireguard") ||
hint.contains("https") ||
hint.contains("tls")
(!hasSecureProxy() && (hint.contains("https") || hint.contains("tls")))
}
/** Human label for the overlay mechanism encrypting a route. */
@@ -78,7 +74,6 @@ fun EndpointCandidate?.overlayMechanism(isTailscaleDetected: Boolean): String {
val hint = security.orEmpty().lowercase()
return when {
r == "tailscale" || (isTailscaleDetected && hint.contains("tailscale")) -> "Tailscale"
r == "plugin_proxy" || r == "plugin-proxy" || hasSecureProxy() -> "Proxy"
hint.contains("wireguard") -> "WireGuard"
hint.contains("https") || hint.contains("tls") -> "TLS"
else -> "Encrypted"
@@ -92,7 +87,10 @@ fun classifySurfaceSecurity(
activeEndpoint: EndpointCandidate?,
isTailscaleDetected: Boolean,
): SurfaceSecurity {
val secureLinkProtected = activeEndpoint.secureLinkProtects(label, url)
val (kind, mechanism) = when {
secureLinkProtected -> SurfaceSecurityKind.Tls to
if (activeEndpoint?.hasHermesReach() == true) "Hermes Reach" else "Hermes Secure Link"
isTlsUrl(url) -> SurfaceSecurityKind.Tls to "TLS"
activeEndpoint.isEncryptedOverlayRoute(isTailscaleDetected) ->
SurfaceSecurityKind.Overlay to activeEndpoint.overlayMechanism(isTailscaleDetected)
@@ -101,6 +99,33 @@ fun classifySurfaceSecurity(
return SurfaceSecurity(label = label, kind = kind, mechanism = mechanism, url = url)
}
private fun EndpointCandidate?.secureLinkProtects(label: String, url: String): Boolean {
val candidate = this ?: return false
val routes = candidate.proxy?.takeIf { candidate.hasSecureProxy() }
?.let { proxy ->
val base = proxy.url.trim().trimEnd('/')
Triple(
"$base/dashboard",
"$base/api",
"wss://${base.substringAfter("://")}/relay/ws",
)
} ?: return false
val normalized = url.trim().trimEnd('/')
val service = when (label) {
"Chat & Manage" -> "dashboard"
"API / sessions" -> "api"
"Relay tools" -> "relay"
else -> return false
}
if (service !in candidate.secureLinkServices()) return false
val expected = when (service) {
"dashboard" -> routes.first
"api" -> routes.second
else -> routes.third
}
return normalized.equals(expected, ignoreCase = true)
}
/**
* Roll up the per-surface verdicts into one connection-level [ConnectionSecurity].
* Pure + side-effect free so it is unit-testable without Android.
@@ -60,6 +60,7 @@ import kotlinx.serialization.json.Json
class ConnectionStore private constructor(
private val dataStore: DataStore<Preferences>,
private val context: Context?,
private val scope: CoroutineScope,
) {
/**
@@ -71,6 +72,7 @@ class ConnectionStore private constructor(
constructor(context: Context) : this(
dataStore = context.relayDataStore,
context = context.applicationContext,
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
)
/**
@@ -81,9 +83,13 @@ class ConnectionStore private constructor(
internal constructor(dataStore: DataStore<Preferences>) : this(
dataStore = dataStore,
context = null,
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
)
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
internal constructor(
dataStore: DataStore<Preferences>,
scope: CoroutineScope,
) : this(dataStore = dataStore, context = null, scope = scope)
private val json = Json {
ignoreUnknownKeys = true
@@ -100,6 +106,13 @@ class ConnectionStore private constructor(
private val _activeConnectionId = MutableStateFlow<String?>(null)
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
/**
* Optional cold-start pin. `null` means restore the last connection the
* user actively selected, which remains the recommended default.
*/
private val _startupConnectionId = MutableStateFlow<String?>(null)
val startupConnectionId: StateFlow<String?> = _startupConnectionId.asStateFlow()
/**
* Flips to `true` once the initial DataStore hydrate completes (success OR
* failure). Until then [connections] / [activeConnection] hold their empty
@@ -128,6 +141,7 @@ class ConnectionStore private constructor(
val oldJson = prefs[KEY_LEGACY_PROFILES]
val activeNew = prefs[KEY_ACTIVE_CONNECTION_ID]
val activeOld = prefs[KEY_LEGACY_ACTIVE_PROFILE_ID]
val startupId = prefs[KEY_STARTUP_CONNECTION_ID]
// Prefer the new key. If absent and the old key has data,
// migrate it once: write to the new key and clear the old ones
@@ -143,15 +157,21 @@ class ConnectionStore private constructor(
p.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
}
}
_connections.value = decodeConnections(oldJson)
_activeConnectionId.value = activeOld
val restored = decodeConnections(oldJson)
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
_connections.value = restored
_startupConnectionId.value = validStartupId
_activeConnectionId.value = validStartupId ?: activeOld
Log.i(
TAG,
"Migrated legacy DataStore keys (profiles_v1 → connections_v1)",
)
} else {
_connections.value = decodeConnections(newJson)
_activeConnectionId.value = activeNew
val restored = decodeConnections(newJson)
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
_connections.value = restored
_startupConnectionId.value = validStartupId
_activeConnectionId.value = validStartupId ?: activeNew
}
} catch (e: Exception) {
Log.w(TAG, "Initial hydrate failed: ${e.message}")
@@ -229,6 +249,10 @@ class ConnectionStore private constructor(
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
_activeConnectionId.value = null
}
if (prefs[KEY_STARTUP_CONNECTION_ID] == id) {
prefs.remove(KEY_STARTUP_CONNECTION_ID)
_startupConnectionId.value = null
}
}
removed?.let { deleteTokenStoresFor(it) }
}
@@ -247,10 +271,12 @@ class ConnectionStore private constructor(
removed = decodeConnections(prefs[KEY_CONNECTIONS])
prefs.remove(KEY_CONNECTIONS)
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
prefs.remove(KEY_STARTUP_CONNECTION_ID)
prefs.remove(KEY_LEGACY_PROFILES)
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
_connections.value = emptyList()
_activeConnectionId.value = null
_startupConnectionId.value = null
}
removed.forEach { deleteTokenStoresFor(it) }
}
@@ -259,6 +285,7 @@ class ConnectionStore private constructor(
suspend fun replaceConnections(
connections: List<Connection>,
activeConnectionId: String? = null,
startupConnectionId: String? = null,
) {
writeMutex.withLock {
var removed: List<Connection> = emptyList()
@@ -266,6 +293,8 @@ class ConnectionStore private constructor(
val normalizedActiveId = activeConnectionId
?.takeIf { id -> normalizedConnections.any { it.id == id } }
?: normalizedConnections.firstOrNull()?.id
val normalizedStartupId = startupConnectionId
?.takeIf { id -> normalizedConnections.any { it.id == id } }
dataStore.edit { prefs ->
removed = decodeConnections(prefs[KEY_CONNECTIONS])
@@ -281,8 +310,14 @@ class ConnectionStore private constructor(
}
prefs.remove(KEY_LEGACY_PROFILES)
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
if (normalizedStartupId == null) {
prefs.remove(KEY_STARTUP_CONNECTION_ID)
} else {
prefs[KEY_STARTUP_CONNECTION_ID] = normalizedStartupId
}
_connections.value = normalizedConnections
_activeConnectionId.value = normalizedActiveId
_activeConnectionId.value = normalizedStartupId ?: normalizedActiveId
_startupConnectionId.value = normalizedStartupId
}
removed.forEach { deleteTokenStoresFor(it) }
}
@@ -315,12 +350,44 @@ class ConnectionStore private constructor(
suspend fun setActiveConnection(id: String) {
writeMutex.withLock {
dataStore.edit { prefs ->
val current = decodeConnections(prefs[KEY_CONNECTIONS])
if (current.any { it.id == id }) {
val next = current.map { connection ->
if (connection.id == id) {
connection.copy(lastUsedAt = System.currentTimeMillis())
} else {
connection
}
}
prefs[KEY_CONNECTIONS] = encodeConnections(next)
_connections.value = next
}
prefs[KEY_ACTIVE_CONNECTION_ID] = id
_activeConnectionId.value = id
}
}
}
/** Set a specific cold-start connection, or `null` to restore last used. */
suspend fun setStartupConnection(id: String?) {
writeMutex.withLock {
dataStore.edit { prefs ->
val validId = id?.takeIf { candidate ->
decodeConnections(prefs[KEY_CONNECTIONS]).any { it.id == candidate }
}
if (validId == null) {
prefs.remove(KEY_STARTUP_CONNECTION_ID)
_activeConnectionId.value?.let { activeId ->
prefs[KEY_ACTIVE_CONNECTION_ID] = activeId
}
} else {
prefs[KEY_STARTUP_CONNECTION_ID] = validId
}
_startupConnectionId.value = validId
}
}
}
/**
* Update just the `lastActiveSessionId` on the identified connection.
* Called whenever the user picks a chat session so connection-switch can
@@ -476,34 +543,12 @@ class ConnectionStore private constructor(
}
}
private fun Connection.withDashboardDefaults(): Connection {
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
val normalizedRoutes = routeCandidates.ifEmpty {
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
}
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
}
return if (
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
normalizedRoutes != routeCandidates ||
normalizedPreferredRouteRole != preferredRouteRole
) {
copy(
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
routeCandidates = normalizedRoutes,
preferredRouteRole = normalizedPreferredRouteRole,
)
} else {
this
}
}
companion object {
private const val TAG = "ConnectionStore"
private val KEY_CONNECTIONS = stringPreferencesKey("connections_v1")
private val KEY_ACTIVE_CONNECTION_ID = stringPreferencesKey("active_connection_id")
private val KEY_STARTUP_CONNECTION_ID = stringPreferencesKey("startup_connection_id")
// Pre-rename DataStore keys — read once in init on first launch after
// the rename, then wiped. See the init block above.
@@ -517,3 +562,40 @@ class ConnectionStore private constructor(
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
}
}
/**
* Restore route defaults after loading a serialized connection. This remains
* internal so focused persistence tests can exercise the same normalization
* path used by [ConnectionStore].
*/
internal fun Connection.withDashboardDefaults(): Connection {
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
val effectiveDashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl
val storedOrDefaultRoutes = routeCandidates.ifEmpty {
Connection.buildRouteCandidates(
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
dashboardUrl = effectiveDashboardUrl,
)
}
val normalizedRoutes = Connection.reconcileDashboardRoutes(
dashboardUrl = effectiveDashboardUrl,
candidates = storedOrDefaultRoutes,
)
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
}
return if (
dashboardUrl != effectiveDashboardUrl ||
normalizedRoutes != routeCandidates ||
normalizedPreferredRouteRole != preferredRouteRole
) {
copy(
dashboardUrl = effectiveDashboardUrl,
routeCandidates = normalizedRoutes,
preferredRouteRole = normalizedPreferredRouteRole,
)
} else {
this
}
}
@@ -52,6 +52,45 @@ object ConnectionValidation {
kind = "relay URL",
)
/** Dashboard/Gateway URL must be HTTP(S) when configured. */
fun validateDashboardUrl(raw: String): String? = validateOptionalUrl(
raw = raw,
allowedSchemes = setOf("http", "https"),
kind = "Dashboard URL",
)
/** A blank API server means the optional SSE fallback is not configured. */
fun validateOptionalApiServerUrl(raw: String): String? = validateOptionalUrl(
raw = raw,
allowedSchemes = setOf("http", "https"),
kind = "API server URL",
)
/** A blank Relay URL means Relay-only power features are not configured. */
fun validateOptionalRelayUrl(raw: String): String? = validateOptionalUrl(
raw = raw,
allowedSchemes = setOf("ws", "wss"),
kind = "relay URL",
)
/**
* Validate the independently optional connection surfaces. A connection
* needs at least one endpoint, but Dashboard-only, API-only, and
* Relay-only records are all structurally valid.
*/
fun validateConnectionEndpoints(
dashboardUrl: String?,
apiServerUrl: String,
relayUrl: String,
): String? {
if (dashboardUrl.isNullOrBlank() && apiServerUrl.isBlank() && relayUrl.isBlank()) {
return "Configure at least one Hermes endpoint"
}
return validateDashboardUrl(dashboardUrl.orEmpty())
?: validateOptionalApiServerUrl(apiServerUrl)
?: validateOptionalRelayUrl(relayUrl)
}
/**
* Catches the "added the same server twice" mistake. Matches when the
* candidate's api + relay URLs exactly match an existing connection
@@ -67,12 +106,33 @@ object ConnectionValidation {
apiServerUrl: String,
relayUrl: String,
excludeId: String? = null,
dashboardUrl: String? = null,
): Connection? = connections.firstOrNull { c ->
c.id != excludeId &&
c.apiServerUrl.equals(apiServerUrl, ignoreCase = true) &&
c.relayUrl.equals(relayUrl, ignoreCase = true)
if (c.id == excludeId) {
false
} else {
val legacyExactMatch =
(apiServerUrl.isNotBlank() || relayUrl.isNotBlank()) &&
urlsEqual(c.apiServerUrl, apiServerUrl) &&
urlsEqual(c.relayUrl, relayUrl)
val candidateDashboard = dashboardUrl
?.takeIf { it.isNotBlank() }
?: Connection.deriveDefaultDashboardUrl(apiServerUrl)
val dashboardMatch = !candidateDashboard.isNullOrBlank() &&
urlsEqual(c.resolvedDashboardUrl, candidateDashboard)
legacyExactMatch || dashboardMatch
}
}
private fun validateOptionalUrl(
raw: String,
allowedSchemes: Set<String>,
kind: String,
): String? = if (raw.isBlank()) null else validateUrl(raw, allowedSchemes, kind)
private fun urlsEqual(first: String, second: String): Boolean =
first.trim().trimEnd('/').equals(second.trim().trimEnd('/'), ignoreCase = true)
private fun validateUrl(raw: String, allowedSchemes: Set<String>, kind: String): String? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return "$kind can't be blank"
@@ -72,10 +72,11 @@ class DataManager(
* - v5 (2026-06-08): full connection backups. Adds active connection id
* and `connectionSecrets`, including API keys, relay tokens, device id,
* paired metadata, and dashboard cookies.
* - v6 (2026-07-19): preserves the optional pinned startup connection.
*/
@Serializable
data class AppBackup(
val version: Int = 5,
val version: Int = 6,
val serverUrl: String? = null, // legacy (v1 compat)
val apiServerUrl: String? = null,
val relayUrl: String? = null,
@@ -83,6 +84,7 @@ class DataManager(
val onboardingCompleted: Boolean = false,
val connections: List<Connection> = emptyList(),
val activeConnectionId: String? = null,
val startupConnectionId: String? = null,
val containsSensitiveData: Boolean = true,
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
val exportedAt: Long = System.currentTimeMillis(),
@@ -160,6 +162,7 @@ class DataManager(
onboardingCompleted = onboardingCompleted,
connections = connectionsSnapshot,
activeConnectionId = connectionStore?.activeConnectionId?.value,
startupConnectionId = connectionStore?.startupConnectionId?.value,
containsSensitiveData = true,
connectionSecrets = connectionSecrets,
exportedAt = System.currentTimeMillis(),
@@ -173,6 +176,7 @@ class DataManager(
store.replaceConnections(
connections = backup.connections,
activeConnectionId = backup.activeConnectionId,
startupConnectionId = backup.startupConnectionId,
)
val connectionsById = backup.connections.associateBy { it.id }
@@ -251,9 +255,11 @@ class DataManager(
suspend fun writeBackupToUri(uri: Uri, backup: String): Boolean {
return withContext(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(backup.toByteArray(Charsets.UTF_8))
outputStream.flush()
val outputStream = context.contentResolver.openOutputStream(uri)
?: return@withContext false
outputStream.use {
it.write(backup.toByteArray(Charsets.UTF_8))
it.flush()
}
true
} catch (e: Exception) {
@@ -284,9 +290,10 @@ class DataManager(
* - Clear DataStore preferences
* - Clear EncryptedSharedPreferences (auth tokens)
* - Clear any cached data
* Does NOT clear the onboarding flag (that's separate via [resetOnboarding]).
* Preserves the onboarding flag. Use [resetOnboarding] when the next launch
* should show onboarding again.
*/
suspend fun resetAppData() {
suspend fun resetAppData(): Boolean =
try {
// Preserve onboarding state before clearing
val onboarding = isOnboardingCompleted()
@@ -316,10 +323,11 @@ class DataManager(
}
Log.d(TAG, "App data reset complete")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to reset app data", e)
false
}
}
private suspend fun deleteSensitivePreferenceFiles() {
withContext(Dispatchers.IO) {
@@ -380,16 +388,17 @@ class DataManager(
* Reset only the onboarding completion flag.
* Next app launch will show onboarding again.
*/
suspend fun resetOnboarding() {
suspend fun resetOnboarding(): Boolean =
try {
context.relayDataStore.edit { preferences ->
preferences.remove(KEY_ONBOARDING_COMPLETED)
}
Log.d(TAG, "Onboarding flag reset")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to reset onboarding flag", e)
false
}
}
/**
* Check if onboarding has been completed.
@@ -408,13 +417,14 @@ class DataManager(
/**
* Mark onboarding as completed.
*/
suspend fun setOnboardingCompleted(completed: Boolean) {
suspend fun setOnboardingCompleted(completed: Boolean): Boolean =
try {
context.relayDataStore.edit { preferences ->
preferences[KEY_ONBOARDING_COMPLETED] = completed
}
true
} catch (e: Exception) {
Log.e(TAG, "Failed to set onboarding completed", e)
false
}
}
}
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.net.URI
/**
* One entry in a pairing payload's `endpoints` array (ADR 24 — multi-endpoint
@@ -38,12 +39,19 @@ import kotlinx.serialization.Serializable
data class EndpointCandidate(
val role: String,
val priority: Int = 0,
val api: ApiEndpoint,
val relay: RelayEndpoint,
/** Optional legacy/API-server surface. Dashboard-only routes omit it. */
val api: ApiEndpoint? = null,
/** Optional Hermes-Relay bridge surface. Standard upstream routes omit it. */
val relay: RelayEndpoint? = null,
val dashboard: DashboardEndpoint? = null,
val proxy: ProxyEndpoint? = null,
/** Optional outbound rendezvous carrying the pinned [proxy] byte stream. */
val broker: BrokerEndpoint? = null,
val security: String? = null,
val recommended: Boolean = false,
val experimental: Boolean = false,
@SerialName("display_name")
val displayName: String? = null,
)
/**
@@ -107,6 +115,27 @@ data class ProxyEndpoint(
val transportHint: String? = null,
@SerialName("pin_sha256")
val pinSha256: String? = null,
/** Independently authenticated services carried by this pinned origin. */
val surfaces: List<String> = listOf("relay"),
)
/**
* Hermes Reach rendezvous metadata from an operator-reviewed pairing payload.
* The token authenticates only this broker route; Hermes service credentials
* remain inside the QR-pinned Secure Link TLS connection.
*/
@Serializable
data class BrokerEndpoint(
val url: String,
@SerialName("protocol_version")
val protocolVersion: Int = 1,
@SerialName("host_id")
val hostId: String,
@SerialName("credential_kind")
val credentialKind: String,
val token: String,
@SerialName("expires_at")
val expiresAt: Long? = null,
)
/**
@@ -120,7 +149,7 @@ data class ProxyEndpoint(
*/
fun EndpointCandidate.isKnownRole(): Boolean {
return when (role.lowercase()) {
"lan", "tailscale", "public", "plugin_proxy", "plugin-proxy", "https" -> true
"lan", "tailscale", "public", "plugin_proxy", "plugin-proxy", "outbound_broker", "https" -> true
else -> false
}
}
@@ -137,15 +166,107 @@ fun EndpointCandidate.displayLabel(): String {
return when (role.lowercase()) {
"lan" -> "LAN"
"tailscale" -> "Tailscale"
"public" -> if (api.tls) "HTTPS" else "Public"
"public" -> if (primaryRouteUrl()?.startsWith("https://", ignoreCase = true) == true) {
"HTTPS"
} else {
"Public"
}
"https" -> "HTTPS"
"plugin_proxy", "plugin-proxy" -> "Plugin proxy"
"plugin_proxy", "plugin-proxy" -> "Hermes Secure Link"
"outbound_broker", "broker", "relay_broker" -> "Hermes Reach · Experimental"
else -> "Custom VPN ($role)"
}
}
/** Dashboard-first URL identity for routing, diagnostics, and UI labels. */
fun EndpointCandidate.primaryRouteUrl(): String? =
dashboard?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
?: api?.url
?: relay?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
?: proxy?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
/** Stable host/port identity without assuming that an API surface exists. */
fun EndpointCandidate.routeAuthority(): String? {
val rawUrl = primaryRouteUrl() ?: return null
val httpUrl = when {
rawUrl.startsWith("ws://", ignoreCase = true) -> "http://${rawUrl.substringAfter("://")}"
rawUrl.startsWith("wss://", ignoreCase = true) -> "https://${rawUrl.substringAfter("://")}"
else -> rawUrl
}
val uri = runCatching { URI(httpUrl) }.getOrNull() ?: return null
val host = uri.host?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val port = when {
uri.port > 0 -> uri.port
uri.scheme.equals("https", ignoreCase = true) -> 443
else -> 80
}
return "$host:$port"
}
fun EndpointCandidate.hasSecureProxy(): Boolean =
proxy?.url?.startsWith("https://", ignoreCase = true) == true ||
proxy?.url?.startsWith("wss://", ignoreCase = true) == true ||
role.equals("plugin_proxy", ignoreCase = true) ||
role.equals("plugin-proxy", ignoreCase = true)
proxy?.isValidPinnedProxy() == true
/** Product-facing service inventory; wire identifiers remain unchanged. */
fun EndpointCandidate.secureLinkServices(): List<String> =
if (!hasSecureProxy()) emptyList() else proxy.orEmptySurfaces()
fun EndpointCandidate.secureLinkCoversAllServices(): Boolean =
secureLinkServices().containsAll(listOf("relay", "api", "dashboard"))
fun EndpointCandidate.presentationRouteUrl(): String? =
broker?.url?.takeIf { hasHermesReach() } ?: proxy?.url?.takeIf { hasSecureProxy() } ?: primaryRouteUrl()
fun EndpointCandidate.hasHermesReach(): Boolean =
role.lowercase() in setOf("outbound_broker", "broker", "relay_broker") &&
broker?.isValidHermesReach() == true && hasSecureProxy()
fun BrokerEndpoint.isValidHermesReach(): Boolean {
if (protocolVersion != 1 || !hostId.isCanonicalBase64Url(16) || !token.isCanonicalBase64Url(32)) return false
if (credentialKind !in setOf("bootstrap", "route")) return false
if (credentialKind == "bootstrap" && expiresAt?.let { it <= System.currentTimeMillis() / 1000L } == true) return false
val uri = runCatching { URI(url.trim()) }.getOrNull() ?: return false
if (!uri.scheme.equals("wss", ignoreCase = true) || uri.host.isNullOrBlank()) return false
if (!uri.rawUserInfo.isNullOrBlank() || uri.rawQuery != null || uri.rawFragment != null) return false
return uri.rawPath.orEmpty().let { it.isEmpty() || it == "/" || it == "/v1/connect" }
}
private fun String.isCanonicalBase64Url(byteCount: Int): Boolean {
if (isBlank() || '=' in this) return false
val decoded = runCatching { java.util.Base64.getUrlDecoder().decode(this) }.getOrNull() ?: return false
return decoded.size == byteCount &&
java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(decoded) == this
}
/** Exact host locator + broker authority replacement; never crosses devices. */
internal fun replaceHermesReachCredential(
source: List<EndpointCandidate>,
expected: BrokerEndpoint,
replacement: EndpointCandidate,
): List<EndpointCandidate> = source.map { candidate ->
if (candidate.broker?.hostId == expected.hostId &&
sameBrokerAuthority(candidate.broker.url, expected.url)
) replacement else candidate
}
internal fun sameBrokerAuthority(left: String, right: String): Boolean = runCatching {
val a = URI(left.trim())
val b = URI(right.trim())
fun port(uri: URI) = if (uri.port > 0) uri.port else 443
a.scheme.equals("wss", true) && b.scheme.equals("wss", true) &&
a.host.equals(b.host, true) && port(a) == port(b) &&
a.rawPath.orEmpty().trimEnd('/') == b.rawPath.orEmpty().trimEnd('/')
}.getOrDefault(false)
private fun ProxyEndpoint?.orEmptySurfaces(): List<String> = this?.surfaces.orEmpty()
.map { it.trim().lowercase() }
.filter { it in setOf("relay", "api", "dashboard") }
.distinct()
fun ProxyEndpoint.isValidPinnedProxy(): Boolean {
val uri = runCatching { URI(url.trim().trimEnd('/')) }.getOrNull() ?: return false
if (!uri.scheme.equals("https", ignoreCase = true) || uri.host.isNullOrBlank()) return false
if (!uri.rawUserInfo.isNullOrBlank() || uri.rawQuery != null || uri.rawFragment != null) return false
if (uri.rawPath.orEmpty().let { it.isNotEmpty() && it != "/" }) return false
val pin = pinSha256?.trim()?.removePrefix("sha256/") ?: return false
return runCatching { java.util.Base64.getDecoder().decode(pin).size == 32 }.getOrDefault(false)
}
@@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.map
/**
* Feature flags with compile-time defaults and runtime overrides.
*
* In debug builds, all features are unlocked by default.
* In debug builds, Developer Options are unlocked by default until the user
* explicitly locks them.
* In release builds, experimental features are hidden unless the user
* enables Developer Options (tap version 7 times in Settings > About).
*
@@ -21,7 +22,7 @@ object FeatureFlags {
// DataStore keys
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
private val KEY_RELAY_ENABLED = booleanPreferencesKey("feature_relay_enabled")
private val KEY_PET_TERRAIN_OVERLAY = booleanPreferencesKey("pet_terrain_overlay")
/** Whether the app is running a debug build. */
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
@@ -29,15 +30,38 @@ object FeatureFlags {
/** Observe whether Developer Options have been unlocked. */
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
context.relayDataStore.data.map { prefs ->
if (isDevBuild) true else prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: false
prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: isDevBuild
}
/** Observe whether relay features (settings, pairing, onboarding pages) are enabled. */
fun relayEnabled(context: Context): Flow<Boolean> =
/**
* Developer-only visualization of the floating pet's measured terrain.
*
* The persisted request is intentionally weaker than both gates: release
* builds can never enable it, and explicitly locking Developer Options
* suppresses it immediately.
*/
fun petTerrainOverlayEnabled(context: Context): Flow<Boolean> =
context.relayDataStore.data.map { prefs ->
if (isDevBuild) true else prefs[KEY_RELAY_ENABLED] ?: false
petTerrainOverlayEffective(
isDevBuild = isDevBuild,
devOptionsUnlocked = prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: isDevBuild,
requested = prefs[KEY_PET_TERRAIN_OVERLAY] ?: false,
)
}
internal fun petTerrainOverlayEffective(
isDevBuild: Boolean,
devOptionsUnlocked: Boolean,
requested: Boolean,
): Boolean = isDevBuild && devOptionsUnlocked && requested
/** Persist the developer's overlay request. Runtime gates remain authoritative. */
suspend fun setPetTerrainOverlayEnabled(context: Context, enabled: Boolean) {
context.relayDataStore.edit { prefs ->
prefs[KEY_PET_TERRAIN_OVERLAY] = enabled
}
}
/** Unlock Developer Options. */
suspend fun unlockDevOptions(context: Context) {
context.relayDataStore.edit { prefs ->
@@ -45,18 +69,11 @@ object FeatureFlags {
}
}
/** Lock Developer Options and disable all experimental features. */
/** Lock Developer Options, including in debug builds. */
suspend fun lockDevOptions(context: Context) {
context.relayDataStore.edit { prefs ->
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false
prefs[KEY_RELAY_ENABLED] = false
}
}
/** Toggle relay features (terminal/bridge settings, pairing, onboarding relay page). */
suspend fun setRelayEnabled(context: Context, enabled: Boolean) {
context.relayDataStore.edit { prefs ->
prefs[KEY_RELAY_ENABLED] = enabled
prefs[KEY_PET_TERRAIN_OVERLAY] = false
}
}
@@ -2,6 +2,8 @@ package com.hermesandroid.relay.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* A rich content card emitted inline in an assistant message via the
@@ -117,6 +119,8 @@ data class HermesCardInput(
val kind: String,
/** Quick-answer chips (clarify). Empty = no chip row. */
val choices: List<String> = emptyList(),
/** Choices toggle independently and require an explicit submit. */
val multiSelect: Boolean = false,
/** Render the inline free-text mini field under the chips. */
val allowFreeText: Boolean = false,
/** Password-style field: masked glyphs + reveal toggle (secret/sudo). */
@@ -124,7 +128,7 @@ data class HermesCardInput(
/** Submit is a 650ms hold-to-confirm press-fill instead of a tap (sudo). */
val holdToConfirm: Boolean = false,
/**
* Wall-clock expiry for timed asks (sudo 120s, clarify/secret 300s).
* Wall-clock expiry for asks with an advertised deadline.
* The renderer shows a countdown footer (Amber under 30s) and
* self-collapses to "Expired — not granted" past it. Null = no timeout
* (approval is session-scoped).
@@ -155,6 +159,10 @@ data class HermesCardInput(
}
}
/** Exact JSON-array wire value expected by upstream multi-select clarify. */
internal fun encodeClarifyMultiSelectAnswer(values: List<String>): String =
Json.encodeToString(values.map(String::trim).filter(String::isNotEmpty).distinct())
/**
* A label/value row inside a card. [value] is rendered as markdown so the
* agent can embed emphasis, inline code, or links.
@@ -0,0 +1,135 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/** Exact cadence values consumed by the floating-pet behavior director. */
data class PetBehaviorPacing(
val responseVisitDelayMs: Long,
val roamIntervalMs: Long,
val idleReactionCadenceMs: Long,
) {
init {
require(responseVisitDelayMs > 0L)
require(roamIntervalMs > responseVisitDelayMs)
require(idleReactionCadenceMs > roamIntervalMs)
}
}
/**
* User-facing pet activity presets.
*
* These values control how often an otherwise-idle pet may act. Existing
* animation, reduced-motion, touch-exploration, scrolling, and agent-activity
* gates remain authoritative and may always defer an action.
*/
enum class PetTemperament(val pacing: PetBehaviorPacing) {
Calm(
PetBehaviorPacing(
responseVisitDelayMs = 2_500L,
roamIntervalMs = 12_000L,
idleReactionCadenceMs = 28_000L,
),
),
Balanced(
PetBehaviorPacing(
responseVisitDelayMs = 1_500L,
roamIntervalMs = 8_000L,
idleReactionCadenceMs = 18_000L,
),
),
Playful(
PetBehaviorPacing(
responseVisitDelayMs = 750L,
roamIntervalMs = 5_000L,
idleReactionCadenceMs = 10_000L,
),
),
}
val DEFAULT_PET_TEMPERAMENT: PetTemperament = PetTemperament.Balanced
const val DEFAULT_PET_SIZE_SCALE: Float = 1f
const val MIN_PET_SIZE_SCALE: Float = 0.6f
const val MAX_PET_SIZE_SCALE: Float = 1.2f
private const val LEGACY_PET_SIZE_BASE_SCALE: Float = 1.25f
private const val CURRENT_PET_SIZE_SCALE_VERSION: Int = 2
internal fun sanitizedPetSizeScale(value: Float?): Float =
value?.takeIf(Float::isFinite)?.coerceIn(MIN_PET_SIZE_SCALE, MAX_PET_SIZE_SCALE)
?: DEFAULT_PET_SIZE_SCALE
internal fun decodeStoredPetSizeScale(value: Float?, version: Int?): Float {
if (value == null) return DEFAULT_PET_SIZE_SCALE
val rebased = if (version == null) value / LEGACY_PET_SIZE_BASE_SCALE else value
return sanitizedPetSizeScale(rebased)
}
data class PetBehaviorPreferences(
val temperament: PetTemperament = DEFAULT_PET_TEMPERAMENT,
val sizeScale: Float = DEFAULT_PET_SIZE_SCALE,
) {
/**
* Runtime seam for the behavior director. A disabled motion gate returns
* no pacing rather than weakening the app's accessibility policy.
*/
fun pacingWhenMotionAllowed(motionAllowed: Boolean): PetBehaviorPacing? =
temperament.pacing.takeIf { motionAllowed }
}
/** Additive, phone-local DataStore persistence for pet behavior preferences. */
class PetBehaviorPreferencesRepository(
private val dataStore: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.relayDataStore)
companion object {
internal val KEY_TEMPERAMENT = stringPreferencesKey("pet_temperament")
internal val KEY_SIZE_SCALE = floatPreferencesKey("pet_size_scale")
internal val KEY_SIZE_SCALE_VERSION = intPreferencesKey("pet_size_scale_version")
}
val flow: Flow<PetBehaviorPreferences> = dataStore.data
.map { preferences ->
PetBehaviorPreferences(
temperament = decodeTemperament(preferences[KEY_TEMPERAMENT]),
sizeScale = decodeStoredPetSizeScale(
value = preferences[KEY_SIZE_SCALE],
version = preferences[KEY_SIZE_SCALE_VERSION],
),
)
}
.distinctUntilChanged()
val temperament: Flow<PetTemperament> = flow
.map { preferences -> preferences.temperament }
.distinctUntilChanged()
val sizeScale: Flow<Float> = flow
.map { preferences -> preferences.sizeScale }
.distinctUntilChanged()
suspend fun setTemperament(temperament: PetTemperament) {
dataStore.edit { preferences ->
preferences[KEY_TEMPERAMENT] = temperament.name
}
}
suspend fun setSizeScale(sizeScale: Float) {
dataStore.edit { preferences ->
preferences[KEY_SIZE_SCALE] = sanitizedPetSizeScale(sizeScale)
preferences[KEY_SIZE_SCALE_VERSION] = CURRENT_PET_SIZE_SCALE_VERSION
}
}
private fun decodeTemperament(raw: String?): PetTemperament =
raw?.let { stored -> PetTemperament.entries.firstOrNull { it.name == stored } }
?: DEFAULT_PET_TEMPERAMENT
}
@@ -47,9 +47,10 @@ import kotlinx.serialization.Serializable
*
* **Hermes profile API metadata.** A relay can advertise an isolated
* profile API server without exposing its secret. When [apiServerUrl] is
* present, Android routes chat/session traffic to that URL and reuses the
* active connection's stored API key. Operators that use distinct API keys
* per profile should pair those profile API servers as separate connections.
* present, Android routes chat/session traffic to that URL using the active
* connection credential. A positively identified shared multiplex
* `/p/<profile>` route instead uses a separately encrypted profile credential;
* the root connection key is never reused for that route.
*/
@Serializable
data class Profile(
@@ -136,3 +136,91 @@ data class ProfileMemoryUpdateResponse(
@SerialName("bytes_written")
val bytesWritten: Long,
)
/** Authoritative upstream `profiles.describe` snapshot. */
data class GatewayProfileDescription(
val name: String,
val description: String,
val soul: String,
val provider: String,
val model: String,
val skills: List<GatewayProfileSkill>,
val toolsets: List<GatewayProfileToolset>,
val toolsetsPinned: Boolean,
)
data class GatewayProfileSkill(val name: String, val enabled: Boolean)
data class GatewayProfileToolset(
val name: String,
val description: String,
val toolCount: Int,
val enabled: Boolean,
)
enum class GatewayProfileSection(val wireName: String) {
Description("description"),
Soul("soul"),
Model("model"),
Skills("skills"),
Toolsets("toolsets"),
}
/** Null leaves a section unchanged; empty lists retain upstream replace semantics. */
data class GatewayProfilePatch(
val description: String? = null,
val soul: String? = null,
val provider: String? = null,
val model: String? = null,
val disabledSkills: List<String>? = null,
val enabledToolsets: List<String>? = null,
) {
val requestedSections: Set<GatewayProfileSection>
get() = buildSet {
if (description != null) add(GatewayProfileSection.Description)
if (soul != null) add(GatewayProfileSection.Soul)
if (provider != null && model != null) add(GatewayProfileSection.Model)
if (disabledSkills != null) add(GatewayProfileSection.Skills)
if (enabledToolsets != null) add(GatewayProfileSection.Toolsets)
}
}
data class GatewayProfileConfigureResult(
val requested: Set<GatewayProfileSection>,
val applied: Set<GatewayProfileSection>,
) {
val failed: Set<GatewayProfileSection> get() = requested - applied
}
interface GatewayProfileEditorClient {
suspend fun describeProfile(profileName: String): Result<GatewayProfileDescription>
suspend fun configureProfile(
profileName: String,
patch: GatewayProfilePatch,
): Result<GatewayProfileConfigureResult>
}
class GatewayProfileEditorUnsupportedException : Exception(
"Profile editing is not supported by this gateway",
)
/** Relay fallback retained for older gateways and Relay-only memory files. */
interface LegacyProfileInspectorClient {
suspend fun fetchConfig(profileName: String): Result<ProfileConfigResponse>
suspend fun fetchSkills(profileName: String): Result<ProfileSkillsResponse>
suspend fun fetchSoul(profileName: String): Result<ProfileSoulResponse>
suspend fun fetchMemory(profileName: String): Result<ProfileMemoryResponse>
suspend fun updateSoul(profileName: String, content: String): Result<ProfileSoulUpdateResponse>
suspend fun updateMemoryEntry(
profileName: String,
filename: String,
content: String,
): Result<ProfileMemoryUpdateResponse>
suspend fun updateSkillToggle(skillName: String, enabled: Boolean): Result<RelaySkillToggleResult>
suspend fun probeSkillToggleSupported(): Boolean
}
sealed interface RelaySkillToggleResult {
data object Ok : RelaySkillToggleResult
data object NotImplemented : RelaySkillToggleResult
}
@@ -26,7 +26,6 @@ object ProfilePresentationPolicy {
fun availableKeys(profiles: List<Profile>): List<String> = buildList {
add(AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
profiles.asSequence()
.filterNot { AgentDisplay.isServerDefaultAlias(it.name) }
.map(Profile::name)
.distinct()
.forEach(::add)
@@ -49,6 +48,12 @@ object ProfilePresentationPolicy {
): List<String> = orderedKeys(profiles, presentation).filter { key ->
key == selectedKey || key !in presentation.hidden
}
fun shouldShowShelf(
profiles: List<Profile>,
presentation: ProfilePresentation,
selectedKey: String,
): Boolean = visibleKeys(profiles, presentation, selectedKey).size > 1
}
class ProfilePresentationStore(
@@ -0,0 +1,7 @@
package com.hermesandroid.relay.data
/** Live activity surfaced beside a session without conflating it with selection. */
enum class SessionActivityState {
Working,
NeedsInput,
}
@@ -13,6 +13,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
val DEFAULT_VOICE_STOP_PHRASES: List<String> = listOf("stop")
/**
* User-tunable voice mode preferences.
@@ -36,6 +41,16 @@ data class VoiceSettings(
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
val interactionMode: String = "tap",
val silenceThresholdMs: Long = 1250L,
/** Exact phrases that end an active voice chat; empty disables the command. */
val stopPhrases: List<String> = DEFAULT_VOICE_STOP_PHRASES,
/**
* When true, voice keeps progress visual and waits for the settled Hermes
* answer before speaking. Tool status, service updates, and intermediate
* assistant commentary are not narrated.
*/
val finalAnswerOnly: Boolean = false,
/** Presentation only; changing this never restarts or interrupts voice. */
val presentationMode: String = VoicePresentationMode.Focus.storageValue,
val realtimeTraceDetails: Boolean = false,
/**
* When true (default), Realtime Agent keeps one provider session/socket open
@@ -122,6 +137,16 @@ enum class VoiceAudioRoute(val storageValue: String) {
}
}
enum class VoicePresentationMode(val storageValue: String) {
Focus("focus"),
Conversation("conversation");
companion object {
fun fromStorage(value: String?): VoicePresentationMode =
values().firstOrNull { it.storageValue == value } ?: Focus
}
}
/**
* Active scope for per-profile voice prefs.
*
@@ -182,6 +207,9 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
// un-namespaced means switching profiles never churns these.
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
private val KEY_STOP_PHRASES = stringPreferencesKey("voice_stop_phrases")
private val KEY_FINAL_ANSWER_ONLY = booleanPreferencesKey("voice_final_answer_only")
private val KEY_PRESENTATION_MODE = stringPreferencesKey("voice_presentation_mode")
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
private val KEY_REALTIME_PERSISTENT_SESSION =
booleanPreferencesKey("voice_realtime_persistent_session")
@@ -191,8 +219,14 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
const val DEFAULT_INTERACTION_MODE = "tap"
// 1250 ms matches hermes-desktop voice_mode `silenceMs` end-of-speech.
const val DEFAULT_SILENCE_THRESHOLD_MS = 1250L
const val DEFAULT_FINAL_ANSWER_ONLY = false
const val DEFAULT_PRESENTATION_MODE = "focus"
const val DEFAULT_REALTIME_TRACE_DETAILS = false
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
private val stopPhrasesJson = Json {
ignoreUnknownKeys = true
isLenient = true
}
/**
* Build the storage name for a per-profile [base] key under [scope].
@@ -258,6 +292,11 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
// --- global (shared across profiles) ---
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
stopPhrases = decodeStopPhrases(prefs[KEY_STOP_PHRASES]),
finalAnswerOnly = prefs[KEY_FINAL_ANSWER_ONLY] ?: DEFAULT_FINAL_ANSWER_ONLY,
presentationMode = VoicePresentationMode.fromStorage(
prefs[KEY_PRESENTATION_MODE] ?: DEFAULT_PRESENTATION_MODE,
).storageValue,
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
?: DEFAULT_REALTIME_TRACE_DETAILS,
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
@@ -367,6 +406,23 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
}
suspend fun setStopPhrases(phrases: List<String>) {
val normalized = phrases.asSequence()
.map(String::trim)
.filter(String::isNotEmpty)
.distinct()
.toList()
dataStore.edit { it[KEY_STOP_PHRASES] = stopPhrasesJson.encodeToString(normalized) }
}
suspend fun setFinalAnswerOnly(enabled: Boolean) {
dataStore.edit { it[KEY_FINAL_ANSWER_ONLY] = enabled }
}
suspend fun setPresentationMode(mode: VoicePresentationMode) {
dataStore.edit { it[KEY_PRESENTATION_MODE] = mode.storageValue }
}
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
}
@@ -375,6 +431,17 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
dataStore.edit { it[KEY_REALTIME_PERSISTENT_SESSION] = enabled }
}
private fun decodeStopPhrases(raw: String?): List<String> {
if (raw == null) return DEFAULT_VOICE_STOP_PHRASES
return runCatching { stopPhrasesJson.decodeFromString<List<String>>(raw) }
.getOrDefault(DEFAULT_VOICE_STOP_PHRASES)
.asSequence()
.map(String::trim)
.filter(String::isNotEmpty)
.distinct()
.toList()
}
/**
* Atomically apply the phone-side portion of [preset]. Only fields owned by
* the preset are written, so route/provider/model/voice overrides and other
@@ -3,6 +3,8 @@ package com.hermesandroid.relay.diagnostics
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.hermesandroid.relay.reliability.ReliabilityCenter
import com.hermesandroid.relay.reliability.ReliabilityRedactor
enum class DiagnosticCategory(val label: String) {
Api("API"),
@@ -25,7 +27,16 @@ data class DiagnosticLogEntry(
val severity: DiagnosticSeverity,
val title: String,
val detail: String? = null,
/** Human-readable action that produced this event, not merely its subsystem. */
val operation: String? = null,
val endpointRole: String? = null,
/** User/configuration-facing route before protocol/path normalization. */
val configuredUrl: String? = null,
/** Exact sanitized URL attempted on the wire, including the diagnostic path. */
val requestUrl: String? = null,
/** Concrete next troubleshooting step for failures with a known interpretation. */
val suggestion: String? = null,
/** Legacy single-URL field retained for diagnostics that have not needed split context. */
val url: String? = null,
val elapsedMs: Long? = null,
/**
@@ -34,7 +45,11 @@ data class DiagnosticLogEntry(
* the detail view shows this. Null for non-error / manually-recorded entries.
*/
val stacktrace: String? = null,
)
) {
/** Best route for mode inference and compact list rendering. */
val primaryUrl: String?
get() = configuredUrl ?: requestUrl ?: url
}
/**
* Current health of a single subsystem on the Diagnostics status timeline.
@@ -81,19 +96,29 @@ object DiagnosticsLog {
severity: DiagnosticSeverity = DiagnosticSeverity.Info,
title: String,
detail: String? = null,
operation: String? = null,
endpointRole: String? = null,
configuredUrl: String? = null,
requestUrl: String? = null,
suggestion: String? = null,
url: String? = null,
elapsedMs: Long? = null,
stacktrace: String? = null,
) {
val safeConfiguredUrl = sanitizeUrl(configuredUrl)
val safeRequestUrl = sanitizeUrl(requestUrl)
val entry = DiagnosticLogEntry(
timestampMs = System.currentTimeMillis(),
category = category,
severity = severity,
title = clean(title) ?: title.take(MAX_TEXT_LENGTH),
detail = clean(detail),
operation = clean(operation),
endpointRole = clean(endpointRole),
url = sanitizeUrl(url),
configuredUrl = safeConfiguredUrl,
requestUrl = safeRequestUrl,
suggestion = clean(suggestion),
url = if (safeConfiguredUrl == null && safeRequestUrl == null) sanitizeUrl(url) else null,
elapsedMs = elapsedMs,
stacktrace = redactTrace(stacktrace),
)
@@ -121,20 +146,40 @@ object DiagnosticsLog {
title: String,
detail: String? = null,
throwable: Throwable? = null,
operation: String? = null,
endpointRole: String? = null,
configuredUrl: String? = null,
requestUrl: String? = null,
suggestion: String? = null,
url: String? = null,
elapsedMs: Long? = null,
reliabilityContext: String? = null,
) {
record(
category = category,
severity = DiagnosticSeverity.Error,
title = title,
detail = detail ?: throwable?.message,
operation = operation,
endpointRole = endpointRole,
configuredUrl = configuredUrl,
requestUrl = requestUrl,
suggestion = suggestion,
url = url,
elapsedMs = elapsedMs,
stacktrace = throwable?.let { stackTraceText(it) },
)
if (throwable != null) {
runCatching {
ReliabilityCenter.recordHandled(
title = title,
detail = detail ?: throwable.message,
throwable = throwable,
context = reliabilityContext,
routeRole = endpointRole,
)
}
}
}
private fun stackTraceText(t: Throwable): String =
@@ -167,10 +212,8 @@ object DiagnosticsLog {
val prefix = noQuery.substring(0, schemeEnd + 3)
val rest = noQuery.substring(schemeEnd + 3)
val slash = rest.indexOf('/').let { if (it < 0) rest.length else it }
val authority = rest.substring(0, slash)
val path = rest.substring(slash)
val safeAuthority = authority.substringAfterLast('@')
prefix + safeAuthority + path
prefix + "[host]" + path
} else {
noQuery
}
@@ -197,7 +240,7 @@ object DiagnosticsLog {
*/
private fun redactTrace(value: String?): String? {
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
val redacted = redact(trimmed)
val redacted = ReliabilityRedactor.redact(trimmed, MAX_TRACE_LENGTH)
return if (redacted.length > MAX_TRACE_LENGTH) {
redacted.take(MAX_TRACE_LENGTH) + "\n… (truncated)"
} else {
@@ -205,8 +248,5 @@ object DiagnosticsLog {
}
}
private fun redact(value: String): String =
value.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
"${it.groupValues[1]}=[hidden]"
}
private fun redact(value: String): String = ReliabilityRedactor.redact(value, MAX_TRACE_LENGTH)
}
@@ -0,0 +1,42 @@
package com.hermesandroid.relay.diagnostics
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
/**
* Maps network failure classes to narrow, truthful next steps.
*
* These messages are diagnostic guidance, not recovery behavior: callers still
* own retries, routing, and authentication. Walking the cause chain preserves
* useful classification when OkHttp or a coroutine boundary wraps the socket
* exception in a higher-level failure.
*/
object NetworkDiagnosticGuidance {
fun forThrowable(throwable: Throwable, target: String): String? {
val causes = generateSequence(throwable as Throwable?) { it.cause }.take(12).toList()
return when {
causes.any { it is ConnectException } ->
"Verify $target is running and listening on the configured host and port."
causes.any { it is UnknownHostException } ->
"Verify the configured hostname resolves from this device."
causes.any { it is NoRouteToHostException } ->
"Verify the device has a network path to the configured host."
causes.any { it is SocketTimeoutException } ->
"Check network routing or firewall rules between this device and $target."
causes.any { it is SSLException } ->
"Verify the TLS scheme, certificate, and trust configuration for $target."
else -> null
}
}
fun forHttpStatus(statusCode: Int, target: String): String? = when (statusCode) {
401, 403 -> "Verify the configured $target credentials or pair the device again."
404 -> "Verify this URL points to the expected $target route and version."
429 -> "Wait for the server's backoff period before retrying."
in 500..599 -> "Check the $target server logs for the failing request."
else -> null
}
}
@@ -5,19 +5,26 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.SystemClock
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.CertPinStore
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.network.shared.pluginProxyRoutesOrNull
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.network.shared.EndpointResolver
import com.hermesandroid.relay.network.shared.EndpointSurface
import com.hermesandroid.relay.network.shared.fullJitterDelayMs
import com.hermesandroid.relay.network.shutdownOffMainThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -43,6 +50,20 @@ enum class ConnectionState {
Reconnecting
}
internal fun isRelayRateLimitBackoffActive(untilMs: Long, nowMs: Long): Boolean =
untilMs > nowMs
internal fun canOverrideScheduledRelayReconnect(
state: ConnectionState,
backoffWaiting: Boolean,
rateLimitBackoffActive: Boolean,
): Boolean = !rateLimitBackoffActive && when (state) {
ConnectionState.Disconnected -> true
ConnectionState.Reconnecting -> backoffWaiting
ConnectionState.Connecting,
ConnectionState.Connected -> false
}
/**
* Build an OkHttp request for a relay socket URL, or `null` if the URL is
* malformed. OkHttp's [Request.Builder.url] throws [IllegalArgumentException]
@@ -57,6 +78,9 @@ internal fun buildRelayRequestOrNull(url: String): Request? =
null
}
private fun EndpointCandidate.relayWebSocketUrl(): String? =
pluginProxyRoutesOrNull()?.relayWebSocketUrl ?: relay?.url
class ConnectionManager(
private val multiplexer: ChannelMultiplexer,
/**
@@ -120,6 +144,10 @@ class ConnectionManager(
* non-null — the manager falls back to the single-URL path.
*/
private val deviceIdProvider: (suspend () -> String?)? = null,
/** Random source for ordinary reconnect full-jitter; exact backoffs never use it. */
private val reconnectJitterUnit: () -> Double = { kotlin.random.Random.nextDouble() },
/** Exact-authority pinned client for a plugin-proxy WSS URL. */
private val proxyClientProvider: ((String) -> OkHttpClient?)? = null,
) {
private val supervisorJob = SupervisorJob()
private val scope = CoroutineScope(supervisorJob + Dispatchers.IO)
@@ -129,7 +157,7 @@ class ConnectionManager(
encodeDefaults = true
}
private fun buildClient(): OkHttpClient {
private fun buildClient(url: String? = null): OkHttpClient {
val builder = OkHttpClient.Builder()
// OkHttp's 10s default connectTimeout is LAN-tuned; a Tailscale
// DERP-relayed cold-start handshake can exceed it, and a failed
@@ -143,7 +171,9 @@ class ConnectionManager(
// that wipes a pin would still be subject to the pre-wipe rules.
certPinStore?.let { store ->
try {
builder.certificatePinner(store.buildPinnerSnapshot())
builder.certificatePinner(
url?.let(store::buildPinnerSnapshotFor) ?: store.buildPinnerSnapshot(),
)
} catch (e: Exception) {
Log.w(TAG, "CertificatePinner build failed: ${e.message}")
builder.certificatePinner(CertificatePinner.DEFAULT)
@@ -160,7 +190,11 @@ class ConnectionManager(
@Volatile
private var serverUrl: String? = null
private var reconnectAttempt = 0
private val reconnectState = RelayReconnectState()
@Volatile
private var reconnectJob: Job? = null
@Volatile
private var reconnectBackoffWaiting = false
private var shouldReconnect = true
// Last HTTP status seen during WSS upgrade, captured in onFailure.
// Used by scheduleReconnect() to pick an appropriate backoff — notably
@@ -168,14 +202,8 @@ class ConnectionManager(
// we don't re-fill the ban bucket and brick our own auth window.
@Volatile
private var lastUpgradeResponseCode: Int? = null
// Consecutive relay socket failures (response == null) since the last
// successful onOpen. One slow Tailscale/DERP cold-start handshake must not
// immediately evict the active route from the SHARED resolver cache (chat +
// dashboard ride the same resolver), so we only poison the route after a
// couple of consecutive transport-level failures.
@Volatile
private var consecutiveSocketFailures = 0
private var rateLimitBackoffUntilMs: Long = 0L
// The relay requires the FIRST frame on a socket to be `system/auth` and
// rejects the whole connection otherwise ("expected system/auth, got
@@ -204,6 +232,12 @@ class ConnectionManager(
// Endpoints card in Settings.
private val _activeEndpoint = MutableStateFlow<EndpointCandidate?>(null)
val activeEndpoint: StateFlow<EndpointCandidate?> = _activeEndpoint.asStateFlow()
private val _activeApiEndpoint = MutableStateFlow<EndpointCandidate?>(null)
val activeApiEndpoint: StateFlow<EndpointCandidate?> = _activeApiEndpoint.asStateFlow()
/** Relay-only winner, deliberately separate from the standard route. */
private val _activeRelayEndpoint = MutableStateFlow<EndpointCandidate?>(null)
val activeRelayEndpoint: StateFlow<EndpointCandidate?> = _activeRelayEndpoint.asStateFlow()
/**
* Manual role override. When non-null, the resolver's output is replaced
@@ -261,9 +295,9 @@ class ConnectionManager(
private const val TAG = "ConnectionManager"
private const val MAX_BACKOFF_MS = 30_000L
private const val BASE_BACKOFF_MS = 1_000L
// How many consecutive relay socket failures before we mark the active
// endpoint unreachable in the shared resolver cache. Tolerates a single
// cold-start blip on a slow remote (Tailscale DERP) link.
// How many consecutive failures on one relay socket URL before we mark
// that candidate's Relay surface unreachable. Standard Dashboard/API
// reachability is cached independently and remains healthy.
private const val MARK_UNREACHABLE_AFTER_FAILURES = 2
// Settle window before re-resolving after a network event. Long
// enough to coalesce the onAvailable burst of a handoff, short
@@ -324,18 +358,24 @@ class ConnectionManager(
// behavior for freshly-upgraded installs and for v1/v2 QRs where
// the synthesized list just collapses to the same URL anyway.
scope.launch {
val resolved = resolveBestEndpointSafe()
val targetUrl = resolved?.relay?.url ?: url
val resolved = resolveBestEndpointSafe(EndpointSurface.Dashboard)
?: resolveBestEndpointSafe(EndpointSurface.Standard)
val apiResolved = resolveBestEndpointSafe(EndpointSurface.Api)
val relayResolved = resolveBestEndpointSafe(EndpointSurface.Relay)
val resolvedRelayUrl = relayResolved?.relayWebSocketUrl()?.takeIf { it.isNotBlank() }
val targetUrl = resolvedRelayUrl ?: url.takeIf { it.isNotBlank() }
_activeRelayEndpoint.value = relayResolved
_activeApiEndpoint.value = apiResolved
if (resolved != null) {
_activeEndpoint.value = resolved
Log.i(TAG, "connect: resolver picked role=${resolved.role} " +
"relay=${resolved.relay.url} (fallback would have been $url)")
Log.i(TAG, "connect: standard resolver picked role=${resolved.role} " +
"route=${resolved.primaryRouteUrl()}")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.conn_diag_route_selected) ?: "Relay route selected",
title = context?.getString(R.string.conn_diag_route_selected) ?: "Route selected",
endpointRole = resolved.role,
url = resolved.relay.url,
url = resolved.primaryRouteUrl(),
)
} else {
_activeEndpoint.value = null
@@ -348,10 +388,56 @@ class ConnectionManager(
url = url,
)
}
connectToUrlOnMainPath(targetUrl)
relayResolved?.let { relayRoute ->
Log.i(
TAG,
"connect: relay resolver picked role=${relayRoute.role} " +
"url=${relayRoute.relayWebSocketUrl()}",
)
}
if (targetUrl != null) {
connectToUrlOnMainPath(targetUrl)
} else {
Log.d(TAG, "connect: selected route has no Relay surface; route published for HTTP/Gateway clients")
}
}
}
/**
* Replace an ordinary scheduled reconnect with an immediate attempt.
*
* Foregrounding the app or opening Relay status is an explicit signal that
* the route may be usable again, so exponential/slow-poll backoff should not
* make the user wait. A server-issued 429 is different: retrying early would
* extend the server block, so that protected backoff is never overridden.
*/
fun reconnectNowIfAllowed(url: String): Boolean {
val rateLimitActive = isRelayRateLimitBackoffActive(
rateLimitBackoffUntilMs,
SystemClock.elapsedRealtime(),
)
if (!canOverrideScheduledRelayReconnect(
state = _connectionState.value,
backoffWaiting = reconnectBackoffWaiting,
rateLimitBackoffActive = rateLimitActive,
)
) {
if (rateLimitActive) {
Log.i(TAG, "reconnectNowIfAllowed: preserving rate-limit backoff")
}
return false
}
reconnectJob?.cancel()
reconnectJob = null
reconnectBackoffWaiting = false
// connectToUrlOnMainPath suppresses duplicate opens while the manager is
// Reconnecting. Move to the honest idle state before starting the fresh
// resolver/open path; the ViewModel's grace window prevents UI flicker.
_connectionState.value = ConnectionState.Disconnected
connect(url)
return true
}
/**
* Same as [connect] but bypasses the resolver — used by the network-
* change callback when we've already picked a winner and just want to
@@ -362,6 +448,7 @@ class ConnectionManager(
private fun connectToUrlOnMainPath(
url: String,
replaceReason: String = "Relay socket replaced",
preserveReconnectBackoff: Boolean = false,
) {
val isInsecure = url.startsWith("ws://") && !url.startsWith("wss://")
if (isInsecure && !_insecureMode.value) {
@@ -371,7 +458,9 @@ class ConnectionManager(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.conn_diag_socket_blocked) ?: "Relay socket blocked",
detail = "ws:// is disabled",
url = url,
operation = "Open Relay WebSocket",
configuredUrl = url,
suggestion = "Use wss:// or explicitly allow plain ws:// for a trusted LAN or VPN.",
)
return
}
@@ -382,7 +471,9 @@ class ConnectionManager(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.conn_diag_url_invalid) ?: "Relay socket URL invalid",
detail = "URL must start with ws:// or wss://",
url = url,
operation = "Open Relay WebSocket",
configuredUrl = url,
suggestion = "Edit or re-pair the Relay route with a ws:// or wss:// URL.",
)
return
}
@@ -392,6 +483,14 @@ class ConnectionManager(
// hits the HTTP root and comes back as 404 Not Found during the
// upgrade handshake. We still accept an explicit path if present.
val normalized = normalizeRelayUrl(url)
if (isRelayRateLimitBackoffActive(
rateLimitBackoffUntilMs,
SystemClock.elapsedRealtime(),
)
) {
Log.i(TAG, "connect: preserving active rate-limit backoff")
return
}
val existingState = _connectionState.value
if (serverUrl == normalized &&
(existingState == ConnectionState.Connecting ||
@@ -410,20 +509,28 @@ class ConnectionManager(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.conn_diag_opening_insecure) ?: "Opening insecure relay socket",
url = normalized,
operation = "Open Relay WebSocket",
configuredUrl = url,
requestUrl = normalized,
)
} else {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.conn_diag_opening_socket) ?: "Opening relay socket",
url = normalized,
operation = "Open Relay WebSocket",
configuredUrl = url,
requestUrl = normalized,
)
}
serverUrl = normalized
shouldReconnect = true
reconnectAttempt = 0
if (preserveReconnectBackoff) {
reconnectState.beginAutomaticRouteSwap(normalized)
} else {
reconnectState.beginExplicitConnect(normalized)
}
doConnect(normalized, previousSocket, replaceReason)
}
@@ -439,9 +546,13 @@ class ConnectionManager(
* Wraps the DataStore read in a 1-second timeout; if DataStore stalls
* for any reason we don't block the connect loop forever.
*/
suspend fun resolveBestEndpoint(): EndpointCandidate? = resolveBestEndpointSafe()
suspend fun resolveBestEndpoint(): EndpointCandidate? =
resolveBestEndpointSafe(EndpointSurface.Dashboard)
?: resolveBestEndpointSafe(EndpointSurface.Standard)
private suspend fun resolveBestEndpointSafe(): EndpointCandidate? {
private suspend fun resolveBestEndpointSafe(
surface: EndpointSurface,
): EndpointCandidate? {
val resolver = endpointResolver ?: return null
val ctx = context ?: return null
@@ -480,14 +591,14 @@ class ConnectionManager(
}
if (preferred != null) {
// Single-element list still respects the 2s probe gate.
val winner = resolver.resolve(listOf(preferred))
val winner = resolver.resolve(listOf(preferred), surface)
if (winner != null) return winner
Log.i(TAG, "manualRoleOverride=$preferredRole not reachable — " +
"falling through to strict-priority resolve")
}
}
return resolver.resolve(endpoints)
return resolver.resolve(endpoints, surface)
}
/**
@@ -515,7 +626,10 @@ class ConnectionManager(
suspend fun probeAndReconnectNow(): EndpointCandidate? {
endpointResolver?.clearCache()
val current = serverUrl
val resolved = resolveBestEndpointSafe()
val resolved = resolveBestEndpointSafe(EndpointSurface.Dashboard)
?: resolveBestEndpointSafe(EndpointSurface.Standard)
val apiResolved = resolveBestEndpointSafe(EndpointSurface.Api)
val relayResolved = resolveBestEndpointSafe(EndpointSurface.Relay)
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
// Transient probe miss while the relay socket is demonstrably up
// — keep the live route published rather than downgrading every
@@ -523,7 +637,9 @@ class ConnectionManager(
return _activeEndpoint.value
}
_activeEndpoint.value = resolved
val targetUrl = resolved?.relay?.url ?: current ?: return resolved
_activeApiEndpoint.value = apiResolved
if (relayResolved != null) _activeRelayEndpoint.value = relayResolved
val targetUrl = relayResolved?.relayWebSocketUrl() ?: current ?: return resolved
val normalizedTarget = normalizeRelayUrl(targetUrl)
// Reconnect when the winner changed, and also when the socket is
// stale/disconnected on the same winner. The latter makes the
@@ -560,7 +676,9 @@ class ConnectionManager(
*/
suspend fun refreshActiveEndpoint(clearProbeCache: Boolean = false): EndpointCandidate? {
if (clearProbeCache) endpointResolver?.clearCache()
val resolved = resolveBestEndpointSafe()
val resolved = resolveBestEndpointSafe(EndpointSurface.Dashboard)
?: resolveBestEndpointSafe(EndpointSurface.Standard)
val apiResolved = resolveBestEndpointSafe(EndpointSurface.Api)
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
// Transient probe miss while the relay socket is demonstrably up
// (slow resume, mid-handoff blip) — keep publishing the live
@@ -569,6 +687,7 @@ class ConnectionManager(
return _activeEndpoint.value
}
_activeEndpoint.value = resolved
_activeApiEndpoint.value = apiResolved
return resolved
}
@@ -584,9 +703,9 @@ class ConnectionManager(
fun getManualRoleOverride(): String? = _manualRoleOverride.value
private fun markActiveEndpointUnreachable(reason: String) {
val active = _activeEndpoint.value ?: return
endpointResolver?.markUnreachable(active)
private fun markActiveRelayEndpointUnreachable(reason: String) {
val active = _activeRelayEndpoint.value ?: return
endpointResolver?.markUnreachable(active, EndpointSurface.Relay)
Log.i(TAG, "marked endpoint role=${active.role} unreachable ($reason)")
}
@@ -609,9 +728,11 @@ class ConnectionManager(
// Tailscale's tun churns onAvailable repeatedly — coalesces into a
// single cache wipe + re-probe instead of one per event. onLost
// manages its own cache (clear + markUnreachable) and passes false.
if (wipeCache) endpointResolver?.clearCache()
if (wipeCache) endpointResolver.clearCache()
val current = serverUrl
val resolved = resolveBestEndpointSafe()
val resolved = resolveBestEndpointSafe(EndpointSurface.Dashboard)
?: resolveBestEndpointSafe(EndpointSurface.Standard)
val apiResolved = resolveBestEndpointSafe(EndpointSurface.Api)
if (resolved == null) {
// Hysteresis for the AUTOMATIC (network-callback) path. A
// transient cold-route probe miss must NOT null the published
@@ -648,6 +769,7 @@ class ConnectionManager(
}
sustainedLossDeclared = false
_activeEndpoint.value = resolved
_activeApiEndpoint.value = apiResolved
if (current == null) return@launch
// After an explicit disconnect() the route still publishes above
// (HTTP surfaces keep roaming), but no socket action: without
@@ -656,10 +778,34 @@ class ConnectionManager(
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so
// the swap path never re-checked it.)
if (!shouldReconnect) return@launch
val normalizedNew = normalizeRelayUrl(resolved.relay.url)
val relayResolved = resolveBestEndpointSafe(EndpointSurface.Relay)
if (relayResolved != null) _activeRelayEndpoint.value = relayResolved
val relayUrl = relayResolved?.relayWebSocketUrl()?.takeIf { it.isNotBlank() }
?: return@launch
if (isRelayRateLimitBackoffActive(
rateLimitBackoffUntilMs,
SystemClock.elapsedRealtime(),
)
) {
// Keep publishing the newly resolved standard/Relay routes, but
// leave the protected retry job intact. It will resolve the
// latest Relay winner again when the server cooldown expires.
Log.i(TAG, "network change: preserving rate-limit retry job")
return@launch
}
val normalizedNew = normalizeRelayUrl(relayUrl)
if (normalizedNew != current) {
Log.i(TAG, "network change: swapping $current → $normalizedNew")
connectToUrlOnMainPath(resolved.relay.url, closeReason)
if (reconnectBackoffWaiting) {
reconnectJob?.cancel()
reconnectJob = null
reconnectBackoffWaiting = false
}
connectToUrlOnMainPath(
relayUrl,
closeReason,
preserveReconnectBackoff = true,
)
} else if (_connectionState.value == ConnectionState.Disconnected &&
reconnectGate()
) {
@@ -701,7 +847,9 @@ class ConnectionManager(
Log.i(TAG, "network loss sustained past grace — marking active endpoint unreachable and resolving fallback")
sustainedLossDeclared = true
endpointResolver?.clearCache()
markActiveEndpointUnreachable("network lost (sustained)")
_activeEndpoint.value?.let { active ->
endpointResolver?.markUnreachable(active, EndpointSurface.Standard)
}
// wipeCache=false: we just cleared + poisoned the dead route
// above; re-wiping inside the job would drop that negative
// entry and let the dead route win the resolve again.
@@ -755,6 +903,11 @@ class ConnectionManager(
fun disconnect() {
shouldReconnect = false
reconnectJob?.cancel()
reconnectJob = null
reconnectBackoffWaiting = false
rateLimitBackoffUntilMs = 0L
lastUpgradeResponseCode = null
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
@@ -772,6 +925,9 @@ class ConnectionManager(
// the ViewModel on the next connection load.
_manualRoleOverride.value = null
_activeEndpoint.value = null
_activeApiEndpoint.value = null
_activeRelayEndpoint.value = null
reconnectState.reset()
}
fun shutdown() {
@@ -808,19 +964,28 @@ class ConnectionManager(
url: String,
previousSocketToClose: WebSocket? = null,
replaceReason: String = "Relay socket replaced",
scheduledReconnect: Boolean = false,
) {
if (isRelayRateLimitBackoffActive(
rateLimitBackoffUntilMs,
SystemClock.elapsedRealtime(),
)
) {
Log.i(TAG, "doConnect: preserving active rate-limit backoff")
return
}
val existingState = _connectionState.value
if (previousSocketToClose == null &&
serverUrl == url &&
(existingState == ConnectionState.Connecting ||
existingState == ConnectionState.Connected ||
existingState == ConnectionState.Reconnecting)
(existingState == ConnectionState.Reconnecting && !scheduledReconnect))
) {
Log.i(TAG, "doConnect: already ${existingState.name.lowercase()} to $url — skipping duplicate open")
return
}
_connectionState.value = if (reconnectAttempt > 0) {
_connectionState.value = if (reconnectState.reconnectAttempt > 0) {
ConnectionState.Reconnecting
} else {
ConnectionState.Connecting
@@ -841,7 +1006,18 @@ class ConnectionManager(
// Every new socket starts unauthenticated — the send-gate stays closed
// (auth frame excepted) until this socket's own auth.ok arrives.
authenticated = false
client = buildClient()
val isPluginProxyUrl = _activeRelayEndpoint.value?.pluginProxyRoutesOrNull()
?.relayWebSocketUrl
?.equals(url, ignoreCase = true) == true
client = if (isPluginProxyUrl) {
proxyClientProvider?.invoke(url) ?: run {
Log.e(TAG, "Pinned plugin proxy client unavailable — refusing generic TLS fallback")
_connectionState.value = ConnectionState.Disconnected
return
}
} else {
buildClient(url)
}
val request = buildRelayRequestOrNull(url)
if (request == null) {
@@ -856,7 +1032,9 @@ class ConnectionManager(
severity = DiagnosticSeverity.Error,
title = "Invalid relay URL",
detail = "The relay address could not be parsed; re-pair to refresh it.",
url = url,
operation = "Build Relay WebSocket request",
configuredUrl = url,
suggestion = "Edit or re-pair the Relay route to replace the invalid address.",
)
authenticated = false
_connectionState.value = ConnectionState.Disconnected
@@ -877,16 +1055,20 @@ class ConnectionManager(
webSocket.cancel()
return
}
reconnectAttempt = 0
reconnectState.connected(url)
reconnectJob?.cancel()
reconnectJob = null
reconnectBackoffWaiting = false
rateLimitBackoffUntilMs = 0L
lastUpgradeResponseCode = null
consecutiveSocketFailures = 0
_connectionState.value = ConnectionState.Connected
Log.i(TAG, "onOpen: WSS handshake complete ($url)")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.conn_diag_connected) ?: "Relay socket connected",
url = url,
operation = "Relay WebSocket handshake",
requestUrl = url,
)
// TOFU: record the peer cert fingerprint if we don't have one
@@ -947,7 +1129,9 @@ class ConnectionManager(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.conn_diag_closed) ?: "Relay socket closed",
detail = "code=$code reason=$reason",
url = url,
operation = "Relay WebSocket session",
requestUrl = url,
suggestion = if (code == 1000) null else "Check the Relay server logs for the matching close code and reason.",
)
authenticated = false
_connectionState.value = ConnectionState.Disconnected
@@ -970,20 +1154,24 @@ class ConnectionManager(
t.message,
code?.let { "HTTP $it" },
).joinToString(": "),
url = url,
operation = "Relay WebSocket handshake",
requestUrl = url,
suggestion = code?.let {
NetworkDiagnosticGuidance.forHttpStatus(it, "Relay")
} ?: NetworkDiagnosticGuidance.forThrowable(t, "Relay"),
)
lastUpgradeResponseCode = code
if (response == null) {
// Transport-level failure (no HTTP upgrade response): on a
// remote (Tailscale) link the first handshake can fail cold.
// Don't evict the only working route from the shared resolver
// on a single blip — wait for it to repeat. A genuinely
// sustained network loss is handled separately by onLost.
consecutiveSocketFailures++
if (consecutiveSocketFailures >= MARK_UNREACHABLE_AFTER_FAILURES) {
markActiveEndpointUnreachable("socket failure x$consecutiveSocketFailures")
// Don't evict this Relay surface on a single blip — wait
// for the same socket URL to fail again. Standard route
// health is separate and is never poisoned here.
val failureCount = reconnectState.recordSocketFailure(url)
if (failureCount >= MARK_UNREACHABLE_AFTER_FAILURES) {
markActiveRelayEndpointUnreachable("socket failure x$failureCount")
} else {
Log.i(TAG, "relay socket failure $consecutiveSocketFailures/$MARK_UNREACHABLE_AFTER_FAILURES — not yet poisoning route")
Log.i(TAG, "relay socket failure $failureCount/$MARK_UNREACHABLE_AFTER_FAILURES — not yet poisoning route")
}
}
authenticated = false
@@ -1022,7 +1210,12 @@ class ConnectionManager(
}
val url = serverUrl ?: return
reconnectAttempt++
val reconnectAttempt = reconnectState.nextReconnectAttempt()
// Keep the socket lifecycle visibly in-flight for the whole backoff
// window. Callers such as reconnectIfStale() treat Disconnected as an
// invitation to call connect() again; leaving this state Disconnected
// let screen entry restart both the route resolve and the retry counter.
_connectionState.value = ConnectionState.Reconnecting
// Server-issued 429 means we're IP-banned — keep retrying at our
// normal exponential cadence and we'll re-fill the ban bucket on
@@ -1033,6 +1226,7 @@ class ConnectionManager(
// block window instead of re-filling the ban bucket at our normal
// cadence.
lastUpgradeResponseCode == 429 -> {
rateLimitBackoffUntilMs = SystemClock.elapsedRealtime() + RATE_LIMIT_BACKOFF_MS
Log.i(TAG, "scheduleReconnect: rate-limited (429) — backing off ${RATE_LIMIT_BACKOFF_MS}ms")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
@@ -1057,8 +1251,9 @@ class ConnectionManager(
SLOW_POLL_BACKOFF_MS
}
else -> {
val ms = (BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
val capMs = (BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
.coerceAtMost(MAX_BACKOFF_MS)
val ms = fullJitterDelayMs(capMs, reconnectJitterUnit())
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
@@ -1070,14 +1265,17 @@ class ConnectionManager(
}
}
scope.launch {
reconnectJob?.cancel()
reconnectBackoffWaiting = true
val scheduledJob = scope.launch {
delay(backoffMs)
reconnectBackoffWaiting = false
// Re-check the gate after the backoff — by the time the delay
// expires, auth state may have changed (e.g., user hit Revoke
// during the retry window).
if (shouldReconnect && reconnectGate()) {
val resolved = resolveBestEndpointSafe()
val targetUrl = resolved?.relay?.url
val resolved = resolveBestEndpointSafe(EndpointSurface.Relay)
val targetUrl = resolved?.relayWebSocketUrl()
if (resolved != null) {
// Mirror scheduleNetworkReResolve: clear the sustained-loss
// latch on a successful resolve so a later transient miss
@@ -1085,24 +1283,28 @@ class ConnectionManager(
// in onLost's grace job but can be cleared on EITHER success
// edge — network-callback or relay-timer.)
sustainedLossDeclared = false
_activeEndpoint.value = resolved
} else if (sustainedLossDeclared || _activeEndpoint.value == null) {
// Same hysteresis as scheduleNetworkReResolve: a transient
// miss during a relay reconnect must not flip every effective
// URL back to the dead saved host. Keep the last-known route;
// we fall through to doConnect(url) and retry it with backoff.
_activeEndpoint.value = null
_activeRelayEndpoint.value = resolved
}
if (targetUrl != null && normalizeRelayUrl(targetUrl) != url) {
Log.i(TAG, "scheduleReconnect: switching $url → ${normalizeRelayUrl(targetUrl)}")
connectToUrlOnMainPath(targetUrl)
connectToUrlOnMainPath(
targetUrl,
preserveReconnectBackoff = true,
)
} else {
doConnect(url)
doConnect(url, scheduledReconnect = true)
}
} else if (!reconnectGate()) {
Log.i(TAG, "scheduleReconnect: gate turned false during backoff — aborting retry")
_connectionState.value = ConnectionState.Disconnected
}
}
reconnectJob = scheduledJob
scheduledJob.invokeOnCompletion {
if (reconnectJob === scheduledJob) {
reconnectJob = null
reconnectBackoffWaiting = false
}
}
}
}
@@ -7,10 +7,12 @@ import com.hermesandroid.relay.auth.PairedDeviceInfo
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
@@ -59,6 +61,10 @@ class RelayHttpClient(
companion object {
private const val TAG = "RelayHttpClient"
const val MAX_MODEL_CAPABILITY_ROWS = 64
private const val MAX_MODEL_CAPABILITY_PROVIDER_CHARS = 128
private const val MAX_MODEL_CAPABILITY_MODEL_CHARS = 512
private const val MAX_MODEL_CAPABILITY_PROFILE_CHARS = 128
private val sessionsJson = Json {
ignoreUnknownKeys = true
isLenient = true
@@ -135,6 +141,91 @@ class RelayHttpClient(
val text: String,
)
@Serializable
data class ImageActivitySnapshot(
@SerialName("session_id") val sessionId: String,
val profile: String,
val activities: List<ImageActivity> = emptyList(),
)
@Serializable
data class ImageActivity(
@SerialName("call_id") val callId: String,
@SerialName("tool_name") val toolName: String,
val state: String,
@SerialName("started_at") val startedAt: Double,
@SerialName("completed_at") val completedAt: Double? = null,
)
/**
* Poll the optional Relay image lifecycle bridge. A null success means the
* connected Relay predates the endpoint; callers should stop polling and
* continue using native Gateway events without surfacing an error.
*/
suspend fun fetchImageActivity(
profile: String,
sessionId: String,
sinceEpochSeconds: Double,
): Result<ImageActivitySnapshot?> = withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
if (relayUrl.isEmpty()) {
return@withContext Result.failure(
IllegalStateException("Relay URL not configured")
)
}
val sessionToken = sessionTokenProvider()
if (sessionToken.isNullOrBlank()) {
return@withContext Result.failure(
IllegalStateException("Relay not paired — session token missing")
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val url = try {
"$httpBase/chat/image-activity".toHttpUrl().newBuilder()
.addQueryParameter("profile", profile)
.addQueryParameter("session_id", sessionId)
.addQueryParameter("since", sinceEpochSeconds.toString())
.build()
} catch (e: IllegalArgumentException) {
return@withContext Result.failure(IOException("Invalid relay URL: ${e.message}"))
}
val request = Request.Builder()
.url(url)
.get()
.header("Authorization", "Bearer $sessionToken")
.header("Accept", "application/json")
.build()
val activityClient = okHttpClient.newBuilder()
.callTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
.build()
try {
activityClient.newCall(request).execute().use { response ->
if (response.code == 404) {
return@withContext Result.success(null)
}
if (!response.isSuccessful) {
return@withContext Result.failure(
IOException("Image activity request failed (HTTP ${response.code})")
)
}
val body = response.body?.string().orEmpty()
if (body.isBlank()) {
return@withContext Result.failure(IOException("Empty response body"))
}
Result.success(
sessionsJson.decodeFromString(ImageActivitySnapshot.serializer(), body)
)
}
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Fetch `GET /media/<token>` from the relay over HTTP(S). Returns a
* [Result] — success carries a [FetchedMedia], failure wraps the
@@ -615,6 +706,45 @@ class RelayHttpClient(
val capabilities: List<String> = emptyList(),
val profiles: List<RelayProfileInfo> = emptyList(),
val health: String = "unknown",
@SerialName("gateway_heartbeat") val gatewayHeartbeat: GatewayHeartbeat? = null,
)
@Serializable
data class GatewayHeartbeat(
val status: String = "missing",
val supported: Boolean = false,
@SerialName("age_seconds") val ageSeconds: Int? = null,
)
@Serializable
data class ModelCapabilityRequestRow(
val provider: String,
val model: String,
)
@Serializable
data class ModelCapabilitiesRequest(
@SerialName("schema_version") val schemaVersion: Int = 1,
val profile: String? = null,
val refresh: Boolean = false,
val models: List<ModelCapabilityRequestRow>,
)
@Serializable
data class ModelCapabilityRow(
val provider: String,
val model: String,
val reasoning: Boolean? = null,
@SerialName("reasoning_efforts") val reasoningEfforts: List<String> = emptyList(),
@SerialName("reasoning_efforts_exact") val reasoningEffortsExact: Boolean = false,
val source: String = "",
)
@Serializable
data class ModelCapabilitiesResponse(
@SerialName("schema_version") val schemaVersion: Int = 1,
@SerialName("contract_version") val contractVersion: String = "",
val capabilities: List<ModelCapabilityRow> = emptyList(),
)
/** Fetch the installed plugin/protocol/profile capability contract. */
@@ -650,6 +780,64 @@ class RelayHttpClient(
}
}
/** Optional provider/model reasoning overlay; 404 and no pairing are fail-soft. */
suspend fun fetchModelCapabilities(
models: List<ModelCapabilityRequestRow>,
profile: String? = null,
refresh: Boolean = false,
): Result<ModelCapabilitiesResponse?> = withContext(Dispatchers.IO) {
val boundedModels = models
.asSequence()
.map {
ModelCapabilityRequestRow(
it.provider.trim().take(MAX_MODEL_CAPABILITY_PROVIDER_CHARS),
it.model.trim().take(MAX_MODEL_CAPABILITY_MODEL_CHARS),
)
}
.filter { it.provider.isNotEmpty() && it.model.isNotEmpty() }
.distinct()
.take(MAX_MODEL_CAPABILITY_ROWS)
.toList()
if (boundedModels.isEmpty()) return@withContext Result.success(null)
val relayUrl = relayUrlProvider()?.trim().orEmpty()
val token = sessionTokenProvider()
if (relayUrl.isEmpty() || token.isNullOrBlank()) return@withContext Result.success(null)
val base = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val url = runCatching { "$base/relay/model-capabilities".toHttpUrl() }.getOrElse {
return@withContext Result.success(null)
}
val payload = ModelCapabilitiesRequest(
profile = profile?.trim()?.take(MAX_MODEL_CAPABILITY_PROFILE_CHARS)
?.takeIf { it.isNotEmpty() },
refresh = refresh,
models = boundedModels,
)
val request = Request.Builder()
.url(url)
.post(sessionsJson.encodeToString(payload).toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer $token")
.header("Accept", "application/json")
.build()
try {
okHttpClient.newBuilder().callTimeout(4, java.util.concurrent.TimeUnit.SECONDS).build()
.newCall(request).execute().use { response ->
if (response.code == 404) return@withContext Result.success(null)
if (!response.isSuccessful) return@withContext Result.failure(IOException("HTTP ${response.code}"))
val body = response.body?.string().orEmpty()
val parsed = body.takeIf { it.isNotBlank() }?.let {
sessionsJson.decodeFromString(ModelCapabilitiesResponse.serializer(), it)
}
if (parsed?.schemaVersion != 1) Result.success(null) else Result.success(parsed)
}
} catch (e: Exception) {
Log.w(TAG, "fetchModelCapabilities failed: ${e.message}")
Result.failure(e)
}
}
/**
* Ask the relay whether a newer plugin release is available — it compares its
* installed version against the latest `plugin-v*` GitHub release (cached an
@@ -1046,6 +1234,7 @@ class RelayHttpClient(
logSuccess: Boolean = true,
): Result<RelayHealth> = withContext(Dispatchers.IO) {
val trimmed = relayUrl.trim()
val operation = "Relay health probe before WebSocket connection"
if (trimmed.isEmpty()) {
return@withContext Result.failure(
IllegalArgumentException("Relay URL is empty")
@@ -1066,7 +1255,9 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.http_diag_url_invalid) ?: "Relay URL invalid",
detail = e.message,
url = relayUrl,
operation = operation,
configuredUrl = relayUrl,
suggestion = "Enter a Relay URL beginning with ws:// or wss://.",
)
return@withContext Result.failure(
IOException("Invalid relay URL: ${e.message}")
@@ -1087,6 +1278,7 @@ class RelayHttpClient(
.get()
.header("Accept", "application/json")
.build()
val requestUrl = request.url.toString()
try {
fastClient.newCall(request).execute().use { response ->
@@ -1096,8 +1288,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "HTTP ${response.code}",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forHttpStatus(response.code, "Relay"),
)
return@withContext Result.failure(
IOException("Relay responded HTTP ${response.code}")
@@ -1110,8 +1305,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Empty response",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = "Verify this route points to a Hermes-Relay server and inspect its logs.",
)
return@withContext Result.failure(
IOException("Relay returned an empty response")
@@ -1127,8 +1325,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Non-JSON response",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = "Verify this route points to a Hermes-Relay server rather than another HTTP service.",
)
return@withContext Result.failure(
IOException("Relay returned non-JSON: ${e.message ?: "parse error"}")
@@ -1141,8 +1342,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "status=${status ?: "missing"}",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = "Check the Relay service health and server logs.",
)
return@withContext Result.failure(
IOException("Relay reports status=${status ?: "missing"} (expected 'ok')")
@@ -1155,8 +1359,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Missing version field",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = "Verify this route points to a current Hermes-Relay server.",
)
return@withContext Result.failure(
IOException("Response doesn't look like a hermes-relay — missing 'version' field")
@@ -1172,7 +1379,9 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.http_diag_health_ok) ?: "Relay health ok",
detail = "version=$version clients=$clients sessions=$sessions",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
)
}
@@ -1185,8 +1394,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_timeout) ?: "Relay health timeout",
detail = "No HTTP response in 3s",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
)
Result.failure(IOException("Relay is not responding (3s timeout)"))
} catch (e: java.net.ConnectException) {
@@ -1196,8 +1408,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.http_diag_conn_refused) ?: "Relay connection refused",
detail = e.message,
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
)
Result.failure(IOException("Connection refused — is the relay running on this URL?"))
} catch (e: IOException) {
@@ -1207,8 +1422,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = e.message ?: "Network error",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
)
Result.failure(IOException("Network error: ${e.message ?: "unreachable"}"))
} catch (e: Exception) {
@@ -1218,8 +1436,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = e.message ?: e.javaClass.simpleName,
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
)
Result.failure(e)
}
@@ -7,6 +7,8 @@ import com.hermesandroid.relay.data.ProfileSkillsResponse
import com.hermesandroid.relay.data.ProfileSoulResponse
import com.hermesandroid.relay.data.ProfileSoulUpdateResponse
import com.hermesandroid.relay.data.ProfileMemoryUpdateResponse
import com.hermesandroid.relay.data.LegacyProfileInspectorClient
import com.hermesandroid.relay.data.RelaySkillToggleResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
@@ -48,7 +50,7 @@ class RelayProfileInspectorClient(
private val okHttpClient: OkHttpClient,
private val relayUrlProvider: () -> String?,
private val sessionTokenProvider: suspend () -> String?,
) {
) : LegacyProfileInspectorClient {
companion object {
private const val TAG = "RelayProfileInspector"
@@ -79,19 +81,19 @@ class RelayProfileInspectorClient(
/** Fetch `GET /api/profiles/{name}/config`. */
suspend fun fetchConfig(profileName: String): Result<ProfileConfigResponse> =
override suspend fun fetchConfig(profileName: String): Result<ProfileConfigResponse> =
get(profileName, "config", ProfileConfigResponse.serializer())
/** Fetch `GET /api/profiles/{name}/skills`. */
suspend fun fetchSkills(profileName: String): Result<ProfileSkillsResponse> =
override suspend fun fetchSkills(profileName: String): Result<ProfileSkillsResponse> =
get(profileName, "skills", ProfileSkillsResponse.serializer())
/** Fetch `GET /api/profiles/{name}/soul`. */
suspend fun fetchSoul(profileName: String): Result<ProfileSoulResponse> =
override suspend fun fetchSoul(profileName: String): Result<ProfileSoulResponse> =
get(profileName, "soul", ProfileSoulResponse.serializer())
/** Fetch `GET /api/profiles/{name}/memory`. */
suspend fun fetchMemory(profileName: String): Result<ProfileMemoryResponse> =
override suspend fun fetchMemory(profileName: String): Result<ProfileMemoryResponse> =
get(profileName, "memory", ProfileMemoryResponse.serializer())
/**
@@ -108,7 +110,7 @@ class RelayProfileInspectorClient(
* would be a protocol violation; we send empty-string for an empty
* SOUL.
*/
suspend fun updateSoul(
override suspend fun updateSoul(
profileName: String,
content: String,
): Result<ProfileSoulUpdateResponse> = withContext(Dispatchers.IO) {
@@ -137,7 +139,7 @@ class RelayProfileInspectorClient(
* Used for both creating a new memory entry (the relay writes the
* file if missing) and updating an existing entry.
*/
suspend fun updateMemoryEntry(
override suspend fun updateMemoryEntry(
profileName: String,
filename: String,
content: String,
@@ -270,10 +272,10 @@ class RelayProfileInspectorClient(
* server" snackbar and ghost out the toggle. When the real
* implementation lands server-side, this method needs no change.
*/
suspend fun updateSkillToggle(
override suspend fun updateSkillToggle(
skillName: String,
enabled: Boolean,
): Result<SkillToggleResult> = withContext(Dispatchers.IO) {
): Result<RelaySkillToggleResult> = withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
if (relayUrl.isEmpty()) {
return@withContext Result.failure(
@@ -319,8 +321,8 @@ class RelayProfileInspectorClient(
try {
okHttpClient.newCall(request).execute().use { response ->
when (response.code) {
in 200..299 -> Result.success(SkillToggleResult.Ok)
501 -> Result.success(SkillToggleResult.NotImplemented)
in 200..299 -> Result.success(RelaySkillToggleResult.Ok)
501 -> Result.success(RelaySkillToggleResult.NotImplemented)
401, 403 -> Result.failure(
IOException("Unauthorized — re-pair with the relay")
)
@@ -348,7 +350,7 @@ class RelayProfileInspectorClient(
* "not implemented" and any 2xx as "supported". The relay serves
* OPTIONS via aiohttp's CORS handling by default.
*/
suspend fun probeSkillToggleSupported(): Boolean = withContext(Dispatchers.IO) {
override suspend fun probeSkillToggleSupported(): Boolean = withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
if (relayUrl.isEmpty()) return@withContext false
val sessionToken = sessionTokenProvider() ?: return@withContext false
@@ -402,11 +404,6 @@ class RelayProfileInspectorClient(
* answered 501 — not implemented yet" without inventing magic
* error strings.
*/
sealed class SkillToggleResult {
data object Ok : SkillToggleResult()
data object NotImplemented : SkillToggleResult()
}
/**
* Best-effort pull of a `detail` or `error` string out of a relay
* 400 body. Falls back to the first 120 chars of the payload when
@@ -0,0 +1,61 @@
package com.hermesandroid.relay.network.relay
/**
* Route-aware retry state for the Relay WebSocket.
*
* Automatic LAN/Tailscale fallback keeps the accumulated reconnect attempt so
* swapping URLs cannot restart exponential backoff. Socket-failure streaks are
* scoped to one URL, so failures on different roles cannot combine and poison
* the newly selected route.
*/
internal class RelayReconnectState {
@Volatile
var reconnectAttempt: Int = 0
private set
private var socketFailureRoute: String? = null
private var consecutiveSocketFailures: Int = 0
@Synchronized
fun beginExplicitConnect(route: String) {
reconnectAttempt = 0
resetSocketFailures(route)
}
@Synchronized
fun beginAutomaticRouteSwap(route: String) {
resetSocketFailures(route)
}
@Synchronized
fun nextReconnectAttempt(): Int {
reconnectAttempt++
return reconnectAttempt
}
@Synchronized
fun recordSocketFailure(route: String): Int {
if (socketFailureRoute != route) {
resetSocketFailures(route)
}
consecutiveSocketFailures++
return consecutiveSocketFailures
}
@Synchronized
fun connected(route: String) {
reconnectAttempt = 0
resetSocketFailures(route)
}
@Synchronized
fun reset() {
reconnectAttempt = 0
resetSocketFailures(null)
}
private fun resetSocketFailures(route: String?) {
socketFailureRoute = route
consecutiveSocketFailures = 0
}
}
@@ -98,6 +98,7 @@ class RelayVoiceClient(
private val webSocketFactory: ((Request, WebSocketListener) -> WebSocket)? = null,
private val realtimeResumeRetryIntervalMs: Long = REALTIME_RESUME_RETRY_INTERVAL_MS,
private val realtimeResumeRetryWindowMs: Long = REALTIME_RESUME_RETRY_WINDOW_MS,
private val voiceOutputFirstAudioTimeoutMs: Long = VOICE_OUTPUT_FIRST_AUDIO_TIMEOUT_MS,
) {
companion object {
@@ -108,6 +109,7 @@ class RelayVoiceClient(
private val WAV_AUDIO = "audio/wav".toMediaType()
private val OCTET_STREAM = "application/octet-stream".toMediaType()
private const val REALTIME_TIMEOUT_MS = 90_000L
private const val VOICE_OUTPUT_FIRST_AUDIO_TIMEOUT_MS = 15_000L
private const val REALTIME_AGENT_IDLE_TIMEOUT_MS = 90_000L
private const val REALTIME_AGENT_MAX_TURN_MS = 5 * 60_000L
private const val REALTIME_AGENT_WAIT_SLICE_MS = 1_000L
@@ -834,6 +836,11 @@ class RelayVoiceClient(
suspend fun runVoiceOutput(
text: String,
renderMode: String? = "verbatim",
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
language: String? = null,
onHandoff: (VoiceHandoffEvent) -> Unit = {},
onEvent: (RealtimeVoiceEvent) -> Unit,
): Result<VoiceOutputSummary> = withContext(Dispatchers.IO) {
@@ -844,7 +851,15 @@ class RelayVoiceClient(
return@withContext Result.failure(missingAuthError())
}
val sessionResult = createVoiceOutputSession(httpBase, token)
val sessionResult = createVoiceOutputSession(
httpBase = httpBase,
token = token,
provider = provider,
model = model,
voice = voice,
sampleRate = sampleRate,
language = language,
)
if (sessionResult.isFailure) {
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Voice output session failed"))
}
@@ -854,6 +869,7 @@ class RelayVoiceClient(
val resumeAttempted = AtomicBoolean(false)
val routeProbeRequested = AtomicBoolean(false)
val currentSocket = AtomicReference<WebSocket?>()
val firstAudioSeen = AtomicBoolean(false)
val socketGeneration = AtomicLong(0L)
val activeSocketGeneration = AtomicLong(0L)
val lastEventId = AtomicLong(0L)
@@ -967,6 +983,7 @@ class RelayVoiceClient(
}
onEvent(event)
if (event.isAudioDelta) {
firstAudioSeen.set(true)
event.audioEventId?.let {
lastPlayedAudioEventId.updateAndGet { current -> maxOf(current, it) }
}
@@ -1110,6 +1127,15 @@ class RelayVoiceClient(
onHandoff = onHandoff,
completeFailure = ::completeFailure,
)
val firstAudioWatchdog = launch {
delay(voiceOutputFirstAudioTimeoutMs)
if (!completed.get() && !firstAudioSeen.get()) {
completeFailure(
"Voice output produced no audio within ${voiceOutputFirstAudioTimeoutMs}ms",
)
currentSocket.get()?.cancel()
}
}
try {
withTimeout(REALTIME_TIMEOUT_MS) {
finished.await()
@@ -1119,6 +1145,7 @@ class RelayVoiceClient(
socket.close(1001, "timeout")
Result.failure(IOException("Voice output timed out", e))
} finally {
firstAudioWatchdog.cancel()
routeWatcher?.cancel()
}
}
@@ -1261,8 +1288,11 @@ class RelayVoiceClient(
inputSampleRate: Int = 16_000,
chatSessionId: String? = null,
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
finalAnswerOnly: Boolean = false,
onHandoff: (VoiceHandoffEvent) -> Unit = {},
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
@@ -1290,8 +1320,11 @@ class RelayVoiceClient(
token = token,
chatSessionId = chatSessionId,
conversationContext = conversationContext,
provider = provider,
model = model,
voice = voice,
sampleRate = sampleRate,
finalAnswerOnly = finalAnswerOnly,
)
if (sessionResult.isFailure) {
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
@@ -1805,6 +1838,28 @@ class RelayVoiceClient(
if (event.type == "hermes.run.promoted") {
longRunningTurn.set(true)
Log.i(TAG, "Realtime agent turn marked long-running (run promoted); relaxing idle guard")
if (persistent &&
event.spokenHandoff == false &&
activeTurn.compareAndSet(true, false)
) {
Log.i(
TAG,
"Realtime agent foreground turn ended at silent background promotion",
)
onTurnComplete(
RealtimeVoiceSummary(
provider = event.provider ?: session.provider,
model = event.model ?: session.model,
voice = event.voice ?: session.voice,
sampleRate = session.sampleRate,
audioChunks = audioChunks,
audioBytes = audioBytes,
firstAudioMs = event.firstAudioMs,
responseDoneMs = event.responseDoneMs,
eventLogPath = event.eventLogPath ?: session.eventLogPath,
)
)
}
}
if (event.isAudioDelta) {
audioChunks += 1
@@ -1830,13 +1885,12 @@ class RelayVoiceClient(
responseDoneMs = event.responseDoneMs,
eventLogPath = event.eventLogPath ?: session.eventLogPath,
)
if (persistent) {
if (persistent && activeTurn.compareAndSet(true, false)) {
// Turn boundary, not session boundary: keep the socket
// open for the next utterance.
activeTurn.set(false)
longRunningTurn.set(false)
onTurnComplete(summary)
} else {
} else if (!persistent) {
if (claimTerminalSocket(
webSocket,
generation,
@@ -2658,17 +2712,29 @@ class RelayVoiceClient(
token: String,
chatSessionId: String?,
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
finalAnswerOnly: Boolean = false,
): Result<RealtimeSessionResponse> {
val body = buildJsonObject {
putProfile()
provider?.trim()?.takeIf { it.isNotBlank() }?.let {
put("provider", JsonPrimitive(it))
}
model?.trim()?.takeIf { it.isNotBlank() }?.let {
put("model", JsonPrimitive(it))
}
voice?.trim()?.takeIf { it.isNotBlank() }?.let {
put("voice", JsonPrimitive(it))
}
sampleRate?.takeIf { it > 0 }?.let {
put("sample_rate", JsonPrimitive(it))
}
if (finalAnswerOnly) {
put("final_answer_only", JsonPrimitive(true))
}
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
put("chat_session_id", JsonPrimitive(it))
}
@@ -2726,8 +2792,23 @@ class RelayVoiceClient(
}
}
private fun createVoiceOutputSession(httpBase: String, token: String): Result<VoiceOutputSessionResponse> {
val body = buildJsonObject { putProfile() }.toString()
private fun createVoiceOutputSession(
httpBase: String,
token: String,
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
language: String? = null,
): Result<VoiceOutputSessionResponse> {
val body = buildVoiceOutputSessionPayload(
profile = currentProfileName(),
provider = provider,
model = model,
voice = voice,
sampleRate = sampleRate,
language = language,
)
val request = Request.Builder()
.url("$httpBase/voice/output/session")
.post(body.toRequestBody(JSON_MEDIA_TYPE))
@@ -2940,6 +3021,9 @@ class RelayVoiceClient(
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
floor = (obj["floor"] as? JsonPrimitive)?.contentOrNull,
spokenHandoff = (obj["spoken_handoff"] as? JsonPrimitive)
?.contentOrNull
?.toBooleanStrictOrNull(),
activeToolName = (obj["active_tool_name"] as? JsonPrimitive)?.contentOrNull,
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull
?: (obj["tool_count"] as? JsonPrimitive)?.intOrNull,
@@ -2970,6 +3054,22 @@ class RelayVoiceClient(
}
}
internal fun buildVoiceOutputSessionPayload(
profile: String?,
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
language: String? = null,
): String = buildJsonObject {
profile?.trim()?.takeIf { it.isNotBlank() }?.let { put("profile", JsonPrimitive(it)) }
provider?.trim()?.takeIf { it.isNotBlank() }?.let { put("provider", JsonPrimitive(it)) }
model?.trim()?.takeIf { it.isNotBlank() }?.let { put("model", JsonPrimitive(it)) }
voice?.trim()?.takeIf { it.isNotBlank() }?.let { put("voice", JsonPrimitive(it)) }
sampleRate?.let { put("sample_rate", JsonPrimitive(it)) }
language?.trim()?.takeIf { it.isNotBlank() }?.let { put("language", JsonPrimitive(it)) }
}.toString()
/**
* Wire shape of `GET /voice/config`. Providers are returned as nested
* objects describing the currently-active STT and TTS backend. Extra
@@ -3318,6 +3418,7 @@ data class RealtimeVoiceEvent(
// ADR 33: background-run promotion fields.
val tier: String? = null,
val floor: String? = null,
val spokenHandoff: Boolean? = null,
// hermes.run.progress extras — drive the live background-run chip.
val activeToolName: String? = null,
val completedToolCount: Int? = null,
@@ -4,9 +4,12 @@ import android.content.Context
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
@@ -45,6 +48,18 @@ data class RouteProbeOutcome(
val atMillis: Long,
)
/**
* Service whose reachability is being resolved. Standard Hermes surfaces and
* Relay are intentionally independent: a healthy Dashboard must not vouch for
* a dead Relay listener on the same host.
*/
enum class EndpointSurface {
Standard,
Dashboard,
Api,
Relay,
}
/**
* Picks the highest-priority **reachable** [EndpointCandidate] from a
* per-device list, driven by ADR 24 "Multi-endpoint pairing + network-aware
@@ -56,7 +71,10 @@ data class RouteProbeOutcome(
* candidate is reachable we use it; reachability never promotes a lower
* priority over a higher one. Reachability is **only** the tiebreaker
* among candidates that share the same priority.
* * **Reachability probe.** `HEAD ${api.url}/health` with a 2-second
* * **Reachability probe.** Dashboard-first routes use `GET
* ${dashboard.url}/api/status`; legacy API routes use `GET
* ${api.url}/health`. Relay-only routes use `GET ${relay.httpUrl}/health`.
* Each request has a 4-second
* per-candidate timeout. Positive results are cached longer than negative
* results so repeated `connect()` calls don't hammer healthy routes, while
* transient handoff misses do not pin a good fallback offline.
@@ -93,6 +111,8 @@ class EndpointResolver(
* expected path for plain JVM tests.
*/
private val context: Context? = null,
/** Route-aware client for pinned plugin proxy probes. */
private val clientForCandidate: ((EndpointCandidate) -> OkHttpClient?)? = null,
) {
/**
@@ -101,6 +121,12 @@ class EndpointResolver(
*/
private data class CacheEntry(val expiresAt: Long, val reachable: Boolean)
private data class ProbeTarget(
val baseUrl: String,
val requestUrl: String,
val path: String,
)
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
@@ -113,9 +139,14 @@ class EndpointResolver(
*/
val probeOutcomes: StateFlow<Map<String, RouteProbeOutcome>> = _probeOutcomes.asStateFlow()
private fun recordOutcome(candidate: EndpointCandidate, reachable: Boolean, detail: String?) {
private fun recordOutcome(
candidate: EndpointCandidate,
surface: EndpointSurface,
reachable: Boolean,
detail: String?,
) {
_probeOutcomes.update { outcomes ->
outcomes + (cacheKey(candidate) to RouteProbeOutcome(
outcomes + (cacheKey(candidate, surface) to RouteProbeOutcome(
reachable = reachable,
detail = detail,
atMillis = clock(),
@@ -156,21 +187,49 @@ class EndpointResolver(
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)"
/**
* Stable cache key for a candidate: `"<role>|<api.host>:<api.port>"`.
* Stable cache key for one candidate surface:
* `"<surface>|<role>|<surface host>:<port>"`.
* Roles are preserved case-verbatim (HMAC canonicalization contract)
* but hostnames are lowercased — two roles pointing at the same
* host:port share reachability state.
*/
internal fun cacheKey(candidate: EndpointCandidate): String =
"${candidate.role}|${candidate.api.host.lowercase()}:${candidate.api.port}"
internal fun cacheKey(
candidate: EndpointCandidate,
surface: EndpointSurface = EndpointSurface.Standard,
): String {
val authority = when (surface) {
EndpointSurface.Standard ->
candidate.routeAuthority() ?: candidate.primaryRouteUrl().orEmpty().lowercase()
EndpointSurface.Dashboard ->
routeAuthority(candidate.pluginProxyRoutesOrNull()?.dashboardBaseUrl ?: candidate.dashboard?.url).orEmpty()
EndpointSurface.Api ->
routeAuthority(candidate.pluginProxyRoutesOrNull()?.apiBaseUrl ?: candidate.api?.url).orEmpty()
EndpointSurface.Relay ->
candidate.pluginProxyRoutesOrNull()?.authority
?: routeAuthority(candidate.relay?.url).orEmpty()
}
return "${surface.name.lowercase()}|${candidate.role}|$authority"
}
private fun routeAuthority(rawUrl: String?): String? {
val candidate = rawUrl?.trim()?.takeIf { it.isNotBlank() } ?: return null
val httpUrl = when {
candidate.startsWith("ws://", ignoreCase = true) ->
"http://${candidate.substringAfter("://")}"
candidate.startsWith("wss://", ignoreCase = true) ->
"https://${candidate.substringAfter("://")}"
else -> candidate
}
return httpUrl.toHttpUrlOrNull()?.let { url -> "${url.host}:${url.port}" }
}
}
/**
* Run the resolver against [candidates].
*
* 1. Group by `priority` ascending.
* 2. For each priority group, race a HEAD /health probe against every
* candidate in the group (2 s per candidate). First 2xx wins; ties
* 2. For each priority group, race the selected surface's health probe
* against every candidate in the group. First 2xx wins; ties
* broken by whichever response lands first.
* 3. If the entire group is unreachable, fall through to the next
* priority group.
@@ -182,37 +241,47 @@ class EndpointResolver(
* its tier). An empty [candidates] list returns null immediately without
* touching the network.
*/
suspend fun resolve(candidates: List<EndpointCandidate>): EndpointCandidate? {
if (candidates.isEmpty()) return null
suspend fun resolve(
candidates: List<EndpointCandidate>,
surface: EndpointSurface = EndpointSurface.Standard,
): EndpointCandidate? {
val eligible = candidates.filter { probeTarget(it, surface) != null }
if (eligible.isEmpty()) return null
// Strict priority: sort ascending so priority-0 lands first. Grouping
// preserves emitted order within a priority class (DNS SRV parity).
val groups = candidates.groupBy { it.priority }.toSortedMap()
// Supported routes always run before experimental routes. Priority is
// strict inside each stability tier, so Reach remains available as a
// last-resort fallback without displacing Tailscale or direct TLS.
val supported = eligible.filterNot { it.experimental || it.role.equals("outbound_broker", ignoreCase = true) }
val experimental = eligible.filter { it.experimental || it.role.equals("outbound_broker", ignoreCase = true) }
val groups = (supported.groupBy { it.priority }.toSortedMap().values +
experimental.groupBy { it.priority }.toSortedMap().values)
for ((priority, group) in groups) {
for (group in groups) {
val priority = group.first().priority
Log.d(TAG, "probing priority=$priority group (size=${group.size})")
val winner = raceGroup(group)
val winner = raceGroup(group, surface)
if (winner != null) {
val winnerUrl = probeTarget(winner, surface)?.baseUrl
Log.i(TAG, "resolve winner: role=${winner.role} " +
"api=${winner.api.host}:${winner.api.port} priority=$priority")
"surface=$surface route=$winnerUrl priority=$priority")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.endpoint_diag_selected) ?: "Endpoint selected",
detail = "priority=$priority",
endpointRole = winner.role,
url = winner.relay.url,
url = winnerUrl,
)
return winner
}
}
Log.w(TAG, "resolve: no reachable candidate across ${candidates.size} record(s)")
Log.w(TAG, "resolve: no reachable $surface candidate across ${eligible.size} record(s)")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_no_reachable) ?: "No reachable endpoint",
detail = "${candidates.size} configured route(s) failed health probes",
detail = "${eligible.size} configured $surface route(s) failed health probes",
)
return null
}
@@ -226,17 +295,20 @@ class EndpointResolver(
* for "first 2xx wins" so latency matters. The losing probes' results
* still land in the cache, though, so the next call benefits.
*/
private suspend fun raceGroup(group: List<EndpointCandidate>): EndpointCandidate? {
private suspend fun raceGroup(
group: List<EndpointCandidate>,
surface: EndpointSurface,
): EndpointCandidate? {
if (group.isEmpty()) return null
if (group.size == 1) {
val only = group.first()
return if (isReachable(only)) only else null
return if (isReachable(only, surface)) only else null
}
// Fast-path: any cached-reachable candidate wins immediately without
// touching the network.
for (candidate in group) {
val cached = probeCache[cacheKey(candidate)]
val cached = probeCache[cacheKey(candidate, surface)]
if (cached != null && cached.expiresAt > clock() && cached.reachable) {
return candidate
}
@@ -245,7 +317,7 @@ class EndpointResolver(
return coroutineScope {
val deferred = group.map { candidate ->
async(Dispatchers.IO) {
if (isReachable(candidate)) candidate else null
if (isReachable(candidate, surface)) candidate else null
}
}
// Collect results in arrival order: iterate through awaitAll +
@@ -265,8 +337,11 @@ class EndpointResolver(
* [probeCache] first; on miss or expiry, runs a HEAD /health probe and
* records the result.
*/
private suspend fun isReachable(candidate: EndpointCandidate): Boolean {
val key = cacheKey(candidate)
private suspend fun isReachable(
candidate: EndpointCandidate,
surface: EndpointSurface,
): Boolean {
val key = cacheKey(candidate, surface)
val now = clock()
val cached = probeCache[key]
if (cached != null && cached.expiresAt > now) {
@@ -274,47 +349,62 @@ class EndpointResolver(
return cached.reachable
}
val reachable = probe(candidate)
val reachable = probe(candidate, surface)
val ttl = if (reachable) CACHE_TTL_MS else NEGATIVE_CACHE_TTL_MS
probeCache[key] = CacheEntry(expiresAt = now + ttl, reachable = reachable)
return reachable
}
/**
* One-shot HEAD /health probe against a candidate. 2-second timeout,
* One-shot probe against a candidate's primary configured surface.
* no retries — callers that need retry semantics can re-invoke after
* the cache expires.
*
* Returns false on any failure (timeout, I/O, non-2xx, invalid URL).
* We never raise: a bad record shouldn't crash the connect loop.
*/
private suspend fun probe(candidate: EndpointCandidate): Boolean {
private suspend fun probe(
candidate: EndpointCandidate,
surface: EndpointSurface,
): Boolean {
val startedAtMs = clock()
val url = "${candidate.api.url}/health".toHttpUrlOrNull()
val operation = when (surface) {
EndpointSurface.Standard -> "Dashboard or API route health probe"
EndpointSurface.Dashboard -> "Dashboard route health probe"
EndpointSurface.Api -> "API route health probe"
EndpointSurface.Relay -> "Relay route health probe"
}
val target = probeTarget(candidate, surface)
val url = target?.requestUrl?.toHttpUrlOrNull()
?: run {
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
detail = "Invalid API URL",
detail = "No valid Dashboard, API, or Relay URL",
operation = operation,
endpointRole = candidate.role,
url = candidate.api.url,
configuredUrl = candidate.primaryRouteUrl(),
suggestion = "Edit or re-pair this route so it contains a valid service URL.",
)
recordOutcome(candidate, reachable = false, detail = "Invalid API URL")
recordOutcome(candidate, surface, reachable = false, detail = "Invalid route URL")
return false
}
val fastClient = httpClient.newBuilder()
val fastClient = (clientForCandidate?.invoke(candidate) ?: httpClient).newBuilder()
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
val request = Request.Builder()
val requestBuilder = Request.Builder()
.url(url)
.head()
.header("Accept", "*/*")
.build()
// Hermes API's aiohttp health route accepts GET but returns 405 to
// HEAD. That response proves connectivity while the old probe marked
// the route unreachable. Health payloads are tiny, so follow the
// endpoint's actual public contract on every surface.
val request = requestBuilder.get().build()
return withContext(Dispatchers.IO) {
try {
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
@@ -330,14 +420,22 @@ class EndpointResolver(
severity = if (ok) DiagnosticSeverity.Info else DiagnosticSeverity.Warning,
title = probeTitle,
detail = if (ok) null else "HTTP ${resp.code}",
operation = operation,
endpointRole = candidate.role,
url = candidate.api.url,
configuredUrl = target.baseUrl,
requestUrl = target.requestUrl,
elapsedMs = clock() - startedAtMs,
suggestion = if (ok) {
null
} else {
NetworkDiagnosticGuidance.forHttpStatus(resp.code, surface.diagnosticTarget())
},
)
recordOutcome(
candidate,
surface,
reachable = ok,
detail = if (ok) null else "HTTP ${resp.code} from /health",
detail = if (ok) null else "HTTP ${resp.code} from ${target.path}",
)
ok
}
@@ -346,12 +444,15 @@ class EndpointResolver(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
operation = operation,
endpointRole = candidate.role,
url = candidate.api.url,
configuredUrl = target.baseUrl,
requestUrl = target.requestUrl,
elapsedMs = clock() - startedAtMs,
suggestion = "Check network routing or firewall rules between this device and ${surface.diagnosticTarget()}.",
)
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
recordOutcome(candidate, surface, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
false
}
} catch (_: TimeoutCancellationException) {
@@ -359,31 +460,119 @@ class EndpointResolver(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
operation = operation,
endpointRole = candidate.role,
url = candidate.api.url,
configuredUrl = target.baseUrl,
requestUrl = target.requestUrl,
elapsedMs = clock() - startedAtMs,
suggestion = "Check network routing or firewall rules between this device and ${surface.diagnosticTarget()}.",
)
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
recordOutcome(candidate, surface, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
false
} catch (e: Exception) {
Log.d(TAG, "probe failed role=${candidate.role} " +
"host=${candidate.api.host}: ${e.javaClass.simpleName}")
"route=${target.baseUrl}: ${e.javaClass.simpleName}")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
detail = e.javaClass.simpleName,
detail = humanProbeFailure(e),
operation = operation,
endpointRole = candidate.role,
url = candidate.api.url,
configuredUrl = target.baseUrl,
requestUrl = target.requestUrl,
elapsedMs = clock() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, surface.diagnosticTarget()),
)
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
recordOutcome(candidate, surface, reachable = false, detail = humanProbeFailure(e))
false
}
}
}
/** Choose the standard Dashboard/Gateway surface first when advertised. */
private fun probeTarget(
candidate: EndpointCandidate,
surface: EndpointSurface,
): ProbeTarget? {
if (surface == EndpointSurface.Dashboard) {
candidate.pluginProxyRoutesOrNull()?.dashboardBaseUrl?.let { base ->
return ProbeTarget(base, "$base/api/status", "/dashboard/api/status")
}
candidate.dashboard?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }?.let { base ->
return ProbeTarget(base, "$base/api/status", "/api/status")
}
return null
}
if (surface == EndpointSurface.Api) {
candidate.pluginProxyRoutesOrNull()?.apiBaseUrl?.let { base ->
return ProbeTarget(base, "$base/health", "/api/health")
}
candidate.api?.url?.let { base -> return ProbeTarget(base, "$base/health", "/health") }
return null
}
if (surface == EndpointSurface.Relay) candidate.pluginProxyRoutesOrNull()?.let { proxy ->
return ProbeTarget(
baseUrl = proxy.relayHttpUrl,
requestUrl = "${proxy.relayHttpUrl}/health",
path = "/relay/health",
)
}
if (surface == EndpointSurface.Relay) {
return relayProbeTarget(candidate)
}
candidate.dashboard?.url
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?.let { base ->
return ProbeTarget(
baseUrl = base,
requestUrl = "$base/api/status",
path = "/api/status",
)
}
candidate.api?.url?.let { base ->
return ProbeTarget(
baseUrl = base,
requestUrl = "$base/health",
path = "/health",
)
}
return relayProbeTarget(candidate)
}
private fun relayProbeTarget(candidate: EndpointCandidate): ProbeTarget? {
candidate.relay?.url
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?.let { relayUrl ->
val httpBase = when {
relayUrl.startsWith("ws://", ignoreCase = true) ->
"http://${relayUrl.substringAfter("://")}"
relayUrl.startsWith("wss://", ignoreCase = true) ->
"https://${relayUrl.substringAfter("://")}"
else -> return null
}
return ProbeTarget(
baseUrl = relayUrl,
requestUrl = "$httpBase/health",
path = "/health",
)
}
return null
}
internal fun probeRequestUrlForTest(
candidate: EndpointCandidate,
surface: EndpointSurface,
): String? = probeTarget(candidate, surface)?.requestUrl
/**
* Map a probe exception to a short, actionable string for the Routes
* card. The TLS case is the headline: a route saved with `https://`
@@ -399,6 +588,13 @@ class EndpointResolver(
else -> e.javaClass.simpleName
}
private fun EndpointSurface.diagnosticTarget(): String = when (this) {
EndpointSurface.Standard -> "Dashboard or API server"
EndpointSurface.Dashboard -> "Dashboard"
EndpointSurface.Api -> "API server"
EndpointSurface.Relay -> "Relay"
}
/**
* Mark [candidate] unreachable without re-probing. Called from
* `ConnectionManager`'s `NetworkCallback.onLost` so the next resolve()
@@ -408,13 +604,21 @@ class EndpointResolver(
* transition can skip the known-dead active route without suppressing a
* valid fallback for the whole positive cache window.
*/
fun markUnreachable(candidate: EndpointCandidate) {
val key = cacheKey(candidate)
fun markUnreachable(
candidate: EndpointCandidate,
surface: EndpointSurface = EndpointSurface.Standard,
) {
val key = cacheKey(candidate, surface)
probeCache[key] = CacheEntry(
expiresAt = clock() + NEGATIVE_CACHE_TTL_MS,
reachable = false,
)
recordOutcome(candidate, reachable = false, detail = "Network changed — assumed offline")
recordOutcome(
candidate,
surface,
reachable = false,
detail = "Network changed — assumed offline",
)
}
/**
@@ -8,22 +8,29 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.Inet4Address
import java.net.InetAddress
import java.util.concurrent.TimeUnit
data class HermesLanDiscoveryResult(
val host: String,
val hostname: String? = null,
val apiUrl: String,
val dashboardUrl: String?,
val apiReachable: Boolean,
val dashboardReachable: Boolean,
)
) {
val displayHost: String
get() = hostname ?: host
}
/**
* User-triggered local-network discovery for standard Hermes setup.
@@ -54,12 +61,14 @@ object HermesLanDiscovery {
.callTimeout(PROBE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)
.build()
coroutineScope {
val results = coroutineScope {
val semaphore = Semaphore(MAX_CONCURRENT_PROBES)
hosts.map { host ->
async {
semaphore.withPermit {
probeHost(client, host, apiPort, dashboardPort)
probeHost(client, host, apiPort, dashboardPort)?.let { result ->
result.copy(hostname = resolveHostname(host))
}
}
}
}.awaitAll()
@@ -70,6 +79,8 @@ object HermesLanDiscovery {
.thenBy { it.host },
)
}
Log.d(TAG, "scan complete hosts=${hosts.size} matches=${results.size}")
results
}
private fun probeHost(
@@ -124,12 +135,29 @@ object HermesLanDiscovery {
val body = response.body.string().take(2_048)
expectedBody(body, contentType)
}
} catch (e: Exception) {
Log.d(TAG, "probe failed url=$url type=${e.javaClass.simpleName}")
} catch (_: Exception) {
false
}
}
private suspend fun resolveHostname(address: String): String? =
withTimeoutOrNull(350L) {
runInterruptible(Dispatchers.IO) {
normalizeResolvedHostname(
address = address,
resolved = InetAddress.getByName(address).canonicalHostName,
)
}
}
internal fun normalizeResolvedHostname(address: String, resolved: String?): String? {
val normalized = resolved?.trim()?.trimEnd('.')?.takeIf { it.isNotBlank() } ?: return null
if (normalized.equals(address.trim(), ignoreCase = true)) return null
if (normalized.equals("localhost", ignoreCase = true)) return null
if (normalized.matches(Regex("^\\d{1,3}(?:\\.\\d{1,3}){3}$"))) return null
return normalized
}
private fun looksLikeDashboardStatus(body: String, contentType: String): Boolean {
val lower = body.lowercase()
return contentType.contains("json", ignoreCase = true) && (
@@ -0,0 +1,402 @@
package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.isValidHermesReach
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketAddress
import java.net.SocketException
import java.net.URI
import java.security.SecureRandom
import java.util.Base64
import java.util.ArrayDeque
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import javax.net.SocketFactory
private const val REACH_PROTOCOL_VERSION = 1
private const val REACH_MAX_FRAME_BYTES = 1024 * 1024
internal const val REACH_MAX_QUEUED_FRAMES = 32
internal const val REACH_MAX_QUEUED_BYTES = 8 * 1024 * 1024
private const val REACH_MATCH_TIMEOUT_MS = 10_000L
/**
* Connection metadata for Hermes Reach's outer WSS rendezvous.
*
* This is deliberately transport-only. The inner HTTPS/WSS origin and its
* pairing-authenticated SPKI pin continue to be owned by [PluginProxyRoutes],
* so broker reachability can never weaken Secure Link trust.
*/
data class HermesReachRoute(
val brokerUrl: String,
val hostId: String,
val credentialKind: String,
val token: String,
) {
fun tunnelUrlOrNull(): String? {
if (hostId.isBlank() || token.isBlank()) return null
if (credentialKind !in setOf("bootstrap", "route")) return null
val uri = runCatching { URI(brokerUrl.trim()) }.getOrNull() ?: return null
if (!uri.scheme.equals("wss", ignoreCase = true) || uri.host.isNullOrBlank()) return null
if (!uri.rawUserInfo.isNullOrBlank() || uri.rawQuery != null || uri.rawFragment != null) return null
if (uri.rawPath.orEmpty().let { it.isNotEmpty() && it != "/" && it != "/v1/connect" }) return null
val authority = buildString {
append(if (':' in uri.host) "[${uri.host}]" else uri.host)
if (uri.port > 0 && uri.port != 443) append(":${uri.port}")
}
return "wss://$authority/v1/connect"
}
}
fun EndpointCandidate.hermesReachRouteOrNull(): HermesReachRoute? {
val metadata = broker?.takeIf { it.isValidHermesReach() } ?: return null
if (pluginProxyRoutesOrNull() == null) return null
return HermesReachRoute(
brokerUrl = metadata.url,
hostId = metadata.hostId,
credentialKind = metadata.credentialKind,
token = metadata.token,
)
}
/** Build the pinned inner Secure Link client over an outer Hermes Reach WSS. */
fun buildHermesReachClient(
baseBuilder: OkHttpClient.Builder,
outerClient: OkHttpClient,
candidate: EndpointCandidate,
sessionTokenProvider: () -> String?,
includeRelaySessionHeader: Boolean = true,
): OkHttpClient? {
val secureLink = candidate.pluginProxyRoutesOrNull() ?: return null
val reach = candidate.hermesReachRouteOrNull() ?: return null
return buildPluginProxyClient(
baseBuilder = baseBuilder,
routes = secureLink,
sessionTokenProvider = sessionTokenProvider,
includeRelaySessionHeader = includeRelaySessionHeader,
rawSocketFactory = HermesReachSocketFactory(outerClient, reach),
)
}
@Serializable
private data class ReachRegistration(
val type: String = "register",
@SerialName("protocol_version") val protocolVersion: Int = REACH_PROTOCOL_VERSION,
val role: String = "client",
@SerialName("host_id") val hostId: String,
@SerialName("connection_id") val connectionId: String,
@SerialName("credential_kind") val credentialKind: String,
val token: String,
)
@Serializable
private data class ReachControl(
val type: String? = null,
@SerialName("protocol_version") val protocolVersion: Int? = null,
@SerialName("stream_id") val streamId: String? = null,
val code: String? = null,
)
internal object HermesReachHandshake {
private val json = Json {
ignoreUnknownKeys = false
encodeDefaults = true
}
fun registration(route: HermesReachRoute, connectionId: String): String = json.encodeToString(
ReachRegistration(
hostId = route.hostId,
connectionId = connectionId,
credentialKind = route.credentialKind,
token = route.token,
),
)
fun validateMatched(payload: String): String? {
val control = runCatching { json.decodeFromString<ReachControl>(payload) }
.getOrElse { return "Hermes Reach returned an invalid match response" }
if (control.type == "error") {
return "Hermes Reach rejected the route (${control.code ?: "unknown"})"
}
val streamIdValid = control.streamId?.let(::isCanonicalId) == true
if (control.type != "matched" ||
control.protocolVersion != REACH_PROTOCOL_VERSION ||
!streamIdValid
) {
return "Hermes Reach returned a mismatched route response"
}
return null
}
private fun isCanonicalId(value: String): Boolean {
if (value.isBlank() || '=' in value) return false
val decoded = runCatching { Base64.getUrlDecoder().decode(value) }.getOrNull() ?: return false
return decoded.size == 16 && Base64.getUrlEncoder().withoutPadding().encodeToString(decoded) == value
}
}
/**
* Raw socket factory that carries bytes through Hermes Reach. OkHttp layers
* the normal Secure Link TLS socket factory over the returned socket, so SNI,
* hostname verification, and the QR SPKI pin all apply to the inner endpoint.
*/
class HermesReachSocketFactory(
private val outerClient: OkHttpClient,
private val route: HermesReachRoute,
) : SocketFactory() {
init {
require(route.tunnelUrlOrNull() != null) { "Invalid Hermes Reach route" }
}
override fun createSocket(): Socket = HermesReachSocket(outerClient, route)
override fun createSocket(host: String?, port: Int): Socket =
createSocket().apply { connect(InetSocketAddress(host, port)) }
override fun createSocket(host: String?, port: Int, localHost: InetAddress?, localPort: Int): Socket =
createSocket().apply {
if (localHost != null) bind(InetSocketAddress(localHost, localPort))
connect(InetSocketAddress(host, port))
}
override fun createSocket(host: InetAddress?, port: Int): Socket =
createSocket().apply { connect(InetSocketAddress(host, port)) }
override fun createSocket(
address: InetAddress?,
port: Int,
localAddress: InetAddress?,
localPort: Int,
): Socket = createSocket().apply {
if (localAddress != null) bind(InetSocketAddress(localAddress, localPort))
connect(InetSocketAddress(address, port))
}
}
private class HermesReachSocket(
private val outerClient: OkHttpClient,
private val route: HermesReachRoute,
) : Socket() {
private val inbound = ReachInputStream()
private val matchLatch = CountDownLatch(1)
private val connectionId = randomConnectionId()
@Volatile private var matchError: IOException? = null
@Volatile private var webSocket: WebSocket? = null
@Volatile private var connected = false
@Volatile private var closed = false
@Volatile private var matched = false
@Volatile private var remote: InetSocketAddress? = null
private var readTimeoutMs: Int = 0
private val outbound = object : OutputStream() {
override fun write(value: Int) = write(byteArrayOf(value.toByte()))
override fun write(bytes: ByteArray, offset: Int, length: Int) {
if (length == 0) return
if (!matched || closed) throw SocketException("Hermes Reach tunnel is not open")
var cursor = offset
var remaining = length
while (remaining > 0) {
val count = minOf(remaining, REACH_MAX_FRAME_BYTES)
val accepted = webSocket?.send(ByteString.of(*bytes.copyOfRange(cursor, cursor + count))) == true
if (!accepted) throw SocketException("Hermes Reach could not queue tunnel bytes")
cursor += count
remaining -= count
}
}
}
override fun connect(endpoint: SocketAddress?) = connect(endpoint, REACH_MATCH_TIMEOUT_MS.toInt())
override fun connect(endpoint: SocketAddress?, timeout: Int) {
if (connected) throw SocketException("Socket is already connected")
if (closed) throw SocketException("Socket is closed")
remote = endpoint as? InetSocketAddress
?: throw SocketException("Hermes Reach requires an internet socket target")
val request = Request.Builder().url(requireNotNull(route.tunnelUrlOrNull())).build()
webSocket = outerClient.newWebSocket(request, listener)
val waitMs = minOf(
timeout.takeIf { it > 0 }?.toLong() ?: REACH_MATCH_TIMEOUT_MS,
REACH_MATCH_TIMEOUT_MS,
)
if (!matchLatch.await(waitMs, TimeUnit.MILLISECONDS)) {
closeWithError(IOException("Hermes Reach host match timed out"))
}
matchError?.let { throw it }
if (!matched) throw IOException("Hermes Reach closed before matching the host")
connected = true
}
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
val registration = HermesReachHandshake.registration(route, connectionId)
if (!webSocket.send(registration)) {
closeWithError(IOException("Hermes Reach registration could not be sent"))
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
if (matched) {
closeWithError(IOException("Hermes Reach sent text after matching"))
return
}
HermesReachHandshake.validateMatched(text)?.let { message ->
closeWithError(IOException(message))
return
}
matched = true
matchLatch.countDown()
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
if (!matched) {
closeWithError(IOException("Hermes Reach sent bytes before matching"))
return
}
if (bytes.size > REACH_MAX_FRAME_BYTES) {
closeWithError(IOException("Hermes Reach frame exceeds 1 MiB"))
return
}
if (!inbound.offer(bytes.toByteArray())) {
closeWithError(IOException("Hermes Reach receive queue exceeded its safe limit"))
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(code, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (!matched) matchError = IOException("Hermes Reach closed before matching the host")
closed = true
inbound.close(matchError)
matchLatch.countDown()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
closeWithError(IOException("Hermes Reach connection failed", t))
}
}
private fun closeWithError(error: IOException) {
matchError = error
closed = true
webSocket?.cancel()
inbound.close(error)
matchLatch.countDown()
}
override fun getInputStream(): InputStream {
if (!connected || closed) throw SocketException("Hermes Reach tunnel is not open")
inbound.readTimeoutMs = readTimeoutMs
return inbound
}
override fun getOutputStream(): OutputStream {
if (!connected || closed) throw SocketException("Hermes Reach tunnel is not open")
return outbound
}
override fun close() {
if (closed) return
closed = true
webSocket?.close(1000, null)
inbound.close(null)
matchLatch.countDown()
}
override fun isConnected(): Boolean = connected
override fun isClosed(): Boolean = closed
override fun getRemoteSocketAddress(): SocketAddress? = remote
override fun getInetAddress(): InetAddress? = remote?.address
override fun getPort(): Int = remote?.port ?: 0
override fun setSoTimeout(timeout: Int) { readTimeoutMs = timeout }
override fun getSoTimeout(): Int = readTimeoutMs
override fun setTcpNoDelay(on: Boolean) = Unit
override fun getTcpNoDelay(): Boolean = true
override fun setKeepAlive(on: Boolean) = Unit
override fun getKeepAlive(): Boolean = true
override fun setReuseAddress(on: Boolean) = Unit
override fun getReuseAddress(): Boolean = false
}
internal class ReachInputStream : InputStream() {
private val chunks = ArrayDeque<ByteArray>()
private var offset = 0
private var queuedBytes = 0
private var terminalError: IOException? = null
private var closed = false
@Volatile var readTimeoutMs: Int = 0
@Synchronized
fun offer(bytes: ByteArray): Boolean {
if (closed) return false
if (chunks.size >= REACH_MAX_QUEUED_FRAMES || queuedBytes + bytes.size > REACH_MAX_QUEUED_BYTES) {
return false
}
chunks.addLast(bytes)
queuedBytes += bytes.size
(this as java.lang.Object).notifyAll()
return true
}
@Synchronized
fun close(error: IOException?) {
if (closed) return
closed = true
terminalError = error
(this as java.lang.Object).notifyAll()
}
override fun read(): Int {
val one = ByteArray(1)
return if (read(one, 0, 1) < 0) -1 else one[0].toInt() and 0xff
}
@Synchronized
override fun read(target: ByteArray, targetOffset: Int, length: Int): Int {
if (length == 0) return 0
val started = System.nanoTime()
while (chunks.isEmpty() && !closed) {
val waitMs = if (readTimeoutMs > 0) {
val elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)
(readTimeoutMs - elapsed).coerceAtLeast(0)
} else 0L
if (readTimeoutMs > 0 && waitMs == 0L) throw java.net.SocketTimeoutException("Hermes Reach read timed out")
(this as java.lang.Object).wait(if (readTimeoutMs > 0) waitMs else 0L)
}
if (chunks.isEmpty()) {
terminalError?.let { throw it }
return -1
}
val chunk = chunks.first()
val count = minOf(length, chunk.size - offset)
chunk.copyInto(target, targetOffset, offset, offset + count)
offset += count
queuedBytes -= count
if (offset == chunk.size) {
chunks.remove(chunk)
offset = 0
}
return count
}
}
private fun randomConnectionId(): String {
val bytes = ByteArray(16).also(SecureRandom()::nextBytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
@@ -0,0 +1,145 @@
package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.ProxyEndpoint
import com.hermesandroid.relay.data.isValidPinnedProxy
import okhttp3.CertificatePinner
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.net.URI
import java.security.KeyStore
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import javax.net.SocketFactory
/** Runtime endpoints exposed beneath one plugin-owned pinned-TLS origin. */
data class PluginProxyRoutes(
val authority: String,
val host: String,
val port: Int,
val relayHttpUrl: String,
val relayWebSocketUrl: String,
val apiBaseUrl: String?,
val dashboardBaseUrl: String?,
val pinSha256: String,
)
/**
* Resolve and validate the pairing-advertised proxy contract. Invalid or
* incomplete advertisements are never treated as secure routes.
*/
fun ProxyEndpoint.toPluginProxyRoutesOrNull(): PluginProxyRoutes? {
if (!isValidPinnedProxy()) return null
val base = url.trim().trimEnd('/')
val uri = runCatching { URI(base) }.getOrNull() ?: return null
if (!uri.scheme.equals("https", ignoreCase = true)) return null
val host = uri.host?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
if (!uri.rawUserInfo.isNullOrBlank() || uri.rawQuery != null || uri.rawFragment != null) return null
val rawPath = uri.rawPath.orEmpty()
if (rawPath.isNotEmpty() && rawPath != "/") return null
val port = if (uri.port > 0) uri.port else 443
val pin = pinSha256!!.trim()
val authority = "$host:$port"
val wsBase = "wss://${formatHost(host)}${if (port == 443) "" else ":$port"}$rawPath"
.trimEnd('/')
val surfaces = surfaces.map(String::lowercase).toSet()
return PluginProxyRoutes(
authority = authority,
host = host,
port = port,
relayHttpUrl = "$base/relay",
relayWebSocketUrl = "$wsBase/relay/ws",
apiBaseUrl = "$base/api".takeIf { "api" in surfaces },
dashboardBaseUrl = "$base/dashboard".takeIf { "dashboard" in surfaces },
pinSha256 = pin,
)
}
fun EndpointCandidate.pluginProxyRoutesOrNull(): PluginProxyRoutes? =
proxy?.toPluginProxyRoutesOrNull()
private fun formatHost(host: String): String = if (':' in host) "[$host]" else host
/**
* Build a client that trusts the system normally, plus exactly the
* pairing-advertised SPKI for this proxy. The authority guard keeps a pin
* scoped to host *and port*; OkHttp's CertificatePinner alone is host-only.
*/
fun buildPluginProxyClient(
baseBuilder: OkHttpClient.Builder,
routes: PluginProxyRoutes,
sessionTokenProvider: () -> String?,
includeRelaySessionHeader: Boolean = true,
rawSocketFactory: SocketFactory? = null,
): OkHttpClient {
val expectedHost = routes.host
val expectedPort = routes.port
val systemTrust = systemTrustManager()
val pinnedTrust = PinnedOrSystemTrustManager(systemTrust, routes.pinSha256)
val sslContext = SSLContext.getInstance("TLS").apply {
init(null, arrayOf(pinnedTrust), SecureRandom())
}
if (rawSocketFactory != null) baseBuilder.socketFactory(rawSocketFactory)
return baseBuilder
.sslSocketFactory(sslContext.socketFactory, pinnedTrust)
.certificatePinner(
CertificatePinner.Builder().add(expectedHost, routes.pinSha256).build(),
)
.addNetworkInterceptor(Interceptor { chain ->
val requestUrl = chain.request().url
if (!requestUrl.host.equals(expectedHost, ignoreCase = true) ||
requestUrl.port != expectedPort
) {
throw java.io.IOException("Pinned proxy redirect left its paired authority")
}
val token = sessionTokenProvider().takeIf { includeRelaySessionHeader }
?.takeIf { it.isNotBlank() }
val request = if (token != null) {
chain.request().newBuilder()
.header("X-Hermes-Relay-Session", token)
.build()
} else {
chain.request()
}
chain.proceed(request)
})
.build()
}
private fun systemTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509TrustManager>().single()
}
private class PinnedOrSystemTrustManager(
private val system: X509TrustManager,
private val expectedPin: String,
) : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) =
system.checkClientTrusted(chain, authType)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
val certificates = chain?.takeIf { it.isNotEmpty() }
?: throw CertificateException("Proxy supplied no certificate chain")
val systemAccepted = runCatching { system.checkServerTrusted(chain, authType) }.isSuccess
if (systemAccepted) return
val leaf = certificates.first()
leaf.checkValidity()
val actual = "sha256/" + java.util.Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-256").digest(leaf.publicKey.encoded),
)
if (!MessageDigest.isEqual(actual.toByteArray(), expectedPin.toByteArray())) {
throw CertificateException("Plugin proxy certificate does not match the paired pin")
}
}
override fun getAcceptedIssuers(): Array<X509Certificate> = system.acceptedIssuers
}
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.network.shared
import java.net.URI
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* Resolves profile-scoped Hermes API URLs for phone use.
@@ -43,6 +44,66 @@ object ProfileApiUrlResolver {
return "$scheme://$hostPart$portPart$pathPart$queryPart$fragmentPart".trimEnd('/')
}
/**
* Resolve the canonical API base for a selected Hermes profile.
*
* A dedicated profile API URL remains authoritative when advertised. When
* the dashboard has positively identified a shared multiplex gateway and
* the selected non-default profile is in its served-profile list, route
* through the upstream `/p/<profile>` mirror on the connection's root API
* origin. Older/single-profile servers and incomplete topology snapshots
* deliberately keep the root URL.
*/
fun resolveChatBase(
profileApiUrl: String?,
baseApiUrl: String?,
selectedProfileName: String?,
gatewayMode: String?,
servedProfiles: Collection<String>,
): String? {
val base = normalize(baseApiUrl)
val dedicated = resolveForConnection(profileApiUrl, base)
if (dedicated != null) return dedicated
val profile = selectedProfileName?.trim() ?: return base
if (!usesMultiplexProfileKey(
profileApiUrl = profileApiUrl,
selectedProfileName = profile,
gatewayMode = gatewayMode,
servedProfiles = servedProfiles,
)
) return base
val root = base?.toHttpUrlOrNull() ?: return base
return root.newBuilder()
.addPathSegment("p")
.addPathSegment(profile)
.build()
.toString()
.trimEnd('/')
}
/**
* A profile-specific credential is required only for the positively
* identified shared `/p/<profile>` mirror. Dedicated profile APIs retain
* the connection credential contract, while default/legacy/unknown routes
* stay on the root client.
*/
fun usesMultiplexProfileKey(
profileApiUrl: String?,
selectedProfileName: String?,
gatewayMode: String?,
servedProfiles: Collection<String>,
): Boolean {
if (normalize(profileApiUrl) != null) return false
val profile = selectedProfileName
?.trim()
?.takeIf { it.isNotBlank() && !it.equals("default", ignoreCase = true) }
?: return false
return gatewayMode.equals("multiplex", ignoreCase = true) &&
servedProfiles.any { it == profile }
}
private fun isLocalBindHost(host: String): Boolean {
return when (host.lowercase().trim('[', ']')) {
"localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> true
@@ -0,0 +1,13 @@
package com.hermesandroid.relay.network.shared
import kotlin.random.Random
/** Full-jitter retry delay in the inclusive range 0..[capMs]. */
internal fun fullJitterDelayMs(
capMs: Long,
unit: Double = Random.nextDouble(),
): Long {
if (capMs <= 0L) return 0L
val boundedUnit = unit.coerceIn(0.0, Math.nextDown(1.0))
return (boundedUnit * (capMs + 1.0)).toLong().coerceAtMost(capMs)
}

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