Compare commits

..
Author SHA1 Message Date
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 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 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 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 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 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 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 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
315 changed files with 34035 additions and 3801 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ otherwise use verified Co-authored-by trailers. Write "N/A" for original work.
## Checklist ## 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 - [ ] 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 - [ ] 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 - [ ] Server changes: focused `python -m unittest ...` checks ran, or rationale is listed above
@@ -89,7 +89,7 @@ jobs:
VERSION: ${{ steps.metadata.outputs.version }} VERSION: ${{ steps.metadata.outputs.version }}
run: | run: |
gh workflow run release-android.yml \ gh workflow run release-android.yml \
--ref="android-v${VERSION}" \ --ref=main \
-f version="$VERSION" -f version="$VERSION"
- name: Approval summary - name: Approval summary
@@ -97,4 +97,4 @@ jobs:
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY" echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$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"
+3 -2
View File
@@ -38,10 +38,11 @@ on:
- ".github/workflows/approve-release-android.yml" - ".github/workflows/approve-release-android.yml"
- ".github/workflows/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: concurrency:
group: ci-android-${{ github.ref }} 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: jobs:
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@@ -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 -1
View File
@@ -50,6 +50,7 @@ jobs:
- name: Syntax check (plugin relay — canonical location) - name: Syntax check (plugin relay — canonical location)
run: | run: |
python -m py_compile plugin/relay/config.py
python -m py_compile plugin/relay/server.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/terminal.py
python -m py_compile plugin/relay/channels/chat.py python -m py_compile plugin/relay/channels/chat.py
@@ -103,4 +104,6 @@ jobs:
plugin/tests/test_relay_security.py \ plugin/tests/test_relay_security.py \
plugin/tests/test_voice_routes.py \ plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.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
+9 -1
View File
@@ -4,11 +4,13 @@ on:
pull_request: pull_request:
paths: paths:
- "legacy-pages-redirect/**" - "legacy-pages-redirect/**"
- "website/public/privacy.html"
- ".github/workflows/legacy-docs-redirect.yml" - ".github/workflows/legacy-docs-redirect.yml"
push: push:
branches: [main] branches: [main]
paths: paths:
- "legacy-pages-redirect/**" - "legacy-pages-redirect/**"
- "website/public/privacy.html"
- ".github/workflows/legacy-docs-redirect.yml" - ".github/workflows/legacy-docs-redirect.yml"
workflow_dispatch: workflow_dispatch:
@@ -34,10 +36,12 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
source_file="legacy-pages-redirect/redirect.html" source_file="legacy-pages-redirect/redirect.html"
privacy_file="website/public/privacy.html"
output_dir="legacy-pages-redirect/_site" output_dir="legacy-pages-redirect/_site"
rm -rf "$output_dir" rm -rf "$output_dir"
mkdir -p \ mkdir -p \
"$output_dir/guide/getting-started" \ "$output_dir/guide/getting-started" \
"$output_dir/privacy" \
"$output_dir/reference/relay-server" \ "$output_dir/reference/relay-server" \
"$output_dir/architecture" "$output_dir/architecture"
for target in \ for target in \
@@ -50,8 +54,12 @@ jobs:
architecture/connection-security.html; do architecture/connection-security.html; do
cp "$source_file" "$output_dir/$target" cp "$source_file" "$output_dir/$target"
done done
cp "$privacy_file" "$output_dir/privacy.html"
cp "$privacy_file" "$output_dir/privacy/index.html"
touch "$output_dir/.nojekyll" touch "$output_dir/.nojekyll"
test "$(find "$output_dir" -type f | wc -l)" -eq 8 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 if grep -R -E '<title>VitePress|<div id="app">' "$output_dir"; then
echo "Full documentation content must not be deployed by this workflow." >&2 echo "Full documentation content must not be deployed by this workflow." >&2
exit 1 exit 1
@@ -81,6 +81,7 @@ jobs:
- name: Validate release metadata and source compatibility - name: Validate release metadata and source compatibility
run: | run: |
python3 scripts/check-version-tracks.py python3 scripts/check-version-tracks.py
python3 scripts/check-privacy-policy.py --live
python3 scripts/check-android-locales.py python3 scripts/check-android-locales.py
python3 scripts/check-android-collection-apis.py python3 scripts/check-android-collection-apis.py
python3 -m json.tool app/src/main/assets/changelog.json >/dev/null python3 -m json.tool app/src/main/assets/changelog.json >/dev/null
+37 -9
View File
@@ -3,7 +3,7 @@
# Triggered when an Android release tag (android-v*) is pushed. # Triggered when an Android release tag (android-v*) is pushed.
# Validates the tag matches the app version in libs.versions.toml, # Validates the tag matches the app version in libs.versions.toml,
# runs focused Android checks, builds release APK/AAB artifacts, and creates a # 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 name: Release Android
@@ -12,8 +12,9 @@ on:
tags: tags:
- "android-v*" - "android-v*"
# Approve Android Release creates its tag with GITHUB_TOKEN, whose tag event # Approve Android Release creates its tag with GITHUB_TOKEN, whose tag event
# does not recursively start workflows. It explicitly dispatches this file # does not recursively start workflows. It dispatches the current workflow
# at that tag instead. Manual tag pushes continue to use the push trigger. # definition from main, while every job checks out the immutable tag. Manual
# tag pushes continue to use the push trigger.
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
@@ -35,19 +36,24 @@ jobs:
version_code: ${{ steps.version.outputs.version_code }} version_code: ${{ steps.version.outputs.version_code }}
steps: steps:
- uses: actions/checkout@v7 - 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 - name: Extract version from tag
id: version id: version
env: env:
DISPATCHED_VERSION: ${{ inputs.version }} DISPATCHED_VERSION: ${{ inputs.version }}
run: | run: |
REF_VERSION="${GITHUB_REF#refs/tags/android-v}" if [ -n "$DISPATCHED_VERSION" ]; then
if [ "$GITHUB_REF" = "$REF_VERSION" ]; then
REF_VERSION="$DISPATCHED_VERSION" REF_VERSION="$DISPATCHED_VERSION"
fi TAG_COMMIT=$(git rev-list -n 1 "android-v${REF_VERSION}")
if [ -n "$DISPATCHED_VERSION" ] && [ "$DISPATCHED_VERSION" != "$REF_VERSION" ]; then if [ -z "$TAG_COMMIT" ] || [ "$TAG_COMMIT" != "$(git rev-parse HEAD)" ]; then
echo "::error::Dispatched version $DISPATCHED_VERSION does not match ref version $REF_VERSION" echo "::error::Checked-out commit does not match immutable tag android-v${REF_VERSION}"
exit 1 exit 1
fi
else
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
fi fi
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml) VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT" 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" echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
exit 1 exit 1
fi 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" 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 - name: Require successful Play preflight for this exact release tree
if: ${{ !contains(steps.version.outputs.version, '-') }} if: ${{ !contains(steps.version.outputs.version, '-') }}
env: env:
@@ -92,6 +115,8 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v5 uses: actions/setup-java@v5
@@ -107,6 +132,7 @@ jobs:
- name: Validate release metadata and Android API compatibility - name: Validate release metadata and Android API compatibility
run: | run: |
python3 scripts/check-version-tracks.py python3 scripts/check-version-tracks.py
python3 scripts/check-privacy-policy.py
python3 scripts/check-android-locales.py python3 scripts/check-android-locales.py
python3 scripts/check-android-collection-apis.py python3 scripts/check-android-collection-apis.py
@@ -127,6 +153,8 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v5 uses: actions/setup-java@v5
+13 -9
View File
@@ -1,8 +1,8 @@
name: Release CLI name: Release Desktop
on: on:
push: push:
tags: ['cli-v*'] tags: ['desktop-v*']
permissions: permissions:
contents: write contents: write
@@ -36,13 +36,17 @@ jobs:
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
version="${GITHUB_REF_NAME#cli-v}" version="${GITHUB_REF_NAME#desktop-v}"
if [[ -z "$version" || "$version" == "$GITHUB_REF_NAME" ]]; then 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 exit 1
fi fi
echo "version=$version" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT"
npm run check:version-sync -- --expect "$version" 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 - name: Verify tagged commit belongs to main
shell: bash shell: bash
@@ -52,7 +56,7 @@ jobs:
git fetch origin main --no-tags git fetch origin main --no-tags
tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")" tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")"
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then 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 exit 1
fi fi
@@ -234,9 +238,9 @@ jobs:
# (the other publish-release steps only consume downloaded build artifacts). # (the other publish-release steps only consume downloaded build artifacts).
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- name: Extract CLI version - name: Extract Desktop version
id: 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 - uses: actions/download-artifact@v8
with: with:
@@ -253,7 +257,7 @@ jobs:
# Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub # Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub
# Release body. __VERSION__ = bare version (0.3.0), __TAG__ = full tag # 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 - name: Render release notes
env: env:
VERSION: ${{ steps.version.outputs.version }} VERSION: ${{ steps.version.outputs.version }}
@@ -266,7 +270,7 @@ jobs:
- name: Publish GitHub Release - name: Publish GitHub Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3
with: with:
name: Hermes-Relay-CLI v${{ steps.version.outputs.version }} name: Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
draft: false draft: false
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }} 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: on:
push: push:
tags: tags:
- "plugin-v*" - "server-v*"
permissions: permissions:
contents: write contents: write
jobs: jobs:
validate: validate:
name: Validate Plugin release name: Validate Server release
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
outputs: outputs:
version: ${{ steps.version.outputs.version }} version: ${{ steps.version.outputs.version }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Extract version from tag - name: Extract version from tag
id: version 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 - name: Verify Server version sync and changelog
run: python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION" 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: env:
TAG_VERSION: ${{ steps.version.outputs.version }} 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: test:
name: Test Plugin package name: Test Plugin package
needs: validate needs: validate
@@ -100,8 +117,8 @@ jobs:
- name: Publish GitHub Release - name: Publish GitHub Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3
with: with:
name: Hermes-Relay-Plugin v${{ needs.validate.outputs.version }} name: Hermes-Relay-Server v${{ needs.validate.outputs.version }}
tag_name: plugin-v${{ needs.validate.outputs.version }} tag_name: server-v${{ needs.validate.outputs.version }}
prerelease: ${{ contains(needs.validate.outputs.version, '-') }} prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
fail_on_unmatched_files: true fail_on_unmatched_files: true
body_path: release_notes_rendered.md body_path: release_notes_rendered.md
+33 -10
View File
@@ -5,26 +5,49 @@ coding agent (Claude Code, Codex, Cursor, etc.).
## Read this first ## Read this first
The detailed, authoritative context lives in **[CLAUDE.md](CLAUDE.md)** — This file is the provider-neutral canonical agent context. Read it before
architecture, the upstream Hermes API reference, repository layout, per-language touching code, then `docs/spec.md` and `docs/decisions.md`. Provider adapters
code style, the dev loop, and the Key Files map. Read it before touching code, such as **[CLAUDE.md](CLAUDE.md)** may add tool-specific guidance, but they do
then `docs/spec.md` and `docs/decisions.md`. not redefine the branch, release, or hotfix policy here and in `RELEASE.md`.
- Release process → **[RELEASE.md](RELEASE.md)** - Release process → **[RELEASE.md](RELEASE.md)**
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)** - Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.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) - 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) ## Non-negotiables (the short list)
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection — - **Vanilla Hermes path = upstream-only.** The standard (no-plugin) connection
chat via the API server, Vanilla Hermes voice via the Hermes dashboard — must work uses the upstream Dashboard/Gateway for chat, authentication, Manage, sessions,
against unmodified upstream hermes-agent. Server-side needs go through upstream and Vanilla Hermes voice. The API server is an optional automatic fallback and
PRs or the optional relay plugin, never fork patches. 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` / - **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
`tui_gateway/server.py` in hermes-agent) before assuming a route exists. `tui_gateway/server.py` in hermes-agent) before assuming a route exists.
- **Conventional Commits + `main`/`dev` branching.** Feature branches off `dev`, - **Conventional Commits + `main`/`dev` branching.** Normal branches start at
`--no-ff` merges, version bumps at release-prep on `dev`, tags cut from `main`. `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), - **Android:** Jetpack Compose only (no XML), kotlinx.serialization (no Gson),
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin. OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
- **Plugin (Python 3.11+):** aiohttp + asyncio (no threading), type hints - **Plugin (Python 3.11+):** aiohttp + asyncio (no threading), type hints
+87 -1
View File
@@ -6,6 +6,93 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased] ## [Unreleased]
### Fixed
- **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.
## [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 ### 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. - **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.
@@ -13,7 +100,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed ### 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. - **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.
- **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.
## [Android 1.4.6] - 2026-07-15 ## [Android 1.4.6] - 2026-07-15
+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 ## What This Is
@@ -183,18 +186,16 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
### Git ### Git
- **Conventional Commits:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore` - **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`. - **Branch/release policy:** follow the branch-contract table in `AGENTS.md` and
- **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). the executable release and hotfix procedures in `RELEASE.md`. Do not maintain
- **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." a Claude-specific parallel policy here.
- **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.
### Testing ### Testing
- **Android:** JUnit + Compose testing for UI, MockK for mocks - **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) - **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 ## 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. 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. 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. 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. 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`. 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 ### Release Process
See [RELEASE.md](RELEASE.md) for the full recipe. See [AGENTS.md](AGENTS.md) for the canonical branch contract and
[RELEASE.md](RELEASE.md) for version sources, release trains, surface tags,
- **Android version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`); bump with `scripts/bump-android-version.sh` hotfixes, secrets, publishing, and verification. Claude-specific automation
- **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` must not infer release authority from feature completion.
- **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`
## Integration Points ## Integration Points
+21 -2
View File
@@ -92,9 +92,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:`. 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 ## Stale PR salvage and contributor credit
@@ -158,10 +168,19 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
## Testing ## 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.
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing) - **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
- **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. - **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. 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? ## Questions?
+312
View File
@@ -1,5 +1,263 @@
# Hermes-Relay — Dev Log # Hermes-Relay — Dev Log
## 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 ## 2026-07-17 — Smooth streamed-reply rendering and finalization
Uninterrupted Gateway turns treat their structured live assistant, reasoning, and Uninterrupted Gateway turns treat their structured live assistant, reasoning, and
@@ -194,6 +452,60 @@ Verification: seven profile-avatar endpoint tests and the focused Android host
avatar client plus profile-controller suites passed. Android lint and final diff avatar client plus profile-controller suites passed. Android lint and final diff
checks are recorded with the completed work. 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 ## 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 Android 1.4.5 shipped as versionCode 28 after the signed release build, final
+8 -6
View File
@@ -1,20 +1,22 @@
# Hermes-Relay-Plugin v__VERSION__ # Hermes-Relay-Plugin v__VERSION__
**Release Date:** July 15, 2026 **Release Date:** July 22, 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 patch hardens Relay authorization, adds upstream-aware diagnostics, and keeps plugin bootstrap work off the Gateway event loop.
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. It can accompany Hermes-Relay-Android v1.5.0 for optional Relay diagnostics and power features. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
## What's changed ## What's changed
### Added ### 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. - **Upstream-aware Gateway diagnostics.** Doctor and `/relay/info` expose optional health, configuration-route, and capability signals so clients can explain compatibility gaps without treating an older upstream install as a broken Relay.
### Fixed ### 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. - **Privileged Relay paths enforce host authorization and active grants.** Pairing, Android bridge, terminal, session policy, remote profile configuration, and voice provider origins retain their intended trust boundaries.
- **Plugin bootstrap remains responsive.** Database initialization and compatibility inspection run outside the Gateway event loop while preserving compatibility with older upstream bootstrap contracts.
- **Windows Gateway detection is non-signalling.** Starting Relay and periodic profile rescans no longer risk terminating an existing Gateway process.
## Install / update ## Install / update
@@ -33,4 +35,4 @@ Pairs with Hermes-Relay-Android v1.4.6 for profile image import. Standard chat a
--- ---
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*.
+28 -32
View File
@@ -54,52 +54,48 @@ Install → connect → talk, in about two minutes.
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. 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://hermes-relay.dev/docs/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 ```bash
hermes setup --portal # install / log in / pick a provider — skip if already done hermes setup --portal # install / log in / pick a provider — skip if already done
hermes dashboard # start the standard Dashboard/Gateway surface
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
``` ```
`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)
> **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://hermes-relay.dev/docs/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://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.
### 3 · Connect and talk ### 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. The separate API server can be discovered automatically or added later under
- **Vanilla Hermes** → type the address (`http://<host>:8642`) and key by hand. **Advanced** as a chat fallback or for a headless compatibility setup. Its API
- **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. 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: The wizard probes everything and finishes with a capability card:
| Line | What it means | | Line | What it means |
|------|---------------| |------|---------------|
| **Chat** | API server reachable — you can talk | | **Chat** | Dashboard/Gateway ready — you can talk |
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone | | **Manage** | Models, keys, skills, and profiles are available from the phone |
| **Voice** | Speech ready via your server (or one Manage sign-in away) | | **Voice** | Speech ready via your server (or one Manage sign-in away) |
| **Remote** | Fallback route configured — keeps working away from home | | **API fallback** | Optional API route available/unavailable |
| **Relay** | Optional power tools — fine to leave unpaired | | **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://hermes-relay.dev/docs/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 ### 4 · Optional: install Relay for power tools
@@ -133,7 +129,7 @@ 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). 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 ## Screenshots
@@ -201,7 +197,7 @@ hermes-relay daemon start # background tool router — agen
hermes-relay update # self-update via GitHub Releases 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 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.
@@ -236,7 +232,7 @@ configure API, dashboard, and relay routes without merging their auth models.
| [API Reference](https://hermes-relay.dev/docs/reference/api.html) | Hermes API endpoints used by both surfaces | | [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 | | [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal | | [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> <details>
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary> <summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
+130 -67
View File
@@ -13,23 +13,24 @@ with optional prerelease identifiers.
- `PATCH` — bug fixes, backwards compatible - `PATCH` — bug fixes, backwards compatible
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`) - Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
Hermes-Relay now ships three independently versioned surfaces. Public GitHub Hermes-Relay ships three independently versioned production surfaces. Public
Release titles use product names (`Hermes-Relay-Android`, GitHub Release titles use product names (`Hermes-Relay-Android`,
`Hermes-Relay-Plugin`, `Hermes-Relay-CLI`); tag prefixes stay short and stable `Hermes-Relay-Server`, `Hermes-Relay-Desktop`); immutable tag prefixes select
for automation. the corresponding build and deployment lane.
| Surface | Tag prefix | Version source | Bump script | Release workflow | | 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-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-Server | `server-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-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 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 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 `versionCode` bump, and CLI alphas can continue on their own cadence. Historical
Android releases before this naming split used bare `v*` tags. Historical Android releases before this naming split used bare `v*` tags. Historical
plugin/server releases used `relay-v*` tags, and historical CLI prereleases used plugin/server releases used `relay-v*` and `plugin-v*` tags. Historical
`desktop-v*` tags. New releases use the explicit tag prefixes above. desktop/CLI releases also include `cli-v*` tags. Those tags remain immutable;
new releases use the canonical prefixes above.
### Android app versioning ### Android app versioning
@@ -87,7 +88,7 @@ lockstep:
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata | | `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata | | `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
Always bump Plugin releases via: Always bump Server releases via:
```bash ```bash
bash scripts/bump-plugin-version.sh 0.6.2 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 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 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 consistent. It deliberately does not require all three tracks to share the same
SemVer. 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 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. artifacts.
### CLI / tray versioning ### CLI / tray versioning
`desktop/package.json` is the CLI release track's source of truth. Its version `desktop/package.json` is the Desktop/CLI release track's source of truth. Its version
must match the generated CLI and native Windows systray metadata. The systray is 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, a menu-only controller for the installed CLI; it has no application window,
WebView, embedded terminal, or separate desktop product surface. The public WebView, embedded terminal, or separate desktop product surface. The public
release remains one `Hermes-Relay-CLI` track containing CLI binaries plus the release remains one `Hermes-Relay-Desktop` track containing CLI binaries plus the
optional Windows installer. optional Windows installer.
| File | Purpose | | File | Purpose |
@@ -166,12 +167,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 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 (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`, 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 **Staging is an environment, not a branch.** Deploy an exact tested `dev` SHA or
`dev` so merged features get exercised against real data before they an immutable release-candidate tag to staging. Record that source in the Forge
reach a tag. Users (Play Store, sideload, `hermes-relay-update`) only release issue/session. Never deploy a moving branch name as the source of record
see state that lives on `main` and on release tags. 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 ### Branch names
@@ -211,23 +228,35 @@ version files and, for Android, on `appVersionCode` (which must be
monotonic). monotonic).
Version-bump commits live on `dev` as the last commit of release-prep 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 work. Android commits use `release(android): android-vX.Y.Z`; server commits
use `release(plugin): plugin-vX.Y.Z`; CLI commits use use `release(server): server-vX.Y.Z`; desktop commits use
`release(cli): cli-vX.Y.Z`. A release PR then merges `dev` → `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` with `--no-ff`, and the matching tag is cut from the resulting
`main` tip. `main` tip.
### Branch protection ### 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 The intended settings are:
here. PR must pass CI (Android + Plugin) before merge. Force push and
branch deletion blocked. - **`main`** — PRs required; `Required checks` required and current; force push
- **`dev`** — direct pushes blocked for non-trivial work; feature and deletion blocked. Normal work does not target this branch.
branches PR in. PR must pass CI. Force push and branch deletion - **`dev`** — PRs and `Required checks` required; force push and deletion
blocked. blocked. This is the normal contribution target.
- Signed commits + review approval NOT required (solo-dev overhead). - **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 ## One-time Setup
@@ -388,6 +417,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 `hermes-relay-update --branch rc/vX.Y.Z-rc.N` without being auto-pushed
the unstable build. 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 ## Release Process
### 1. Bump the Android app version ### 1. Bump the Android app version
@@ -430,7 +468,7 @@ the new app version and a higher `appVersionCode`.
three* surfaces (Android + CLI + plugin), but releases are three* surfaces (Android + CLI + plugin), but releases are
per-surface. Move only the entries for the surface you're cutting into 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 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` / (Those tracks' GitHub-Release bodies come from `CLI_RELEASE_NOTES.md` /
`PLUGIN_RELEASE_NOTES.md`, so the split here only governs this file's `PLUGIN_RELEASE_NOTES.md`, so the split here only governs this file's
historical record.) historical record.)
@@ -572,8 +610,12 @@ git push origin dev
Then open **Actions → Approve Android Release**, choose **Run workflow**, select Then open **Actions → Approve Android Release**, choose **Run workflow**, select
`main`, and enter the version. Starting the workflow is the release approval. It `main`, and enter the version. Starting the workflow is the release approval. It
verifies that `main` has the exact preflighted tree and creates the verifies that `main` has the exact preflighted tree and creates the
`android-v<version>` tag. Manual stable tags are still guarded by the same `android-v<version>` tag. Because tags created with `GITHUB_TOKEN` do not trigger
preflight proof in the tag workflow. 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 The tag-triggered `.github/workflows/release-android.yml` rebuilds and scans the
artifacts, changes the existing Play Production draft to `completed` (submitting artifacts, changes the existing Play Production draft to `completed` (submitting
@@ -585,14 +627,14 @@ publication.
Plugin/Python version files are intentionally not part of an Android app Plugin/Python version files are intentionally not part of an Android app
release unless the plugin package itself is also being released. 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 Use this when plugin or relay behavior changes independently of Android app
delivery, for example CLI channel support, bridge routes, pairing server fixes, delivery, for example CLI channel support, bridge routes, pairing server fixes,
voice auth, dashboard plugin UI, or packaging changes. voice auth, dashboard plugin UI, or packaging changes.
First **rewrite `PLUGIN_RELEASE_NOTES.md`** — it is the GitHub Release body for 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 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 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. (the workflow substitutes it), and apply the same public-distribution scrub as §2.
@@ -603,31 +645,31 @@ git pull --ff-only origin dev
bash scripts/bump-plugin-version.sh 0.6.2 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 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 git push origin dev
# Open the release PR (dev -> main) and merge with --no-ff. # Open the release PR (dev -> main) and merge with --no-ff.
# After merge, tag from the new main tip: # After merge, tag from the new main tip:
git checkout main git checkout main
git pull --ff-only origin main git pull --ff-only origin main
git tag plugin-v0.6.2 git tag server-v0.6.2
git push origin plugin-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 validates all plugin-owned version metadata with
`scripts/check-plugin-version-sync.py`. Run `scripts/check-plugin-version-sync.py`. Run
`python scripts/check-version-tracks.py` locally before tagging when a change `python scripts/check-version-tracks.py` locally before tagging when a change
touches more than one release surface. The workflow also runs plugin tests, touches more than one release surface. The workflow also runs plugin tests,
builds a wheel and sdist, generates `SHA256SUMS.txt`, and creates a GitHub 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 ### CLI / Windows systray release
Use this when the standalone CLI, daemon, desktop tools, or Windows tray changes. Use this when the standalone CLI, daemon, desktop tools, or Windows tray changes.
Android and plugin versions do not need to move with it. 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: CLI/tray-relevant changelog bullets into the release block. Then:
```powershell ```powershell
@@ -641,7 +683,7 @@ cd ..
git add desktop/package.json desktop/package-lock.json desktop/src/version.ts ` 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 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 git push origin dev
# Open the release PR (dev -> main) and merge with --no-ff. # Open the release PR (dev -> main) and merge with --no-ff.
@@ -651,8 +693,8 @@ git pull --ff-only origin main
cd desktop cd desktop
npm run check:version-sync -- --expect 0.4.0-alpha.2 npm run check:version-sync -- --expect 0.4.0-alpha.2
cd .. cd ..
git tag cli-v0.4.0-alpha.2 git tag desktop-v0.4.0-alpha.2
git push origin cli-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 The tag workflow rejects version drift and tags whose commit is not in
@@ -768,7 +810,8 @@ plugin changes from forcing an Android app `versionCode` bump.
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`: 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). `gradle/libs.versions.toml` (mismatches fail the workflow).
2. Runs the Android debug build and the stable sideload pairing/connection 2. Runs the Android debug build and the stable sideload pairing/connection
regression slice with explicit timeouts. regression slice with explicit timeouts.
@@ -778,30 +821,35 @@ On every push of a tag matching `android-v*`, `.github/workflows/release-android
(`./gradlew bundleRelease assembleRelease`); only the sideload APK and (`./gradlew bundleRelease assembleRelease`); only the sideload APK and
googlePlay AAB are attached (see §Release assets). googlePlay AAB are attached (see §Release assets).
5. Generates `SHA256SUMS.txt` covering the two attached files. 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 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. 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 8. Prints a `$GITHUB_STEP_SUMMARY` with the release and Play result.
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
that the artifacts are debug-signed and unsuitable for Play Store.
On every push of a tag matching `plugin-v*`, On every push of a tag matching `server-v*`,
`.github/workflows/release-plugin.yml`: `.github/workflows/release-plugin.yml`:
1. Validates the tag matches all plugin-owned version metadata checked by 1. Verifies the tag commit is contained in `main`, validates the tag against
`scripts/check-plugin-version-sync.py`. 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. 2. Runs plugin syntax checks and the focused route/auth/session test slice.
3. Builds the Python wheel and sdist with `python -m build`. 3. Builds the Python wheel and sdist with `python -m build`.
4. Generates `dist/SHA256SUMS.txt`. 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. 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 `.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` 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 (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. 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. Added/Changed/Fixed groups at CLI release-prep and apply the §2 public scrub.
Dashboard-only changes are covered by Dashboard-only changes are covered by
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin, `.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
@@ -816,19 +864,28 @@ in the built bundle.
| `HERMES_KEYSTORE_PASSWORD` | Store password | Password set during `keytool -genkey` | | `HERMES_KEYSTORE_PASSWORD` | Store password | Password set during `keytool -genkey` |
| `HERMES_KEY_ALIAS` | Key alias | Alias 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 | | `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 Stable Android releases require `PLAY_SERVICE_ACCOUNT_JSON`. Preflight uploads
the `googlePlay` AAB to the **Production track as a DRAFT** automatically (stable the Production draft and the tag workflow promotes that exact version code to
tags only — prereleases are skipped). CI does the upload; you still click **Start `completed`. The workflow does not fall back to manual upload or publish GitHub
rollout** in Play Console. If the secret is unset, the workflow skips the upload first. With Play Managed Publishing off, an approved release publishes
and you upload manually (§5) — nothing else changes. automatically; with it on, Play holds the approved change for an operator action
that the Developer API does not expose.
## Hotfix Recipe ## Hotfix Recipe
When production has a bug and you need to ship a fix without picking up When production has a bug, use the same invariant for every surface:
unreleased work from `dev`, branch from the affected release tag and only
bump the version source for the surface you are shipping. 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: For an Android app hotfix:
@@ -842,17 +899,23 @@ For an Android app hotfix:
5. Open a PR from `fix/short-name` into `main`, merge with `--no-ff`. 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` 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. 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`) 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, so `dev` picks up the hotfix and the versionCode bump. Without this,
`dev`'s `appVersionCode` lags behind `main` and the next app release `dev`'s `appVersionCode` lags behind `main` and the next app release
bump collides. 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 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. `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 ## Troubleshooting
**`Tag version (X) does not match appVersionName (Y)` in CI validate step** **`Tag version (X) does not match appVersionName (Y)` in CI validate step**
+10 -12
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.4.6 # Hermes-Relay-Android v1.5.2
**Release Date:** July 15, 2026 **Release Date:** July 28, 2026
## Download ## 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.5.2-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone. The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,19 +12,17 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary ## 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. This patch restores reliable dashboard sign-in for self-hosted OIDC and Nous Portal connections, including private-LAN and Tailscale routes, and prevents replayed chat events from destabilizing the conversation list.
## 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.
## Fixed ## 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. - Self-hosted OIDC returns through the dashboard cookie flow instead of a desktop-only loopback callback.
- Host image import distinguishes an outdated Relay from a genuinely missing avatar and presents **Choose file** as a reliable fallback. - Nous Portal authentication opens in the system browser so provider security challenges can complete.
- Native PKCE uses standards-compatible unpadded Base64URL and preserves the dashboard's canonical HTTPS callback origin while keeping tokens scoped to the active route.
- Full-screen in-app sign-in remains available for compatible dashboard providers.
- Replayed upstream chat events are coalesced before rendering, preventing duplicate message keys.
## Install / Verify ## Install / Verify
- App version: **1.4.6** (versionCode **29**). - App version: **1.5.2** (versionCode **35**).
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes. - Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
+86 -8
View File
@@ -6,6 +6,24 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
--- ---
## 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 ## Active — Remove temporary GitHub Pages docs redirects
PR #210 moved current source and production documentation to PR #210 moved current source and production documentation to
@@ -36,6 +54,68 @@ 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) ## Multi-profile Phone/Threads routing — deferred (2026-07-12)
Android profile hot-swap and concurrent Gateway turns are separate from proactive Android profile hot-swap and concurrent Gateway turns are separate from proactive
@@ -637,9 +717,10 @@ Deferred:
A 5-agent audit compared the chat surface to Discord/Telegram/Messenger/iMessage/ 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 GitHub-mobile. **Shipped this pass (pending on-device verification):** a chat-tuned
`markdownTypography()` ramp (headings were falling through to M3 display roles — `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 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` 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 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 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 conversation gets its own beat; long-press haptic on the action menu; streaming dots
@@ -651,10 +732,6 @@ gated to pre-first-token. Deferred:
parses one full CommonMark document so global link references, indentation, and parses one full CommonMark document so global link references, indentation, and
nested containers remain correct; the viewport now anchors that same remeasure. nested containers remain correct; the viewport now anchors that same remeasure.
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device. Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
- **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.
- **Tail-corner on last-in-group only (design decision).** The audit flagged the - **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 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" (every bubble tails). Switching to iMessage-style "tail on the last bubble only"
@@ -1070,7 +1147,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). - **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`. - **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? - `**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 — 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. - **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"). - **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").
@@ -1121,6 +1198,7 @@ Follow-ups:
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`) ## 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. - **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. - **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. - **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.
+4 -3
View File
@@ -37,7 +37,7 @@ android {
// exempt from Play's 14-day closed-testing rule. See RELEASE.md. // exempt from Play's 14-day closed-testing rule. See RELEASE.md.
applicationId = "com.axiomlabs.hermesrelay" applicationId = "com.axiomlabs.hermesrelay"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 36
versionCode = libs.versions.appVersionCode.get().toInt() versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get() versionName = libs.versions.appVersionName.get()
@@ -245,6 +245,7 @@ dependencies {
// Activity // Activity
implementation(libs.activity.compose) implementation(libs.activity.compose)
implementation(libs.browser)
implementation(libs.appcompat) implementation(libs.appcompat)
// Core // Core
@@ -326,8 +327,8 @@ dependencies {
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric). // [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
// Renders real composables on the JVM at an exact canvas — no device, no // Renders real composables on the JVM at an exact canvas — no device, no
// status bar, no clipping. See StoreScreenshotTest. // status bar, no clipping. See StoreScreenshotTest.
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.68.0") testImplementation("io.github.takahirom.roborazzi:roborazzi:1.70.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.68.0") testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.70.0")
testImplementation(libs.compose.ui.test.junit4) testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.ui.test.manifest) testImplementation(libs.compose.ui.test.manifest)
testImplementation("androidx.test.ext:junit:1.3.0") 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"
}
}
@@ -121,42 +121,39 @@ class OnboardingFlowTest {
} }
@Test @Test
fun connectPage_showsStandardChoiceFirst() { fun connectPage_showsNearbyFirst() {
setOnboardingContent() setOnboardingContent()
navigateToPage(4) navigateToPage(4)
composeTestRule composeTestRule
.onNodeWithText("Vanilla Hermes") .onNodeWithText("Enter address instead")
.assertIsDisplayed() .assertIsDisplayed()
} }
@Test @Test
fun standardSetup_showsApiFields() { fun manualSetup_showsHermesAddressWithoutApiCredentials() {
setOnboardingContent() setOnboardingContent()
navigateToPage(4) navigateToPage(4)
composeTestRule.onNodeWithText("Vanilla Hermes").performClick() composeTestRule.onNodeWithText("Enter address instead").performClick()
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
composeTestRule composeTestRule
.onNodeWithText("API server URL") .onNodeWithText("Hermes address")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("API key")
.assertIsDisplayed() .assertIsDisplayed()
} }
@Test @Test
fun standardSetup_connectButton_isEnabled_withDefaultUrl() { fun manualSetup_findButton_isShown() {
setOnboardingContent() setOnboardingContent()
navigateToPage(4) navigateToPage(4)
composeTestRule.onNodeWithText("Vanilla Hermes").performClick() composeTestRule.onNodeWithText("Enter address instead").performClick()
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
composeTestRule composeTestRule
.onNodeWithText("Connect") .onNodeWithText("Find Hermes")
.assertIsEnabled() .assertIsDisplayed()
} }
@Test @Test
@@ -164,6 +161,9 @@ class OnboardingFlowTest {
setOnboardingContent() setOnboardingContent()
navigateToPage(4) navigateToPage(4)
composeTestRule.onNodeWithText("Other connection methods").performClick()
composeTestRule.waitForIdle()
composeTestRule composeTestRule
.onNodeWithText("Pair Relay by code") .onNodeWithText("Pair Relay by code")
.assertIsDisplayed() .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

@@ -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. Dashboard sign-in now completes reliably for self-hosted OIDC and Nous Portal, including private-LAN and Tailscale routes. Nous opens securely in the system browser, while compatible providers retain full-screen in-app sign-in. Replayed chat updates no longer duplicate conversation rows.
@@ -1 +1 @@
“服务器默认”现在会让代理、聊天、会话抽屉和记录保持在 Hermes 当前活动配置文件中。可按连接重新排序或隐藏配置文件,从手机选择图标,或从已更新且配对的 Relay 导入 avatar.png/profile.jpg。图像导入也会明确区分 Relay 版本过旧与头像文件缺失。 Hermes 仪表板登录现在可为自托管 OIDC 和 Nous Portal 可靠完成认证,并支持私有局域网与 Tailscale 路由。Nous 会在系统浏览器中安全打开,兼容的提供商仍可使用应用内全屏登录。重放的聊天更新不再产生重复会话行。
+5 -7
View File
@@ -90,12 +90,10 @@
android:name=".notifications.ProactiveReplyReceiver" android:name=".notifications.ProactiveReplyReceiver"
android:exported="false" /> android:exported="false" />
<!-- Opt-in "Persistent connection" — holds the user's connection to <!-- Protects user-started active turns automatically; the optional
Hermes open while backgrounded so messages and live features stay "Persistent connection" setting extends the same foreground
responsive (relay-paired setups also keep device control + protection to idle/background connectivity (and relay-paired
notification mirroring reachable). In main so BOTH flavors ship it device features). In main so BOTH flavors ship it. specialUse
(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. --> needs a Play Console foreground-service declaration at submission. -->
<service <service
android:name=".network.upstream.GatewayKeepAliveService" android:name=".network.upstream.GatewayKeepAliveService"
@@ -103,7 +101,7 @@
android:foregroundServiceType="specialUse"> android:foregroundServiceType="specialUse">
<property <property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" 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> </service>
</application> </application>
+139
View File
@@ -1,5 +1,144 @@
{ {
"versions": [ "versions": [
{
"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", "version": "1.4.6",
"title": "Profiles stay together", "title": "Profiles stay together",
+5 -7
View File
@@ -1,8 +1,6 @@
v1.4.6 - Profiles stay together v1.5.2 - Sign in without detours
Profile continuity * Complete self-hosted OIDC sign-in through the dashboard callback.
* Server default now keeps its agent, chats, drawer, and transcript in the active Hermes profile. * Open Nous Portal securely in the system browser.
* Reorder or hide profiles per connection without changing the server. * Sign in over private-LAN and Tailscale dashboard routes.
* Keep replayed chat updates from duplicating conversation rows.
Profile icons
* Choose an image file on your phone or import avatar.png/profile.jpg from an updated paired Relay.
@@ -20,6 +20,7 @@ import com.hermesandroid.relay.bridge.BridgeForegroundService
import com.hermesandroid.relay.bridge.UnattendedAccessManager import com.hermesandroid.relay.bridge.UnattendedAccessManager
import com.hermesandroid.relay.data.BuildFlavor import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.notifications.TurnCompleteNotifier import com.hermesandroid.relay.notifications.TurnCompleteNotifier
import com.hermesandroid.relay.notifications.InteractionRequestNotifier
import com.hermesandroid.relay.ui.RelayApp import com.hermesandroid.relay.ui.RelayApp
import com.hermesandroid.relay.util.NavRouteRequest import com.hermesandroid.relay.util.NavRouteRequest
import com.hermesandroid.relay.viewmodel.ConnectionViewModel import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -142,6 +143,10 @@ class MainActivity : AppCompatActivity() {
// Returning to the app clears the one-slot "Hermes finished // Returning to the app clears the one-slot "Hermes finished
// responding" notification — the chat surface is the answer. // responding" notification — the chat surface is the answer.
TurnCompleteNotifier.cancel(this) 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 // v0.4.1 — register this activity as the host for
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so // KeyguardManager.requestDismissKeyguard. Cleared in onPause so
// we don't leak the Activity past its lifecycle. The unattended- // we don't leak the Activity past its lifecycle. The unattended-
@@ -31,7 +31,16 @@ object AgentDisplay {
fun effectiveDisplayProfile( fun effectiveDisplayProfile(
selectedProfile: Profile?, selectedProfile: Profile?,
profiles: List<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 { isServerDefaultAlias(it.name) }
}
// The NAME goes in the name slot. Non-default profiles use their profile // The NAME goes in the name slot. Non-default profiles use their profile
// name first. The synthetic default profile uses its description only when // name first. The synthetic default profile uses its description only when
@@ -142,6 +142,21 @@ data class ChatMessage(
* through `copy`, while [id] remains the authoritative lookup/wire id. * through `copy`, while [id] remains the authoritative lookup/wire id.
*/ */
val uiKey: String = id, val uiKey: String = id,
/**
* 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. */ /** One Chat-visible identity for a promoted/durable realtime Hermes run. */
@@ -392,6 +407,8 @@ data class ChatSession(
* for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12). * for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12).
*/ */
val source: String? = null, val source: String? = null,
/** Server reports a persisted session runtime/model binding. */
val hasModelConfig: Boolean = false,
) { ) {
val activityTimestamp: Long val activityTimestamp: Long
get() = firstPositive(lastActivityAt, updatedAt, startedAt) get() = firstPositive(lastActivityAt, updatedAt, startedAt)
@@ -66,6 +66,17 @@ data class ChatTurnAssistantCheckpoint(
val cardDispatches: List<HermesCardDispatch> = emptyList(), val cardDispatches: List<HermesCardDispatch> = emptyList(),
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(), val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null, 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 @Serializable
@@ -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,16 @@ data class DashboardConnectionStatus(
val authProvider: String? = null, val authProvider: String? = null,
val gatewayTicketAvailable: Boolean? = null, val gatewayTicketAvailable: Boolean? = null,
val message: String? = null, val message: String? = null,
val gatewayMode: String? = null,
val profiles: List<String> = emptyList(),
) )
/** /**
* A "connection" = a distinct Hermes server connection the app can switch between. * A "connection" = a distinct Hermes server connection the app can switch between.
* *
* Each connection has its own: * 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 * - EncryptedSharedPreferences file (keyed by [tokenStoreKey]) holding the
* session token, device ID, API key, and paired-session metadata. * session token, device ID, API key, and paired-session metadata.
* - Cert pin (already host-keyed in [com.hermesandroid.relay.auth.CertPinStore] * - Cert pin (already host-keyed in [com.hermesandroid.relay.auth.CertPinStore]
@@ -73,17 +76,34 @@ data class Connection(
val preferredRouteRole: String? = null, val preferredRouteRole: String? = null,
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */ /** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
val pairedAt: Long? = null, val pairedAt: Long? = null,
/** Last time the user explicitly selected this connection. */
val lastUsedAt: Long? = null,
val lastActiveSessionId: String? = null, val lastActiveSessionId: String? = null,
val transportHint: String? = null, val transportHint: String? = null,
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */ /** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
val expiresAt: Long? = null, 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 val resolvedDashboardUrl: String
get() = dashboardUrl get() = dashboardUrl
?.trim() ?.trim()
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty() ?: 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 { companion object {
/** /**
* The pre-multi-connection EncryptedSharedPreferences filename. Matches * The pre-multi-connection EncryptedSharedPreferences filename. Matches
@@ -94,6 +114,8 @@ data class Connection(
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw" const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
const val DEFAULT_DASHBOARD_PORT: Int = 9119 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 * Derive a stable per-connection EncryptedSharedPreferences filename
@@ -111,12 +133,40 @@ data class Connection(
* user typed a malformed value — better to show something recognizable * user typed a malformed value — better to show something recognizable
* than to crash). * than to crash).
*/ */
fun extractDefaultLabel(apiServerUrl: String): String { fun extractDefaultLabel(apiServerUrl: String): String =
return try { extractHost(apiServerUrl) ?: apiServerUrl
URI(apiServerUrl).host ?: apiServerUrl
} catch (_: Exception) { /** Preserve explicit labels while upgrading an auto-generated IP label to a discovered host name. */
apiServerUrl 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) ?: primary
}
private fun extractHost(url: String): String? = try {
URI(url).host
} catch (_: Exception) {
null
} }
fun deriveDefaultDashboardUrl( fun deriveDefaultDashboardUrl(
@@ -141,6 +191,29 @@ data class Connection(
return "$scheme://$hostPart:$dashboardPort" 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 { fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty() val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
if (trimmed.isEmpty()) return true if (trimmed.isEmpty()) return true
@@ -150,7 +223,7 @@ data class Connection(
fun deriveDefaultRelayUrl( fun deriveDefaultRelayUrl(
apiServerUrl: String, apiServerUrl: String,
relayPort: Int = 8767, relayPort: Int = DEFAULT_RELAY_PORT,
): String? { ): String? {
val trimmed = apiServerUrl.trim().trimEnd('/') val trimmed = apiServerUrl.trim().trimEnd('/')
if (trimmed.isEmpty()) return null if (trimmed.isEmpty()) return null
@@ -174,6 +247,7 @@ data class Connection(
apiServerUrl: String, apiServerUrl: String,
relayUrl: String, relayUrl: String,
extraApiUrls: List<Pair<String, String>> = emptyList(), extraApiUrls: List<Pair<String, String>> = emptyList(),
dashboardUrl: String? = null,
): List<EndpointCandidate> { ): List<EndpointCandidate> {
val routes = buildList { val routes = buildList {
endpointCandidateFromApiUrl( endpointCandidateFromApiUrl(
@@ -182,6 +256,7 @@ data class Connection(
apiServerUrl = apiServerUrl, apiServerUrl = apiServerUrl,
relayUrl = relayUrl.takeIf { it.isNotBlank() } relayUrl = relayUrl.takeIf { it.isNotBlank() }
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(), ?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
dashboardUrl = dashboardUrl,
)?.let(::add) )?.let(::add)
extraApiUrls extraApiUrls
@@ -193,13 +268,14 @@ data class Connection(
priority = index + 1, priority = index + 1,
apiServerUrl = url, apiServerUrl = url,
relayUrl = deriveDefaultRelayUrl(url).orEmpty(), relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
dashboardUrl = dashboardUrl,
)?.let(::add) )?.let(::add)
} }
} }
return routes return routes
.distinctBy { .distinctBy {
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" "${it.role.lowercase()}|${it.routeAuthority()}"
} }
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role }) .sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
} }
@@ -222,13 +298,13 @@ data class Connection(
existing: List<EndpointCandidate>, existing: List<EndpointCandidate>,
): List<EndpointCandidate> { ): List<EndpointCandidate> {
val rebuiltHostPorts = rebuilt val rebuiltHostPorts = rebuilt
.map { "${it.api.host.lowercase()}:${it.api.port}" } .mapNotNull { it.mergeAuthority() }
.toSet() .toSet()
val preserved = existing val preserved = existing
.filter { it.priority > 0 } .filter { it.priority > 0 }
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts } .filterNot { it.mergeAuthority() in rebuiltHostPorts }
return (rebuilt + preserved) 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 }) .sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
} }
@@ -267,6 +343,7 @@ data class Connection(
priority: Int, priority: Int,
apiServerUrl: String, apiServerUrl: String,
relayUrl: String, relayUrl: String,
dashboardUrl: String? = null,
): EndpointCandidate? { ): EndpointCandidate? {
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull() val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
?: return null ?: return null
@@ -290,12 +367,141 @@ data class Connection(
role = role.ifBlank { inferRouteRole(apiServerUrl) }, role = role.ifBlank { inferRouteRole(apiServerUrl) },
priority = priority, priority = priority,
api = ApiEndpoint(host = host, port = port, tls = tls), 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) }, ?.let { DashboardEndpoint(url = it) },
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint), 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 { fun inferRouteRole(apiServerUrl: String): String {
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host } val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
.getOrNull() .getOrNull()
@@ -60,6 +60,7 @@ import kotlinx.serialization.json.Json
class ConnectionStore private constructor( class ConnectionStore private constructor(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
private val context: Context?, private val context: Context?,
private val scope: CoroutineScope,
) { ) {
/** /**
@@ -71,6 +72,7 @@ class ConnectionStore private constructor(
constructor(context: Context) : this( constructor(context: Context) : this(
dataStore = context.relayDataStore, dataStore = context.relayDataStore,
context = context.applicationContext, context = context.applicationContext,
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
) )
/** /**
@@ -81,9 +83,13 @@ class ConnectionStore private constructor(
internal constructor(dataStore: DataStore<Preferences>) : this( internal constructor(dataStore: DataStore<Preferences>) : this(
dataStore = dataStore, dataStore = dataStore,
context = null, 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 { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -100,6 +106,13 @@ class ConnectionStore private constructor(
private val _activeConnectionId = MutableStateFlow<String?>(null) private val _activeConnectionId = MutableStateFlow<String?>(null)
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow() 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 * Flips to `true` once the initial DataStore hydrate completes (success OR
* failure). Until then [connections] / [activeConnection] hold their empty * failure). Until then [connections] / [activeConnection] hold their empty
@@ -128,6 +141,7 @@ class ConnectionStore private constructor(
val oldJson = prefs[KEY_LEGACY_PROFILES] val oldJson = prefs[KEY_LEGACY_PROFILES]
val activeNew = prefs[KEY_ACTIVE_CONNECTION_ID] val activeNew = prefs[KEY_ACTIVE_CONNECTION_ID]
val activeOld = prefs[KEY_LEGACY_ACTIVE_PROFILE_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, // 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 // 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) p.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
} }
} }
_connections.value = decodeConnections(oldJson) val restored = decodeConnections(oldJson)
_activeConnectionId.value = activeOld val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
_connections.value = restored
_startupConnectionId.value = validStartupId
_activeConnectionId.value = validStartupId ?: activeOld
Log.i( Log.i(
TAG, TAG,
"Migrated legacy DataStore keys (profiles_v1 → connections_v1)", "Migrated legacy DataStore keys (profiles_v1 → connections_v1)",
) )
} else { } else {
_connections.value = decodeConnections(newJson) val restored = decodeConnections(newJson)
_activeConnectionId.value = activeNew val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
_connections.value = restored
_startupConnectionId.value = validStartupId
_activeConnectionId.value = validStartupId ?: activeNew
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Initial hydrate failed: ${e.message}") Log.w(TAG, "Initial hydrate failed: ${e.message}")
@@ -229,6 +249,10 @@ class ConnectionStore private constructor(
prefs.remove(KEY_ACTIVE_CONNECTION_ID) prefs.remove(KEY_ACTIVE_CONNECTION_ID)
_activeConnectionId.value = null _activeConnectionId.value = null
} }
if (prefs[KEY_STARTUP_CONNECTION_ID] == id) {
prefs.remove(KEY_STARTUP_CONNECTION_ID)
_startupConnectionId.value = null
}
} }
removed?.let { deleteTokenStoresFor(it) } removed?.let { deleteTokenStoresFor(it) }
} }
@@ -247,10 +271,12 @@ class ConnectionStore private constructor(
removed = decodeConnections(prefs[KEY_CONNECTIONS]) removed = decodeConnections(prefs[KEY_CONNECTIONS])
prefs.remove(KEY_CONNECTIONS) prefs.remove(KEY_CONNECTIONS)
prefs.remove(KEY_ACTIVE_CONNECTION_ID) prefs.remove(KEY_ACTIVE_CONNECTION_ID)
prefs.remove(KEY_STARTUP_CONNECTION_ID)
prefs.remove(KEY_LEGACY_PROFILES) prefs.remove(KEY_LEGACY_PROFILES)
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID) prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
_connections.value = emptyList() _connections.value = emptyList()
_activeConnectionId.value = null _activeConnectionId.value = null
_startupConnectionId.value = null
} }
removed.forEach { deleteTokenStoresFor(it) } removed.forEach { deleteTokenStoresFor(it) }
} }
@@ -259,6 +285,7 @@ class ConnectionStore private constructor(
suspend fun replaceConnections( suspend fun replaceConnections(
connections: List<Connection>, connections: List<Connection>,
activeConnectionId: String? = null, activeConnectionId: String? = null,
startupConnectionId: String? = null,
) { ) {
writeMutex.withLock { writeMutex.withLock {
var removed: List<Connection> = emptyList() var removed: List<Connection> = emptyList()
@@ -266,6 +293,8 @@ class ConnectionStore private constructor(
val normalizedActiveId = activeConnectionId val normalizedActiveId = activeConnectionId
?.takeIf { id -> normalizedConnections.any { it.id == id } } ?.takeIf { id -> normalizedConnections.any { it.id == id } }
?: normalizedConnections.firstOrNull()?.id ?: normalizedConnections.firstOrNull()?.id
val normalizedStartupId = startupConnectionId
?.takeIf { id -> normalizedConnections.any { it.id == id } }
dataStore.edit { prefs -> dataStore.edit { prefs ->
removed = decodeConnections(prefs[KEY_CONNECTIONS]) removed = decodeConnections(prefs[KEY_CONNECTIONS])
@@ -281,8 +310,14 @@ class ConnectionStore private constructor(
} }
prefs.remove(KEY_LEGACY_PROFILES) prefs.remove(KEY_LEGACY_PROFILES)
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID) 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 _connections.value = normalizedConnections
_activeConnectionId.value = normalizedActiveId _activeConnectionId.value = normalizedStartupId ?: normalizedActiveId
_startupConnectionId.value = normalizedStartupId
} }
removed.forEach { deleteTokenStoresFor(it) } removed.forEach { deleteTokenStoresFor(it) }
} }
@@ -315,12 +350,44 @@ class ConnectionStore private constructor(
suspend fun setActiveConnection(id: String) { suspend fun setActiveConnection(id: String) {
writeMutex.withLock { writeMutex.withLock {
dataStore.edit { prefs -> 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 prefs[KEY_ACTIVE_CONNECTION_ID] = id
_activeConnectionId.value = 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. * Update just the `lastActiveSessionId` on the identified connection.
* Called whenever the user picks a chat session so connection-switch can * 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 { companion object {
private const val TAG = "ConnectionStore" private const val TAG = "ConnectionStore"
private val KEY_CONNECTIONS = stringPreferencesKey("connections_v1") private val KEY_CONNECTIONS = stringPreferencesKey("connections_v1")
private val KEY_ACTIVE_CONNECTION_ID = stringPreferencesKey("active_connection_id") 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 // Pre-rename DataStore keys — read once in init on first launch after
// the rename, then wiped. See the init block above. // 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" 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", 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 * Catches the "added the same server twice" mistake. Matches when the
* candidate's api + relay URLs exactly match an existing connection * candidate's api + relay URLs exactly match an existing connection
@@ -67,12 +106,33 @@ object ConnectionValidation {
apiServerUrl: String, apiServerUrl: String,
relayUrl: String, relayUrl: String,
excludeId: String? = null, excludeId: String? = null,
dashboardUrl: String? = null,
): Connection? = connections.firstOrNull { c -> ): Connection? = connections.firstOrNull { c ->
c.id != excludeId && if (c.id == excludeId) {
c.apiServerUrl.equals(apiServerUrl, ignoreCase = true) && false
c.relayUrl.equals(relayUrl, ignoreCase = true) } 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? { private fun validateUrl(raw: String, allowedSchemes: Set<String>, kind: String): String? {
val trimmed = raw.trim() val trimmed = raw.trim()
if (trimmed.isEmpty()) return "$kind can't be blank" 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 * - v5 (2026-06-08): full connection backups. Adds active connection id
* and `connectionSecrets`, including API keys, relay tokens, device id, * and `connectionSecrets`, including API keys, relay tokens, device id,
* paired metadata, and dashboard cookies. * paired metadata, and dashboard cookies.
* - v6 (2026-07-19): preserves the optional pinned startup connection.
*/ */
@Serializable @Serializable
data class AppBackup( data class AppBackup(
val version: Int = 5, val version: Int = 6,
val serverUrl: String? = null, // legacy (v1 compat) val serverUrl: String? = null, // legacy (v1 compat)
val apiServerUrl: String? = null, val apiServerUrl: String? = null,
val relayUrl: String? = null, val relayUrl: String? = null,
@@ -83,6 +84,7 @@ class DataManager(
val onboardingCompleted: Boolean = false, val onboardingCompleted: Boolean = false,
val connections: List<Connection> = emptyList(), val connections: List<Connection> = emptyList(),
val activeConnectionId: String? = null, val activeConnectionId: String? = null,
val startupConnectionId: String? = null,
val containsSensitiveData: Boolean = true, val containsSensitiveData: Boolean = true,
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(), val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
val exportedAt: Long = System.currentTimeMillis(), val exportedAt: Long = System.currentTimeMillis(),
@@ -160,6 +162,7 @@ class DataManager(
onboardingCompleted = onboardingCompleted, onboardingCompleted = onboardingCompleted,
connections = connectionsSnapshot, connections = connectionsSnapshot,
activeConnectionId = connectionStore?.activeConnectionId?.value, activeConnectionId = connectionStore?.activeConnectionId?.value,
startupConnectionId = connectionStore?.startupConnectionId?.value,
containsSensitiveData = true, containsSensitiveData = true,
connectionSecrets = connectionSecrets, connectionSecrets = connectionSecrets,
exportedAt = System.currentTimeMillis(), exportedAt = System.currentTimeMillis(),
@@ -173,6 +176,7 @@ class DataManager(
store.replaceConnections( store.replaceConnections(
connections = backup.connections, connections = backup.connections,
activeConnectionId = backup.activeConnectionId, activeConnectionId = backup.activeConnectionId,
startupConnectionId = backup.startupConnectionId,
) )
val connectionsById = backup.connections.associateBy { it.id } val connectionsById = backup.connections.associateBy { it.id }
@@ -251,9 +255,11 @@ class DataManager(
suspend fun writeBackupToUri(uri: Uri, backup: String): Boolean { suspend fun writeBackupToUri(uri: Uri, backup: String): Boolean {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
context.contentResolver.openOutputStream(uri)?.use { outputStream -> val outputStream = context.contentResolver.openOutputStream(uri)
outputStream.write(backup.toByteArray(Charsets.UTF_8)) ?: return@withContext false
outputStream.flush() outputStream.use {
it.write(backup.toByteArray(Charsets.UTF_8))
it.flush()
} }
true true
} catch (e: Exception) { } catch (e: Exception) {
@@ -284,9 +290,10 @@ class DataManager(
* - Clear DataStore preferences * - Clear DataStore preferences
* - Clear EncryptedSharedPreferences (auth tokens) * - Clear EncryptedSharedPreferences (auth tokens)
* - Clear any cached data * - 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 { try {
// Preserve onboarding state before clearing // Preserve onboarding state before clearing
val onboarding = isOnboardingCompleted() val onboarding = isOnboardingCompleted()
@@ -316,10 +323,11 @@ class DataManager(
} }
Log.d(TAG, "App data reset complete") Log.d(TAG, "App data reset complete")
true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to reset app data", e) Log.e(TAG, "Failed to reset app data", e)
false
} }
}
private suspend fun deleteSensitivePreferenceFiles() { private suspend fun deleteSensitivePreferenceFiles() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -380,16 +388,17 @@ class DataManager(
* Reset only the onboarding completion flag. * Reset only the onboarding completion flag.
* Next app launch will show onboarding again. * Next app launch will show onboarding again.
*/ */
suspend fun resetOnboarding() { suspend fun resetOnboarding(): Boolean =
try { try {
context.relayDataStore.edit { preferences -> context.relayDataStore.edit { preferences ->
preferences.remove(KEY_ONBOARDING_COMPLETED) preferences.remove(KEY_ONBOARDING_COMPLETED)
} }
Log.d(TAG, "Onboarding flag reset") Log.d(TAG, "Onboarding flag reset")
true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to reset onboarding flag", e) Log.e(TAG, "Failed to reset onboarding flag", e)
false
} }
}
/** /**
* Check if onboarding has been completed. * Check if onboarding has been completed.
@@ -408,13 +417,14 @@ class DataManager(
/** /**
* Mark onboarding as completed. * Mark onboarding as completed.
*/ */
suspend fun setOnboardingCompleted(completed: Boolean) { suspend fun setOnboardingCompleted(completed: Boolean): Boolean =
try { try {
context.relayDataStore.edit { preferences -> context.relayDataStore.edit { preferences ->
preferences[KEY_ONBOARDING_COMPLETED] = completed preferences[KEY_ONBOARDING_COMPLETED] = completed
} }
true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to set onboarding completed", e) 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.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.net.URI
/** /**
* One entry in a pairing payload's `endpoints` array (ADR 24 — multi-endpoint * One entry in a pairing payload's `endpoints` array (ADR 24 — multi-endpoint
@@ -38,8 +39,10 @@ import kotlinx.serialization.Serializable
data class EndpointCandidate( data class EndpointCandidate(
val role: String, val role: String,
val priority: Int = 0, val priority: Int = 0,
val api: ApiEndpoint, /** Optional legacy/API-server surface. Dashboard-only routes omit it. */
val relay: RelayEndpoint, val api: ApiEndpoint? = null,
/** Optional Hermes-Relay bridge surface. Standard upstream routes omit it. */
val relay: RelayEndpoint? = null,
val dashboard: DashboardEndpoint? = null, val dashboard: DashboardEndpoint? = null,
val proxy: ProxyEndpoint? = null, val proxy: ProxyEndpoint? = null,
val security: String? = null, val security: String? = null,
@@ -137,13 +140,42 @@ fun EndpointCandidate.displayLabel(): String {
return when (role.lowercase()) { return when (role.lowercase()) {
"lan" -> "LAN" "lan" -> "LAN"
"tailscale" -> "Tailscale" "tailscale" -> "Tailscale"
"public" -> if (api.tls) "HTTPS" else "Public" "public" -> if (primaryRouteUrl()?.startsWith("https://", ignoreCase = true) == true) {
"HTTPS"
} else {
"Public"
}
"https" -> "HTTPS" "https" -> "HTTPS"
"plugin_proxy", "plugin-proxy" -> "Plugin proxy" "plugin_proxy", "plugin-proxy" -> "Plugin proxy"
else -> "Custom VPN ($role)" 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 = fun EndpointCandidate.hasSecureProxy(): Boolean =
proxy?.url?.startsWith("https://", ignoreCase = true) == true || proxy?.url?.startsWith("https://", ignoreCase = true) == true ||
proxy?.url?.startsWith("wss://", ignoreCase = true) == true || proxy?.url?.startsWith("wss://", ignoreCase = true) == true ||
@@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.map
/** /**
* Feature flags with compile-time defaults and runtime overrides. * 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 * In release builds, experimental features are hidden unless the user
* enables Developer Options (tap version 7 times in Settings > About). * enables Developer Options (tap version 7 times in Settings > About).
* *
@@ -21,7 +22,6 @@ object FeatureFlags {
// DataStore keys // DataStore keys
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked") private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
private val KEY_RELAY_ENABLED = booleanPreferencesKey("feature_relay_enabled")
/** Whether the app is running a debug build. */ /** Whether the app is running a debug build. */
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
@@ -29,13 +29,7 @@ object FeatureFlags {
/** Observe whether Developer Options have been unlocked. */ /** Observe whether Developer Options have been unlocked. */
fun devOptionsUnlocked(context: Context): Flow<Boolean> = fun devOptionsUnlocked(context: Context): Flow<Boolean> =
context.relayDataStore.data.map { prefs -> 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> =
context.relayDataStore.data.map { prefs ->
if (isDevBuild) true else prefs[KEY_RELAY_ENABLED] ?: false
} }
/** Unlock Developer Options. */ /** Unlock Developer Options. */
@@ -45,18 +39,10 @@ object FeatureFlags {
} }
} }
/** Lock Developer Options and disable all experimental features. */ /** Lock Developer Options, including in debug builds. */
suspend fun lockDevOptions(context: Context) { suspend fun lockDevOptions(context: Context) {
context.relayDataStore.edit { prefs -> context.relayDataStore.edit { prefs ->
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false 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
} }
} }
@@ -36,6 +36,14 @@ data class VoiceSettings(
val audioRoute: String = VoiceAudioRoute.Auto.storageValue, val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
val interactionMode: String = "tap", val interactionMode: String = "tap",
val silenceThresholdMs: Long = 1250L, val silenceThresholdMs: Long = 1250L,
/**
* 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, val realtimeTraceDetails: Boolean = false,
/** /**
* When true (default), Realtime Agent keeps one provider session/socket open * When true (default), Realtime Agent keeps one provider session/socket open
@@ -122,6 +130,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. * Active scope for per-profile voice prefs.
* *
@@ -182,6 +200,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
// un-namespaced means switching profiles never churns these. // un-namespaced means switching profiles never churns these.
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode") private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms") private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
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_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
private val KEY_REALTIME_PERSISTENT_SESSION = private val KEY_REALTIME_PERSISTENT_SESSION =
booleanPreferencesKey("voice_realtime_persistent_session") booleanPreferencesKey("voice_realtime_persistent_session")
@@ -191,6 +211,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
const val DEFAULT_INTERACTION_MODE = "tap" const val DEFAULT_INTERACTION_MODE = "tap"
// 1250 ms matches hermes-desktop voice_mode `silenceMs` end-of-speech. // 1250 ms matches hermes-desktop voice_mode `silenceMs` end-of-speech.
const val DEFAULT_SILENCE_THRESHOLD_MS = 1250L 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_TRACE_DETAILS = false
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
@@ -258,6 +280,10 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
// --- global (shared across profiles) --- // --- global (shared across profiles) ---
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE, interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS, silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
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] realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
?: DEFAULT_REALTIME_TRACE_DETAILS, ?: DEFAULT_REALTIME_TRACE_DETAILS,
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION] realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
@@ -367,6 +393,14 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) } dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
} }
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) { suspend fun setRealtimeTraceDetails(enabled: Boolean) {
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled } dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
} }
@@ -9,6 +9,7 @@ import android.util.Log
import com.hermesandroid.relay.R import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.CertPinStore import com.hermesandroid.relay.auth.CertPinStore
import com.hermesandroid.relay.data.EndpointCandidate import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.PairingPreferences import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.diagnostics.DiagnosticCategory import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
@@ -325,17 +326,18 @@ class ConnectionManager(
// the synthesized list just collapses to the same URL anyway. // the synthesized list just collapses to the same URL anyway.
scope.launch { scope.launch {
val resolved = resolveBestEndpointSafe() val resolved = resolveBestEndpointSafe()
val targetUrl = resolved?.relay?.url ?: url val resolvedRelayUrl = resolved?.relay?.url?.takeIf { it.isNotBlank() }
val targetUrl = resolvedRelayUrl ?: url.takeIf { it.isNotBlank() }
if (resolved != null) { if (resolved != null) {
_activeEndpoint.value = resolved _activeEndpoint.value = resolved
Log.i(TAG, "connect: resolver picked role=${resolved.role} " + Log.i(TAG, "connect: resolver picked role=${resolved.role} " +
"relay=${resolved.relay.url} (fallback would have been $url)") "route=${resolved.primaryRouteUrl()} (relay fallback would have been $url)")
DiagnosticsLog.record( DiagnosticsLog.record(
category = DiagnosticCategory.Relay, category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info, severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.conn_diag_route_selected) ?: "Relay route selected", title = context?.getString(R.string.conn_diag_route_selected) ?: "Relay route selected",
endpointRole = resolved.role, endpointRole = resolved.role,
url = resolved.relay.url, url = resolved.primaryRouteUrl(),
) )
} else { } else {
_activeEndpoint.value = null _activeEndpoint.value = null
@@ -348,7 +350,11 @@ class ConnectionManager(
url = url, url = url,
) )
} }
connectToUrlOnMainPath(targetUrl) if (targetUrl != null) {
connectToUrlOnMainPath(targetUrl)
} else {
Log.d(TAG, "connect: selected route has no Relay surface; route published for HTTP/Gateway clients")
}
} }
} }
@@ -656,10 +662,11 @@ class ConnectionManager(
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so // (connectToUrlOnMainPath force-sets shouldReconnect = true, so
// the swap path never re-checked it.) // the swap path never re-checked it.)
if (!shouldReconnect) return@launch if (!shouldReconnect) return@launch
val normalizedNew = normalizeRelayUrl(resolved.relay.url) val relayUrl = resolved.relay?.url?.takeIf { it.isNotBlank() } ?: return@launch
val normalizedNew = normalizeRelayUrl(relayUrl)
if (normalizedNew != current) { if (normalizedNew != current) {
Log.i(TAG, "network change: swapping $current → $normalizedNew") Log.i(TAG, "network change: swapping $current → $normalizedNew")
connectToUrlOnMainPath(resolved.relay.url, closeReason) connectToUrlOnMainPath(relayUrl, closeReason)
} else if (_connectionState.value == ConnectionState.Disconnected && } else if (_connectionState.value == ConnectionState.Disconnected &&
reconnectGate() reconnectGate()
) { ) {
@@ -135,6 +135,91 @@ class RelayHttpClient(
val text: String, 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 * Fetch `GET /media/<token>` from the relay over HTTP(S). Returns a
* [Result] — success carries a [FetchedMedia], failure wraps the * [Result] — success carries a [FetchedMedia], failure wraps the
@@ -615,6 +700,14 @@ class RelayHttpClient(
val capabilities: List<String> = emptyList(), val capabilities: List<String> = emptyList(),
val profiles: List<RelayProfileInfo> = emptyList(), val profiles: List<RelayProfileInfo> = emptyList(),
val health: String = "unknown", 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,
) )
/** Fetch the installed plugin/protocol/profile capability contract. */ /** Fetch the installed plugin/protocol/profile capability contract. */
@@ -834,6 +834,11 @@ class RelayVoiceClient(
suspend fun runVoiceOutput( suspend fun runVoiceOutput(
text: String, text: String,
renderMode: String? = "verbatim", renderMode: String? = "verbatim",
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
language: String? = null,
onHandoff: (VoiceHandoffEvent) -> Unit = {}, onHandoff: (VoiceHandoffEvent) -> Unit = {},
onEvent: (RealtimeVoiceEvent) -> Unit, onEvent: (RealtimeVoiceEvent) -> Unit,
): Result<VoiceOutputSummary> = withContext(Dispatchers.IO) { ): Result<VoiceOutputSummary> = withContext(Dispatchers.IO) {
@@ -844,7 +849,15 @@ class RelayVoiceClient(
return@withContext Result.failure(missingAuthError()) 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) { if (sessionResult.isFailure) {
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Voice output session failed")) return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Voice output session failed"))
} }
@@ -1261,8 +1274,11 @@ class RelayVoiceClient(
inputSampleRate: Int = 16_000, inputSampleRate: Int = 16_000,
chatSessionId: String? = null, chatSessionId: String? = null,
conversationContext: List<RealtimeConversationContextMessage> = emptyList(), conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
provider: String? = null,
model: String? = null, model: String? = null,
voice: String? = null, voice: String? = null,
sampleRate: Int? = null,
finalAnswerOnly: Boolean = false,
onHandoff: (VoiceHandoffEvent) -> Unit = {}, onHandoff: (VoiceHandoffEvent) -> Unit = {},
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null, turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {}, onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
@@ -1290,8 +1306,11 @@ class RelayVoiceClient(
token = token, token = token,
chatSessionId = chatSessionId, chatSessionId = chatSessionId,
conversationContext = conversationContext, conversationContext = conversationContext,
provider = provider,
model = model, model = model,
voice = voice, voice = voice,
sampleRate = sampleRate,
finalAnswerOnly = finalAnswerOnly,
) )
if (sessionResult.isFailure) { if (sessionResult.isFailure) {
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed")) return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
@@ -1805,6 +1824,28 @@ class RelayVoiceClient(
if (event.type == "hermes.run.promoted") { if (event.type == "hermes.run.promoted") {
longRunningTurn.set(true) longRunningTurn.set(true)
Log.i(TAG, "Realtime agent turn marked long-running (run promoted); relaxing idle guard") 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) { if (event.isAudioDelta) {
audioChunks += 1 audioChunks += 1
@@ -1830,13 +1871,12 @@ class RelayVoiceClient(
responseDoneMs = event.responseDoneMs, responseDoneMs = event.responseDoneMs,
eventLogPath = event.eventLogPath ?: session.eventLogPath, eventLogPath = event.eventLogPath ?: session.eventLogPath,
) )
if (persistent) { if (persistent && activeTurn.compareAndSet(true, false)) {
// Turn boundary, not session boundary: keep the socket // Turn boundary, not session boundary: keep the socket
// open for the next utterance. // open for the next utterance.
activeTurn.set(false)
longRunningTurn.set(false) longRunningTurn.set(false)
onTurnComplete(summary) onTurnComplete(summary)
} else { } else if (!persistent) {
if (claimTerminalSocket( if (claimTerminalSocket(
webSocket, webSocket,
generation, generation,
@@ -2658,17 +2698,29 @@ class RelayVoiceClient(
token: String, token: String,
chatSessionId: String?, chatSessionId: String?,
conversationContext: List<RealtimeConversationContextMessage> = emptyList(), conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
provider: String? = null,
model: String? = null, model: String? = null,
voice: String? = null, voice: String? = null,
sampleRate: Int? = null,
finalAnswerOnly: Boolean = false,
): Result<RealtimeSessionResponse> { ): Result<RealtimeSessionResponse> {
val body = buildJsonObject { val body = buildJsonObject {
putProfile() putProfile()
provider?.trim()?.takeIf { it.isNotBlank() }?.let {
put("provider", JsonPrimitive(it))
}
model?.trim()?.takeIf { it.isNotBlank() }?.let { model?.trim()?.takeIf { it.isNotBlank() }?.let {
put("model", JsonPrimitive(it)) put("model", JsonPrimitive(it))
} }
voice?.trim()?.takeIf { it.isNotBlank() }?.let { voice?.trim()?.takeIf { it.isNotBlank() }?.let {
put("voice", JsonPrimitive(it)) 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 { chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
put("chat_session_id", JsonPrimitive(it)) put("chat_session_id", JsonPrimitive(it))
} }
@@ -2726,8 +2778,23 @@ class RelayVoiceClient(
} }
} }
private fun createVoiceOutputSession(httpBase: String, token: String): Result<VoiceOutputSessionResponse> { private fun createVoiceOutputSession(
val body = buildJsonObject { putProfile() }.toString() 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() val request = Request.Builder()
.url("$httpBase/voice/output/session") .url("$httpBase/voice/output/session")
.post(body.toRequestBody(JSON_MEDIA_TYPE)) .post(body.toRequestBody(JSON_MEDIA_TYPE))
@@ -2940,6 +3007,9 @@ class RelayVoiceClient(
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull, responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull, tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
floor = (obj["floor"] 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, activeToolName = (obj["active_tool_name"] as? JsonPrimitive)?.contentOrNull,
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull
?: (obj["tool_count"] as? JsonPrimitive)?.intOrNull, ?: (obj["tool_count"] as? JsonPrimitive)?.intOrNull,
@@ -2970,6 +3040,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 * Wire shape of `GET /voice/config`. Providers are returned as nested
* objects describing the currently-active STT and TTS backend. Extra * objects describing the currently-active STT and TTS backend. Extra
@@ -3318,6 +3404,7 @@ data class RealtimeVoiceEvent(
// ADR 33: background-run promotion fields. // ADR 33: background-run promotion fields.
val tier: String? = null, val tier: String? = null,
val floor: String? = null, val floor: String? = null,
val spokenHandoff: Boolean? = null,
// hermes.run.progress extras — drive the live background-run chip. // hermes.run.progress extras — drive the live background-run chip.
val activeToolName: String? = null, val activeToolName: String? = null,
val completedToolCount: Int? = null, val completedToolCount: Int? = null,
@@ -4,6 +4,8 @@ import android.content.Context
import android.util.Log import android.util.Log
import com.hermesandroid.relay.R import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.EndpointCandidate 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.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog import com.hermesandroid.relay.diagnostics.DiagnosticsLog
@@ -56,7 +58,10 @@ data class RouteProbeOutcome(
* candidate is reachable we use it; reachability never promotes a lower * candidate is reachable we use it; reachability never promotes a lower
* priority over a higher one. Reachability is **only** the tiebreaker * priority over a higher one. Reachability is **only** the tiebreaker
* among candidates that share the same priority. * 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 * per-candidate timeout. Positive results are cached longer than negative
* results so repeated `connect()` calls don't hammer healthy routes, while * results so repeated `connect()` calls don't hammer healthy routes, while
* transient handoff misses do not pin a good fallback offline. * transient handoff misses do not pin a good fallback offline.
@@ -101,6 +106,12 @@ class EndpointResolver(
*/ */
private data class CacheEntry(val expiresAt: Long, val reachable: Boolean) 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 probeCache = ConcurrentHashMap<String, CacheEntry>()
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap()) private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
@@ -156,13 +167,13 @@ class EndpointResolver(
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)" 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 a candidate: `"<role>|<primary host>:<port>"`.
* Roles are preserved case-verbatim (HMAC canonicalization contract) * Roles are preserved case-verbatim (HMAC canonicalization contract)
* but hostnames are lowercased — two roles pointing at the same * but hostnames are lowercased — two roles pointing at the same
* host:port share reachability state. * host:port share reachability state.
*/ */
internal fun cacheKey(candidate: EndpointCandidate): String = internal fun cacheKey(candidate: EndpointCandidate): String =
"${candidate.role}|${candidate.api.host.lowercase()}:${candidate.api.port}" "${candidate.role}|${candidate.routeAuthority() ?: candidate.primaryRouteUrl().orEmpty().lowercase()}"
} }
/** /**
@@ -194,14 +205,14 @@ class EndpointResolver(
val winner = raceGroup(group) val winner = raceGroup(group)
if (winner != null) { if (winner != null) {
Log.i(TAG, "resolve winner: role=${winner.role} " + Log.i(TAG, "resolve winner: role=${winner.role} " +
"api=${winner.api.host}:${winner.api.port} priority=$priority") "route=${winner.primaryRouteUrl()} priority=$priority")
DiagnosticsLog.record( DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint, category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Info, severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.endpoint_diag_selected) ?: "Endpoint selected", title = context?.getString(R.string.endpoint_diag_selected) ?: "Endpoint selected",
detail = "priority=$priority", detail = "priority=$priority",
endpointRole = winner.role, endpointRole = winner.role,
url = winner.relay.url, url = winner.primaryRouteUrl(),
) )
return winner return winner
} }
@@ -281,7 +292,7 @@ class EndpointResolver(
} }
/** /**
* 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 * no retries — callers that need retry semantics can re-invoke after
* the cache expires. * the cache expires.
* *
@@ -290,18 +301,19 @@ class EndpointResolver(
*/ */
private suspend fun probe(candidate: EndpointCandidate): Boolean { private suspend fun probe(candidate: EndpointCandidate): Boolean {
val startedAtMs = clock() val startedAtMs = clock()
val url = "${candidate.api.url}/health".toHttpUrlOrNull() val target = probeTarget(candidate)
val url = target?.requestUrl?.toHttpUrlOrNull()
?: run { ?: run {
Log.w(TAG, "probe: invalid url for role=${candidate.role}") Log.w(TAG, "probe: invalid url for role=${candidate.role}")
DiagnosticsLog.record( DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint, category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Error, severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid", title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
detail = "Invalid API URL", detail = "No valid Dashboard, API, or Relay URL",
endpointRole = candidate.role, endpointRole = candidate.role,
url = candidate.api.url, url = candidate.primaryRouteUrl(),
) )
recordOutcome(candidate, reachable = false, detail = "Invalid API URL") recordOutcome(candidate, reachable = false, detail = "Invalid route URL")
return false return false
} }
val fastClient = httpClient.newBuilder() val fastClient = httpClient.newBuilder()
@@ -310,11 +322,14 @@ class EndpointResolver(
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS) .writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS) .callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build() .build()
val request = Request.Builder() val requestBuilder = Request.Builder()
.url(url) .url(url)
.head()
.header("Accept", "*/*") .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) { return withContext(Dispatchers.IO) {
try { try {
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) { withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
@@ -331,13 +346,13 @@ class EndpointResolver(
title = probeTitle, title = probeTitle,
detail = if (ok) null else "HTTP ${resp.code}", detail = if (ok) null else "HTTP ${resp.code}",
endpointRole = candidate.role, endpointRole = candidate.role,
url = candidate.api.url, url = target.baseUrl,
elapsedMs = clock() - startedAtMs, elapsedMs = clock() - startedAtMs,
) )
recordOutcome( recordOutcome(
candidate, candidate,
reachable = ok, reachable = ok,
detail = if (ok) null else "HTTP ${resp.code} from /health", detail = if (ok) null else "HTTP ${resp.code} from ${target.path}",
) )
ok ok
} }
@@ -346,9 +361,9 @@ class EndpointResolver(
category = DiagnosticCategory.Endpoint, category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning, severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout", 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",
endpointRole = candidate.role, endpointRole = candidate.role,
url = candidate.api.url, url = target.baseUrl,
elapsedMs = clock() - startedAtMs, elapsedMs = clock() - startedAtMs,
) )
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL) recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
@@ -359,23 +374,23 @@ class EndpointResolver(
category = DiagnosticCategory.Endpoint, category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning, severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout", 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",
endpointRole = candidate.role, endpointRole = candidate.role,
url = candidate.api.url, url = target.baseUrl,
elapsedMs = clock() - startedAtMs, elapsedMs = clock() - startedAtMs,
) )
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL) recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
false false
} catch (e: Exception) { } catch (e: Exception) {
Log.d(TAG, "probe failed role=${candidate.role} " + Log.d(TAG, "probe failed role=${candidate.role} " +
"host=${candidate.api.host}: ${e.javaClass.simpleName}") "route=${target.baseUrl}: ${e.javaClass.simpleName}")
DiagnosticsLog.record( DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint, category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning, severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed", title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
detail = e.javaClass.simpleName, detail = e.javaClass.simpleName,
endpointRole = candidate.role, endpointRole = candidate.role,
url = candidate.api.url, url = target.baseUrl,
elapsedMs = clock() - startedAtMs, elapsedMs = clock() - startedAtMs,
) )
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e)) recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
@@ -384,6 +399,50 @@ class EndpointResolver(
} }
} }
/** Choose the standard Dashboard/Gateway surface first when advertised. */
private fun probeTarget(candidate: EndpointCandidate): ProbeTarget? {
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",
)
}
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
}
/** /**
* Map a probe exception to a short, actionable string for the Routes * Map a probe exception to a short, actionable string for the Routes
* card. The TLS case is the headline: a route saved with `https://` * card. The TLS case is the headline: a route saved with `https://`
@@ -8,22 +8,29 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.net.Inet4Address import java.net.Inet4Address
import java.net.InetAddress
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
data class HermesLanDiscoveryResult( data class HermesLanDiscoveryResult(
val host: String, val host: String,
val hostname: String? = null,
val apiUrl: String, val apiUrl: String,
val dashboardUrl: String?, val dashboardUrl: String?,
val apiReachable: Boolean, val apiReachable: Boolean,
val dashboardReachable: Boolean, val dashboardReachable: Boolean,
) ) {
val displayHost: String
get() = hostname ?: host
}
/** /**
* User-triggered local-network discovery for standard Hermes setup. * User-triggered local-network discovery for standard Hermes setup.
@@ -59,7 +66,9 @@ object HermesLanDiscovery {
hosts.map { host -> hosts.map { host ->
async { async {
semaphore.withPermit { semaphore.withPermit {
probeHost(client, host, apiPort, dashboardPort) probeHost(client, host, apiPort, dashboardPort)?.let { result ->
result.copy(hostname = resolveHostname(host))
}
} }
} }
}.awaitAll() }.awaitAll()
@@ -130,6 +139,24 @@ object HermesLanDiscovery {
} }
} }
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 { private fun looksLikeDashboardStatus(body: String, contentType: String): Boolean {
val lower = body.lowercase() val lower = body.lowercase()
return contentType.contains("json", ignoreCase = true) && ( return contentType.contains("json", ignoreCase = true) && (
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.network.shared package com.hermesandroid.relay.network.shared
import java.net.URI import java.net.URI
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/** /**
* Resolves profile-scoped Hermes API URLs for phone use. * Resolves profile-scoped Hermes API URLs for phone use.
@@ -43,6 +44,44 @@ object ProfileApiUrlResolver {
return "$scheme://$hostPart$portPart$pathPart$queryPart$fragmentPart".trimEnd('/') 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()
?.takeIf { it.isNotBlank() && !it.equals("default", ignoreCase = true) }
?: return base
val isKnownMultiplexProfile = gatewayMode.equals("multiplex", ignoreCase = true) &&
servedProfiles.any { it.equals(profile, ignoreCase = false) }
if (!isKnownMultiplexProfile) return base
val root = base?.toHttpUrlOrNull() ?: return base
return root.newBuilder()
.addPathSegment("p")
.addPathSegment(profile)
.build()
.toString()
.trimEnd('/')
}
private fun isLocalBindHost(host: String): Boolean { private fun isLocalBindHost(host: String): Boolean {
return when (host.lowercase().trim('[', ']')) { return when (host.lowercase().trim('[', ']')) {
"localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> true "localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> true
@@ -3,6 +3,31 @@ package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.VoiceAudioRoute import com.hermesandroid.relay.data.VoiceAudioRoute
import java.io.File import java.io.File
enum class VoiceSpeechStreamStatus {
Completed,
Fallback,
Stopped,
Failed,
}
data class VoiceSpeechStreamOutcome(
val status: VoiceSpeechStreamStatus,
val audioStarted: Boolean,
val error: Throwable? = null,
)
data class VoiceSpeechStreamCallbacks(
val onStart: (sampleRate: Int, channels: Int) -> Unit = { _, _ -> },
val onPcm: (pcm16Le: ByteArray, sampleRate: Int) -> Unit,
)
interface VoiceSpeechStream {
fun append(text: String)
fun finish()
fun stop()
suspend fun awaitOutcome(): VoiceSpeechStreamOutcome
}
/** /**
* Transport-neutral STT/TTS contract. The routing seam between the Standard * Transport-neutral STT/TTS contract. The routing seam between the Standard
* (dashboard) and Relay voice clients — implementations live in `network.upstream` * (dashboard) and Relay voice clients — implementations live in `network.upstream`
@@ -25,6 +50,16 @@ interface VoiceAudioClient {
suspend fun transcribe(audioFile: File): Result<String> suspend fun transcribe(audioFile: File): Result<String>
suspend fun synthesize(text: String): Result<File> suspend fun synthesize(text: String): Result<File>
/**
* Open one provider-backed PCM stream for an assistant reply. A null
* success means this route has no streaming surface and the caller should
* keep using [synthesize]. Concrete implementations must queue [VoiceSpeechStream.append]
* calls made before the socket opens and report whether any PCM was emitted
* so callers never replay already-heard audio during compatibility fallback.
*/
suspend fun openSpeechStream(callbacks: VoiceSpeechStreamCallbacks): Result<VoiceSpeechStream?> =
Result.success(null)
} }
/** /**
@@ -70,6 +105,12 @@ class AutoVoiceAudioClient(
override suspend fun synthesize(text: String): Result<File> = override suspend fun synthesize(text: String): Result<File> =
runWithSelectedRoute { it.synthesize(text) } runWithSelectedRoute { it.synthesize(text) }
override suspend fun openSpeechStream(
callbacks: VoiceSpeechStreamCallbacks,
): Result<VoiceSpeechStream?> = runWithSelectedRoute { client ->
client.openSpeechStream(callbacks)
}
private suspend fun <T> runWithSelectedRoute( private suspend fun <T> runWithSelectedRoute(
block: suspend (VoiceAudioClient) -> Result<T>, block: suspend (VoiceAudioClient) -> Result<T>,
): Result<T> { ): Result<T> {
@@ -0,0 +1,76 @@
package com.hermesandroid.relay.network.upstream
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Process-local ownership of background protection for work the user already
* started. Keys are connection/profile/session scoped, so detached sibling
* sessions retain independent leases and one completion cannot release
* another session's protection.
*/
object ActiveTurnKeepAliveRegistry {
data class Snapshot(
val activeTurnCount: Int = 0,
val waitingSessionCount: Int = 0,
) {
val required: Boolean get() = activeTurnCount > 0
}
private val lock = Any()
private val leases = linkedMapOf<String, Boolean>()
private val _snapshot = MutableStateFlow(Snapshot())
val snapshot: StateFlow<Snapshot> = _snapshot.asStateFlow()
fun acquire(key: String) {
synchronized(lock) {
leases[key] = leases[key] ?: false
publishLocked()
}
}
fun setWaiting(key: String, waiting: Boolean) {
synchronized(lock) {
if (key in leases) {
leases[key] = waiting
publishLocked()
}
}
}
fun rename(oldKey: String, newKey: String) {
if (oldKey == newKey) return
synchronized(lock) {
val waiting = leases.remove(oldKey) ?: return
leases[newKey] = waiting
publishLocked()
}
}
fun release(key: String) {
synchronized(lock) {
if (leases.remove(key) != null) publishLocked()
}
}
fun releaseAll() {
synchronized(lock) {
if (leases.isNotEmpty()) {
leases.clear()
publishLocked()
}
}
}
internal fun resetForTest() {
releaseAll()
}
private fun publishLocked() {
_snapshot.value = Snapshot(
activeTurnCount = leases.size,
waitingSessionCount = leases.count { it.value },
)
}
}
@@ -10,11 +10,11 @@ import com.hermesandroid.relay.data.ChatTurnCheckpoint
import com.hermesandroid.relay.data.HermesCard import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.MessageDeliveryStatus import com.hermesandroid.relay.data.MessageDeliveryStatus
import com.hermesandroid.relay.data.MessageRole import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.MoaReference
import com.hermesandroid.relay.data.RealtimeTurnTrace import com.hermesandroid.relay.data.RealtimeTurnTrace
import com.hermesandroid.relay.data.ToolCall import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.data.VoiceIntentTrace import com.hermesandroid.relay.data.VoiceIntentTrace
import com.hermesandroid.relay.network.shared.LocalDispatchResult import com.hermesandroid.relay.network.shared.LocalDispatchResult
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
import com.hermesandroid.relay.network.upstream.models.MessageItem import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
import com.hermesandroid.relay.network.upstream.models.SessionItem import com.hermesandroid.relay.network.upstream.models.SessionItem
@@ -43,6 +43,9 @@ class ChatHandler {
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */ /** Maximum number of messages kept in memory per session. Oldest are trimmed. */
internal const val MAX_MESSAGES = 500 internal const val MAX_MESSAGES = 500
private const val MAX_MOA_REFERENCES = 32
private const val MAX_MOA_LABEL_CHARS = 120
private const val MAX_MOA_REFERENCE_CHARS = 16_000
private fun timestampToMillis(timestamp: Double?): Long { private fun timestampToMillis(timestamp: Double?): Long {
val value = timestamp ?: return 0L val value = timestamp ?: return 0L
@@ -155,6 +158,15 @@ class ChatHandler {
*/ */
var onMediaBarePathRequested: (messageId: String, originalPath: String) -> Unit = { _, _ -> } var onMediaBarePathRequested: (messageId: String, originalPath: String) -> Unit = { _, _ -> }
/**
* Fired for a canonical `@image:<path>` directive found on a persisted
* USER history row. This is intentionally separate from free-form
* assistant `MEDIA:` parsing: only the bounded upstream directive parser
* can reach this callback.
*/
var onPersistedUserImageRequested: (messageId: String, originalPath: String) -> Unit =
{ _, _ -> }
/** /**
* Buffer for incomplete lines during streaming. Tool annotations are line-oriented * Buffer for incomplete lines during streaming. Tool annotations are line-oriented
* (backtick + emoji + tool_name + backtick), so we accumulate text until we see a * (backtick + emoji + tool_name + backtick), so we accumulate text until we see a
@@ -512,6 +524,42 @@ class ChatHandler {
} }
} }
/**
* Collapse a provisional post-interim segment back into its sealed
* assistant bubble when the terminal text proves they are one response.
* Tool/card state accumulated after the interim remains attached.
*/
fun reconcileInterimMessage(
interimMessageId: String,
currentMessageId: String,
content: String,
) {
_messages.update { messages ->
val interim = messages.firstOrNull { it.id == interimMessageId } ?: return@update messages
val current = messages.firstOrNull { it.id == currentMessageId }
val mergedTools = (interim.toolCalls + current?.toolCalls.orEmpty())
.distinctBy { it.id ?: "${it.name}:${it.startedAt}" }
val merged = interim.copy(
content = content,
isStreaming = true,
toolCalls = mergedTools,
thinkingContent = current?.thinkingContent
?.takeIf { it.isNotBlank() }
?: interim.thinkingContent,
isThinkingStreaming = current?.isThinkingStreaming
?: interim.isThinkingStreaming,
badges = (interim.badges + current?.badges.orEmpty()).distinct(),
cards = (interim.cards + current?.cards.orEmpty()).distinct(),
cardDispatches = (interim.cardDispatches + current?.cardDispatches.orEmpty())
.distinctBy { "${it.cardKey}:${it.actionValue}:${it.timestamp}" },
backgroundTask = current?.backgroundTask ?: interim.backgroundTask,
)
messages
.filterNot { it.id == currentMessageId && currentMessageId != interimMessageId }
.map { if (it.id == interimMessageId) merged else it }
}
}
/** Remove a provisional client-side message that never became a real turn. */ /** Remove a provisional client-side message that never became a real turn. */
fun removeMessage(messageId: String) { fun removeMessage(messageId: String) {
_messages.update { messages -> messages.filterNot { it.id == messageId } } _messages.update { messages -> messages.filterNot { it.id == messageId } }
@@ -973,6 +1021,27 @@ class ChatHandler {
startedAt = task.startedAt, startedAt = task.startedAt,
) )
} }
val checkpointMoaReferences = assistant.moaReferences
.filter { it.index in 1..MAX_MOA_REFERENCES }
.distinctBy { it.index }
.sortedBy { it.index }
.take(MAX_MOA_REFERENCES)
.map { reference ->
MoaReference(
index = reference.index,
count = reference.count,
label = reference.label.take(MAX_MOA_LABEL_CHARS),
text = if (reference.available) {
reference.text.take(MAX_MOA_REFERENCE_CHARS)
} else {
""
},
available = reference.available,
)
}
val restoredMoaReferences = currentAssistant?.moaReferences
?.takeIf { it.isNotEmpty() }
?: checkpointMoaReferences
val restoredAssistant = ChatMessage( val restoredAssistant = ChatMessage(
id = assistant.id, id = assistant.id,
role = MessageRole.ASSISTANT, role = MessageRole.ASSISTANT,
@@ -996,6 +1065,7 @@ class ChatHandler {
cardDispatches = currentAssistant?.cardDispatches?.takeIf { it.isNotEmpty() } cardDispatches = currentAssistant?.cardDispatches?.takeIf { it.isNotEmpty() }
?: assistant.cardDispatches, ?: assistant.cardDispatches,
backgroundTask = currentAssistant?.backgroundTask ?: restoredBackgroundTask, backgroundTask = currentAssistant?.backgroundTask ?: restoredBackgroundTask,
moaReferences = restoredMoaReferences,
) )
activeAgentName = restoredAssistant.agentName ?: activeAgentName activeAgentName = restoredAssistant.agentName ?: activeAgentName
@@ -1153,11 +1223,20 @@ class ChatHandler {
// so we can attach results back to the originating assistant message's ToolCall // so we can attach results back to the originating assistant message's ToolCall
val toolResults = items.filter { it.role == "tool" } val toolResults = items.filter { it.role == "tool" }
.associateBy { it.toolCallId } .associateBy { it.toolCallId }
// A reconnect/rejoin history response can repeat a persisted message row.
// Chat's LazyColumn renders domain ids as stable keys (via ChatMessage.uiKey),
// so allowing both copies through would crash Compose before either copy
// could be reconciled. A domain id identifies one persisted message: retain
// its first transcript position while adopting the latest repeated snapshot.
// Rows without ids remain independent, and tool/hidden rows keep their
// separate handling above/below.
val renderedItems = coalesceRenderedHistoryItems(items)
// Accumulator for media markers we find in loaded content — fired AFTER // Accumulator for media markers we find in loaded content — fired AFTER
// the wholesale `_messages.value = ...` assignment so the ViewModel's // the wholesale `_messages.value = ...` assignment so the ViewModel's
// mutateMessage lookups find the newly-loaded messages. // mutateMessage lookups find the newly-loaded messages.
val pendingMediaHits = mutableListOf<Pair<String, MediaMarkerHit>>() val pendingMediaHits = mutableListOf<Pair<String, MediaMarkerHit>>()
val pendingPersistedUserImages = mutableListOf<Pair<String, String>>()
// Reconcile optimistic (client-UUID) live ids to their server ids BEFORE // Reconcile optimistic (client-UUID) live ids to their server ids BEFORE
// building the carry map, so the id-keyed delta-merge updates rows in // building the carry map, so the id-keyed delta-merge updates rows in
@@ -1169,8 +1248,8 @@ class ChatHandler {
// silently misses those rows, so a gateway turn's tokens/badges survived // silently misses those rows, so a gateway turn's tokens/badges survived
// only if a content match happened to cover them. See // only if a content match happened to cover them. See
// [reconcileLiveIdsToServer]. // [reconcileLiveIdsToServer].
val serverItemIds = items.mapNotNullTo(HashSet()) { it.id } val serverItemIds = renderedItems.mapNotNullTo(HashSet()) { it.id }
val idRemap = reconcileLiveIdsToServer(items, serverItemIds) val idRemap = reconcileLiveIdsToServer(renderedItems, serverItemIds)
// Carry CLIENT-ONLY enrichment forward across the reload, keyed by the // Carry CLIENT-ONLY enrichment forward across the reload, keyed by the
// RECONCILED message id. The server transcript (MessageItem) rebuilds // RECONCILED message id. The server transcript (MessageItem) rebuilds
@@ -1207,11 +1286,16 @@ class ChatHandler {
// clientOnly bubbles (same exchange, pre-sync copy). // clientOnly bubbles (same exchange, pre-sync copy).
val syncedRealtimeTurnContents = mutableSetOf<String>() val syncedRealtimeTurnContents = mutableSetOf<String>()
val loaded = items.mapNotNull { item -> val loaded = renderedItems.mapNotNull { item ->
val role = when (item.role) { val displayKind = item.displayKind?.trim()?.lowercase()
"user" -> MessageRole.USER if (displayKind == "hidden") return@mapNotNull null
"assistant" -> MessageRole.ASSISTANT val role = when {
"system" -> displayKind == "model_switch" ||
displayKind == "async_delegation_complete" ||
displayKind == "auto_continue" -> MessageRole.SYSTEM
item.role == "user" -> MessageRole.USER
item.role == "assistant" -> MessageRole.ASSISTANT
item.role == "system" ->
// Upstream injects role:system STEERING markers into the // Upstream injects role:system STEERING markers into the
// session history on model/personality change — e.g. // session history on model/personality change — e.g.
// "[System: The active model for this chat has changed to …]" // "[System: The active model for this chat has changed to …]"
@@ -1227,9 +1311,11 @@ class ChatHandler {
} else { } else {
MessageRole.SYSTEM MessageRole.SYSTEM
} }
"tool" -> return@mapNotNull null // Merged into assistant tool calls above item.role == "tool" -> return@mapNotNull null // Merged into assistant tool calls above
else -> return@mapNotNull null else -> return@mapNotNull null
} }
val displayContent = displayEventContent(displayKind, item.displayMetadata)
val rawServerContent = displayContent ?: item.contentText ?: ""
// If > 1e12, already in milliseconds; otherwise convert from seconds // If > 1e12, already in milliseconds; otherwise convert from seconds
val ts = item.timestamp ?: 0.0 val ts = item.timestamp ?: 0.0
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong() val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
@@ -1241,15 +1327,21 @@ class ChatHandler {
emptyList() emptyList()
} }
val messageId = item.id?.toString() ?: java.util.UUID.randomUUID().toString() val messageId = item.id ?: java.util.UUID.randomUUID().toString()
val rawContent = item.contentText ?: "" val rawContent = rawServerContent
val persistedImages = if (role == MessageRole.USER && rawContent.isNotEmpty()) {
PersistedImageReferenceParser.parse(rawContent)
} else {
PersistedImageReferences(rawContent, emptyList())
}
// Run the media marker parser on assistant content; strip matched // Run the media marker parser on assistant content; strip matched
// lines and queue hits for post-assignment dispatch. // lines and queue hits for post-assignment dispatch.
val afterMedia = if (role == MessageRole.ASSISTANT && rawContent.isNotEmpty()) { val afterMedia = if (role == MessageRole.ASSISTANT && persistedImages.cleanedText.isNotEmpty()) {
extractMediaMarkersFromContent(messageId, rawContent, pendingMediaHits) extractMediaMarkersFromContent(messageId, persistedImages.cleanedText, pendingMediaHits)
} else { } else {
rawContent persistedImages.cleanedText
} }
// Cards are synchronous (no async fetch) so we attach them // Cards are synchronous (no async fetch) so we attach them
@@ -1287,7 +1379,14 @@ class ChatHandler {
// content-keyed queue. Inbound attachments are intentionally // content-keyed queue. Inbound attachments are intentionally
// excluded — they come back via the marker re-dispatch. // excluded — they come back via the marker re-dispatch.
val carriedAttachments = run { val carriedAttachments = run {
val byId = prior?.attachments.orEmpty().filter { it.relayToken == null } val persistedImagePaths = persistedImages.paths.toHashSet()
val byId = prior?.attachments.orEmpty().filter { attachment ->
attachment.relayToken == null ||
(
role == MessageRole.USER &&
attachment.relayToken in persistedImagePaths
)
}
when { when {
byId.isNotEmpty() -> byId byId.isNotEmpty() -> byId
role == MessageRole.USER -> role == MessageRole.USER ->
@@ -1295,6 +1394,15 @@ class ChatHandler {
else -> emptyList() else -> emptyList()
} }
} }
if (
role == MessageRole.USER &&
carriedAttachments.isEmpty() &&
persistedImages.paths.isNotEmpty()
) {
persistedImages.paths.forEach { path ->
pendingPersistedUserImages += messageId to path
}
}
// Server reasoning is authoritative when present; absent, keep the // Server reasoning is authoritative when present; absent, keep the
// live-streamed thinking rather than blanking it on reload. // live-streamed thinking rather than blanking it on reload.
val serverThinking = val serverThinking =
@@ -1339,6 +1447,10 @@ class ChatHandler {
} else { } else {
prior.badges prior.badges
}, },
// Keep sanitized advisor state while reconciling a still-live
// row, but clear it once completion made history authoritative.
// The server transcript never becomes the source of these blocks.
moaReferences = if (prior.isStreaming) prior.moaReferences else emptyList(),
) )
} else { } else {
// INSERT — a server message with no local row yet. Built from // INSERT — a server message with no local row yet. Built from
@@ -1431,6 +1543,37 @@ class ChatHandler {
} }
} }
} }
for ((messageId, path) in pendingPersistedUserImages) {
onPersistedUserImageRequested(messageId, path)
}
}
/**
* Collapse replayed visible history rows by their authoritative message id.
*
* Replacing the value at its first-seen slot preserves transcript ordering;
* the last repeated value wins so a later, more complete snapshot is not lost.
* Null ids cannot be proven identical and therefore remain separate rows.
*/
private fun coalesceRenderedHistoryItems(items: List<MessageItem>): List<MessageItem> {
val firstSlotById = HashMap<String, Int>()
val coalesced = ArrayList<MessageItem>(items.size)
for (item in items) {
if (renderedRoleOf(item) == null) continue
val id = item.id
if (id == null) {
coalesced += item
continue
}
val existingSlot = firstSlotById[id]
if (existingSlot == null) {
firstSlotById[id] = coalesced.size
coalesced += item
} else {
coalesced[existingSlot] = item
}
}
return coalesced
} }
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */ /** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
@@ -1496,19 +1639,54 @@ class ChatHandler {
* [loadMessageHistory] so reconciliation only adopts ids onto rows that * [loadMessageHistory] so reconciliation only adopts ids onto rows that
* actually render. * actually render.
*/ */
private fun renderedRoleOf(item: MessageItem): MessageRole? = when (item.role) { private fun renderedRoleOf(item: MessageItem): MessageRole? =
"user" -> MessageRole.USER when (item.displayKind?.trim()?.lowercase()) {
"assistant" -> MessageRole.ASSISTANT "hidden" -> null
"system" -> "model_switch", "async_delegation_complete", "auto_continue" -> MessageRole.SYSTEM
if (!showSystemMarkers && else -> when (item.role) {
item.contentText?.trimStart()?.startsWith("[System:") == true "user" -> MessageRole.USER
) { "assistant" -> MessageRole.ASSISTANT
null "system" ->
} else { if (!showSystemMarkers &&
MessageRole.SYSTEM item.contentText?.trimStart()?.startsWith("[System:") == true
) {
null
} else {
MessageRole.SYSTEM
}
else -> null
} }
else -> null }
}
private fun displayEventContent(displayKind: String?, metadata: JsonObject?): String? =
when (displayKind) {
"model_switch" -> {
val model = metadata.stringField("model")
?: metadata.stringField("to_model")
?: metadata.stringField("target_model")
if (model.isNullOrBlank()) "Model changed" else "Model changed to $model"
}
"async_delegation_complete" -> {
val count = metadata.intField("task_count")
?: metadata.intField("tasks")
?: metadata.intField("count")
when (count) {
null -> "Background work completed"
1 -> "1 background task completed"
else -> "$count background tasks completed"
}
}
"auto_continue" -> "Continued after an interrupted turn"
else -> null
}
private fun JsonObject?.stringField(key: String): String? =
(this?.get(key) as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
private fun JsonObject?.intField(key: String): Int? =
(this?.get(key) as? JsonPrimitive)?.let { primitive ->
primitive.contentOrNull?.toIntOrNull()
}
/** /**
* Normalize content for reconciliation matching: strip `MEDIA:`/`CARD:` marker * Normalize content for reconciliation matching: strip `MEDIA:`/`CARD:` marker
@@ -1525,6 +1703,7 @@ class ChatHandler {
val t = line.trim() val t = line.trim()
if (t.isEmpty()) continue if (t.isEmpty()) continue
if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue
if (PersistedImageReferenceParser.parse(t).paths.isNotEmpty()) continue
if (cardMarkerRegex.containsMatchIn(t)) continue if (cardMarkerRegex.containsMatchIn(t)) continue
if (sb.isNotEmpty()) sb.append('\n') if (sb.isNotEmpty()) sb.append('\n')
sb.append(t) sb.append(t)
@@ -1682,7 +1861,10 @@ class ChatHandler {
// sessions as "Untitled" in the drawer (issue #133). Preserve the known // sessions as "Untitled" in the drawer (issue #133). Preserve the known
// local title whenever the server hasn't supplied a non-blank one. // local title whenever the server hasn't supplied a non-blank one.
val existingById = _sessions.value.associateBy { it.sessionId } val existingById = _sessions.value.associateBy { it.sessionId }
val mapped = items.map { item -> // The drawer is always composed, even while closed, and keys rows by
// session id. A refresh race or duplicated upstream row must not put
// the same key into Compose's LazyColumn.
val mapped = items.distinctBy { it.id }.map { item ->
val startedAtMs = timestampToMillis(item.startedAt) val startedAtMs = timestampToMillis(item.startedAt)
val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity) val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity)
val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs) val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs)
@@ -1710,6 +1892,7 @@ class ChatHandler {
// SessionItem; the other ChatSession() call sites are local optimistic // SessionItem; the other ChatSession() call sites are local optimistic
// rows (default source). (ADR 12 — Threads surface, slice 1.) // rows (default source). (ADR 12 — Threads surface, slice 1.)
source = item.source, source = item.source,
hasModelConfig = item.hasModelConfig,
) )
}.sortedByDescending { it.activityTimestamp } }.sortedByDescending { it.activityTimestamp }
// Preserve the active session's optimistic row when the server list // Preserve the active session's optimistic row when the server list
@@ -1760,7 +1943,15 @@ class ChatHandler {
* Add a newly created session to the list. * Add a newly created session to the list.
*/ */
fun addSession(session: ChatSession) { fun addSession(session: ChatSession) {
_sessions.update { listOf(session) + it } // Gateway onSessionId and an overlapping REST refresh can both publish
// the same freshly-created session. Treat this as an idempotent upsert,
// preferring an existing server-enriched row while collapsing any
// duplicates that were already present.
_sessions.update { current ->
val existing = current.firstOrNull { it.sessionId == session.sessionId }
listOf(existing ?: session) +
current.filterNot { it.sessionId == session.sessionId }
}
} }
// --- SSE streaming event entry points --- // --- SSE streaming event entry points ---
@@ -2542,6 +2733,44 @@ class ChatHandler {
// --- Gateway subagent lanes --- // --- Gateway subagent lanes ---
fun onMoaReference(messageId: String, event: GatewayMoaReference) {
_messages.update { messages ->
val targetIndex = messages.indexOfLast {
it.id == messageId && it.role == MessageRole.ASSISTANT
}
if (targetIndex < 0) return@update messages
_isStreaming.value = true
val message = messages[targetIndex]
val nextIndex = event.index ?: ((message.moaReferences.maxOfOrNull { it.index } ?: 0) + 1)
if (nextIndex !in 1..MAX_MOA_REFERENCES) return@update messages
val reference = MoaReference(
index = nextIndex,
count = event.count,
label = event.label.take(MAX_MOA_LABEL_CHARS),
text = if (event.available) event.text.take(MAX_MOA_REFERENCE_CHARS) else "",
available = event.available,
)
val existingAtIndex = message.moaReferences.firstOrNull { it.index == nextIndex }
val exactReplay = existingAtIndex == reference
val base = if (nextIndex == 1 && !exactReplay) {
emptyList()
} else {
message.moaReferences
}
if (exactReplay) {
messages
} else {
val upserted = (base.filterNot { it.index == nextIndex } + reference)
.sortedBy(MoaReference::index)
.take(MAX_MOA_REFERENCES)
messages.toMutableList().also {
it[targetIndex] = message.copy(moaReferences = upserted)
}
}
}
}
/** /**
* Lane labels by task index, captured from `subagent.start` (goal * Lane labels by task index, captured from `subagent.start` (goal
* truncated to 60 chars) and stamped onto every child ToolCall so * truncated to 60 chars) and stamped onto every child ToolCall so
@@ -2867,13 +3096,22 @@ class ChatHandler {
} }
_messages.update { messages -> _messages.update { messages ->
messages.map { msg -> messages
if (msg.id == messageId || msg.isStreaming) { .filterNot { msg ->
msg.copy(isStreaming = false, isThinkingStreaming = false) msg.id == messageId &&
} else { msg.role == MessageRole.ASSISTANT &&
msg msg.toolCalls.isEmpty() &&
msg.backgroundTask == null &&
msg.thinkingContent.isBlank() &&
(msg.content.isBlank() || isIntentionalSilenceMarker(msg.content))
}
.map { msg ->
if (msg.id == messageId || msg.isStreaming) {
msg.copy(isStreaming = false, isThinkingStreaming = false)
} else {
msg
}
} }
}
} }
// Post-stream reconciliation: re-scan final content for any annotation // Post-stream reconciliation: re-scan final content for any annotation
@@ -3073,3 +3311,15 @@ internal fun formatPhoneActionResult(
append("Status ${result.status}.") append("Status ${result.status}.")
} }
} }
internal fun isIntentionalSilenceMarker(content: String): Boolean =
when (content.trim().trim('"', '\'', '`').uppercase()) {
"NO_REPLY",
"[NO_REPLY]",
"SILENT",
"[SILENT]",
"<SILENT>",
"(SILENT)",
-> true
else -> false
}
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.auth.buildRawTokenStore
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -49,8 +50,40 @@ data class DashboardStatus(
val authRequired: Boolean, val authRequired: Boolean,
val authProviders: List<String> = emptyList(), val authProviders: List<String> = emptyList(),
val authProviderDetails: List<DashboardAuthProvider> = emptyList(), val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
@SerialName("auth_flows") val authFlows: List<String> = emptyList(),
val version: String? = null, val version: String? = null,
val message: String? = null, val message: String? = null,
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
val profiles: List<String> = emptyList(),
@SerialName("gateway_mode") val gatewayMode: String? = null,
val gateways: List<DashboardGatewayTopology> = emptyList(),
val componentHealth: DashboardComponentHealthRollup = DashboardComponentHealthRollup(),
)
@Serializable
data class DashboardGatewayTopology(
val profile: String,
val ports: Map<String, Int> = emptyMap(),
@SerialName("served_profiles") val servedProfiles: List<String> = emptyList(),
)
@Serializable
data class DashboardComponentHealthRollup(
val supported: Boolean = false,
val overall: String? = null,
val components: List<DashboardComponentHealth> = emptyList(),
)
@Serializable
data class DashboardComponentHealth(
val name: String,
val status: String,
val message: String? = null,
val configured: Int? = null,
val connected: Int? = null,
val healthy: Boolean? = null,
val ok: Boolean? = null,
@SerialName("unhandled_5xx_count_5m") val unhandled5xxCount5m: Int? = null,
) )
@Serializable @Serializable
@@ -92,6 +125,54 @@ data class DashboardChatDisplaySettings(
val toolDisplay: String? = null, val toolDisplay: String? = null,
) )
data class DashboardMcpOAuthFlow(
val flowId: String,
val serverName: String,
val status: String,
val authorizationUrl: String? = null,
val error: String? = null,
) {
val isTerminal: Boolean get() = status == "approved" || status == "error"
}
data class DashboardCustomEndpoint(
val id: String,
val name: String,
val baseUrl: String,
val model: String,
val models: List<String> = emptyList(),
val contextLength: Int? = null,
val discoverModels: Boolean = true,
val hasApiKey: Boolean = false,
val apiKeyPreview: String? = null,
val isCurrent: Boolean = false,
)
data class DashboardCustomEndpoints(
val endpoints: List<DashboardCustomEndpoint>,
val currentProvider: String? = null,
val currentModel: String? = null,
)
data class DashboardCustomEndpointDraft(
val id: String? = null,
val name: String,
val baseUrl: String,
val model: String,
val models: List<String> = emptyList(),
val apiKey: String? = null,
val contextLength: Int? = null,
val discoverModels: Boolean = true,
val makeDefault: Boolean = false,
)
data class DashboardCustomEndpointValidation(
val ok: Boolean,
val reachable: Boolean,
val message: String,
val models: List<String>,
)
/** One entry from `GET /api/audio/elevenlabs/voices` — non-secret voice metadata. */ /** One entry from `GET /api/audio/elevenlabs/voices` — non-secret voice metadata. */
data class ElevenLabsVoice( data class ElevenLabsVoice(
val voiceId: String, val voiceId: String,
@@ -255,6 +336,14 @@ class DashboardApiClient(
*/ */
suspend fun getConfigSchema(): Result<JsonObject> = getJsonObject("/api/config/schema") suspend fun getConfigSchema(): Result<JsonObject> = getJsonObject("/api/config/schema")
/**
* Runtime TTS provider matrix used by upstream's Tools/Capabilities picker.
* This adds plugin provider names and readiness metadata. Command-provider
* IDs remain sourced from the dynamic config-schema enum.
*/
suspend fun getTtsToolsetConfig(): Result<JsonObject> =
getJsonObject("/api/tools/toolsets/tts/config")
/** /**
* Replace the runtime config (`PUT /api/config`). Upstream `save_config` * Replace the runtime config (`PUT /api/config`). Upstream `save_config`
* writes the WHOLE document, so [config] MUST be the full values tree * writes the WHOLE document, so [config] MUST be the full values tree
@@ -466,25 +555,100 @@ class DashboardApiClient(
suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> = suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
deleteJsonObject("/api/cron/jobs/${pathSegment(jobId)}${profileQuery(profile)}") deleteJsonObject("/api/cron/jobs/${pathSegment(jobId)}${profileQuery(profile)}")
suspend fun setMcpServerEnabled(name: String, enabled: Boolean): Result<JsonObject> = suspend fun setMcpServerEnabled(
name: String,
enabled: Boolean,
profile: String? = null,
): Result<JsonObject> =
putJsonObject( putJsonObject(
path = "/api/mcp/servers/${pathSegment(name)}/enabled", path = "/api/mcp/servers/${pathSegment(name)}/enabled${profileQuery(profile)}",
payload = buildJsonObject { put("enabled", enabled) }, payload = buildJsonObject { put("enabled", enabled) },
) )
suspend fun testMcpServer(name: String): Result<JsonObject> = suspend fun testMcpServer(name: String, profile: String? = null): Result<JsonObject> =
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test") postJsonObject("/api/mcp/servers/${pathSegment(name)}/test${profileQuery(profile)}")
suspend fun removeMcpServer(name: String): Result<JsonObject> = suspend fun removeMcpServer(name: String, profile: String? = null): Result<JsonObject> =
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}") deleteJsonObject("/api/mcp/servers/${pathSegment(name)}${profileQuery(profile)}")
suspend fun startMcpOAuth(
name: String,
profile: String? = null,
): Result<DashboardMcpOAuthFlow> =
postJsonObject("/api/mcp/servers/${pathSegment(name)}/auth${profileQuery(profile)}")
.mapCatching(::parseMcpOAuthFlow)
suspend fun getMcpOAuthFlow(flowId: String): Result<DashboardMcpOAuthFlow> =
getJsonObject("/api/mcp/oauth/flows/${pathSegment(flowId)}")
.mapCatching(::parseMcpOAuthFlow)
/**
* Read-only hosted-OAuth capability probe. New dashboards recognize the
* flow-status route and return its canonical expired-flow 404; older
* FastAPI routers return the generic route-level 404. No OAuth worker is
* started and no provider/browser interaction occurs.
*/
suspend fun supportsHostedMcpOAuth(): Result<Boolean> {
val result = getJsonObject("/api/mcp/oauth/flows/__relay_capability_probe_never_a_flow__")
return result.fold(
onSuccess = { Result.success(true) },
onFailure = { error ->
val message = error.message.orEmpty()
when {
message.contains("OAuth flow not found or expired") -> Result.success(true)
message.contains("HTTP 404") -> Result.success(false)
else -> Result.failure(error)
}
},
)
}
suspend fun getCustomEndpoints(): Result<DashboardCustomEndpoints> =
getJsonObject("/api/providers/custom-endpoints")
.mapCatching(::parseCustomEndpoints)
suspend fun saveCustomEndpoint(
draft: DashboardCustomEndpointDraft,
): Result<DashboardCustomEndpoints> =
postJsonObject(
"/api/providers/custom-endpoints",
customEndpointPayload(draft),
).mapCatching(::parseCustomEndpoints)
suspend fun validateCustomEndpoint(
draft: DashboardCustomEndpointDraft,
): Result<DashboardCustomEndpointValidation> =
postJsonObject(
"/api/providers/custom-endpoints/validate",
customEndpointPayload(draft),
).mapCatching { root ->
DashboardCustomEndpointValidation(
ok = root.booleanField("ok") == true,
reachable = root.booleanField("reachable") == true,
message = root.stringField("message").orEmpty(),
models = root.stringList("models"),
)
}
suspend fun activateCustomEndpoint(
id: String,
): Result<JsonObject> =
postJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}/activate")
suspend fun deleteCustomEndpoint(
id: String,
): Result<DashboardCustomEndpoints> =
deleteJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}")
.mapCatching(::parseCustomEndpoints)
suspend fun installMcpCatalogEntry( suspend fun installMcpCatalogEntry(
name: String, name: String,
env: Map<String, String> = emptyMap(), env: Map<String, String> = emptyMap(),
enable: Boolean = true, enable: Boolean = true,
profile: String? = null,
): Result<JsonObject> = ): Result<JsonObject> =
postJsonObject( postJsonObject(
path = "/api/mcp/catalog/install", path = "/api/mcp/catalog/install${profileQuery(profile)}",
payload = buildJsonObject { payload = buildJsonObject {
put("name", name) put("name", name)
put( put(
@@ -929,17 +1093,87 @@ class DashboardApiClient(
return params.joinToString(prefix = "?", separator = "&") return params.joinToString(prefix = "?", separator = "&")
} }
private fun parseMcpOAuthFlow(root: JsonObject): DashboardMcpOAuthFlow {
val flowId = root.stringField("flow_id")
?: throw IOException("MCP OAuth response did not include a flow id")
val status = root.stringField("status")
?: throw IOException("MCP OAuth response did not include a status")
return DashboardMcpOAuthFlow(
flowId = flowId,
serverName = root.stringField("server_name").orEmpty(),
status = status,
authorizationUrl = root.stringField("authorization_url"),
error = root.stringField("error"),
)
}
private fun parseCustomEndpoints(root: JsonObject): DashboardCustomEndpoints {
val current = root["current"] as? JsonObject
val endpoints = (root["endpoints"] as? JsonArray).orEmpty().mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val id = obj.stringField("id") ?: return@mapNotNull null
DashboardCustomEndpoint(
id = id,
name = obj.stringField("name") ?: id,
baseUrl = obj.stringField("base_url").orEmpty(),
model = obj.stringField("model").orEmpty(),
models = obj.stringList("models"),
contextLength = obj.intField("context_length"),
discoverModels = obj.booleanField("discover_models") != false,
hasApiKey = obj.booleanField("has_api_key") == true,
apiKeyPreview = obj.stringField("api_key_preview"),
isCurrent = obj.booleanField("is_current") == true,
)
}
return DashboardCustomEndpoints(
endpoints = endpoints,
currentProvider = current?.stringField("provider"),
currentModel = current?.stringField("model"),
)
}
private fun customEndpointPayload(draft: DashboardCustomEndpointDraft): JsonObject =
buildJsonObject {
draft.id?.takeIf { it.isNotBlank() }?.let { put("id", it) }
put("name", draft.name)
put("base_url", draft.baseUrl)
put("model", draft.model)
draft.models
.asSequence()
.map(String::trim)
.filter(String::isNotBlank)
.distinct()
.take(MAX_CUSTOM_ENDPOINT_MODELS)
.map(::JsonPrimitive)
.toList()
.takeIf { it.isNotEmpty() }
?.let { put("models", JsonArray(it)) }
draft.apiKey?.takeIf { it.isNotBlank() }?.let { put("api_key", it) }
draft.contextLength?.takeIf { it > 0 }?.let { put("context_length", it) }
put("discover_models", draft.discoverModels)
put("make_default", draft.makeDefault)
}
private const val MAX_CUSTOM_ENDPOINT_MODELS = 256
fun defaultClient( fun defaultClient(
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(), cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
): OkHttpClient = OkHttpClient.Builder() bearerAuth: DashboardBearerAuth? = null,
.cookieJar(DashboardCookieJar(cookieStore)) ): OkHttpClient {
.connectTimeout(10, TimeUnit.SECONDS) val builder = OkHttpClient.Builder()
// Skills-hub search fans out server-side with a 30s overall .cookieJar(DashboardCookieJar(cookieStore))
// timeout; keep the read window above it so a slow-but-successful .connectTimeout(10, TimeUnit.SECONDS)
// search doesn't die client-side at the edge. // Skills-hub search fans out server-side with a 30s overall
.readTimeout(45, TimeUnit.SECONDS) // timeout; keep the read window above it so a slow-but-successful
.writeTimeout(30, TimeUnit.SECONDS) // search doesn't die client-side at the edge.
.build() .readTimeout(45, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
bearerAuth?.let {
builder.addInterceptor(it)
builder.authenticator(it)
}
return builder.build()
}
fun parseStatus(root: JsonObject): DashboardStatus { fun parseStatus(root: JsonObject): DashboardStatus {
val authObject = root["auth"] as? JsonObject val authObject = root["auth"] as? JsonObject
@@ -947,14 +1181,70 @@ class DashboardApiClient(
?: root["providers"] ?: root["providers"]
?: authObject?.get("providers") ?: authObject?.get("providers")
val providers = parseProviders(providersElement) val providers = parseProviders(providersElement)
val profiles = (root["profiles"] as? JsonArray).orEmpty().mapNotNull {
(it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotBlank)
}
val gateways = (root["gateways"] as? JsonArray).orEmpty().mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val profile = obj.stringField("profile") ?: return@mapNotNull null
val ports = (obj["ports"] as? JsonObject).orEmpty().mapNotNull { (name, value) ->
(value as? JsonPrimitive)?.contentOrNull?.toIntOrNull()?.let { name to it }
}.toMap()
val served = (obj["served_profiles"] as? JsonArray).orEmpty().mapNotNull {
(it as? JsonPrimitive)?.contentOrNull
}
DashboardGatewayTopology(profile = profile, ports = ports, servedProfiles = served)
}
return DashboardStatus( return DashboardStatus(
authRequired = root.booleanField("auth_required") authRequired = root.booleanField("auth_required")
?: authObject.booleanField("required") ?: authObject.booleanField("required")
?: false, ?: false,
authProviders = providers.map { it.name }, authProviders = providers.map { it.name },
authProviderDetails = providers, authProviderDetails = providers,
authFlows = (root["auth_flows"] as? JsonArray).orEmpty().mapNotNull {
(it as? JsonPrimitive)?.contentOrNull
},
version = root.stringField("version"), version = root.stringField("version"),
message = root.stringField("message") ?: root.stringField("detail"), message = root.stringField("message") ?: root.stringField("detail"),
nousSessionValid = root.stringField("nous_session_valid"),
profiles = profiles,
gatewayMode = root.stringField("gateway_mode"),
gateways = gateways,
componentHealth = parseComponentHealth(root),
)
}
private fun parseComponentHealth(root: JsonObject): DashboardComponentHealthRollup {
val rawComponents = root["components"] as? JsonObject
?: return DashboardComponentHealthRollup()
val components = rawComponents.mapNotNull { (name, element) ->
val component = element as? JsonObject ?: return@mapNotNull null
val safeName = name.trim().takeIf { it.isNotBlank() } ?: return@mapNotNull null
DashboardComponentHealth(
name = safeName,
status = component.stringField("status")
?: component.stringField("state")
?: component.stringField("health")
?: component.booleanField("ok")?.let { if (it) "ok" else "degraded" }
?: component.booleanField("healthy")?.let { if (it) "ok" else "degraded" }
?: "unknown",
message = component.stringField("message")
?: component.stringField("summary")
?: component.stringField("error")
?: component.stringField("reason"),
configured = component.intField("configured"),
connected = component.intField("connected"),
healthy = component.booleanField("healthy"),
ok = component.booleanField("ok"),
unhandled5xxCount5m = component.intField("unhandled_5xx_count_5m"),
)
}.sortedBy { it.name }
return DashboardComponentHealthRollup(
supported = true,
overall = root.stringField("overall")
?: root.stringField("status")
?: root.stringField("health"),
components = components,
) )
} }
@@ -1072,6 +1362,35 @@ class DashboardApiClient(
} }
} }
/**
* Bearer credentials are scoped to the exact saved dashboard base, including
* reverse-proxy path prefix. A same-host or arbitrary Add Connection probe is
* not sufficient authority to receive the active connection's token.
*/
fun sameDashboardBase(candidate: String, trusted: String): Boolean {
val candidateUrl = candidate.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
val trustedUrl = trusted.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
return candidateUrl.scheme == trustedUrl.scheme &&
candidateUrl.host == trustedUrl.host &&
candidateUrl.port == trustedUrl.port &&
candidateUrl.encodedPath.trimEnd('/') == trustedUrl.encodedPath.trimEnd('/') &&
candidateUrl.query == null &&
trustedUrl.query == null
}
fun trustedDashboardBearerAuthOrNull(
candidate: String,
trusted: String,
tokenStoreProvider: () -> NativeDashboardTokenStore,
): DashboardBearerAuth? =
if (isNativeDashboardTransportEligible(candidate) &&
sameDashboardBase(candidate, trusted)
) {
DashboardBearerAuth(candidate, tokenStoreProvider())
} else {
null
}
interface DashboardCookieStore { interface DashboardCookieStore {
fun load(): List<StoredDashboardCookie> fun load(): List<StoredDashboardCookie>
fun save(cookies: List<StoredDashboardCookie>) fun save(cookies: List<StoredDashboardCookie>)
@@ -1203,6 +1522,61 @@ class DashboardCookieJar(
} }
} }
/**
* Copy only Hermes' authenticated dashboard session cookies to another host
* that belongs to the same saved Connection. Dashboard cookies are host-only
* by design, while a Connection may reach one server through LAN and
* Tailscale hostnames/IPs. The encrypted store remains the source of truth and
* explicit sign-out clears every mirrored host together.
*
* PKCE, SSO-attempt, and unrelated application cookies are intentionally not
* copied. Secure cookies also remain Secure; this helper never downgrades them
* for an HTTP route.
*/
fun mirrorDashboardSessionCookies(
store: DashboardCookieStore,
targetUrl: String,
trustedHosts: Set<String>,
clockMillis: () -> Long = { System.currentTimeMillis() },
): Int {
val targetHost = targetUrl.toHttpUrlOrNull()?.host?.lowercase() ?: return 0
val allowedHosts = trustedHosts.mapTo(mutableSetOf()) { it.lowercase() }
if (targetHost !in allowedHosts) return 0
val now = clockMillis()
val all = store.load()
val live = all.filterNot { it.isExpired(now) }
val existingTargetKeys = live.asSequence()
.filter { it.domain.equals(targetHost, ignoreCase = true) }
.map { "${it.name.lowercase()}|$targetHost|${it.path}" }
.toSet()
val mirrored = live.asSequence()
.filter { it.isDashboardSessionCookie() }
.filter { it.domain.lowercase() in allowedHosts }
.filterNot { it.domain.equals(targetHost, ignoreCase = true) }
.groupBy { "${it.name.lowercase()}|${it.path}" }
.values
.mapNotNull { candidates -> candidates.maxByOrNull { it.expiresAt } }
.map { it.copy(domain = targetHost, hostOnly = true) }
.filterNot { it.key in existingTargetKeys }
.toList()
if (mirrored.isNotEmpty() || live.size != all.size) {
store.save(live + mirrored)
}
return mirrored.size
}
private fun StoredDashboardCookie.isDashboardSessionCookie(): Boolean {
val bareName = name
.removePrefix("__Host-")
.removePrefix("__Secure-")
return bareName == "hermes_session" ||
bareName == "hermes_session_at" ||
bareName == "hermes_session_rt" ||
bareName == "hermes_session_provider"
}
/** /**
* Cookie jar that resolves the backing per-connection store at request time. * Cookie jar that resolves the backing per-connection store at request time.
* *
@@ -1353,3 +1727,8 @@ private fun JsonObject?.booleanField(name: String): Boolean? =
private fun JsonObject?.intField(name: String): Int? = private fun JsonObject?.intField(name: String): Int? =
(this?.get(name) as? JsonPrimitive)?.contentOrNull?.toIntOrNull() (this?.get(name) as? JsonPrimitive)?.contentOrNull?.toIntOrNull()
private fun JsonObject?.stringList(name: String): List<String> =
(this?.get(name) as? JsonArray).orEmpty().mapNotNull { element ->
(element as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
}
@@ -84,7 +84,44 @@ fun parseConfigSchema(schemaRoot: JsonObject): List<ConfigSchemaField> {
* `category` field so it is robust to upstream's category-merging. * `category` field so it is robust to upstream's category-merging.
*/ */
fun voiceConfigFields(fields: List<ConfigSchemaField>): List<ConfigSchemaField> = fun voiceConfigFields(fields: List<ConfigSchemaField>): List<ConfigSchemaField> =
fields.filter { it.key.startsWith("tts.") || it.key.startsWith("stt.") } fields.filter {
it.key.startsWith("tts.") ||
it.key.startsWith("stt.") ||
it.key.startsWith("voice.")
}
/** A TTS provider advertised by upstream's `hermes tools` provider registry. */
data class TtsToolsetProvider(
val id: String,
val name: String,
val status: String? = null,
val isActive: Boolean = false,
)
/**
* Parse `GET /api/tools/toolsets/tts/config` into provider choices.
*
* Unlike the config-schema enum, this payload adds readiness metadata and
* reliably discovered plugin providers. Command providers remain schema-owned.
* Older upstream builds may omit `tts_provider`;
* those rows are ignored because their picker label is not a stable config ID.
*/
fun parseTtsToolsetProviders(root: JsonObject): List<TtsToolsetProvider> {
val providers = root["providers"] as? JsonArray ?: return emptyList()
return providers.mapNotNull { element ->
val provider = element as? JsonObject ?: return@mapNotNull null
val id = provider.configString("tts_provider")
?.trim()
?.takeIf { it.isNotEmpty() }
?: return@mapNotNull null
TtsToolsetProvider(
id = id,
name = provider.configString("name")?.takeIf { it.isNotBlank() } ?: id,
status = provider.configString("status"),
isActive = (provider["is_active"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull() ?: false,
)
}.distinctBy { it.id }
}
/** Read the value at a dot-path from the nested config values tree, or null. */ /** Read the value at a dot-path from the nested config values tree, or null. */
fun configValueAt(tree: JsonObject, dotPath: String): JsonElement? { fun configValueAt(tree: JsonObject, dotPath: String): JsonElement? {
File diff suppressed because it is too large Load Diff
@@ -31,13 +31,25 @@ class GatewayEventMapper(
var turnEnded: Boolean = false var turnEnded: Boolean = false
private set private set
internal val currentInteraction: GatewayAsk?
get() = pendingInteraction
internal fun restoreInteraction(ask: GatewayAsk) {
val duplicate = pendingInteraction?.sameRequestAs(ask) == true
pendingInteraction = ask
if (!duplicate) callbacks.onInteractionRequest(ask)
}
private var sawMessageStart = false private var sawMessageStart = false
private var previousEventType: String? = null private var previousEventType: String? = null
private var sawTextDelta = false private var sawTextDelta = false
private var sawThinkingDelta = false private var sawThinkingDelta = false
private var previewedText: String? = null
private var syntheticToolCounter = 0 private var syntheticToolCounter = 0
private var providerWaitStatusActive = false private var providerWaitStatusActive = false
private var compactionStatusActive = false private var compactionStatusActive = false
private var moaStatusActive = false
private var pendingInteraction: GatewayAsk? = null
/** /**
* `tool.complete` events match their `tool.start` by `tool_id`; when a * `tool.complete` events match their `tool.start` by `tool_id`; when a
@@ -56,6 +68,30 @@ class GatewayEventMapper(
fun onEvent(type: String, payload: JsonObject?) { fun onEvent(type: String, payload: JsonObject?) {
if (turnEnded) return if (turnEnded) return
interactionRequest(type, payload)?.let { ask ->
restoreInteraction(ask)
previousEventType = type
return
}
interactionExpiry(type, payload)?.let { expiry ->
val pending = pendingInteraction
if (pending != null && pending.matches(expiry)) {
pendingInteraction = null
}
callbacks.onInteractionExpired(expiry)
previousEventType = type
return
}
if (type in INTERACTION_RESUME_EVENTS) {
pendingInteraction?.let { ask ->
pendingInteraction = null
callbacks.onInteractionResolved(
GatewayAskExpiry(kind = ask.kind, requestId = ask.requestId),
)
}
}
when (type) { when (type) {
"reasoning.delta" -> { "reasoning.delta" -> {
val text = payload.string("text") val text = payload.string("text")
@@ -95,13 +131,30 @@ class GatewayEventMapper(
"message.delta" -> { "message.delta" -> {
val text = payload.string("text") val text = payload.string("text")
if (!text.isNullOrEmpty()) { if (!text.isNullOrEmpty() && !isIntentionalSilenceMarker(text)) {
clearActivityStatuses() clearActivityStatuses()
sawTextDelta = true sawTextDelta = true
previewedText = null
callbacks.onTextDelta(text) callbacks.onTextDelta(text)
} }
} }
"message.interim" -> {
val text = payload.string("text") ?: payload.string("message")
?: payload.string("preview") ?: payload.string("rendered")
val alreadyStreamed = payload.boolean("already_streamed") == true
if (!text.isNullOrBlank() || alreadyStreamed) {
clearActivityStatuses()
if (!sawMessageStart) {
sawMessageStart = true
callbacks.onStart()
}
callbacks.onInterimMessage(text.orEmpty(), alreadyStreamed)
previewedText = text
sawTextDelta = alreadyStreamed
}
}
"message.start" -> { "message.start" -> {
// The upstream background-completion poller currently emits // The upstream background-completion poller currently emits
// message.start immediately before _run_prompt_submit(), which // message.start immediately before _run_prompt_submit(), which
@@ -114,6 +167,7 @@ class GatewayEventMapper(
// assistant message began — close out the previous one. // assistant message began — close out the previous one.
if (sawMessageStart) callbacks.onTurnComplete() if (sawMessageStart) callbacks.onTurnComplete()
sawMessageStart = true sawMessageStart = true
previewedText = null
callbacks.onStart() callbacks.onStart()
} }
@@ -163,8 +217,21 @@ class GatewayEventMapper(
"message.complete" -> { "message.complete" -> {
// Non-streaming servers (or error turns) deliver everything // Non-streaming servers (or error turns) deliver everything
// here; backfill whatever never streamed. // here; backfill whatever never streamed.
val failed = payload.string("status").equals(ERROR_STATUS_KIND, ignoreCase = true)
val error = payload.string("error")
val text = payload.string("text") val text = payload.string("text")
if (!sawTextDelta && !text.isNullOrEmpty()) { ?: error?.takeIf { failed }?.let { "Error: $it" }
val reconcilesInterim = !text.isNullOrEmpty() &&
previewedText?.let { preview ->
preview.isNotEmpty() &&
(text.startsWith(preview) || preview.startsWith(text))
} == true
if (reconcilesInterim) {
callbacks.onInterimReconciled(text)
} else if (!text.isNullOrEmpty() &&
!isIntentionalSilenceMarker(text) &&
(!sawTextDelta || previewedText != null)
) {
callbacks.onTextDelta(text) callbacks.onTextDelta(text)
} }
val reasoning = payload.string("reasoning") val reasoning = payload.string("reasoning")
@@ -172,6 +239,12 @@ class GatewayEventMapper(
callbacks.onThinkingDelta(reasoning) callbacks.onThinkingDelta(reasoning)
} }
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject)) callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
if (failed) {
callbacks.onStatusUpdate(
ERROR_STATUS_KIND,
error?.takeIf { it.isNotBlank() } ?: text.orEmpty().ifBlank { "Turn failed" },
)
}
turnEnded = true turnEnded = true
callbacks.onComplete() callbacks.onComplete()
} }
@@ -209,36 +282,6 @@ class GatewayEventMapper(
) )
} }
"clarify.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
text = payload.string("question") ?: "The agent needs clarification",
choices = (payload?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
?.takeIf { it.isNotEmpty() },
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
),
)
"approval.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.APPROVAL,
// Upstream approvals correlate per-SESSION, never
// per-request — a stray request_id must not be adopted.
requestId = null,
text = listOfNotNull(payload.string("command"), payload.string("description"))
.joinToString(" — ")
.ifBlank { "a command approval" },
choices = payload.approvalChoices(),
smartDenied = payload.boolean("smart_denied") == true,
// Current Hermes omits timeout metadata. Keep the legacy
// no-countdown behavior unless a future contract exposes
// the effective per-request timeout explicitly.
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
),
)
"tool.output_risk" -> { "tool.output_risk" -> {
val toolId = payload.string("tool_id") val toolId = payload.string("tool_id")
val risk = payload.string("risk")?.lowercase() ?: return val risk = payload.string("risk")?.lowercase() ?: return
@@ -259,52 +302,57 @@ class GatewayEventMapper(
} }
} }
// MoA activity proves auto-compaction has resumed even though "moa.reference" -> {
// Android does not currently render these upstream events. clearProviderWaitAndCompaction()
"moa.reference", "moa.aggregating", "tool.progress" -> clearActivityStatuses() val text = payload.string("text")?.trim().orEmpty()
if (text.isNotEmpty()) {
val available = !isFailedMoaReference(text)
callbacks.onMoaReference(
GatewayMoaReference(
index = payload.int("index")?.takeIf { it > 0 },
count = payload.int("count")
?.takeIf { it > 0 },
label = payload.string("label")?.trim()?.take(MAX_MOA_LABEL_CHARS).orEmpty()
.ifBlank { "Advisor" },
text = if (available) text.take(MAX_MOA_REFERENCE_CHARS) else "",
available = available,
),
)
}
}
"sudo.request" -> callbacks.onInteractionRequest( "moa.progress" -> {
GatewayAsk( clearProviderWaitAndCompaction()
kind = GatewayAsk.Kind.SUDO, val total = payload.int("refs_total")
requestId = payload.string("request_id"), ?.takeIf { it > 0 }
// Payload carries request_id ONLY — no command to show. val done = payload.int("refs_done")
text = "Elevated permissions requested", if (total != null && done != null) {
timeoutSeconds = SUDO_TIMEOUT_SECONDS, setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
), }
) }
"secret.request" -> callbacks.onInteractionRequest( "moa.phase" -> {
GatewayAsk( clearProviderWaitAndCompaction()
kind = GatewayAsk.Kind.SECRET, when (payload.string("phase")?.lowercase()) {
requestId = payload.string("request_id"), "aggregator", "aggregating" -> setMoaStatus("MoA: aggregating…")
text = payload.string("prompt") ?: "The agent needs a secret value", "reference", "references" -> {
envVar = payload.string("env_var"), val total = payload.int("refs_total")
timeoutSeconds = SECRET_TIMEOUT_SECONDS, ?.takeIf { it > 0 }
), val done = payload.int("refs_done")
) if (total != null && done != null) {
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
}
}
}
}
"sudo.expire" -> callbacks.onInteractionExpired( // Legacy phase marker retained by upstream for older consumers.
GatewayAskExpiry( "moa.aggregating" -> {
kind = GatewayAsk.Kind.SUDO, clearProviderWaitAndCompaction()
requestId = payload.string("request_id"), setMoaStatus("MoA: aggregating…")
), }
)
"secret.expire" -> callbacks.onInteractionExpired( "tool.progress" -> clearActivityStatuses()
GatewayAskExpiry(
kind = GatewayAsk.Kind.SECRET,
requestId = payload.string("request_id"),
),
)
// Forward-compatible consumer for the proposed upstream approval
// expiry event. Approvals correlate by session, never request id.
"approval.expire" -> callbacks.onInteractionExpired(
GatewayAskExpiry(
kind = GatewayAsk.Kind.APPROVAL,
requestId = null,
),
)
"status.update" -> { "status.update" -> {
val text = payload.string("text") val text = payload.string("text")
@@ -336,27 +384,120 @@ class GatewayEventMapper(
} }
private fun clearActivityStatuses() { private fun clearActivityStatuses() {
clearProviderWaitAndCompaction()
if (!moaStatusActive) return
moaStatusActive = false
callbacks.onStatusClear(MOA_STATUS_KIND)
}
private fun clearProviderWaitAndCompaction() {
clearProviderWaitStatus() clearProviderWaitStatus()
if (!compactionStatusActive) return if (!compactionStatusActive) return
compactionStatusActive = false compactionStatusActive = false
callbacks.onStatusClear(COMPACTION_STATUS_KIND) callbacks.onStatusClear(COMPACTION_STATUS_KIND)
} }
private fun JsonObject?.approvalChoices(): List<String>? = private fun setMoaStatus(text: String) {
(this?.get("choices") as? JsonArray) moaStatusActive = true
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() } callbacks.onStatusUpdate(MOA_STATUS_KIND, text)
?.filter { it in APPROVAL_CHOICES } }
?.distinct()
?.takeIf { it.isNotEmpty() }
private fun JsonObject?.boolean(key: String): Boolean? =
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
companion object { companion object {
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait" const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
const val COMPACTION_STATUS_KIND = "compacting" const val COMPACTION_STATUS_KIND = "compacting"
private val APPROVAL_CHOICES = setOf("once", "session", "always", "deny") const val ERROR_STATUS_KIND = "error"
const val MOA_STATUS_KIND = "moa"
private const val MAX_MOA_LABEL_CHARS = 120
private const val MAX_MOA_REFERENCE_CHARS = 16_000
private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical") private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
private val INTERACTION_RESUME_EVENTS = setOf(
"reasoning.delta",
"thinking.delta",
"reasoning.available",
"message.delta",
"message.interim",
"message.start",
"tool.generating",
"tool.start",
"tool.complete",
"message.complete",
"error",
)
internal fun isFailedMoaReference(text: String): Boolean {
val normalized = text.trimStart().lowercase()
return normalized.startsWith("[failed:") || normalized.startsWith("[skipped:")
}
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
"clarify.request" -> GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
text = payload.string("question") ?: "The agent needs clarification",
choices = (payload?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
?.takeIf { it.isNotEmpty() },
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
)
"approval.request" -> GatewayAsk(
kind = GatewayAsk.Kind.APPROVAL,
// Upstream approvals correlate per-SESSION, never
// per-request — a stray request_id must not be adopted.
requestId = null,
text = listOfNotNull(payload.string("command"), payload.string("description"))
.joinToString(" — ")
.ifBlank { "a command approval" },
choices = payload.approvalChoices(),
smartDenied = payload.boolean("smart_denied") == true,
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
)
"sudo.request" -> GatewayAsk(
kind = GatewayAsk.Kind.SUDO,
requestId = payload.string("request_id"),
text = "Elevated permissions requested",
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
)
"secret.request" -> GatewayAsk(
kind = GatewayAsk.Kind.SECRET,
requestId = payload.string("request_id"),
text = payload.string("prompt") ?: "The agent needs a secret value",
envVar = payload.string("env_var"),
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
)
else -> null
}
fun interactionExpiry(type: String, payload: JsonObject?): GatewayAskExpiry? = when (type) {
"clarify.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
)
"sudo.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.SUDO,
requestId = payload.string("request_id"),
)
"secret.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.SECRET,
requestId = payload.string("request_id"),
)
// Forward-compatible consumer for a future upstream approval
// expiry event. Approvals correlate by session, never request id.
"approval.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.APPROVAL,
requestId = null,
)
else -> null
}
fun isInteractionResumeEvent(type: String): Boolean = type in INTERACTION_RESUME_EVENTS
/** /**
* Hermes 2026-07-15 emits these operational wait lines through the * Hermes 2026-07-15 emits these operational wait lines through the
@@ -422,3 +563,20 @@ private fun JsonObject?.int(key: String): Int? =
private fun JsonObject?.double(key: String): Double? = private fun JsonObject?.double(key: String): Double? =
(this?.get(key) as? JsonPrimitive)?.doubleOrNull (this?.get(key) as? JsonPrimitive)?.doubleOrNull
private fun JsonObject?.boolean(key: String): Boolean? =
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
private fun JsonObject?.approvalChoices(): List<String>? =
(this?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
?.filter { it in setOf("once", "session", "always", "deny") }
?.distinct()
?.takeIf { it.isNotEmpty() }
private fun GatewayAsk.sameRequestAs(other: GatewayAsk): Boolean =
kind == other.kind && requestId == other.requestId
private fun GatewayAsk.matches(expiry: GatewayAskExpiry): Boolean =
kind == expiry.kind &&
(kind == GatewayAsk.Kind.APPROVAL || requestId == expiry.requestId)
@@ -22,8 +22,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* Opt-in foreground service that holds the app process up so the app's * Foreground service that holds the app process up so work the user already
* connection to Hermes survives Android's background-freeze / Doze — i.e. * started survives Android's background-freeze / Doze. It runs automatically
* while one or more turns are active, or continuously when the user enables
* "persistent connection". Concretely it keeps the gateway chat WebSocket * "persistent connection". Concretely it keeps the gateway chat WebSocket
* (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s * (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
* [GatewayChatClient]) open; for relay-paired setups, holding the whole * [GatewayChatClient]) open; for relay-paired setups, holding the whole
@@ -39,13 +40,14 @@ import kotlinx.coroutines.launch
* connection use case Google Play permits. The `specialUse` type is honest for * connection use case Google Play permits. The `specialUse` type is honest for
* an always-on connection (`dataSync` is force-stopped after a 6h/day cap on * an always-on connection (`dataSync` is force-stopped after a 6h/day cap on
* SDK 35) but requires a one-time Play Console foreground-service declaration * SDK 35) but requires a one-time Play Console foreground-service declaration
* at submission. Off by default; only runs while the user enables the toggle. * at submission. Continuous idle retention is off by default; active work is
* protected automatically and releases its lease on terminal settlement.
* *
* # It does NOT own the socket * # It does NOT own the socket
* *
* The service's only job is to hold the process in the foreground. The socket * The service's only job is to hold the process in the foreground. The socket
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its * stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
* idle-close timer while the toggle is on. On task removal (user swipes the app * idle-close timer while retention is required. On task removal (user swipes the app
* away) the ViewModel + socket die with the process, so the service stops * away) the ViewModel + socket die with the process, so the service stops
* itself rather than leave a notification that lies about being connected. * itself rather than leave a notification that lies about being connected.
* *
@@ -63,9 +65,31 @@ class GatewayKeepAliveService : Service() {
private const val CHANNEL_NAME = "Persistent connection" private const val CHANNEL_NAME = "Persistent connection"
const val NOTIFICATION_ID = 4713 const val NOTIFICATION_ID = 4713
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP" const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
private const val ACTION_REFRESH = "com.hermesandroid.relay.gateway.KEEPALIVE_REFRESH"
private const val EXTRA_PERSISTENT = "persistent"
private const val EXTRA_ACTIVE_TURNS = "active_turns"
private const val EXTRA_WAITING_SESSIONS = "waiting_sessions"
@Volatile private var runningInstance: GatewayKeepAliveService? = null
fun start(context: Context) { fun update(
context: Context,
persistent: Boolean,
activeTurns: ActiveTurnKeepAliveRegistry.Snapshot,
) {
if (!persistent && !activeTurns.required) {
stop(context)
return
}
runningInstance?.let { service ->
service.applyState(persistent, activeTurns)
service.startForegroundNotification()
return
}
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java) val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
.setAction(ACTION_REFRESH)
.putExtra(EXTRA_PERSISTENT, persistent)
.putExtra(EXTRA_ACTIVE_TURNS, activeTurns.activeTurnCount)
.putExtra(EXTRA_WAITING_SESSIONS, activeTurns.waitingSessionCount)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.applicationContext.startForegroundService(intent) context.applicationContext.startForegroundService(intent)
} else { } else {
@@ -83,21 +107,38 @@ class GatewayKeepAliveService : Service() {
} }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var persistent = false
private var activeTurns = 0
private var waitingSessions = 0
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
runningInstance = this
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_REFRESH) {
persistent = intent.getBooleanExtra(EXTRA_PERSISTENT, false)
activeTurns = intent.getIntExtra(EXTRA_ACTIVE_TURNS, 0).coerceAtLeast(0)
waitingSessions = intent.getIntExtra(EXTRA_WAITING_SESSIONS, 0)
.coerceIn(0, activeTurns)
}
startForegroundNotification() startForegroundNotification()
if (intent?.action == ACTION_STOP) { if (intent?.action == ACTION_STOP) {
Log.i(TAG, "ACTION_STOP → user dismissed background connection") Log.i(TAG, "ACTION_STOP → user disabled continuous background connection")
// Flip the pref off so ConnectionViewModel's collector won't
// restart us on the next foreground.
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } } scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
stopForeground(STOP_FOREGROUND_REMOVE) persistent = false
stopSelf() if (activeTurns == 0) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
startForegroundNotification()
}
return START_NOT_STICKY return START_NOT_STICKY
} }
return START_STICKY return START_NOT_STICKY
} }
override fun onTaskRemoved(rootIntent: Intent?) { override fun onTaskRemoved(rootIntent: Intent?) {
@@ -105,15 +146,26 @@ class GatewayKeepAliveService : Service() {
// The socket lives in the ViewModel, which dies when the task is // The socket lives in the ViewModel, which dies when the task is
// removed — keeping the notification would be a lie. Stop cleanly. // removed — keeping the notification would be a lie. Stop cleanly.
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive") Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
ActiveTurnKeepAliveRegistry.releaseAll()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
override fun onDestroy() { override fun onDestroy() {
if (runningInstance === this) runningInstance = null
scope.cancel() scope.cancel()
super.onDestroy() super.onDestroy()
} }
private fun applyState(
persistent: Boolean,
turns: ActiveTurnKeepAliveRegistry.Snapshot,
) {
this.persistent = persistent
activeTurns = turns.activeTurnCount
waitingSessions = turns.waitingSessionCount.coerceIn(0, activeTurns)
}
// The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission // The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission
// are all declared in the main manifest (both flavors), so the type is // are all declared in the main manifest (both flavors), so the type is
// satisfied. Suppress retained defensively — lint's ForegroundServiceType // satisfied. Suppress retained defensively — lint's ForegroundServiceType
@@ -148,17 +200,42 @@ class GatewayKeepAliveService : Service() {
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP) val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags) val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
return NotificationCompat.Builder(this, CHANNEL_ID) val (title, body) = when {
waitingSessions > 0 -> {
val title = if (waitingSessions == 1) {
"Hermes is waiting for input"
} else {
"$waitingSessions Hermes sessions need input"
}
title to if (activeTurns > waitingSessions) {
"$waitingSessions waiting · ${activeTurns - waitingSessions} still working"
} else {
"Open the requested session to review and continue."
}
}
activeTurns > 0 -> {
val title = if (activeTurns == 1) {
"Hermes is finishing a turn"
} else {
"Hermes is finishing $activeTurns turns"
}
title to "The connection stays active until this work completes."
}
else -> getString(R.string.gateway_keepalive_title) to
getString(R.string.gateway_keepalive_body)
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.gateway_keepalive_title)) .setContentTitle(title)
.setContentText(getString(R.string.gateway_keepalive_body)) .setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(tapPending) .setContentIntent(tapPending)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE) .setCategory(NotificationCompat.CATEGORY_SERVICE)
.addAction(0, "Turn off", stopPending) if (persistent) builder.addAction(0, "Turn off always-on", stopPending)
.build() return builder.build()
} }
private fun ensureChannel() { private fun ensureChannel() {
@@ -50,6 +50,29 @@ enum class GatewayConnectionState {
Ready, Ready,
} }
/** Profile-persisted approval policy introduced by upstream gateway contract v3. */
enum class GatewayApprovalMode(val wireValue: String) {
Manual("manual"),
Smart("smart"),
Off("off");
companion object {
fun fromWire(value: String?): GatewayApprovalMode? = when (value?.trim()?.lowercase()) {
"manual" -> Manual
"smart" -> Smart
"off" -> Off
else -> null
}
}
}
/** Whether this gateway exposes the contract-v3 profile approval-mode RPCs. */
enum class GatewayApprovalModeCapability {
Unknown,
Supported,
Unsupported,
}
/** /**
* Streaming-endpoint resolution with the gateway tier — pure so the matrix * Streaming-endpoint resolution with the gateway tier — pure so the matrix
* is unit-testable without an AndroidViewModel. ConnectionViewModel * is unit-testable without an AndroidViewModel. ConnectionViewModel
@@ -57,8 +80,9 @@ enum class GatewayConnectionState {
* *
* Manual picks pass through untouched (ChatViewModel handles per-turn * Manual picks pass through untouched (ChatViewModel handles per-turn
* fallback when a "gateway" pick can't serve a send); "auto" prefers the * fallback when a "gateway" pick can't serve a send); "auto" prefers the
* gateway only when the dashboard probe says [GatewayAvailability.Ready], * gateway while the dashboard probe is unresolved or ready. A capability-
* otherwise it falls back to the capability-preferred SSE endpoint. * preferred SSE fallback is selected only after a definitive unavailable,
* unsupported, or sign-in-required verdict.
*/ */
fun resolveStreamingEndpointPreference( fun resolveStreamingEndpointPreference(
preference: String, preference: String,
@@ -66,7 +90,10 @@ fun resolveStreamingEndpointPreference(
capabilities: ServerCapabilities, capabilities: ServerCapabilities,
): String = when (preference) { ): String = when (preference) {
"sessions", "completions", "runs", "gateway" -> preference "sessions", "completions", "runs", "gateway" -> preference
else -> if (gateway == GatewayAvailability.Ready) { else -> if (
gateway == GatewayAvailability.Ready ||
gateway == GatewayAvailability.Unknown
) {
"gateway" "gateway"
} else { } else {
capabilities.preferredChatEndpoint() capabilities.preferredChatEndpoint()
@@ -95,6 +122,28 @@ data class GatewayInflightTurn(
val user: String, val user: String,
val assistant: String, val assistant: String,
val streaming: Boolean, val streaming: Boolean,
val status: String? = null,
val error: String? = null,
val recoverable: Boolean = false,
)
/** A next-turn prompt accepted by upstream while the current turn was busy. */
data class GatewayQueuedTurn(
val user: String,
)
/** A fresh crash marker caused `session.resume` to schedule one continuation. */
data class GatewayAutoContinue(
val attempt: Int,
val interruptedAt: Double?,
)
/** Optional project identity attached to newer upstream session metadata. */
data class GatewaySessionProject(
val id: String?,
val slug: String?,
val name: String,
val primaryPath: String?,
) )
/** Result of reattaching Android to an existing durable Gateway session. */ /** Result of reattaching Android to an existing durable Gateway session. */
@@ -104,9 +153,15 @@ data class GatewaySessionRecovery(
val running: Boolean, val running: Boolean,
val status: String?, val status: String?,
val inflight: GatewayInflightTurn?, val inflight: GatewayInflightTurn?,
val queued: GatewayQueuedTurn?,
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */ /** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
val handle: ActiveTurnHandle?, val handle: ActiveTurnHandle?,
) val autoContinue: GatewayAutoContinue? = null,
) {
/** Whether upstream still owes this client live turn events. */
val hasPendingWork: Boolean
get() = running || queued != null || autoContinue != null
}
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */ /** A detached sibling turn reached its terminal event on the shared Gateway socket. */
data class GatewayBackgroundTurnCompletion( data class GatewayBackgroundTurnCompletion(
@@ -115,6 +170,25 @@ data class GatewayBackgroundTurnCompletion(
val expectedAssistantText: String?, val expectedAssistantText: String?,
) )
/** Input lifecycle from a deliberately detached Gateway turn. */
sealed interface GatewayBackgroundInteractionEvent {
val storedSessionId: String
val profile: String?
val ask: GatewayAsk
data class Requested(
override val storedSessionId: String,
override val profile: String?,
override val ask: GatewayAsk,
) : GatewayBackgroundInteractionEvent
data class Resolved(
override val storedSessionId: String,
override val profile: String?,
override val ask: GatewayAsk,
) : GatewayBackgroundInteractionEvent
}
/** /**
* One server-side interactive ask. The agent thread upstream is BLOCKED * One server-side interactive ask. The agent thread upstream is BLOCKED
* until the matching respond RPC arrives, the ask times out (resolves to "" * until the matching respond RPC arrives, the ask times out (resolves to ""
@@ -273,6 +347,14 @@ data class GatewayModelProvider(
val totalModels: Int = 0, val totalModels: Int = 0,
) )
data class GatewayMoaReference(
val index: Int?,
val count: Int?,
val label: String,
val text: String,
val available: Boolean = true,
)
/** Result of the gateway `model.options` RPC. */ /** Result of the gateway `model.options` RPC. */
data class GatewayModelOptions( data class GatewayModelOptions(
val providers: List<GatewayModelProvider>, val providers: List<GatewayModelProvider>,
@@ -280,6 +362,15 @@ data class GatewayModelOptions(
val currentProvider: String, val currentProvider: String,
) )
/** Reject provider catalogs that completed after a profile/context switch. */
internal fun isCurrentModelOptionsResponse(
requestGeneration: Long,
currentGeneration: Long,
requestProfileKey: String,
currentProfileKey: String,
): Boolean =
requestGeneration == currentGeneration && requestProfileKey == currentProfileKey
/** /**
* The explicit in-chat overrides to bind onto a gateway `session.create` as the * The explicit in-chat overrides to bind onto a gateway `session.create` as the
* new session's PER-SESSION overrides. Matches the upstream desktop client, * new session's PER-SESSION overrides. Matches the upstream desktop client,
@@ -296,7 +387,9 @@ data class GatewayModelOptions(
* *
* [model] is the model id (e.g. `grok-4.3`); [provider] is the authenticated * [model] is the model id (e.g. `grok-4.3`); [provider] is the authenticated
* provider slug (e.g. `xai`). [reasoningEffort] is the upstream effort string * provider slug (e.g. `xai`). [reasoningEffort] is the upstream effort string
* (`low`/`medium`/`high`/…). [fast] pins the priority service tier when true. * (`low`/`medium`/`high`/…). [fast] follows the contract-v4 tri-state: `true`
* pins priority, `false` explicitly pins normal, and `null` omits the field so
* the profile's service tier is inherited.
* Note `yolo` is intentionally absent — upstream `session.create` does NOT * Note `yolo` is intentionally absent — upstream `session.create` does NOT
* accept it as a per-session override, so it is applied post-create instead. * accept it as a per-session override, so it is applied post-create instead.
*/ */
@@ -328,6 +421,19 @@ class GatewayTurnCallbacks(
/** A gateway `message.start` opened an assistant response for this turn. */ /** A gateway `message.start` opened an assistant response for this turn. */
val onStart: () -> Unit, val onStart: () -> Unit,
val onTextDelta: (String) -> Unit, val onTextDelta: (String) -> Unit,
/**
* Gateway `message.interim` sealed an attempted assistant message before
* the terminal `message.complete`. When [alreadyStreamed] is false, [text]
* has not arrived through `message.delta` and should be rendered before
* sealing the current assistant segment.
*/
val onInterimMessage: (text: String, alreadyStreamed: Boolean) -> Unit = { _, _ -> },
/**
* The terminal text is equal/prefix-related to the sealed interim, so the
* existing segment should be replaced in place instead of opening a second
* assistant bubble.
*/
val onInterimReconciled: (text: String) -> Unit = { _ -> },
val onThinkingDelta: (String) -> Unit, val onThinkingDelta: (String) -> Unit,
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit, val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit, val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
@@ -352,6 +458,8 @@ class GatewayTurnCallbacks(
val onToolGenerating: (toolName: String?) -> Unit, val onToolGenerating: (toolName: String?) -> Unit,
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */ /** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
val onSubagentEvent: (GatewaySubagentEvent) -> Unit, val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
/** Successful MoA advisor output for a transient labelled reference block. */
val onMoaReference: (GatewayMoaReference) -> Unit,
/** /**
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks * Server-side interactive ask (clarify/approval/sudo/secret) that blocks
* the turn until answered via the matching respond RPC or the turn is * the turn until answered via the matching respond RPC or the turn is
@@ -360,6 +468,8 @@ class GatewayTurnCallbacks(
val onInteractionRequest: (GatewayAsk) -> Unit, val onInteractionRequest: (GatewayAsk) -> Unit,
/** Server declared a pending interaction expired; clear only the matching card. */ /** Server declared a pending interaction expired; clear only the matching card. */
val onInteractionExpired: (GatewayAskExpiry) -> Unit, val onInteractionExpired: (GatewayAskExpiry) -> Unit,
/** The turn resumed after a pending interaction was resolved elsewhere. */
val onInteractionResolved: (GatewayAskExpiry) -> Unit = { _ -> },
/** /**
* Gateway `status.update` lifecycle line — model fallback, retries, and * Gateway `status.update` lifecycle line — model fallback, retries, and
* errors (often emoji-prefixed: 🔄 fallback, ⏳ retry, ❌ error). Default * errors (often emoji-prefixed: 🔄 fallback, ⏳ retry, ❌ error). Default
@@ -21,6 +21,7 @@ import com.hermesandroid.relay.util.TurnLatencyTracer
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
@@ -28,6 +29,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.put
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -76,6 +78,10 @@ data class ServerCapabilities(
val portable: Boolean, val portable: Boolean,
/** `/health` — basic reachability. */ /** `/health` — basic reachability. */
val healthy: Boolean, val healthy: Boolean,
/** Authenticated provider/model inventory at `/api/model/options`. */
val modelOptions: Boolean = false,
/** Backend-acknowledged per-session model lock. */
val sessionModelLock: Boolean = false,
) { ) {
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */ /** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
fun preferredChatEndpoint(): String = when { fun preferredChatEndpoint(): String = when {
@@ -99,6 +105,8 @@ data class ServerCapabilities(
runs = false, runs = false,
portable = false, portable = false,
healthy = false, healthy = false,
modelOptions = false,
sessionModelLock = false,
) )
} }
} }
@@ -138,6 +146,8 @@ internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities
feature("chat_completions") || feature("chat_completions") ||
endpoint("chat_completions"), endpoint("chat_completions"),
healthy = true, healthy = true,
modelOptions = feature("model_options") || endpoint("model_options"),
sessionModelLock = feature("session_model_lock") || endpoint("session_model_lock"),
) )
} }
@@ -159,6 +169,269 @@ internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
} }
} }
@Serializable
data class ToolsetInfo(
val name: String,
val label: String = "",
val description: String = "",
val enabled: Boolean = false,
val configured: Boolean = false,
val tools: List<String> = emptyList(),
)
@Serializable
private data class ToolsetListResponse(val data: List<ToolsetInfo> = emptyList())
internal fun parseToolsetListBody(json: Json, body: String): List<ToolsetInfo>? = try {
json.decodeFromString<ToolsetListResponse>(body).data
} catch (_: Exception) {
null
}
/** One OpenAI-compatible `/v1/models` row. [id] is always the request value. */
data class ApiModelOption(
val id: String,
val root: String? = null,
val parent: String? = null,
) {
/** Secondary picker copy for a configured route alias. */
val routeDetail: String?
get() = root?.takeIf { it.isNotBlank() && it != id }?.let { "Routes to $it" }
}
/** Authenticated provider/model inventory advertised by `/api/model/options`. */
data class ApiProviderModelOptions(
val providers: List<GatewayModelProvider>,
val currentModel: String,
val currentProvider: String,
)
internal fun parseApiProviderModelOptionsBody(
json: Json,
body: String,
): ApiProviderModelOptions? {
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null
val rows = root["providers"] as? JsonArray ?: return null
val providers = rows.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
GatewayModelProvider(
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
slug = slug,
models = (obj["models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull
?.toIntOrNull() ?: 0,
)
}
return ApiProviderModelOptions(
providers = providers,
currentModel = (root["model"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
currentProvider = (root["provider"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
)
}
enum class ApiModelRoutingErrorCode {
INVENTORY_UNSUPPORTED,
INVENTORY_UNAVAILABLE,
PROVIDER_NOT_AUTHENTICATED,
MODEL_NOT_AVAILABLE,
MODEL_NOT_AVAILABLE_ON_PLAN,
LOCK_CAPABILITY_INCOMPLETE,
LOCK_REJECTED,
LOCK_ACK_MISMATCH,
LEGACY_PROVIDER_UNSUPPORTED,
}
class ApiModelRoutingException(
val code: ApiModelRoutingErrorCode,
message: String,
) : IOException(message)
sealed interface ApiModelSelectionAck {
data object ServerDefault : ApiModelSelectionAck
data class Locked(
val sessionId: String,
val model: String,
val provider: String?,
val effectiveModel: String = model,
val effectiveProvider: String? = provider,
) : ApiModelSelectionAck
data class LegacyModelHint(val model: String) : ApiModelSelectionAck
}
internal enum class ApiModelRoutingStrategy { LOCKED, LEGACY_HINT, INCOMPLETE }
internal fun apiModelRoutingStrategy(capabilities: ServerCapabilities): ApiModelRoutingStrategy =
when {
capabilities.sessionModelLock && capabilities.modelOptions ->
ApiModelRoutingStrategy.LOCKED
capabilities.sessionModelLock ->
ApiModelRoutingStrategy.INCOMPLETE
else ->
ApiModelRoutingStrategy.LEGACY_HINT
}
internal fun sessionTurnModelHint(
acknowledgement: ApiModelSelectionAck,
requestedModel: String?,
): String? =
if (acknowledgement is ApiModelSelectionAck.Locked) null else requestedModel
internal data class ParsedApiModelLockAck(
val sessionId: String?,
val model: String?,
val provider: String?,
val state: String?,
val effectiveModel: String?,
val effectiveProvider: String?,
)
internal fun parseApiModelLockAck(json: Json, body: String): ParsedApiModelLockAck? {
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null
val runtime = root["runtime"] as? JsonObject ?: return null
val requested = runtime["requested"] as? JsonObject
val effective = runtime["effective"] as? JsonObject
return ParsedApiModelLockAck(
sessionId = (root["session_id"] as? JsonPrimitive)?.contentOrNull,
model = (requested?.get("model") as? JsonPrimitive)?.contentOrNull,
provider = (requested?.get("provider") as? JsonPrimitive)?.contentOrNull,
state = (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull,
effectiveModel = (effective?.get("model") as? JsonPrimitive)?.contentOrNull,
effectiveProvider = (effective?.get("provider") as? JsonPrimitive)?.contentOrNull,
)
}
internal fun confirmedRuntimeMatches(
runtime: JsonObject?,
expected: ApiModelSelectionAck.Locked,
): Boolean {
runtime ?: return false
val effective = runtime["effective"] as? JsonObject ?: return false
return (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull == "confirmed" &&
(effective["model"] as? JsonPrimitive)?.contentOrNull == expected.effectiveModel &&
(effective["provider"] as? JsonPrimitive)?.contentOrNull == expected.effectiveProvider
}
internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOption>? {
val data = try {
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
} catch (_: Exception) {
null
} ?: return null
return data.mapNotNull { row ->
val obj = row as? JsonObject ?: return@mapNotNull null
val id = (obj["id"] as? JsonPrimitive)?.contentOrNull
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
ApiModelOption(
id = id,
root = (obj["root"] as? JsonPrimitive)?.contentOrNull,
parent = (obj["parent"] as? JsonPrimitive)?.contentOrNull,
)
}
}
private const val STREAM_ERROR_BODY_LIMIT = 16L * 1024L
/** Preserve the upstream drain code and bounded retry hint without leaking large bodies. */
internal fun streamHttpFailureMessage(
code: Int,
reason: String,
retryAfter: String?,
body: String?,
json: Json,
): String {
val error = body?.takeIf { it.length <= STREAM_ERROR_BODY_LIMIT }?.let { raw ->
runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull()?.get("error")
}
val errorObj = error as? JsonObject
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.contentOrNull
val detail = (errorObj?.get("message") as? JsonPrimitive)?.contentOrNull
?: (error as? JsonPrimitive)?.contentOrNull
return buildString {
append("API error ").append(code).append(": ")
if (!errorCode.isNullOrBlank()) append(errorCode).append(": ")
append(detail?.takeIf { it.isNotBlank() } ?: reason)
retryAfter?.trim()?.toIntOrNull()?.takeIf { it in 0..60 }?.let {
append(" (Retry-After: ").append(it).append("s)")
}
}
}
internal fun gatewayDrainRetryDelayMillis(
httpCode: Int?,
retryAfter: String?,
errorMessage: String,
receivedEvent: Boolean,
retryAlreadyScheduled: Boolean,
): Long? {
if (httpCode != 503 || receivedEvent || retryAlreadyScheduled ||
!errorMessage.startsWith("API error 503: gateway_draining:")
) return null
val seconds = retryAfter?.trim()?.toIntOrNull()?.coerceIn(0, 5) ?: 1
return seconds * 1_000L
}
/** Owns the initial SSE, its one delayed drain retry, and the replacement SSE. */
private class RetryingEventSource(
private val originalRequest: Request,
private val handler: Handler,
) : EventSource {
private val lock = Any()
private var active: EventSource? = null
private var retryRunnable: Runnable? = null
private var cancelled = false
override fun request(): Request = originalRequest
fun attach(source: EventSource) {
synchronized(lock) {
if (cancelled) source.cancel() else active = source
}
}
fun retryAfter(delayMillis: Long, create: () -> EventSource) {
val task = Runnable {
synchronized(lock) {
retryRunnable = null
if (cancelled) return@Runnable
// Keep creation under the same lock as cancel(): once Stop or
// a session switch wins, no delayed POST can start afterward.
active = create()
}
}
synchronized(lock) {
if (cancelled) return
retryRunnable = task
handler.postDelayed(task, delayMillis)
}
}
override fun cancel() {
val source: EventSource?
val task: Runnable?
synchronized(lock) {
if (cancelled) return
cancelled = true
source = active
active = null
task = retryRunnable
retryRunnable = null
}
task?.let(handler::removeCallbacks)
source?.cancel()
}
}
/** /**
* Direct HTTP/SSE client for the Hermes API Server. * Direct HTTP/SSE client for the Hermes API Server.
* *
@@ -174,6 +447,8 @@ class HermesApiClient(
isLenient = true isLenient = true
} }
) { ) {
@Volatile
private var lastCapabilities: ServerCapabilities? = null
private val baseUrl: String = baseUrl.trimEnd('/') private val baseUrl: String = baseUrl.trimEnd('/')
companion object { companion object {
@@ -199,8 +474,13 @@ class HermesApiClient(
/** Shared human-readable message for an SSE [EventSourceListener.onFailure]. */ /** Shared human-readable message for an SSE [EventSourceListener.onFailure]. */
private fun streamFailureMessage(t: Throwable?, response: Response?): String = when { private fun streamFailureMessage(t: Throwable?, response: Response?): String = when {
response != null && !response.isSuccessful -> response != null && !response.isSuccessful -> streamHttpFailureMessage(
"API error ${response.code}: ${response.message}" code = response.code,
reason = response.message,
retryAfter = response.header("Retry-After"),
body = runCatching { response.peekBody(STREAM_ERROR_BODY_LIMIT).string() }.getOrNull(),
json = Json { ignoreUnknownKeys = true },
)
t is IOException -> "$TRANSPORT_ERROR_PREFIX: ${t.message}" t is IOException -> "$TRANSPORT_ERROR_PREFIX: ${t.message}"
t != null -> "Stream error: ${t.message}" t != null -> "Stream error: ${t.message}"
else -> "Unknown stream error" else -> "Unknown stream error"
@@ -445,6 +725,24 @@ class HermesApiClient(
emptyList() emptyList()
} }
/** Authenticated read-only inventory from upstream `GET /v1/toolsets`. */
suspend fun getToolsets(): Result<List<ToolsetInfo>> = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/v1/toolsets").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(IOException("HTTP ${response.code}"))
}
val body = response.body?.string().orEmpty()
val parsed = parseToolsetListBody(json, body)
?: return@withContext Result.failure(IOException("Malformed toolset inventory"))
Result.success(parsed)
}
} catch (e: Exception) {
Result.failure(e)
}
}
// --- Available models --- // --- Available models ---
/** /**
@@ -453,17 +751,13 @@ class HermesApiClient(
* picker. Returns ids in server order; empty on any failure (the picker * picker. Returns ids in server order; empty on any failure (the picker
* then offers only "Server default"). * then offers only "Server default").
*/ */
suspend fun getModels(): List<String> = withContext(Dispatchers.IO) { suspend fun getModelOptions(): List<ApiModelOption> = withContext(Dispatchers.IO) {
try { try {
val request = authRequest("$baseUrl/v1/models").get().build() val request = authRequest("$baseUrl/v1/models").get().build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext emptyList() if (!response.isSuccessful) return@withContext emptyList()
val body = response.body?.string() ?: return@withContext emptyList() val body = response.body?.string() ?: return@withContext emptyList()
val data = (json.parseToJsonElement(body) as? JsonObject) parseModelOptionsBody(json, body).orEmpty()
?.get("data") as? JsonArray ?: return@withContext emptyList()
data.mapNotNull {
((it as? JsonObject)?.get("id") as? JsonPrimitive)?.contentOrNull
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to fetch models: ${e.message}") Log.w(TAG, "Failed to fetch models: ${e.message}")
@@ -471,6 +765,208 @@ class HermesApiClient(
} }
} }
/** Compatibility view for callers that only need request ids. */
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
/** Provider-aware picker inventory; never falls back to unauthenticated local guesses. */
suspend fun getProviderModelOptions(
refresh: Boolean = false,
): Result<ApiProviderModelOptions> = withContext(Dispatchers.IO) {
try {
val suffix = if (refresh) "?refresh=true" else ""
val request = authRequest("$baseUrl/api/model/options$suffix").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(
ApiModelRoutingException(
if (response.code == 404) {
ApiModelRoutingErrorCode.INVENTORY_UNSUPPORTED
} else {
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE
},
if (response.code == 401 || response.code == 403) {
"Model inventory authorization failed (HTTP ${response.code})."
} else {
"Model inventory unavailable (HTTP ${response.code})."
},
),
)
}
val parsed = parseApiProviderModelOptionsBody(json, response.body.string())
?: return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
"Model inventory returned an invalid response.",
),
)
Result.success(parsed)
}
} catch (e: Exception) {
Result.failure(
if (e is ApiModelRoutingException) e else {
ApiModelRoutingException(
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
"Model inventory could not be loaded.",
)
},
)
}
}
/**
* Validate and, on capable servers, persist a model/provider lock before a
* session turn is submitted. This never writes global config.
*/
suspend fun acknowledgeSessionModelSelection(
sessionId: String,
model: String?,
provider: String?,
): Result<ApiModelSelectionAck> = withContext(Dispatchers.IO) {
val selectedModel = AgentDisplay.requestModelName(model)
?: return@withContext Result.success(ApiModelSelectionAck.ServerDefault)
val selectedProvider = provider?.trim()?.takeIf { it.isNotEmpty() }
// Capability snapshots can be populated by a disconnected startup
// probe. Re-probe at the lock boundary instead of trusting a stale
// false forever after the connection recovers.
val capabilities = probeCapabilities()
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.LOCKED) {
val inventory = getProviderModelOptions().getOrElse {
return@withContext Result.failure(it)
}
val aliases = getModelOptions()
val selectedRoot = aliases.firstOrNull { it.id == selectedModel }?.root
?.takeIf { it.isNotBlank() }
val providerModel = selectedRoot ?: selectedModel
val providerRow = when {
selectedProvider != null ->
inventory.providers.firstOrNull { it.slug == selectedProvider }
else -> inventory.providers.singleOrNull { providerModel in it.models }
?: inventory.providers.firstOrNull {
it.isCurrent && providerModel in it.models
}
} ?: return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
"The selected model is not in the API server's authenticated inventory.",
),
)
if (!providerRow.authenticated) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.PROVIDER_NOT_AUTHENTICATED,
"The selected provider is not authenticated on this profile.",
),
)
}
if (providerModel !in providerRow.models) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
"The selected model is not available from ${providerRow.name}.",
),
)
}
if (providerModel in providerRow.unavailableModels) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE_ON_PLAN,
"The selected model is not available on the authenticated account.",
),
)
}
val body = kotlinx.serialization.json.buildJsonObject {
put("model", selectedModel)
put("provider", providerRow.slug)
}
try {
val request = authRequest("$baseUrl/api/sessions/$sessionId/model")
.post(json.encodeToString(JsonObject.serializer(), body).toRequestBody(JSON_MEDIA))
.build()
client.newCall(request).execute().use { response ->
val responseBody = response.body.string()
if (!response.isSuccessful) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.LOCK_REJECTED,
streamHttpFailureMessage(
response.code,
response.message,
response.header("Retry-After"),
responseBody,
json,
),
),
)
}
val ack = parseApiModelLockAck(json, responseBody)
if (
ack?.sessionId != sessionId ||
ack?.model != selectedModel ||
ack?.provider != providerRow.slug ||
ack?.state != "accepted" ||
ack?.effectiveModel.isNullOrBlank() ||
ack?.effectiveProvider.isNullOrBlank()
) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.LOCK_ACK_MISMATCH,
"Server did not acknowledge the requested model lock.",
),
)
}
val confirmedAck = requireNotNull(ack)
Result.success(
ApiModelSelectionAck.Locked(
sessionId = sessionId,
model = selectedModel,
provider = providerRow.slug,
effectiveModel = requireNotNull(confirmedAck.effectiveModel),
effectiveProvider = confirmedAck.effectiveProvider,
),
)
}
} catch (e: Exception) {
Result.failure(
if (e is ApiModelRoutingException) e else {
ApiModelRoutingException(
ApiModelRoutingErrorCode.LOCK_REJECTED,
"Model lock request failed before the message was sent.",
)
},
)
}
} else {
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.INCOMPLETE) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.LOCK_CAPABILITY_INCOMPLETE,
"Server advertises an incomplete model-routing contract.",
),
)
}
if (selectedProvider != null) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.LEGACY_PROVIDER_UNSUPPORTED,
"This Hermes version cannot safely preserve a provider selection on API fallback.",
),
)
}
val advertised = getModelOptions().map { it.id }
if (selectedModel !in advertised) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
"This Hermes version did not advertise the selected model for API fallback.",
),
)
}
Result.success(ApiModelSelectionAck.LegacyModelHint(selectedModel))
}
}
// --- Server personalities --- // --- Server personalities ---
/** /**
@@ -593,6 +1089,7 @@ class HermesApiClient(
onError: (String) -> Unit, onError: (String) -> Unit,
modelOverride: String? = null, modelOverride: String? = null,
profileName: String? = null, profileName: String? = null,
expectedModelLock: ApiModelSelectionAck.Locked? = null,
): EventSource { ): EventSource {
if (!modelOverride.isNullOrBlank()) { if (!modelOverride.isNullOrBlank()) {
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)") Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
@@ -623,6 +1120,10 @@ class HermesApiClient(
} }
val completeCalled = AtomicBoolean(false) val completeCalled = AtomicBoolean(false)
val runtimeConfirmed = AtomicBoolean(expectedModelLock == null)
val receivedEvent = AtomicBoolean(false)
val drainRetryScheduled = AtomicBoolean(false)
val turnSource = RetryingEventSource(request, mainHandler)
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer. // Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
val tracer = TurnLatencyTracer("sessions") val tracer = TurnLatencyTracer("sessions")
@@ -636,10 +1137,17 @@ class HermesApiClient(
type: String?, type: String?,
data: String data: String
) { ) {
receivedEvent.set(true)
tracer.mark("ttfe") tracer.mark("ttfe")
if (data == "[DONE]") { if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) { if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() } mainHandler.post {
if (runtimeConfirmed.get()) {
onComplete()
} else {
onError("Server ended the turn without confirming the selected model route.")
}
}
} }
return return
} }
@@ -715,9 +1223,17 @@ class HermesApiClient(
} }
// assistant.completed — one turn finished, but run may continue with tool calls // assistant.completed — one turn finished, but run may continue with tool calls
"assistant.completed" -> { "assistant.completed" -> {
val runtimeMatches = expectedModelLock?.let {
confirmedRuntimeMatches(event.runtime, it)
} ?: true
if (runtimeMatches) runtimeConfirmed.set(true)
mainHandler.post { mainHandler.post {
onUsage(event.usage) onUsage(event.usage)
if (event.interrupted == true) { if (!runtimeMatches) {
if (completeCalled.compareAndSet(false, true)) {
onError("Server response did not confirm the selected model route.")
}
} else if (event.interrupted == true) {
if (completeCalled.compareAndSet(false, true)) { if (completeCalled.compareAndSet(false, true)) {
onError("Response interrupted") onError("Response interrupted")
} }
@@ -729,9 +1245,15 @@ class HermesApiClient(
// run.completed — the entire agent loop is done (all turns + tool calls) // run.completed — the entire agent loop is done (all turns + tool calls)
"run.completed" -> { "run.completed" -> {
if (completeCalled.compareAndSet(false, true)) { if (completeCalled.compareAndSet(false, true)) {
val runtimeMatches = expectedModelLock?.let {
confirmedRuntimeMatches(event.runtime, it)
} ?: true
if (runtimeMatches) runtimeConfirmed.set(true)
mainHandler.post { mainHandler.post {
onUsage(event.usage) onUsage(event.usage)
if (event.interrupted == true) { if (!runtimeMatches) {
onError("Server response did not confirm the selected model route.")
} else if (event.interrupted == true) {
onError("Run interrupted") onError("Run interrupted")
} else { } else {
onComplete() onComplete()
@@ -741,7 +1263,13 @@ class HermesApiClient(
} }
"done" -> { "done" -> {
if (completeCalled.compareAndSet(false, true)) { if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() } mainHandler.post {
if (runtimeConfirmed.get()) {
onComplete()
} else {
onError("Server ended the turn without confirming the selected model route.")
}
}
} }
} }
"error" -> { "error" -> {
@@ -799,9 +1327,20 @@ class HermesApiClient(
t: Throwable?, t: Throwable?,
response: Response? response: Response?
) { ) {
val msg = streamFailureMessage(t, response)
val retryDelay = gatewayDrainRetryDelayMillis(
response?.code,
response?.header("Retry-After"),
msg,
receivedEvent.get(),
drainRetryScheduled.get(),
)
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
return
}
tracer.done("error") tracer.done("error")
if (completeCalled.compareAndSet(false, true)) { if (completeCalled.compareAndSet(false, true)) {
val msg = streamFailureMessage(t, response)
mainHandler.post { onError(msg) } mainHandler.post { onError(msg) }
} }
} }
@@ -809,12 +1348,19 @@ class HermesApiClient(
override fun onClosed(eventSource: EventSource) { override fun onClosed(eventSource: EventSource) {
tracer.done() tracer.done()
if (completeCalled.compareAndSet(false, true)) { if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() } mainHandler.post {
if (runtimeConfirmed.get()) {
onComplete()
} else {
onError("Server closed the turn without confirming the selected model route.")
}
}
} }
} }
} }
return sseFactory.newEventSource(request, listener) turnSource.attach(sseFactory.newEventSource(request, listener))
return turnSource
} }
// --- OpenAI-compatible chat streaming via /v1/chat/completions --- // --- OpenAI-compatible chat streaming via /v1/chat/completions ---
@@ -876,6 +1422,9 @@ class HermesApiClient(
val completeCalled = AtomicBoolean(false) val completeCalled = AtomicBoolean(false)
val messageStarted = AtomicBoolean(false) val messageStarted = AtomicBoolean(false)
val receivedEvent = AtomicBoolean(false)
val drainRetryScheduled = AtomicBoolean(false)
val turnSource = RetryingEventSource(request, mainHandler)
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer. // Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
val tracer = TurnLatencyTracer("completions") val tracer = TurnLatencyTracer("completions")
@@ -886,6 +1435,7 @@ class HermesApiClient(
type: String?, type: String?,
data: String data: String
) { ) {
receivedEvent.set(true)
tracer.mark("ttfe") tracer.mark("ttfe")
if (data == "[DONE]") { if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) { if (completeCalled.compareAndSet(false, true)) {
@@ -941,9 +1491,20 @@ class HermesApiClient(
t: Throwable?, t: Throwable?,
response: Response? response: Response?
) { ) {
val msg = streamFailureMessage(t, response)
val retryDelay = gatewayDrainRetryDelayMillis(
response?.code,
response?.header("Retry-After"),
msg,
receivedEvent.get(),
drainRetryScheduled.get(),
)
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
return
}
tracer.done("error") tracer.done("error")
if (completeCalled.compareAndSet(false, true)) { if (completeCalled.compareAndSet(false, true)) {
val msg = streamFailureMessage(t, response)
mainHandler.post { onError(msg) } mainHandler.post { onError(msg) }
} }
} }
@@ -956,7 +1517,8 @@ class HermesApiClient(
} }
} }
return sseFactory.newEventSource(request, listener) turnSource.attach(sseFactory.newEventSource(request, listener))
return turnSource
} }
private fun openAiChoice(event: JsonObject): JsonObject? = private fun openAiChoice(event: JsonObject): JsonObject? =
@@ -1070,6 +1632,9 @@ class HermesApiClient(
} }
val completeCalled = AtomicBoolean(false) val completeCalled = AtomicBoolean(false)
val receivedEvent = AtomicBoolean(false)
val drainRetryScheduled = AtomicBoolean(false)
val turnSource = RetryingEventSource(request, mainHandler)
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer. // Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
val tracer = TurnLatencyTracer("runs") val tracer = TurnLatencyTracer("runs")
@@ -1080,6 +1645,7 @@ class HermesApiClient(
type: String?, type: String?,
data: String data: String
) { ) {
receivedEvent.set(true)
tracer.mark("ttfe") tracer.mark("ttfe")
if (data == "[DONE]") { if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) { if (completeCalled.compareAndSet(false, true)) {
@@ -1246,9 +1812,20 @@ class HermesApiClient(
t: Throwable?, t: Throwable?,
response: Response? response: Response?
) { ) {
val msg = streamFailureMessage(t, response)
val retryDelay = gatewayDrainRetryDelayMillis(
response?.code,
response?.header("Retry-After"),
msg,
receivedEvent.get(),
drainRetryScheduled.get(),
)
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
return
}
tracer.done("error") tracer.done("error")
if (completeCalled.compareAndSet(false, true)) { if (completeCalled.compareAndSet(false, true)) {
val msg = streamFailureMessage(t, response)
mainHandler.post { onError(msg) } mainHandler.post { onError(msg) }
} }
} }
@@ -1261,7 +1838,8 @@ class HermesApiClient(
} }
} }
return sseFactory.newEventSource(request, listener) turnSource.attach(sseFactory.newEventSource(request, listener))
return turnSource
} }
// --- Capability detection --- // --- Capability detection ---
@@ -1319,7 +1897,10 @@ class HermesApiClient(
} catch (_: Exception) { } catch (_: Exception) {
false false
} }
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED if (!healthy) {
lastCapabilities = ServerCapabilities.DISCONNECTED
return@withContext ServerCapabilities.DISCONNECTED
}
val advertisedCapabilities = try { val advertisedCapabilities = try {
val req = authRequest("$baseUrl/v1/capabilities").get().build() val req = authRequest("$baseUrl/v1/capabilities").get().build()
@@ -1333,7 +1914,10 @@ class HermesApiClient(
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
if (advertisedCapabilities != null) return@withContext advertisedCapabilities if (advertisedCapabilities != null) {
lastCapabilities = advertisedCapabilities
return@withContext advertisedCapabilities
}
// Reusable HEAD probe — returns true if the route is registered // Reusable HEAD probe — returns true if the route is registered
// (any status except 404 + network errors). Already inside the // (any status except 404 + network errors). Already inside the
@@ -1378,7 +1962,7 @@ class HermesApiClient(
runs = runs, runs = runs,
portable = portable, portable = portable,
healthy = true, healthy = true,
) ).also { lastCapabilities = it }
} }
// --- Lifecycle --- // --- Lifecycle ---
@@ -26,10 +26,12 @@ import kotlinx.serialization.json.putJsonObject
* *
* - `POST /api/sessions/{id}/chat/stream` (`_handle_session_chat_stream`) * - `POST /api/sessions/{id}/chat/stream` (`_handle_session_chat_stream`)
* consumes `message` (or `input`) and `system_message` (or * consumes `message` (or `input`) and `system_message` (or
* `instructions`, string only). `message` accepts either a plain string * `instructions`, string only). Newer servers also parse per-request model
* or OpenAI-style content parts (text + `image_url`) via * fields and reuse a backend-acknowledged session model lock when those
* `_normalize_multimodal_content`. Top-level `messages`, `attachments`, * fields are omitted. Android therefore acknowledges a lock first and
* `model`, and `profile` are NOT parsed. * omits `model` on that turn; the builder's model field remains only for
* older-server compatibility. Top-level `messages`, `attachments`, and
* `profile` are not parsed.
* *
* - `POST /v1/runs` (`_handle_runs`) consumes `input` (string or message * - `POST /v1/runs` (`_handle_runs`) consumes `input` (string or message
* array), `instructions`, `conversation_history` (array of * array), `instructions`, `conversation_history` (array of
@@ -45,9 +47,8 @@ import kotlinx.serialization.json.putJsonObject
* entries are silently skipped and `tool_calls` fields are stripped. * entries are silently skipped and `tool_calls` fields are stripped.
* Top-level `attachments` and `profile` are NOT parsed. * Top-level `attachments` and `profile` are NOT parsed.
* *
* Legacy hint fields we deliberately keep sending although current native * Legacy hint fields we deliberately keep sending: `model` + `profile` on the
* upstream ignores them: `model` + `profile` on the sessions path, * sessions path, `profile` on runs/completions, and `stream` on runs. They are
* `profile` on runs/completions, and `stream` on runs. They are
* configuration hints (never user content, so they cannot mask data * configuration hints (never user content, so they cannot mask data
* loss) honored by legacy fork builds — the runs path in particular only * loss) honored by legacy fork builds — the runs path in particular only
* activates against servers that explicitly advertise SSE-on-POST, which * activates against servers that explicitly advertise SSE-on-POST, which
@@ -0,0 +1,80 @@
package com.hermesandroid.relay.network.upstream
import kotlinx.coroutines.delay
import kotlinx.coroutines.CancellationException
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.io.IOException
/**
* Ephemeral hosted MCP OAuth driver. The opaque flow id and authorization URL
* remain in memory only; OAuth codes, callback state, and tokens never enter
* the Android client. The dashboard owns the complete PKCE/callback exchange.
*/
class McpOAuthFlowCoordinator(
private val client: DashboardApiClient,
private val pollDelayMillis: Long = 1_000,
private val maxPolls: Int = 900,
private val maxConsecutiveFailures: Int = 3,
private val sleep: suspend (Long) -> Unit = { delay(it) },
) {
suspend fun start(
serverName: String,
profile: String? = null,
): Result<DashboardMcpOAuthFlow> = client.startMcpOAuth(serverName, profile).mapCatching { started ->
if (started.status == "error") {
throw IOException(started.error ?: "MCP OAuth failed to start")
}
started
}
suspend fun resume(flowId: String): Result<DashboardMcpOAuthFlow> = runCatching {
var failures = 0
repeat(maxPolls.coerceAtLeast(1)) {
val current = client.getMcpOAuthFlow(flowId)
if (current.isFailure) {
failures += 1
if (failures >= maxConsecutiveFailures.coerceAtLeast(1)) {
throw current.exceptionOrNull() ?: IOException("MCP OAuth status check failed")
}
} else {
failures = 0
val flow = current.getOrThrow()
when (flow.status) {
"approved" -> return@runCatching flow
"error" -> throw IOException(flow.error ?: "MCP OAuth authorization failed")
}
}
sleep(pollDelayMillis.coerceAtLeast(0))
}
throw IOException("MCP OAuth authorization timed out")
}.onFailure { error ->
if (error is CancellationException) throw error
}
suspend fun complete(
serverName: String,
profile: String? = null,
openAuthorization: (String) -> Boolean,
): Result<DashboardMcpOAuthFlow> = runCatching {
val started = start(serverName, profile).getOrThrow()
if (started.status == "approved") return@runCatching started
val authorizationUrl = validatedAuthorizationUrl(started).getOrThrow()
if (!openAuthorization(authorizationUrl)) {
throw IOException("No browser is available to complete MCP OAuth")
}
resume(started.flowId).getOrThrow()
}.onFailure { error ->
if (error is CancellationException) throw error
}
companion object {
fun validatedAuthorizationUrl(flow: DashboardMcpOAuthFlow): Result<String> = runCatching {
val url = flow.authorizationUrl
?: throw IOException("MCP OAuth server did not provide an authorization URL")
if (url.toHttpUrlOrNull()?.scheme != "https") {
throw IOException("MCP OAuth authorization URL must use HTTPS")
}
url
}
}
}
@@ -0,0 +1,478 @@
package com.hermesandroid.relay.network.upstream
import android.content.Context
import com.hermesandroid.relay.auth.SessionTokenStore
import com.hermesandroid.relay.auth.SecureStoreCache
import com.hermesandroid.relay.auth.buildRawTokenStore
import java.io.IOException
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.Route
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okio.ByteString.Companion.toByteString
private const val NATIVE_PKCE_FLOW = "native_pkce"
private const val CALLBACK_PATH = "/callback"
private const val TOKEN_KEY = "dashboard_native_tokens_json"
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
@Serializable
data class NativeDashboardTokens(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String = "",
@SerialName("expires_at") val expiresAt: Long = 0L,
val provider: String = "",
@SerialName("user_id") val userId: String = "",
)
interface NativeDashboardTokenStore {
/** Stable, non-secret identity used to serialize refresh-token rotation. */
val coordinationKey: String
fun load(): NativeDashboardTokens?
fun save(tokens: NativeDashboardTokens)
fun clear()
}
internal fun clearNativeDashboardTokens(store: NativeDashboardTokenStore) {
NativeTokenRefreshCoordinator.clear(store)
}
class EncryptedNativeDashboardTokenStore(
context: Context,
tokenStoreKey: String,
private val json: Json = Json { ignoreUnknownKeys = true },
) : NativeDashboardTokenStore {
override val coordinationKey: String = tokenStoreKey
private val store: SessionTokenStore = SecureStoreCache.getOrBuild(tokenStoreKey) {
buildRawTokenStore(context.applicationContext, tokenStoreKey)
}
override fun load(): NativeDashboardTokens? =
store.getString(TOKEN_KEY)?.let { raw ->
runCatching { json.decodeFromString<NativeDashboardTokens>(raw) }.getOrNull()
}
override fun save(tokens: NativeDashboardTokens) {
store.putString(TOKEN_KEY, json.encodeToString(tokens))
}
override fun clear() {
store.remove(TOKEN_KEY)
}
}
/**
* Ephemeral authorization state. Keep this object in the sign-in coroutine:
* its verifier and CSRF state must never be persisted, logged, or copied into
* Compose/SavedState UI state.
*/
class NativeDashboardAuthorization internal constructor(
val authorizationUrl: String,
internal val verifier: String,
internal val state: String,
internal val generation: Long,
)
class NativeDashboardAuthClient(
baseUrl: String,
private val tokenStore: NativeDashboardTokenStore,
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build(),
private val json: Json = Json { ignoreUnknownKeys = true },
private val random: SecureRandom = SecureRandom(),
) {
private val baseUrl = baseUrl.trim().trimEnd('/')
fun supportsNativePkce(status: DashboardStatus): Boolean =
NATIVE_PKCE_FLOW in status.authFlows
fun beginAuthorization(
redirectUri: String,
provider: String? = null,
): NativeDashboardAuthorization {
requireStrictLoopbackRedirect(redirectUri)
// RFC 7636 uses unpadded Base64URL. Okio's base64Url() preserves
// trailing "=", which makes Hermes' standards-compliant S256
// comparison fail even though both sides hashed the same bytes.
val verifier = randomBytes(32).base64Url().trimEnd('=')
val challenge = MessageDigest.getInstance("SHA-256")
.digest(verifier.toByteArray(Charsets.US_ASCII))
.toByteString()
.base64Url()
.trimEnd('=')
val state = randomBytes(24).base64Url()
val authorizationBaseUrl = resolveAuthorizationBaseUrl(provider)
val root = "$authorizationBaseUrl/auth/native/authorize".toHttpUrlOrNull()
?: throw IOException("Dashboard URL is not a valid http(s) address")
val url = root.newBuilder()
.addQueryParameter("code_challenge", challenge)
.addQueryParameter("code_challenge_method", "S256")
.addQueryParameter("redirect_uri", redirectUri)
.addQueryParameter("state", state)
.apply { provider?.takeIf(String::isNotBlank)?.let { addQueryParameter("provider", it) } }
.build()
.toString()
val generation = NativeTokenRefreshCoordinator.beginAuthorization(
tokenStore.coordinationKey,
)
return NativeDashboardAuthorization(url, verifier, state, generation)
}
/**
* A private-route dashboard may be configured with a canonical HTTPS
* callback origin for its provider. Starting the browser on the private
* origin would scope Hermes' temporary PKCE cookie to the wrong host, so
* discover the provider's declared callback and start native auth there.
* Token exchange still uses [baseUrl], keeping the resulting bearer bound
* to the active connection route.
*/
private fun resolveAuthorizationBaseUrl(provider: String?): String {
val configured = baseUrl.toHttpUrlOrNull() ?: return baseUrl
if (
!provider.equals("nous", ignoreCase = true) ||
configured.scheme != "http" ||
!isPrivateNetworkLiteral(configured.host)
) {
return baseUrl
}
val loginUrl = configured.newBuilder()
.addPathSegments("auth/login")
.addQueryParameter("provider", provider)
.addQueryParameter("next", "/")
.build()
val discoveryClient = client.newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.build()
val location = discoveryClient.newCall(
Request.Builder().url(loginUrl).get().build(),
).execute().use { response ->
if (response.code !in 300..399) null else response.header("Location")
}
return canonicalDashboardBaseFromNousRedirect(location)
?: throw IOException("Dashboard did not advertise a secure Nous callback origin")
}
fun exchangeCallback(
authorization: NativeDashboardAuthorization,
callbackTarget: String,
commitAllowed: () -> Boolean = { true },
): NativeDashboardTokens {
val callback = callbackTarget.toHttpUrlOrNull()
?: "http://127.0.0.1$callbackTarget".toHttpUrlOrNull()
?: throw NativeDashboardCallbackException("Native sign-in callback was malformed")
if (callback.host != "127.0.0.1" || callback.encodedPath != CALLBACK_PATH) {
throw NativeDashboardCallbackException(
"Native sign-in callback did not use the expected loopback path",
)
}
if (callback.queryParameter("state") != authorization.state) {
throw NativeDashboardCallbackException("Native sign-in callback state did not match")
}
callback.queryParameter("error")?.let {
throw NativeDashboardCallbackException(
message = "Gateway rejected native sign-in",
retryable = false,
)
}
val code = callback.queryParameter("code")
?.takeIf(String::isNotBlank)
?: throw NativeDashboardCallbackException(
"Native sign-in callback did not include an authorization code",
)
val payload = NativeTokenExchange(code = code, codeVerifier = authorization.verifier)
return postTokens(
path = "/auth/native/token",
payload = json.encodeToString(payload),
clearOnAuthFailure = false,
expectedGeneration = authorization.generation,
commitAllowed = commitAllowed,
)
}
internal fun cancelAuthorization(authorization: NativeDashboardAuthorization) {
NativeTokenRefreshCoordinator.cancelAuthorization(
tokenStore.coordinationKey,
authorization.generation,
)
}
fun clearStoredSession() {
clearNativeDashboardTokens(tokenStore)
}
fun refresh(tokens: NativeDashboardTokens? = null): NativeDashboardTokens {
return synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
val current = tokenStore.load()
?: tokens
?: throw IOException("No native dashboard session is stored")
// A sibling client may already have rotated the single-use refresh
// token while this caller was waiting. Adopt that winner instead
// of replaying the stale token.
if (tokens != null && current != tokens) return@synchronized current
if (current.refreshToken.isBlank()) {
clearIfUnchanged(current)
throw IOException("Native dashboard session cannot be refreshed")
}
val payload = NativeTokenRefresh(current.refreshToken, current.provider)
val generation = NativeTokenRefreshCoordinator.currentGeneration(
tokenStore.coordinationKey,
)
try {
postTokens(
path = "/auth/native/refresh",
payload = json.encodeToString(payload),
clearOnAuthFailure = false,
expectedGeneration = generation,
)
} catch (error: NativeDashboardAuthHttpException) {
if (error.statusCode == 400 || error.statusCode == 401) {
clearIfUnchanged(current)
}
throw error
}
}
}
private fun postTokens(
path: String,
payload: String,
clearOnAuthFailure: Boolean,
expectedGeneration: Long,
commitAllowed: () -> Boolean = { true },
): NativeDashboardTokens {
val url = "$baseUrl$path".toHttpUrlOrNull()
?: throw IOException("Dashboard URL is not a valid http(s) address")
val request = Request.Builder()
.url(url)
.post(payload.toRequestBody(JSON_MEDIA))
.build()
val tokens = client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
if (clearOnAuthFailure && (response.code == 400 || response.code == 401)) {
tokenStore.clear()
}
throw NativeDashboardAuthHttpException(response.code)
}
val body = response.body?.string().orEmpty()
runCatching { json.decodeFromString<NativeDashboardTokens>(body) }
.getOrElse { throw IOException("Dashboard token response was malformed", it) }
.also {
if (it.accessToken.isBlank()) {
throw IOException("Dashboard token response did not include an access token")
}
}
}
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
if (!commitAllowed() ||
NativeTokenRefreshCoordinator.currentGeneration(tokenStore.coordinationKey) !=
expectedGeneration
) {
throw IOException("Dashboard sign-in is no longer active")
}
tokenStore.save(tokens)
}
return tokens
}
private fun randomBytes(size: Int) = ByteArray(size).also(random::nextBytes).toByteString()
private fun clearIfUnchanged(expected: NativeDashboardTokens) {
if (tokenStore.load() == expected) tokenStore.clear()
}
companion object {
fun requireStrictLoopbackRedirect(redirectUri: String) {
val url = redirectUri.toHttpUrlOrNull()
?: throw IllegalArgumentException("Native redirect must be a valid loopback HTTP URL")
require(url.scheme == "http" && url.host == "127.0.0.1") {
"Native redirect must use the 127.0.0.1 loopback address"
}
require(url.port in 1..65535 && url.encodedPath == CALLBACK_PATH && url.query == null) {
"Native redirect must use an ephemeral port and the exact /callback path"
}
}
}
}
internal class NativeDashboardCallbackException(
message: String,
val retryable: Boolean = true,
) : IOException(message)
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
return url.scheme == "https" ||
(
url.scheme == "http" &&
(url.host == "127.0.0.1" || isPrivateNetworkLiteral(url.host))
)
}
/**
* Hermes already permits explicitly configured HTTP dashboard sessions on
* local routes. The brokered flow is no less protected than that cookie flow,
* but remains unavailable to arbitrary cleartext Internet hosts.
*/
private fun isPrivateNetworkLiteral(host: String): Boolean {
val octets = host.split('.').mapNotNull(String::toIntOrNull)
if (octets.size != 4 || octets.any { it !in 0..255 }) return false
val first = octets[0]
val second = octets[1]
return first == 10 ||
(first == 172 && second in 16..31) ||
(first == 192 && second == 168) ||
(first == 100 && second in 64..127)
}
internal fun canonicalDashboardBaseFromNousRedirect(location: String?): String? {
val providerUrl = location?.toHttpUrlOrNull() ?: return null
if (
providerUrl.scheme != "https" ||
!providerUrl.host.equals("portal.nousresearch.com", ignoreCase = true)
) {
return null
}
val callback = providerUrl.queryParameter("redirect_uri")
?.toHttpUrlOrNull()
?: return null
if (callback.scheme != "https") return null
val callbackSuffix = "/auth/callback"
if (!callback.encodedPath.endsWith(callbackSuffix)) return null
val basePath = callback.encodedPath
.removeSuffix(callbackSuffix)
.ifBlank { "/" }
return callback.newBuilder()
.encodedPath(basePath)
.query(null)
.fragment(null)
.build()
.toString()
.trimEnd('/')
}
/**
* Adds the native bearer to dashboard REST calls and rotates it before expiry
* or after one 401. Refresh requests use a separate bare client, so neither a
* stale bearer nor the authenticator can recurse into token rotation.
*/
class DashboardBearerAuth(
baseUrl: String,
private val tokenStore: NativeDashboardTokenStore,
private val clockSeconds: () -> Long = { System.currentTimeMillis() / 1000L },
) : Interceptor, Authenticator {
private val authClient = NativeDashboardAuthClient(baseUrl, tokenStore)
override fun intercept(chain: Interceptor.Chain): Response {
val tokens = usableTokens(forceRefresh = false, failedAccessToken = null)
val request = tokens?.let {
chain.request().newBuilder()
.header("Authorization", "Bearer ${it.accessToken}")
.build()
} ?: chain.request()
return chain.proceed(request)
}
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= 2) return null
val previous = response.request.header("Authorization") ?: return null
val failedAccessToken = previous.removePrefix("Bearer ").takeIf { it != previous }
val tokens = usableTokens(
forceRefresh = true,
failedAccessToken = failedAccessToken,
) ?: return null
val next = "Bearer ${tokens.accessToken}"
if (next == previous) return null
return response.request.newBuilder().header("Authorization", next).build()
}
private fun usableTokens(
forceRefresh: Boolean,
failedAccessToken: String?,
): NativeDashboardTokens? =
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
val current = tokenStore.load() ?: return@synchronized null
// A request can receive its 401 after another client already
// rotated the token. Retry with the winner; do not rotate again.
if (failedAccessToken != null && current.accessToken != failedAccessToken) {
return@synchronized current
}
val nearExpiry = current.expiresAt <= 0L || clockSeconds() >= current.expiresAt - 60L
if (!forceRefresh && !nearExpiry) return@synchronized current
runCatching { authClient.refresh(current) }.getOrNull()
}
private fun responseCount(response: Response): Int {
var count = 1
var prior = response.priorResponse
while (prior != null) {
count += 1
prior = prior.priorResponse
}
return count
}
}
private class NativeDashboardAuthHttpException(
val statusCode: Int,
) : IOException("Dashboard native authentication failed (HTTP $statusCode)")
private object NativeTokenRefreshCoordinator {
private val locks = ConcurrentHashMap<String, Any>()
private val generations = ConcurrentHashMap<String, Long>()
fun lockFor(key: String): Any = locks.computeIfAbsent(key) { Any() }
fun currentGeneration(key: String): Long =
synchronized(lockFor(key)) { generations[key] ?: 0L }
fun beginAuthorization(key: String): Long =
synchronized(lockFor(key)) {
(generations[key] ?: 0L).plus(1L).also { generations[key] = it }
}
fun cancelAuthorization(key: String, expectedGeneration: Long) {
synchronized(lockFor(key)) {
if ((generations[key] ?: 0L) == expectedGeneration) {
generations[key] = expectedGeneration + 1L
}
}
}
fun clear(store: NativeDashboardTokenStore) {
synchronized(lockFor(store.coordinationKey)) {
generations[store.coordinationKey] =
(generations[store.coordinationKey] ?: 0L) + 1L
store.clear()
}
}
}
@Serializable
private data class NativeTokenExchange(
val code: String,
@SerialName("code_verifier") val codeVerifier: String,
)
@Serializable
private data class NativeTokenRefresh(
@SerialName("refresh_token") val refreshToken: String,
val provider: String,
)
@@ -0,0 +1,266 @@
package com.hermesandroid.relay.network.upstream
import java.io.IOException
import java.io.InputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketTimeoutException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
private const val CALLBACK_PATH = "/callback"
private const val MAX_REQUEST_LINE_BYTES = 8 * 1024
private const val MAX_HEADER_BYTES = 16 * 1024
private const val ACCEPT_POLL_MILLIS = 500
internal const val DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS = 2 * 60 * 1000L
internal enum class DashboardRedirectAuthMode {
NativePkce,
WebView,
}
internal fun dashboardRedirectAuthMode(authFlows: List<String>): DashboardRedirectAuthMode =
if ("native_pkce" in authFlows) {
DashboardRedirectAuthMode.NativePkce
} else {
DashboardRedirectAuthMode.WebView
}
/**
* Nous Portal uses Cloudflare Turnstile and does not support embedded Android
* WebViews. Keep self-hosted OIDC on the dashboard cookie flow, but use the
* gateway's brokered system-browser flow for Nous when it is advertised.
*/
internal fun androidDashboardRedirectAuthMode(
providerName: String,
authFlows: List<String>,
): DashboardRedirectAuthMode =
if (
providerName.equals("nous", ignoreCase = true) &&
dashboardRedirectAuthMode(authFlows) == DashboardRedirectAuthMode.NativePkce
) {
DashboardRedirectAuthMode.NativePkce
} else {
DashboardRedirectAuthMode.WebView
}
/**
* Owns one native dashboard sign-in attempt.
*
* The listener and PKCE authorization are both local to [signIn], so leaving
* the screen, cancellation, timeout, or callback completion closes the port
* and discards verifier/state. Nothing secret enters Compose or saved state.
*/
class NativeDashboardSignInCoordinator(
private val authClient: NativeDashboardAuthClient,
private val timeoutMillis: Long = DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS,
private val serverSocketFactory: () -> ServerSocket = ::ServerSocket,
) {
suspend fun signIn(
provider: String?,
launchAuthorization: suspend (String) -> Unit,
): NativeDashboardTokens =
try {
withContext(Dispatchers.IO) {
withTimeout(timeoutMillis) {
serverSocketFactory().use { server ->
server.reuseAddress = false
server.bind(
InetSocketAddress(
InetAddress.getByName("127.0.0.1"),
0,
),
1,
)
server.soTimeout = ACCEPT_POLL_MILLIS
check(server.inetAddress.hostAddress == "127.0.0.1") {
"Native sign-in listener did not bind to IPv4 loopback"
}
val redirectUri = "http://127.0.0.1:${server.localPort}$CALLBACK_PATH"
val authorization = authClient.beginAuthorization(redirectUri, provider)
val attemptContext = currentCoroutineContext()
var completed = false
try {
launchAuthorization(authorization.authorizationUrl)
awaitValidCallback(
server = server,
authorization = authorization,
commitAllowed = { attemptContext.isActive },
).also { completed = true }
} finally {
if (!completed) {
authClient.cancelAuthorization(authorization)
}
}
}
}
}
} catch (_: TimeoutCancellationException) {
throw IOException("Dashboard sign-in timed out")
}
private suspend fun awaitValidCallback(
server: ServerSocket,
authorization: NativeDashboardAuthorization,
commitAllowed: () -> Boolean,
): NativeDashboardTokens {
while (true) {
val callback = acceptCallback(server)
val tokens = callback.use { socket ->
if (socket.inetAddress.hostAddress != "127.0.0.1") {
writeResponse(
socket,
status = "403 Forbidden",
body = "This sign-in callback was not accepted.",
)
return@use null
}
val target = try {
readCallbackTarget(
input = socket.getInputStream(),
expectedPort = server.localPort,
)
} catch (_: IOException) {
writeResponse(
socket,
status = "400 Bad Request",
body = "This sign-in callback was not accepted.",
)
return@use null
}
try {
authClient.exchangeCallback(
authorization,
target,
commitAllowed = commitAllowed,
).also {
writeResponse(
socket,
status = "200 OK",
body = "Sign-in complete. You can return to Hermes Relay.",
)
}
} catch (error: NativeDashboardCallbackException) {
if (error.retryable) {
writeResponse(
socket,
status = "400 Bad Request",
body = "This sign-in callback was not accepted.",
)
return@use null
}
writeResponse(
socket,
status = "400 Bad Request",
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
)
throw error
} catch (error: Exception) {
writeResponse(
socket,
status = "400 Bad Request",
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
)
throw error
}
}
if (tokens != null) return tokens
}
}
private suspend fun acceptCallback(server: ServerSocket): Socket {
while (true) {
currentCoroutineContext().ensureActive()
try {
return server.accept().apply { soTimeout = 5_000 }
} catch (_: SocketTimeoutException) {
// Poll so coroutine cancellation closes the lifecycle-owned listener promptly.
}
}
}
private fun readCallbackTarget(input: InputStream, expectedPort: Int): String {
val requestLine = readAsciiLine(input, MAX_REQUEST_LINE_BYTES)
?: throw IOException("Native sign-in callback was empty")
val requestParts = requestLine.split(' ')
if (requestParts.size != 3 || requestParts[0] != "GET" ||
!requestParts[1].startsWith("/") ||
!requestParts[2].startsWith("HTTP/1.")
) {
throw IOException("Native sign-in callback request was malformed")
}
var headerBytes = 0
var host: String? = null
while (true) {
val line = readAsciiLine(input, MAX_HEADER_BYTES - headerBytes)
?: throw IOException("Native sign-in callback headers were incomplete")
headerBytes += line.length + 2
if (line.isEmpty()) break
if (line.startsWith("Host:", ignoreCase = true)) {
host = line.substringAfter(':').trim()
}
if (headerBytes >= MAX_HEADER_BYTES) {
throw IOException("Native sign-in callback headers were too large")
}
}
if (host != "127.0.0.1:$expectedPort") {
throw IOException("Native sign-in callback host was not accepted")
}
return requestParts[1]
}
private fun readAsciiLine(input: InputStream, limit: Int): String? {
if (limit <= 0) throw IOException("Native sign-in callback was too large")
val bytes = ArrayList<Byte>(minOf(limit, 128))
var previous = -1
while (bytes.size < limit) {
val current = input.read()
if (current == -1) return if (bytes.isEmpty()) null else throw IOException(
"Native sign-in callback ended unexpectedly",
)
if (previous == '\r'.code && current == '\n'.code) {
bytes.removeAt(bytes.lastIndex)
return bytes.toByteArray().toString(Charsets.US_ASCII)
}
bytes += current.toByte()
previous = current
}
throw IOException("Native sign-in callback line was too large")
}
private fun writeResponse(socket: Socket, status: String, body: String) {
val html = """
<!doctype html>
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body><p>${escapeHtml(body)}</p></body></html>
""".trimIndent().toByteArray(Charsets.UTF_8)
val headers = buildString {
append("HTTP/1.1 ").append(status).append("\r\n")
append("Content-Type: text/html; charset=utf-8\r\n")
append("Content-Length: ").append(html.size).append("\r\n")
append("Cache-Control: no-store\r\n")
append("Connection: close\r\n\r\n")
}.toByteArray(Charsets.US_ASCII)
runCatching {
socket.getOutputStream().apply {
write(headers)
write(html)
flush()
}
}
}
private fun escapeHtml(value: String): String =
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
}
@@ -0,0 +1,93 @@
package com.hermesandroid.relay.network.upstream
/**
* Parsed form of upstream's persisted user-image directives.
*
* Only canonical, full-line `@image:<absolute-path>` values are recognized.
* Unknown or malformed directives stay visible as text. Valid directives are
* removed from the bubble so a host-local path is never exposed in the UI.
*/
internal data class PersistedImageReferences(
val cleanedText: String,
val paths: List<String>,
)
internal object PersistedImageReferenceParser {
private const val MAX_INPUT_CHARS = 256 * 1024
private const val MAX_PATH_CHARS = 2_048
private const val MAX_ATTACHMENTS = 8
private val imageExtensions = setOf(
"avif",
"bmp",
"gif",
"heic",
"heif",
"jpeg",
"jpg",
"png",
"webp",
)
fun parse(content: String): PersistedImageReferences {
if (content.isEmpty() || content.length > MAX_INPUT_CHARS || "@image:" !in content) {
return PersistedImageReferences(content, emptyList())
}
val keptLines = ArrayList<String>()
val paths = ArrayList<String>()
for (line in content.lines()) {
val path = parseDirectiveLine(line)
if (path == null) {
keptLines += line
} else if (paths.size < MAX_ATTACHMENTS) {
paths += path
}
// Recognized refs beyond the attachment cap are still removed:
// exposing a server-local path is worse than omitting an excessive
// attachment from a deliberately bounded gallery.
}
return PersistedImageReferences(
cleanedText = keptLines.joinToString("\n").trim(),
paths = paths,
)
}
private fun parseDirectiveLine(line: String): String? {
if (!line.startsWith("@image:")) return null
val rawValue = line.removePrefix("@image:")
if (rawValue.isEmpty() || rawValue.length > MAX_PATH_CHARS) return null
val path = unwrapCanonicalValue(rawValue) ?: return null
if (path.isBlank() || path.any { it == '\u0000' || it == '\r' || it == '\n' }) return null
if (!isAbsolutePath(path) || !hasImageExtension(path)) return null
return path
}
private fun unwrapCanonicalValue(value: String): String? {
val first = value.first()
if (first !in charArrayOf('`', '"', '\'')) {
return value.takeIf { candidate -> candidate.none { it.isWhitespace() } }
}
if (value.length < 3 || value.last() != first) return null
val inner = value.substring(1, value.lastIndex)
return inner.takeIf { first !in it }
}
private fun isAbsolutePath(path: String): Boolean =
path.startsWith("/") ||
(
path.length >= 3 &&
path[0].isLetter() &&
path[1] == ':' &&
(path[2] == '\\' || path[2] == '/')
)
private fun hasImageExtension(path: String): Boolean {
val fileName = path.substringAfterLast('/').substringAfterLast('\\')
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "").lowercase()
return extension in imageExtensions
}
}
@@ -3,6 +3,12 @@ package com.hermesandroid.relay.network.upstream
import android.content.Context import android.content.Context
import com.hermesandroid.relay.data.VoiceAudioRoute import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.network.shared.VoiceAudioClient import com.hermesandroid.relay.network.shared.VoiceAudioClient
import com.hermesandroid.relay.network.shared.VoiceSpeechStream
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamCallbacks
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamOutcome
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -17,6 +23,9 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.Base64 import java.util.Base64
@@ -32,15 +41,15 @@ import java.util.concurrent.TimeUnit
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT * These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
* on the API server (:8642) — current upstream api_server advertises * on the API server (:8642) — current upstream api_server advertises
* `audio_api: false` and registers no audio routes. Auth is the dashboard * `audio_api: false` and registers no audio routes. Auth is the dashboard
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the * dashboard session (gated_auth_middleware), so [dashboardHttpClientProvider]
* same per-connection cookie jar the Manage tab signs in with; an API bearer * must carry the same exact-origin cookie or native bearer session used by
* header is meaningless on this surface. Revisit when upstream PR #8199 * Manage. The API-server bearer is unrelated to this surface. Revisit when upstream PR #8199
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md section 6). * lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md section 6).
* (No glob spellings in block comments — Kotlin block comments nest.) * (No glob spellings in block comments — Kotlin block comments nest.)
*/ */
class StandardHermesVoiceClient( class StandardHermesVoiceClient(
private val context: Context, private val context: Context,
private val okHttpClient: OkHttpClient, private val dashboardHttpClientProvider: (String) -> OkHttpClient,
private val dashboardUrlProvider: () -> String?, private val dashboardUrlProvider: () -> String?,
// Active chat profile name (null = default/launch). Sent DEFENSIVELY on // Active chat profile name (null = default/launch). Sent DEFENSIVELY on
// /api/audio/speak: upstream `TTSSpeakRequest` is text-only and Pydantic // /api/audio/speak: upstream `TTSSpeakRequest` is text-only and Pydantic
@@ -48,6 +57,7 @@ class StandardHermesVoiceClient(
// upstream ever adds profile-aware TTS. Until then, standard voice remains // upstream ever adds profile-aware TTS. Until then, standard voice remains
// the host's global TTS (see VoiceViewModel's standard-voice profile notice). // the host's global TTS (see VoiceViewModel's standard-voice profile notice).
private val profileProvider: () -> String? = { null }, private val profileProvider: () -> String? = { null },
private val webSocketFactory: ((Request, WebSocketListener) -> WebSocket)? = null,
private val json: Json = Json { private val json: Json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
isLenient = true isLenient = true
@@ -56,14 +66,10 @@ class StandardHermesVoiceClient(
) : VoiceAudioClient { ) : VoiceAudioClient {
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
private val callClient: OkHttpClient =
okHttpClient.newBuilder()
.callTimeout(90, TimeUnit.SECONDS)
.build()
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) { override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
val baseUrl = dashboardBaseUrl() val baseUrl = dashboardBaseUrl()
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured")) ?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
val callClient = callClient(baseUrl)
if (!audioFile.exists() || audioFile.length() == 0L) { if (!audioFile.exists() || audioFile.length() == 0L) {
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}")) return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
} }
@@ -94,7 +100,7 @@ class StandardHermesVoiceClient(
.header("Accept", "application/json") .header("Accept", "application/json")
.build() .build()
executeJson(request, "Hermes audio transcribe").mapCatching { root -> executeJson(request, "Hermes audio transcribe", callClient).mapCatching { root ->
val transcript = root.stringField("transcript") val transcript = root.stringField("transcript")
?: root.stringField("text") ?: root.stringField("text")
?: root.stringField("message") ?: root.stringField("message")
@@ -108,6 +114,7 @@ class StandardHermesVoiceClient(
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) { override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
val baseUrl = dashboardBaseUrl() val baseUrl = dashboardBaseUrl()
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured")) ?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
val callClient = callClient(baseUrl)
val cleanText = text.trim() val cleanText = text.trim()
if (cleanText.isBlank()) { if (cleanText.isBlank()) {
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text")) return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
@@ -130,7 +137,7 @@ class StandardHermesVoiceClient(
.header("Accept", "application/json") .header("Accept", "application/json")
.build() .build()
executeJson(request, "Hermes audio speak").mapCatching { root -> executeJson(request, "Hermes audio speak", callClient).mapCatching { root ->
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl") val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
if (dataUrl.isNullOrBlank()) { if (dataUrl.isNullOrBlank()) {
throw IOException("Hermes audio speak returned no audio") throw IOException("Hermes audio speak returned no audio")
@@ -148,10 +155,54 @@ class StandardHermesVoiceClient(
} }
} }
override suspend fun openSpeechStream(
callbacks: VoiceSpeechStreamCallbacks,
): Result<VoiceSpeechStream?> = withContext(Dispatchers.IO) {
try {
val baseUrl = dashboardBaseUrl()
?: throw IllegalStateException("Hermes dashboard URL not configured")
val callClient = callClient(baseUrl)
val ticketUrl = "$baseUrl/api/auth/ws-ticket".toHttpUrlOrNull()
?: throw IOException("Hermes dashboard URL is not a valid address: $baseUrl")
val ticketRequest = Request.Builder()
.url(ticketUrl)
.post(ByteArray(0).toRequestBody(null))
.build()
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket", callClient)
.getOrThrow()
.stringField("ticket")
?: throw IOException("Dashboard websocket ticket response missing ticket")
val websocketUrl = DashboardApiClient.gatewayWebSocketUrl(
baseUrl = baseUrl,
ticket = ticket,
path = "/api/audio/speak-stream",
) ?: throw IOException("Could not build Hermes speech stream URL")
val request = Request.Builder().url(websocketUrl).build()
StandardHermesSpeechStream(
request = request,
callbacks = callbacks,
json = json,
socketFactory = webSocketFactory ?: callClient::newWebSocket,
).also { it.connect() }
.let { Result.success<VoiceSpeechStream?>(it) }
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
Result.failure(error)
}
}
private fun dashboardBaseUrl(): String? = private fun dashboardBaseUrl(): String? =
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() } dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
private fun executeJson(request: Request, operation: String): Result<JsonObject> { private fun callClient(baseUrl: String): OkHttpClient =
standardHermesDashboardAudioClient(dashboardHttpClientProvider(baseUrl))
private fun executeJson(
request: Request,
operation: String,
callClient: OkHttpClient,
): Result<JsonObject> {
return try { return try {
callClient.newCall(request).execute().use { response -> callClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
@@ -242,3 +293,209 @@ class StandardHermesVoiceClient(
const val MAX_TRANSCRIBE_BYTES = 25L * 1024 * 1024 const val MAX_TRANSCRIBE_BYTES = 25L * 1024 * 1024
} }
} }
private class StandardHermesSpeechStream(
private val request: Request,
private val callbacks: VoiceSpeechStreamCallbacks,
private val json: Json,
private val socketFactory: (Request, WebSocketListener) -> WebSocket,
) : VoiceSpeechStream {
private val lock = Any()
private val outcome = CompletableDeferred<VoiceSpeechStreamOutcome>()
private val pendingFrames = ArrayDeque<String>()
private var socket: WebSocket? = null
private var opened = false
private var stopped = false
private var finished = false
private var audioStarted = false
private var sampleRate = DEFAULT_SAMPLE_RATE
private var oddByteCarry: Byte? = null
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
val frames = synchronized(lock) {
if (stopped || outcome.isCompleted) {
webSocket.cancel()
return
}
socket = webSocket
opened = true
pendingFrames.toList().also { pendingFrames.clear() }
}
frames.forEach(webSocket::send)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val root = runCatching { json.decodeFromString<JsonObject>(text) }.getOrNull() ?: return
when (root.stringField("type")) {
"start" -> {
val nextRate = (root["sample_rate"] as? JsonPrimitive)?.contentOrNull
?.toIntOrNull()
?.takeIf { it > 0 }
?: DEFAULT_SAMPLE_RATE
val channels = (root["channels"] as? JsonPrimitive)?.contentOrNull
?.toIntOrNull()
?.takeIf { it > 0 }
?: 1
if (channels != 1) {
settle(
status = VoiceSpeechStreamStatus.Fallback,
error = IOException("Hermes speech stream returned $channels channels; mono required"),
)
webSocket.cancel()
return
}
synchronized(lock) { sampleRate = nextRate }
callbacks.onStart(nextRate, channels)
}
"fallback" -> {
val heardAudio = synchronized(lock) { audioStarted }
settle(
status = if (heardAudio) {
VoiceSpeechStreamStatus.Completed
} else {
VoiceSpeechStreamStatus.Fallback
},
)
webSocket.close(1000, "fallback")
}
"end" -> {
settle(VoiceSpeechStreamStatus.Completed)
webSocket.close(1000, "complete")
}
}
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
val delivery = synchronized(lock) {
if (stopped || outcome.isCompleted) return
var incoming = bytes.toByteArray()
oddByteCarry?.let { carry ->
incoming = byteArrayOf(carry) + incoming
oddByteCarry = null
}
val usable = incoming.size - (incoming.size % 2)
if (usable < incoming.size) oddByteCarry = incoming.last()
if (usable == 0) return
audioStarted = true
incoming.copyOf(usable) to sampleRate
}
runCatching { callbacks.onPcm(delivery.first, delivery.second) }
.onFailure { error ->
settle(VoiceSpeechStreamStatus.Failed, error)
webSocket.cancel()
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
webSocket.close(code, reason)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
settleForDisconnect(t)
}
}
fun connect() {
val created = socketFactory(request, listener)
synchronized(lock) {
if (socket == null) socket = created
if (stopped) created.cancel()
}
}
override fun append(text: String) {
if (text.isEmpty()) return
sendFrame(buildJsonObject { put("text", text) })
}
override fun finish() {
synchronized(lock) {
if (finished || stopped || outcome.isCompleted) return
finished = true
}
sendFrame(buildJsonObject { put("done", true) })
}
override fun stop() {
val current = synchronized(lock) {
if (stopped) return
stopped = true
socket
}
if (current != null) {
current.send(json.encodeToString(JsonObject.serializer(), buildJsonObject { put("stop", true) }))
current.cancel()
}
settle(VoiceSpeechStreamStatus.Stopped)
}
override suspend fun awaitOutcome(): VoiceSpeechStreamOutcome = outcome.await()
private fun sendFrame(frame: JsonObject) {
val encoded = json.encodeToString(JsonObject.serializer(), frame)
val current = synchronized(lock) {
if (stopped || outcome.isCompleted) return
if (!opened) {
pendingFrames.addLast(encoded)
return
}
socket
}
if (current?.send(encoded) != true) {
settleForDisconnect(IOException("Hermes speech stream send failed"))
}
}
private fun settleForDisconnect(error: Throwable) {
val heardAudio = synchronized(lock) { audioStarted }
settle(
status = if (heardAudio) VoiceSpeechStreamStatus.Failed else VoiceSpeechStreamStatus.Fallback,
error = error,
)
}
private fun settle(status: VoiceSpeechStreamStatus, error: Throwable? = null) {
val result = synchronized(lock) {
if (outcome.isCompleted) return
VoiceSpeechStreamOutcome(
status = status,
audioStarted = audioStarted,
error = error,
)
}
outcome.complete(result)
}
private fun JsonObject.stringField(name: String): String? =
((this[name] as? JsonPrimitive)?.contentOrNull)?.trim()?.takeIf { it.isNotBlank() }
private companion object {
const val DEFAULT_SAMPLE_RATE = 24_000
}
}
internal const val STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS = 180L
internal fun standardHermesDashboardAudioTimeoutSeconds(requestedSeconds: Long): Long =
requestedSeconds.coerceIn(
minimumValue = 180L,
maximumValue = 600L,
)
internal fun standardHermesDashboardAudioClient(
baseClient: OkHttpClient,
timeoutSeconds: Long = STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS,
): OkHttpClient {
val boundedTimeoutSeconds = standardHermesDashboardAudioTimeoutSeconds(timeoutSeconds)
return baseClient.newBuilder()
.callTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
.readTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
.writeTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
.build()
}
@@ -163,7 +163,8 @@ data class SessionItem(
@SerialName("message_count") val messageCount: Int? = null, @SerialName("message_count") val messageCount: Int? = null,
@SerialName("tool_call_count") val toolCallCount: Int? = null, @SerialName("tool_call_count") val toolCallCount: Int? = null,
@SerialName("input_tokens") val inputTokens: Int? = null, @SerialName("input_tokens") val inputTokens: Int? = null,
@SerialName("output_tokens") val outputTokens: Int? = null @SerialName("output_tokens") val outputTokens: Int? = null,
@SerialName("has_model_config") val hasModelConfig: Boolean = false,
) { ) {
val resolvedLastActivity: Double? val resolvedLastActivity: Double?
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
@@ -259,6 +260,8 @@ data class MessageItem(
val toolCallId: String? = null, val toolCallId: String? = null,
val timestamp: Double? = null, val timestamp: Double? = null,
@SerialName("finish_reason") val finishReason: String? = null, @SerialName("finish_reason") val finishReason: String? = null,
@SerialName("display_kind") val displayKind: String? = null,
@SerialName("display_metadata") val displayMetadata: JsonObject? = null,
// Reasoning persisted with the assistant message (upstream serializes // Reasoning persisted with the assistant message (upstream serializes
// both names; reasoning is the canonical one). Restored into // both names; reasoning is the canonical one). Restored into
// ChatMessage.thinkingContent so the Thought-process block survives a // ChatMessage.thinkingContent so the Thought-process block survives a
@@ -389,7 +392,9 @@ data class HermesSseEvent(
@SerialName("thinking_delta") val thinkingDelta: String? = null, @SerialName("thinking_delta") val thinkingDelta: String? = null,
val text: String? = null, // /v1/runs reasoning.available text val text: String? = null, // /v1/runs reasoning.available text
// Usage/token fields (on assistant.completed / run.completed) // Usage/token fields (on assistant.completed / run.completed)
val usage: UsageInfo? = null val usage: UsageInfo? = null,
// Native session routing proof on run.started and terminal events.
val runtime: JsonObject? = null,
) { ) {
/** Resolve the event type from whichever field is populated. */ /** Resolve the event type from whichever field is populated. */
val resolvedType: String? val resolvedType: String?
@@ -0,0 +1,216 @@
package com.hermesandroid.relay.notifications
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.hermesandroid.relay.MainActivity
import com.hermesandroid.relay.R
import com.hermesandroid.relay.network.upstream.GatewayAsk
/**
* Action-required notifications for Gateway turns blocked on user input.
*
* Notification copy is deliberately generic. Approval commands, clarification
* questions, secret prompts, environment-variable names, and password requests
* stay inside the authenticated chat surface and never appear on the lock
* screen. A stable tag derived from the durable session and request identity
* makes replay/reconnect delivery replace the existing notification.
*/
object InteractionRequestNotifier {
private const val TAG = "InteractionNotifier"
internal const val CHANNEL_ID = "chat_interactions"
private const val CHANNEL_NAME = "Hermes needs input"
private const val GROUP_KEY = "gateway-interactions"
internal const val NOTIFICATION_ID = 3823
internal const val DEFAULT_PROFILE_ROUTE_VALUE = "__server_default__"
internal fun shouldPost(
alertsEnabled: Boolean,
appForeground: Boolean,
hasPermission: Boolean,
): Boolean = alertsEnabled && !appForeground && hasPermission
internal fun requestKey(sessionId: String, ask: GatewayAsk, profile: String? = null): String {
val requestIdentity = ask.requestId?.takeIf { it.isNotBlank() } ?: "session"
return "${profile ?: DEFAULT_PROFILE_ROUTE_VALUE}:$sessionId:${ask.kind.name}:$requestIdentity"
}
internal fun notificationTag(
sessionId: String,
ask: GatewayAsk,
profile: String? = null,
): String = "gateway-interaction:${requestKey(sessionId, ask, profile)}"
internal fun chatRoute(sessionId: String, profile: String? = null): String =
"chat?sessionId=${Uri.encode(sessionId)}&profile=${Uri.encode(profile ?: DEFAULT_PROFILE_ROUTE_VALUE)}"
internal fun safeTitle(ask: GatewayAsk): String = when (ask.kind) {
GatewayAsk.Kind.APPROVAL -> "Hermes needs approval"
GatewayAsk.Kind.CLARIFY -> "Hermes has a question"
GatewayAsk.Kind.SUDO,
GatewayAsk.Kind.SECRET,
-> "Hermes needs sensitive input"
}
internal fun safeBody(ask: GatewayAsk): String = when (ask.kind) {
GatewayAsk.Kind.APPROVAL -> "Open Hermes to review the requested action."
GatewayAsk.Kind.CLARIFY -> "Open Hermes to answer and continue this turn."
GatewayAsk.Kind.SUDO,
GatewayAsk.Kind.SECRET,
-> "Open Hermes to respond securely."
}
internal fun safeExpandedBody(
sessionId: String,
ask: GatewayAsk,
profile: String? = null,
): String {
val action = when (ask.kind) {
GatewayAsk.Kind.APPROVAL ->
"Review the requested action. Nothing is approved from the notification."
GatewayAsk.Kind.CLARIFY -> "Open this conversation to answer Hermes' question."
GatewayAsk.Kind.SUDO -> "Open this conversation to respond securely or deny."
GatewayAsk.Kind.SECRET -> "Open this conversation to respond securely or skip."
}
val profileLabel = profile?.takeIf { it.isNotBlank() } ?: "Server default"
val sessionLabel = sessionId.takeLast(12)
return "$action\nProfile: $profileLabel\nSession: …$sessionLabel"
}
internal fun actionLabel(ask: GatewayAsk): String = when (ask.kind) {
GatewayAsk.Kind.APPROVAL -> "Review approval"
GatewayAsk.Kind.CLARIFY -> "Answer"
GatewayAsk.Kind.SUDO,
GatewayAsk.Kind.SECRET,
-> "Respond securely"
}
@SuppressLint("MissingPermission", "NotificationPermission")
fun notify(
context: Context,
sessionId: String,
ask: GatewayAsk,
profile: String? = null,
alertsEnabled: Boolean,
appForeground: Boolean,
): Boolean {
ensureChannel(context)
if (!shouldPost(alertsEnabled, appForeground, hasPostNotificationsPermission(context))) {
return false
}
val tag = notificationTag(sessionId, ask, profile)
val requestKey = requestKey(sessionId, ask, profile)
val requestCode = requestKey.hashCode()
val tapIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
data = Uri.Builder()
.scheme("hermes-relay")
.authority("interaction")
.appendPath(requestKey)
.build()
putExtra(MainActivity.EXTRA_NAV_ROUTE, chatRoute(sessionId, profile))
}
val tapPending = PendingIntent.getActivity(
context,
requestCode,
tapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val title = safeTitle(ask)
val body = safeBody(ask)
val expandedBody = safeExpandedBody(sessionId, ask, profile)
val publicVersion = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Hermes needs your input")
.setContentText("Open Hermes to continue.")
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.build()
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedBody))
.setContentIntent(tapPending)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
.setGroup(GROUP_KEY)
.addAction(0, actionLabel(ask), tapPending)
.build()
return runCatching {
NotificationManagerCompat.from(context).notify(tag, NOTIFICATION_ID, notification)
true
}.onFailure {
Log.w(TAG, "notify failed", it)
}.getOrDefault(false)
}
fun cancel(context: Context, sessionId: String, ask: GatewayAsk, profile: String? = null) {
runCatching {
NotificationManagerCompat.from(context)
.cancel(notificationTag(sessionId, ask, profile), NOTIFICATION_ID)
}.onFailure {
Log.w(TAG, "cancel failed", it)
}
}
/**
* Clear only this feature's notifications. Android keeps notifications
* across process death, so the active-notification scan is also used when
* MainActivity returns without an in-memory request registry.
*/
fun cancelAll(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java) ?: return
runCatching {
manager.activeNotifications
.filter { it.notification.channelId == CHANNEL_ID }
.forEach { manager.cancel(it.tag, it.id) }
}.onFailure {
Log.w(TAG, "cancelAll failed", it)
}
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Alerts when a Hermes turn is waiting for your response."
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
setShowBadge(true)
},
)
}
private fun hasPostNotificationsPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
}
}
@@ -35,6 +35,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@@ -103,10 +104,14 @@ import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.BridgePreferencesRepository import com.hermesandroid.relay.data.BridgePreferencesRepository
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
import com.hermesandroid.relay.data.BuildFlavor import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EnhancedVoiceOverrides import com.hermesandroid.relay.data.EnhancedVoiceOverrides
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.VoiceAudioRoute import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.data.VoicePreferencesRepository import com.hermesandroid.relay.data.VoicePreferencesRepository
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.data.VoiceSettings import com.hermesandroid.relay.data.VoiceSettings
import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.data.displayLabel import com.hermesandroid.relay.data.displayLabel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -128,6 +133,7 @@ import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
import com.hermesandroid.relay.ui.screens.ChatScreen import com.hermesandroid.relay.ui.screens.ChatScreen
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
import com.hermesandroid.relay.ui.screens.DashboardManagementScreen import com.hermesandroid.relay.ui.screens.DashboardManagementScreen
import com.hermesandroid.relay.ui.screens.DashboardSignInScreen
import com.hermesandroid.relay.ui.screens.DeveloperSettingsScreen import com.hermesandroid.relay.ui.screens.DeveloperSettingsScreen
import com.hermesandroid.relay.ui.screens.MediaSettingsScreen import com.hermesandroid.relay.ui.screens.MediaSettingsScreen
import com.hermesandroid.relay.ui.screens.PairedDevicesScreen import com.hermesandroid.relay.ui.screens.PairedDevicesScreen
@@ -150,13 +156,17 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient
import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
import com.hermesandroid.relay.network.upstream.DynamicDashboardCookieJar import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportPath
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ChatViewModel import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
import com.hermesandroid.relay.viewmodel.TerminalViewModel import com.hermesandroid.relay.viewmodel.TerminalViewModel
import com.hermesandroid.relay.viewmodel.VoiceViewModel import com.hermesandroid.relay.viewmodel.VoiceViewModel
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import com.hermesandroid.relay.audio.VoicePlayer import com.hermesandroid.relay.audio.VoicePlayer
import com.hermesandroid.relay.audio.VoiceRecorder import com.hermesandroid.relay.audio.VoiceRecorder
import com.hermesandroid.relay.audio.VoiceSfxPlayer import com.hermesandroid.relay.audio.VoiceSfxPlayer
@@ -174,14 +184,76 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
// Short-lived snackbar by default; retryable errors get Long so users have // Short-lived snackbar by default; retryable errors get Long so users have
// time to tap the action before it auto-dismisses. // time to tap the action before it auto-dismisses.
suspend fun SnackbarHostState.showHumanError(err: HumanError) { suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
showSnackbar( return showSnackbar(
message = err.body, message = err.body,
actionLabel = err.actionLabel, actionLabel = err.actionLabel,
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short, duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
) )
} }
/** Startup chrome should wait for either standard chat surface, not Relay. */
internal fun hasConfiguredStartupChat(connection: Connection?): Boolean =
connection?.capabilities?.chatConfigured == true
/**
* App-root chat health derived only from the two transports that can carry a
* conversation. Optional Relay state is deliberately absent.
*/
internal fun resolveAppChatRuntimeStatus(
connection: Connection?,
gatewayAvailability: GatewayAvailability,
apiHealth: ConnectionViewModel.HealthStatus,
): ChatRuntimeStatus {
val capabilities = connection?.capabilities
val gateway = when {
capabilities?.dashboardGatewayConfigured != true -> ChatTransportReadiness.NotConfigured
gatewayAvailability == GatewayAvailability.Ready -> ChatTransportReadiness.Ready
gatewayAvailability == GatewayAvailability.Unknown -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
}
val api = when {
capabilities?.apiServerConfigured != true -> ChatTransportReadiness.NotConfigured
apiHealth == ConnectionViewModel.HealthStatus.Reachable -> ChatTransportReadiness.Ready
apiHealth == ConnectionViewModel.HealthStatus.Unknown ||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
}
return resolveChatRuntimeStatus(gateway = gateway, apiSse = api)
}
/** Route represented by the app footer's currently usable chat transport. */
internal fun resolveFooterRouteCandidate(
runtimeStatus: ChatRuntimeStatus,
activeEndpoint: EndpointCandidate?,
connection: Connection?,
effectiveDashboardUrl: String,
): EndpointCandidate? {
val connected = runtimeStatus as? ChatRuntimeStatus.Connected ?: return null
return when (connected.transport) {
ChatTransportPath.Gateway -> {
val dashboardUrl = effectiveDashboardUrl.trim().trimEnd('/')
.ifBlank { connection?.resolvedDashboardUrl.orEmpty() }
if (dashboardUrl.isBlank()) {
null
} else {
val activeDashboardUrl = activeEndpoint?.dashboard?.url
?.trim()
?.trimEnd('/')
activeEndpoint?.takeIf { activeDashboardUrl == dashboardUrl }
?: Connection.endpointCandidateFromDashboardUrl(
role = Connection.inferRouteRole(dashboardUrl),
priority = activeEndpoint?.priority ?: 0,
dashboardUrl = dashboardUrl,
)
}
}
ChatTransportPath.ApiSse -> activeEndpoint?.takeIf { it.api != null }
?: connection?.routeCandidates?.firstOrNull { it.api != null }
}
}
sealed class Screen( sealed class Screen(
val route: String, val route: String,
val label: String, val label: String,
@@ -203,17 +275,44 @@ sealed class Screen(
// NavHost, and the NavigationBarItem click must navigate via [route]() // NavHost, and the NavigationBarItem click must navigate via [route]()
// so no unresolved `{openAgentSheet}` leaks into the destination. // so no unresolved `{openAgentSheet}` leaks into the destination.
data object Chat : Screen( data object Chat : Screen(
"chat?openAgentSheet={openAgentSheet}", "chat?openAgentSheet={openAgentSheet}&sessionId={sessionId}&profile={profile}",
"Chat", "Chat",
Icons.AutoMirrored.Filled.Chat, Icons.AutoMirrored.Filled.Chat,
) { ) {
const val ARG_OPEN_AGENT_SHEET: String = "openAgentSheet" const val ARG_OPEN_AGENT_SHEET: String = "openAgentSheet"
fun route(openAgentSheet: Boolean = false): String = const val ARG_SESSION_ID: String = "sessionId"
if (openAgentSheet) "chat?openAgentSheet=true" else "chat" const val ARG_PROFILE: String = "profile"
fun route(
openAgentSheet: Boolean = false,
sessionId: String? = null,
profile: String? = null,
): String {
val params = buildList {
if (openAgentSheet) add("$ARG_OPEN_AGENT_SHEET=true")
sessionId?.takeIf { it.isNotBlank() }?.let {
add("$ARG_SESSION_ID=${android.net.Uri.encode(it)}")
}
profile?.takeIf { it.isNotBlank() }?.let {
add("$ARG_PROFILE=${android.net.Uri.encode(it)}")
}
}
return if (params.isEmpty()) "chat" else "chat?${params.joinToString("&")}"
}
} }
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code) data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid) data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
data object Manage : Screen("manage", "Manage", Icons.Filled.Settings) data object Manage : Screen("manage", "Manage", Icons.Filled.Settings)
data object DashboardSignIn : Screen(
"dashboard_sign_in?source={source}",
"Dashboard sign in",
Icons.Filled.Settings,
) {
const val ARG_SOURCE: String = "source"
const val SOURCE_GENERAL: String = "general"
const val SOURCE_PAIR: String = "pair"
const val SOURCE_ONBOARDING: String = "onboarding"
fun route(source: String = SOURCE_GENERAL): String = "dashboard_sign_in?source=$source"
}
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings) data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
// Non-bottom-nav destinations — reached by explicit navigation, not the // Non-bottom-nav destinations — reached by explicit navigation, not the
@@ -359,6 +458,12 @@ fun RelayApp() {
// ConnectionStore's mutations are all suspend fns and we don't want to // ConnectionStore's mutations are all suspend fns and we don't want to
// block the main dispatcher from inside the composable body. // block the main dispatcher from inside the composable body.
val connectionSwitchScope = rememberCoroutineScope() val connectionSwitchScope = rememberCoroutineScope()
// Add-connection preparation may outlive the initiating list frame now
// that navigation happens immediately. Keep the job by placeholder id so
// an instant Back can wait for creation and then discard it safely.
val pendingAddConnectionJobs = remember {
mutableMapOf<String, kotlinx.coroutines.Job>()
}
// One-time init: the terminal channel ViewModel registers with the shared // One-time init: the terminal channel ViewModel registers with the shared
// multiplexer and observes the relay connection state so it can attach/ // multiplexer and observes the relay connection state so it can attach/
@@ -434,11 +539,13 @@ fun RelayApp() {
} }
} }
// Initialize ChatViewModel reactively when the chat-routed API client becomes available // Bind chat state independently of the optional API fallback client.
val chatApiClient by connectionViewModel.chatApiClient.collectAsState() val chatApiClient by connectionViewModel.chatApiClient.collectAsState()
val chatTransportReady by connectionViewModel.chatReady.collectAsState()
val lastSessionId by connectionViewModel.lastSessionId.collectAsState() val lastSessionId by connectionViewModel.lastSessionId.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState() val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val effectiveSessionProfileName by connectionViewModel.effectiveSessionProfileName.collectAsState() val effectiveSessionProfileName by connectionViewModel.effectiveSessionProfileName.collectAsState()
val effectiveDisplayProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState() val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState() val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState() val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
@@ -493,15 +600,9 @@ fun RelayApp() {
val standardVoiceClient = remember { val standardVoiceClient = remember {
StandardHermesVoiceClient( StandardHermesVoiceClient(
context = mediaContext, context = mediaContext,
okHttpClient = okhttp3.OkHttpClient.Builder() dashboardHttpClientProvider = { dashboardUrl ->
.cookieJar( connectionViewModel.dashboardHttpClientForActive(dashboardUrl)
DynamicDashboardCookieJar { },
connectionViewModel.activeDashboardCookieStore()
},
)
.readTimeout(2, java.util.concurrent.TimeUnit.MINUTES)
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
.build(),
dashboardUrlProvider = { connectionViewModel.activeDashboardUrl() }, dashboardUrlProvider = { connectionViewModel.activeDashboardUrl() },
// Live read (null for the default profile) — sent defensively on // Live read (null for the default profile) — sent defensively on
// /api/audio/speak; upstream ignores it, so standard voice stays the // /api/audio/speak; upstream ignores it, so standard voice stays the
@@ -633,9 +734,10 @@ fun RelayApp() {
} }
} }
LaunchedEffect(chatApiClient) { var boundCatalogConnectionId by remember { mutableStateOf<String?>(null) }
val client = chatApiClient ?: return@LaunchedEffect LaunchedEffect(chatApiClient, activeConnectionId) {
val handler = connectionViewModel.chatHandler val handler = connectionViewModel.chatHandler
val connectionChanged = boundCatalogConnectionId != activeConnectionId
// A route handoff / reconnect rebuilds the API client (new instance) // A route handoff / reconnect rebuilds the API client (new instance)
// while the chat is unchanged — the bound handler is the same. Take the // while the chat is unchanged — the bound handler is the same. Take the
// cheap path: swap the client reference only, no re-init. This is what // cheap path: swap the client reference only, no re-init. This is what
@@ -643,11 +745,24 @@ fun RelayApp() {
// switch or a reconnect. A genuine re-bind (different handler) falls // switch or a reconnect. A genuine re-bind (different handler) falls
// through to the full one-time wiring below. // through to the full one-time wiring below.
if (chatViewModel.boundHandler === handler) { if (chatViewModel.boundHandler === handler) {
chatViewModel.updateApiClient(client) if (connectionChanged) {
chatViewModel.resetConnectionCatalogs()
// The active pointer changes before an API target is rebuilt.
// Never let the outgoing API client refill the new connection's
// catalogs during that window. Dashboard-only (null -> null)
// refreshes immediately through the already-installed loader.
chatViewModel.updateApiClient(null)
} else {
chatViewModel.updateApiClient(chatApiClient)
}
boundCatalogConnectionId = activeConnectionId
return@LaunchedEffect return@LaunchedEffect
} }
chatViewModel.initialize(client, handler) // The Dashboard/Gateway transport is independently sufficient for
// chat, so handler and dashboard callbacks must bind even when no API
// fallback client is configured.
chatViewModel.initialize(chatApiClient, handler)
// Wire inbound-media dependencies. Idempotent rewire of the // Wire inbound-media dependencies. Idempotent rewire of the
// ChatHandler callbacks. // ChatHandler callbacks.
@@ -665,6 +780,9 @@ fun RelayApp() {
chatViewModel.setSelectedProfileProvider { chatViewModel.setSelectedProfileProvider {
connectionViewModel.selectedProfile.value connectionViewModel.selectedProfile.value
} }
chatViewModel.setIsolatedProfileApiProvider {
connectionViewModel.selectedProfileUsesIsolatedApiRoute()
}
chatViewModel.setSessionProfileNameProvider { chatViewModel.setSessionProfileNameProvider {
connectionViewModel.effectiveSessionProfileName.value connectionViewModel.effectiveSessionProfileName.value
} }
@@ -675,10 +793,7 @@ fun RelayApp() {
) )
} }
chatViewModel.setDisplayProfileProvider { chatViewModel.setDisplayProfileProvider {
AgentDisplay.effectiveDisplayProfile( connectionViewModel.effectiveDisplayProfile.value
selectedProfile = connectionViewModel.selectedProfile.value,
profiles = connectionViewModel.agentProfiles.value,
)
} }
chatViewModel.setDisplayAliasProvider { chatViewModel.setDisplayAliasProvider {
connectionViewModel.profileDisplayAlias.value connectionViewModel.profileDisplayAlias.value
@@ -694,6 +809,12 @@ fun RelayApp() {
chatViewModel.setProfileMessageLoader { sessionId -> chatViewModel.setProfileMessageLoader { sessionId ->
connectionViewModel.loadProfileScopedMessages(sessionId) connectionViewModel.loadProfileScopedMessages(sessionId)
} }
// Personality choices live in Dashboard `/api/config` on a
// dashboard-only connection. The setter immediately refreshes after
// this callback is installed, covering the null-API initialization.
chatViewModel.setDashboardConfigLoader {
connectionViewModel.loadActiveDashboardConfig()
}
// …and delete from that same profile's DB so a non-default profile's // …and delete from that same profile's DB so a non-default profile's
// session can't be resurrected by the next profile-scoped list. // session can't be resurrected by the next profile-scoped list.
chatViewModel.profileSessionDeleter = { sessionId -> chatViewModel.profileSessionDeleter = { sessionId ->
@@ -710,6 +831,7 @@ fun RelayApp() {
chatViewModel.onSessionChanged = { sessionId -> chatViewModel.onSessionChanged = { sessionId ->
connectionViewModel.saveLastSessionId(sessionId) connectionViewModel.saveLastSessionId(sessionId)
} }
boundCatalogConnectionId = activeConnectionId
} }
// Reload sessions / switch profile context only on a SEMANTIC change // Reload sessions / switch profile context only on a SEMANTIC change
@@ -718,16 +840,15 @@ fun RelayApp() {
// means a route handoff (which churns the client) no longer triggers a // means a route handoff (which churns the client) no longer triggers a
// refreshSessions() that would flash/reload the chat. `switchProfileContext` // refreshSessions() that would flash/reload the chat. `switchProfileContext`
// already no-ops when the context key + session are unchanged. // already no-ops when the context key + session are unchanged.
val chatClientReady = chatApiClient != null
LaunchedEffect( LaunchedEffect(
chatClientReady, chatTransportReady,
activeConnectionId, activeConnectionId,
selectedProfile?.name, selectedProfile?.name,
effectiveSessionProfileName, effectiveSessionProfileName,
lastSessionId, lastSessionId,
profileSelectionSettled, profileSelectionSettled,
) { ) {
if (!chatClientReady) return@LaunchedEffect if (!chatTransportReady) return@LaunchedEffect
// Cold-start profile-isolation guard: hold the first profile-scoped load // Cold-start profile-isolation guard: hold the first profile-scoped load
// until the persisted profile selection has SETTLED, so the session // until the persisted profile selection has SETTLED, so the session
// drawer (and the restored session context) don't briefly load the // drawer (and the restored session context) don't briefly load the
@@ -770,7 +891,7 @@ fun RelayApp() {
) )
} }
LaunchedEffect(selectedProfile?.name, agentProfiles, profileDisplayAlias) { LaunchedEffect(selectedProfile?.name, effectiveDisplayProfile?.name, agentProfiles, profileDisplayAlias) {
chatViewModel.refreshAgentDisplayName(relabelGenericMessages = true) chatViewModel.refreshAgentDisplayName(relabelGenericMessages = true)
} }
@@ -847,10 +968,10 @@ fun RelayApp() {
// re-runs activeGatewayChatClient(), which RETARGETS the in-flight gateway // re-runs activeGatewayChatClient(), which RETARGETS the in-flight gateway
// client to follow the new dashboard route instead of stranding the turn on // client to follow the new dashboard route instead of stranding the turn on
// the dead one. // the dead one.
val effectiveApiUrl by connectionViewModel.effectiveApiServerUrl.collectAsState() val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
// Debounce a route FLIP before re-acquiring the gateway chat client. The // Debounce a route FLIP before re-acquiring the gateway chat client. The
// network-layer hysteresis (ConnectionManager) already keeps _activeEndpoint // network-layer hysteresis (ConnectionManager) already keeps _activeEndpoint
// stable on a transient endpoint-resolution miss, so effectiveApiUrl should // stable on a transient endpoint-resolution miss, so the Dashboard URL should
// not flap — this is belt-and-suspenders against any residual sub-second // not flap — this is belt-and-suspenders against any residual sub-second
// LAN⇄Tailscale flip, which would otherwise shutdown the warm gateway socket // LAN⇄Tailscale flip, which would otherwise shutdown the warm gateway socket
// (when idle) or retarget mid-turn (burning MAX_TURN_REJOINS). The FIRST // (when idle) or retarget mid-turn (burning MAX_TURN_REJOINS). The FIRST
@@ -859,9 +980,17 @@ fun RelayApp() {
// unaffected; only a genuine url change waits for a settle window, and if // unaffected; only a genuine url change waits for a settle window, and if
// the url flips back within it the LaunchedEffect cancels + restarts so no // the url flips back within it the LaunchedEffect cancels + restarts so no
// rebuild happens. // rebuild happens.
var lastAcquiredApiUrl by remember { mutableStateOf<String?>(null) } var lastAcquiredDashboardUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(streamingEndpoint, serverCapabilities, gatewayAvailability, effectiveApiUrl) { LaunchedEffect(
if (lastAcquiredApiUrl != null && lastAcquiredApiUrl != effectiveApiUrl) { streamingEndpoint,
serverCapabilities,
gatewayAvailability,
effectiveDashboardUrl,
) {
if (
lastAcquiredDashboardUrl != null &&
lastAcquiredDashboardUrl != effectiveDashboardUrl
) {
delay(750L) delay(750L)
} }
val resolved = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint) val resolved = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
@@ -870,7 +999,7 @@ fun RelayApp() {
chatViewModel.updateGatewayClient( chatViewModel.updateGatewayClient(
if (resolved == "gateway") connectionViewModel.activeGatewayChatClient() else null, if (resolved == "gateway") connectionViewModel.activeGatewayChatClient() else null,
) )
lastAcquiredApiUrl = effectiveApiUrl lastAcquiredDashboardUrl = effectiveDashboardUrl
} }
// What's New auto-show // What's New auto-show
@@ -961,7 +1090,6 @@ fun RelayApp() {
CrashReportGate() CrashReportGate()
val navController = rememberNavController() val navController = rememberNavController()
var postOnboardingRoute by remember { mutableStateOf<String?>(null) }
// === PHASE3-safety-rails-followup: cross-layer deep-link nav === // === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
// Collect navigation requests posted by external launchers (e.g., the // Collect navigation requests posted by external launchers (e.g., the
@@ -1026,17 +1154,6 @@ fun RelayApp() {
} }
} }
LaunchedEffect(onboardingCompleted, postOnboardingRoute) {
val route = postOnboardingRoute
if (onboardingCompleted && route != null) {
postOnboardingRoute = null
navController.navigate(route) {
popUpTo(Screen.Onboarding.route) { inclusive = true }
launchSingleTop = true
}
}
}
// startDestination uses the route TEMPLATE so it matches the // startDestination uses the route TEMPLATE so it matches the
// composable registered below; optional args default to null/false. // composable registered below; optional args default to null/false.
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
@@ -1127,11 +1244,8 @@ fun RelayApp() {
// bottom navigation bar so the voice overlay can own the entire screen // bottom navigation bar so the voice overlay can own the entire screen
// without the Chat/Terminal/Bridge/Settings tabs peeking through below. // without the Chat/Terminal/Bridge/Settings tabs peeking through below.
val voiceUiState by voiceViewModel.uiState.collectAsState() val voiceUiState by voiceViewModel.uiState.collectAsState()
val globalConnectionStatus by connectionViewModel.globalConnectionStatus.collectAsState()
val postResumeQuiet by connectionViewModel.postResumeQuiet.collectAsState() val postResumeQuiet by connectionViewModel.postResumeQuiet.collectAsState()
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
val apiHealth by connectionViewModel.apiServerHealth.collectAsState() val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
val relayReady by connectionViewModel.relayReady.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState() val activeConnection by connectionViewModel.activeConnection.collectAsState()
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState() val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState() val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
@@ -1166,17 +1280,16 @@ fun RelayApp() {
startupGateTimedOut = true startupGateTimedOut = true
} }
val hasStartupConnection = activeConnection?.apiServerUrl?.isNotBlank() == true val hasStartupConnection = hasConfiguredStartupChat(activeConnection)
// A published activeEndpoint counts as "hermes online": the route val appChatRuntimeStatus = resolveAppChatRuntimeStatus(
// resolver only publishes a winner after a successful HEAD /health connection = activeConnection,
// probe against that route's API URL. At cold start this evidence gatewayAvailability = gatewayAvailability,
// lands within ~1s — long before the client-based health probe, apiHealth = apiHealth,
// which can't run until the API client exists (the client build )
// used to queue behind the Keystore decrypt; see // A Dashboard/Gateway-only connection is a complete standard Hermes
// apiKeyForClientBuild in ConnectionViewModel). // connection. Startup readiness follows the same transport-neutral
val startupApiUp = apiReachable || // priority as the footer instead of waiting for an optional API probe.
apiHealth == ConnectionViewModel.HealthStatus.Reachable || val startupChatUp = appChatRuntimeStatus is ChatRuntimeStatus.Connected
activeEndpoint != null
// An Unreachable verdict only counts after it SURVIVES a settle // An Unreachable verdict only counts after it SURVIVES a settle
// window: the first health probe often runs against the persisted // window: the first health probe often runs against the persisted
@@ -1185,9 +1298,9 @@ fun RelayApp() {
// first verdict was what flashed the disconnected chat UI at users // first verdict was what flashed the disconnected chat UI at users
// who were connected-just-waiting. The keyed effect restarts on // who were connected-just-waiting. The keyed effect restarts on
// every health flip, cancelling a pending settle. // every health flip, cancelling a pending settle.
LaunchedEffect(apiHealth, startupGateReleased) { LaunchedEffect(appChatRuntimeStatus, startupGateReleased) {
if (startupGateReleased) return@LaunchedEffect if (startupGateReleased) return@LaunchedEffect
if (apiHealth == ConnectionViewModel.HealthStatus.Unreachable) { if (hasStartupConnection && appChatRuntimeStatus is ChatRuntimeStatus.Unavailable) {
delay(3_000L) delay(3_000L)
startupUnreachableSettled = true startupUnreachableSettled = true
} else { } else {
@@ -1211,16 +1324,16 @@ fun RelayApp() {
StartupCheckState.Done, StartupCheckState.Done,
"route · ${startupEndpoint.displayLabel()}", "route · ${startupEndpoint.displayLabel()}",
) )
startupApiUp -> startupChatUp ->
StartupCheck(StartupCheckState.Done, "route · direct") StartupCheck(StartupCheckState.Done, "route · direct")
appReady -> appReady ->
StartupCheck(StartupCheckState.Active, "resolving route") StartupCheck(StartupCheckState.Active, "resolving route")
else -> StartupCheck(StartupCheckState.Pending, "route") else -> StartupCheck(StartupCheckState.Pending, "route")
}, },
when { when {
startupApiUp -> startupChatUp ->
StartupCheck(StartupCheckState.Done, "hermes online") StartupCheck(StartupCheckState.Done, "hermes online")
apiHealth == ConnectionViewModel.HealthStatus.Unreachable -> appChatRuntimeStatus is ChatRuntimeStatus.Unavailable ->
StartupCheck(StartupCheckState.Failed, "hermes unreachable") StartupCheck(StartupCheckState.Failed, "hermes unreachable")
appReady -> appReady ->
StartupCheck(StartupCheckState.Active, "contacting hermes") StartupCheck(StartupCheckState.Active, "contacting hermes")
@@ -1232,7 +1345,7 @@ fun RelayApp() {
when { when {
chatReady && initialChatSettled -> chatReady && initialChatSettled ->
StartupCheck(StartupCheckState.Done, "conversation ready") StartupCheck(StartupCheckState.Done, "conversation ready")
startupApiUp -> startupChatUp ->
StartupCheck(StartupCheckState.Active, "loading conversation") StartupCheck(StartupCheckState.Active, "loading conversation")
else -> StartupCheck(StartupCheckState.Pending, "conversation") else -> StartupCheck(StartupCheckState.Pending, "conversation")
}, },
@@ -1346,7 +1459,8 @@ fun RelayApp() {
// the previous run). The pre-warm fills cold keys and refreshes // the previous run). The pre-warm fills cold keys and refreshes
// stale (disk-hydrated) ones, then mirrors results back to disk. // stale (disk-hydrated) ones, then mirrors results back to disk.
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState() val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl) { val effectiveManageProfile by connectionViewModel.effectiveSessionProfileName.collectAsState()
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl, effectiveManageProfile) {
val connection = activeConnection ?: return@LaunchedEffect val connection = activeConnection ?: return@LaunchedEffect
if (effectiveDashboardUrl.isBlank()) return@LaunchedEffect if (effectiveDashboardUrl.isBlank()) return@LaunchedEffect
val snapshot = connection.dashboardLastStatus ?: return@LaunchedEffect val snapshot = connection.dashboardLastStatus ?: return@LaunchedEffect
@@ -1357,12 +1471,13 @@ fun RelayApp() {
// The VM's cached per-connection store — the prewarm must NOT // The VM's cached per-connection store — the prewarm must NOT
// construct its own (each instance lazily pays a multi-second // construct its own (each instance lazily pays a multi-second
// Keystore keyset build under a process-global Tink lock). // Keystore keyset build under a process-global Tink lock).
val cookieStore = connectionViewModel.activeDashboardCookieStore()
?: return@LaunchedEffect
prewarmDashboardManage( prewarmDashboardManage(
cookieStore = cookieStore, clientFactory = {
connectionViewModel.dashboardClientForActive(effectiveDashboardUrl)
},
connectionId = connection.id, connectionId = connection.id,
dashboardUrl = effectiveDashboardUrl, dashboardUrl = effectiveDashboardUrl,
effectiveProfileName = effectiveManageProfile,
cacheDir = hydrateContext.cacheDir, cacheDir = hydrateContext.cacheDir,
context = hydrateContext, context = hydrateContext,
) )
@@ -1460,7 +1575,7 @@ fun RelayApp() {
// stays fully silent (the health "Connecting" cue used to flash here for a // stays fully silent (the health "Connecting" cue used to flash here for a
// few seconds and then clear with no "Connected" toast). // few seconds and then clear with no "Connected" toast).
val connectionReconnecting = val connectionReconnecting =
globalConnectionStatus?.active == true && !postResumeQuiet && appChatRuntimeStatus is ChatRuntimeStatus.Connecting && !postResumeQuiet &&
!suppressGlobalChrome && !showStartupSphere && !voiceUiState.voiceMode !suppressGlobalChrome && !showStartupSphere && !voiceUiState.voiceMode
// === END v0.4.1 polish === // === END v0.4.1 polish ===
@@ -1600,7 +1715,13 @@ fun RelayApp() {
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = { bottomBar = {
if (!suppressGlobalChrome && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) { if (!suppressGlobalChrome && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) {
val routeLabel = activeEndpoint?.displayLabel() val footerRoute = resolveFooterRouteCandidate(
runtimeStatus = appChatRuntimeStatus,
activeEndpoint = activeEndpoint,
connection = activeConnection,
effectiveDashboardUrl = effectiveDashboardUrl,
)
val routeLabel = footerRoute?.displayLabel()
?: activeConnection?.label ?: activeConnection?.label
?: stringResource(R.string.status_no_route) ?: stringResource(R.string.status_no_route)
val transportStatus = resolveChatTransportStatus( val transportStatus = resolveChatTransportStatus(
@@ -1613,12 +1734,9 @@ fun RelayApp() {
} else { } else {
routeLabel routeLabel
} }
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() } val profileLabel = AgentDisplay.profileDisplayName(effectiveDisplayProfile)
?: stringResource(R.string.status_profile_default) ?: stringResource(R.string.status_profile_default)
val displayProfile = AgentDisplay.effectiveDisplayProfile( val displayProfile = effectiveDisplayProfile
selectedProfile = selectedProfile,
profiles = agentProfiles,
)
val modelLabel = AgentDisplay.displayModelName(gatewayCurrentModel) val modelLabel = AgentDisplay.displayModelName(gatewayCurrentModel)
?: AgentDisplay.displayModelName(displayProfile?.model) ?: AgentDisplay.displayModelName(displayProfile?.model)
?: AgentDisplay.displayModelName(serverModelName) ?: AgentDisplay.displayModelName(serverModelName)
@@ -1701,8 +1819,9 @@ fun RelayApp() {
} }
}, },
onManageSignIn = { onManageSignIn = {
postOnboardingRoute = Screen.Manage.route navController.navigate(
connectionViewModel.completeOnboarding() Screen.DashboardSignIn.route(Screen.DashboardSignIn.SOURCE_ONBOARDING),
)
}, },
onOpenPermissions = { onOpenPermissions = {
navController.navigate(Screen.PermissionsSettings.route) navController.navigate(Screen.PermissionsSettings.route)
@@ -1717,6 +1836,16 @@ fun RelayApp() {
type = NavType.BoolType type = NavType.BoolType
defaultValue = false defaultValue = false
}, },
navArgument(Screen.Chat.ARG_SESSION_ID) {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument(Screen.Chat.ARG_PROFILE) {
type = NavType.StringType
nullable = true
defaultValue = null
},
), ),
) { backStackEntry -> ) { backStackEntry ->
// Responsive bubble width based on screen width. The "Blend" // Responsive bubble width based on screen width. The "Blend"
@@ -1741,6 +1870,48 @@ fun RelayApp() {
// sheet. // sheet.
val openAgentSheetArg = backStackEntry.arguments val openAgentSheetArg = backStackEntry.arguments
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true ?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
val requestedSessionId = backStackEntry.arguments
?.getString(Screen.Chat.ARG_SESSION_ID)
?.takeIf { it.isNotBlank() }
val requestedProfileRoute = backStackEntry.arguments
?.getString(Screen.Chat.ARG_PROFILE)
?.takeIf { it.isNotBlank() }
LaunchedEffect(
requestedSessionId,
requestedProfileRoute,
profileSelectionSettled,
effectiveSessionProfileName,
agentProfiles,
) {
val sessionId = requestedSessionId ?: return@LaunchedEffect
if (requestedProfileRoute != null) {
val targetProfile = requestedProfileRoute.takeUnless {
it == com.hermesandroid.relay.notifications
.InteractionRequestNotifier.DEFAULT_PROFILE_ROUTE_VALUE
}
if (!profileSelectionSettled) return@LaunchedEffect
if (effectiveSessionProfileName != targetProfile) {
val selection = targetProfile?.let { name ->
agentProfiles.firstOrNull { it.name == name }
}
if (targetProfile == null || selection != null) {
connectionViewModel.selectProfile(selection)
}
return@LaunchedEffect
}
chatViewModel.switchProfileContext(
contextKey = AgentDisplay.profileContextKey(
connectionId = activeConnectionId,
profileName = targetProfile,
),
sessionId = sessionId,
)
} else if (chatViewModel.currentSessionId.value != sessionId) {
chatViewModel.switchSession(sessionId)
}
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
}
val screenChatLabel = stringResource(R.string.screen_chat_label) val screenChatLabel = stringResource(R.string.screen_chat_label)
@@ -1750,6 +1921,14 @@ fun RelayApp() {
voiceViewModel = voiceViewModel, voiceViewModel = voiceViewModel,
voiceClient = voiceClient, voiceClient = voiceClient,
maxBubbleWidth = maxBubbleWidth, maxBubbleWidth = maxBubbleWidth,
voicePresentationMode = VoicePresentationMode.fromStorage(
voiceSettings.presentationMode,
),
onVoicePresentationModeChange = { mode ->
connectionSwitchScope.launch {
voicePreferences.setPresentationMode(mode)
}
},
openAgentSheetOnEntry = openAgentSheetArg, openAgentSheetOnEntry = openAgentSheetArg,
onAgentSheetArgConsumed = { onAgentSheetArgConsumed = {
backStackEntry.arguments?.putBoolean( backStackEntry.arguments?.putBoolean(
@@ -1767,6 +1946,16 @@ fun RelayApp() {
launchSingleTop = true launchSingleTop = true
} }
}, },
onRepairConnection = {
navController.navigate(
Screen.Pair.route(
connectionId = activeConnectionId,
autoStart = "relay",
),
) {
launchSingleTop = true
}
},
// Empty-chat "needs connection" card also offers the offline // Empty-chat "needs connection" card also offers the offline
// demo, so a skipped / never-connected first run can explore // demo, so a skipped / never-connected first run can explore
// without leaving Chat. Safe here — this state only shows when // without leaving Chat. Safe here — this state only shows when
@@ -1832,6 +2021,11 @@ fun RelayApp() {
onNavigateToConnections = { onNavigateToConnections = {
navController.navigate(Screen.ConnectionsSettings.route) navController.navigate(Screen.ConnectionsSettings.route)
}, },
onNavigateToSignIn = {
navController.navigate(Screen.DashboardSignIn.route()) {
launchSingleTop = true
}
},
// Standard back: return to wherever Manage was opened // Standard back: return to wherever Manage was opened
// from (Settings → Hermes management, the agent sheet, // from (Settings → Hermes management, the agent sheet,
// etc.). The prior forced navigate(Chat) with // etc.). The prior forced navigate(Chat) with
@@ -1865,6 +2059,39 @@ fun RelayApp() {
) )
} }
} }
composable(
route = Screen.DashboardSignIn.route,
arguments = listOf(
navArgument(Screen.DashboardSignIn.ARG_SOURCE) {
type = NavType.StringType
defaultValue = Screen.DashboardSignIn.SOURCE_GENERAL
},
),
) { backStackEntry ->
val source = backStackEntry.arguments
?.getString(Screen.DashboardSignIn.ARG_SOURCE)
?: Screen.DashboardSignIn.SOURCE_GENERAL
DashboardSignInScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() },
onAuthenticated = {
when (source) {
Screen.DashboardSignIn.SOURCE_ONBOARDING -> {
connectionViewModel.completeOnboarding()
navController.navigate(Screen.Chat.route()) {
popUpTo(Screen.Onboarding.route) { inclusive = true }
launchSingleTop = true
}
}
Screen.DashboardSignIn.SOURCE_PAIR -> {
navController.popBackStack()
navController.popBackStack()
}
else -> navController.popBackStack()
}
},
)
}
composable(Screen.Terminal.route) { composable(Screen.Terminal.route) {
if (coldStartAuthState is AuthState.Paired) { if (coldStartAuthState is AuthState.Paired) {
TerminalScreen( TerminalScreen(
@@ -2082,12 +2309,13 @@ fun RelayApp() {
voiceClient = voiceClient, voiceClient = voiceClient,
connectionId = activeConnectionId, connectionId = activeConnectionId,
selectedProfile = selectedProfile, selectedProfile = selectedProfile,
displayProfile = effectiveDisplayProfile,
standardVoiceAvailability = standardVoiceAvailability, standardVoiceAvailability = standardVoiceAvailability,
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint, standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
relayVoiceReady = relayVoiceReady, relayVoiceReady = relayVoiceReady,
dashboardUrl = voiceDashboardUrl, dashboardUrl = voiceDashboardUrl,
dashboardCookieStoreProvider = { dashboardClientProvider = { dashboardUrl ->
connectionViewModel.activeDashboardCookieStore() connectionViewModel.dashboardClientForActive(dashboardUrl)
}, },
onOpenManage = { onOpenManage = {
navController.navigate(Screen.Manage.route) { navController.navigate(Screen.Manage.route) {
@@ -2230,18 +2458,20 @@ fun RelayApp() {
navController.navigate(Screen.ConnectionDetail.route(id)) navController.navigate(Screen.ConnectionDetail.route(id))
}, },
onAddConnection = { onAddConnection = {
connectionSwitchScope.launch { val id = java.util.UUID.randomUUID().toString()
// Create and switch to the placeholder before // Draw step 1 immediately. Placeholder persistence
// opening the wizard. Otherwise a fast scan or // and the heavy connection-context switch continue
// standard save can write into the outgoing // underneath the discovery UI instead of blocking
// connection's auth store. // navigation on encrypted-store/client setup.
val id = connectionViewModel.beginAddConnection( navController.navigate(Screen.Pair.route(connectionId = id))
preAllocatedId = java.util.UUID.randomUUID().toString(), val job = connectionSwitchScope.launch {
) try {
navController.navigate( connectionViewModel.beginAddConnection(preAllocatedId = id)
Screen.Pair.route(connectionId = id) } finally {
) pendingAddConnectionJobs.remove(id)
}
} }
pendingAddConnectionJobs[id] = job
}, },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
// Pass the VM so the list cards can read live status // Pass the VM so the list cards can read live status
@@ -2283,7 +2513,7 @@ fun RelayApp() {
onRepair = { id -> onRepair = { id ->
connectionSwitchScope.launch { connectionSwitchScope.launch {
connectionViewModel.switchConnection(id).join() connectionViewModel.switchConnection(id).join()
navController.navigate(Screen.Pair.route(id)) navController.navigate(Screen.Pair.route(id, autoStart = "relay"))
} }
}, },
onRevoke = { id -> onRevoke = { id ->
@@ -2333,9 +2563,14 @@ fun RelayApp() {
?.getString(Screen.Pair.ARG_CONNECTION_ID) ?.getString(Screen.Pair.ARG_CONNECTION_ID)
val autoStartArg = backStackEntry.arguments val autoStartArg = backStackEntry.arguments
?.getString(Screen.Pair.ARG_AUTO_START) ?.getString(Screen.Pair.ARG_AUTO_START)
val pairConnections by connectionViewModel.connections.collectAsState()
val pairActiveId by connectionViewModel.activeConnectionId.collectAsState()
val pairSetupReady = connectionIdArg == null ||
(pairActiveId == connectionIdArg && pairConnections.any { it.id == connectionIdArg })
com.hermesandroid.relay.ui.screens.PairScreen( com.hermesandroid.relay.ui.screens.PairScreen(
connectionViewModel = connectionViewModel, connectionViewModel = connectionViewModel,
autoStart = autoStartArg, autoStart = autoStartArg,
setupReady = pairSetupReady,
// Offer demo only on the bare "Connect" entry (the // Offer demo only on the bare "Connect" entry (the
// "No Hermes connection" path) — not on add-connection / // "No Hermes connection" path) — not on add-connection /
// re-pair flows, which have a placeholder connection in // re-pair flows, which have a placeholder connection in
@@ -2354,8 +2589,9 @@ fun RelayApp() {
navController.popBackStack() navController.popBackStack()
}, },
onManageSignIn = { onManageSignIn = {
navController.popBackStack() navController.navigate(
navController.navigate(Screen.Manage.route) { Screen.DashboardSignIn.route(Screen.DashboardSignIn.SOURCE_PAIR),
) {
launchSingleTop = true launchSingleTop = true
} }
}, },
@@ -2367,6 +2603,10 @@ fun RelayApp() {
// never got a pairedAt stamp. // never got a pairedAt stamp.
if (connectionIdArg != null) { if (connectionIdArg != null) {
connectionSwitchScope.launch { connectionSwitchScope.launch {
// If Back wins the race with background
// preparation, wait until the placeholder
// exists before attempting to discard it.
pendingAddConnectionJobs.remove(connectionIdArg)?.join()
connectionViewModel.discardPlaceholderConnection(connectionIdArg) connectionViewModel.discardPlaceholderConnection(connectionIdArg)
} }
} }
@@ -2413,6 +2653,17 @@ fun RelayApp() {
onNavigateToRealtimeVoice = { onNavigateToRealtimeVoice = {
navController.navigate(Screen.RealtimeVoiceTest.route) navController.navigate(Screen.RealtimeVoiceTest.route)
}, },
onNavigateToImageGenerationLab = {
terminalAppContext.startActivity(
android.content.Intent().apply {
setClassName(
terminalAppContext,
"com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity",
)
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
},
) )
} }
composable(Screen.RealtimeVoiceTest.route) { composable(Screen.RealtimeVoiceTest.route) {
@@ -2502,9 +2753,9 @@ fun RelayApp() {
// doesn't happen to match the one we're inspecting // doesn't happen to match the one we're inspecting
// (shouldn't normally happen since the entry is // (shouldn't normally happen since the entry is
// keyed off the same Profile). // keyed off the same Profile).
val selectedProfile by connectionViewModel val inspectorDisplayProfile by connectionViewModel
.selectedProfile.collectAsState() .effectiveDisplayProfile.collectAsState()
val modelLabel = selectedProfile val modelLabel = inspectorDisplayProfile
?.takeIf { it.name == profileNameArg } ?.takeIf { it.name == profileNameArg }
?.model ?.model
File diff suppressed because it is too large Load Diff
@@ -72,6 +72,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.data.ChatMessage import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole import com.hermesandroid.relay.data.MessageRole
@@ -261,8 +262,12 @@ fun AgentTextFlow(
motionEnabled: Boolean, motionEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val flowStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) val flowStyle = MaterialTheme.typography.bodyMedium.copy(
val flowColor = MaterialTheme.colorScheme.onSurfaceVariant fontFamily = FontFamily.Monospace,
fontSize = 15.sp,
lineHeight = 21.sp,
)
val flowColor = MaterialTheme.colorScheme.onSurface
// Readable, non-faded mirror of the visible tail — used as the live-region // Readable, non-faded mirror of the visible tail — used as the live-region
// text on both paths so assistive tech hears the words. // text on both paths so assistive tech hears the words.
@@ -161,14 +161,37 @@ private fun putInlineImage(key: String, bitmap: ImageBitmap, sensitive: Boolean)
fun extractChatInlineImages(content: String): Pair<String, List<ChatInlineImage>> { fun extractChatInlineImages(content: String): Pair<String, List<ChatInlineImage>> {
if (!content.contains("![")) return content to emptyList() if (!content.contains("![")) return content to emptyList()
val images = mutableListOf<ChatInlineImage>() val images = mutableListOf<ChatInlineImage>()
var inlineDataImages = 0
var inlineDataBytes = 0L
var inlineOverflowNoticeAdded = false
val stripped = MARKDOWN_IMAGE_REGEX.replace(content) { m -> val stripped = MARKDOWN_IMAGE_REGEX.replace(content) { m ->
val spoilerWrapped = m.groupValues[1].isNotEmpty() && m.groupValues[4].isNotEmpty() val spoilerWrapped = m.groupValues[1].isNotEmpty() && m.groupValues[4].isNotEmpty()
val alt = m.groupValues[2].trim() val alt = m.groupValues[2].trim()
val src = normalizeImageSrc(m.groupValues[3].trim())
val isInlineData = src.startsWith("data:image/", ignoreCase = true)
val inlineDataSize = inlineImageDecodedSizeUpperBound(src)
val exceedsInlineBudget = isInlineData && (
inlineDataSize == null || inlineDataSize > INLINE_IMAGE_DATA_MAX_BYTES ||
inlineDataImages >= INLINE_IMAGE_DATA_MAX_PER_MESSAGE ||
inlineDataBytes + inlineDataSize > INLINE_IMAGE_DATA_MAX_TOTAL_BYTES
)
if (exceedsInlineBudget) {
return@replace if (inlineOverflowNoticeAdded) {
""
} else {
inlineOverflowNoticeAdded = true
"\n\n_Additional inline images omitted for memory safety._\n\n"
}
}
images += ChatInlineImage( images += ChatInlineImage(
alt = alt, alt = alt,
src = normalizeImageSrc(m.groupValues[3].trim()), src = src,
sensitive = spoilerWrapped || isSensitiveAltText(alt), sensitive = spoilerWrapped || isSensitiveAltText(alt),
) )
if (inlineDataSize != null) {
inlineDataImages += 1
inlineDataBytes += inlineDataSize
}
"" ""
} }
if (images.isEmpty()) return content to emptyList() if (images.isEmpty()) return content to emptyList()
@@ -208,6 +231,9 @@ private fun ChatInlineImage.isRemote(): Boolean {
return s.startsWith("http://") || s.startsWith("https://") return s.startsWith("http://") || s.startsWith("https://")
} }
private fun ChatInlineImage.isInlineDataImage(): Boolean =
src.startsWith("data:image/", ignoreCase = true)
/** /**
* An absolute server-side path (e.g. `/home/agent/out.png`) — what the relay's * An absolute server-side path (e.g. `/home/agent/out.png`) — what the relay's
* `/media/by-path` route expects. Not a remote URL and not a relative ref. * `/media/by-path` route expects. Not a remote URL and not a relative ref.
@@ -232,6 +258,7 @@ fun ChatInlineImages(
images.forEach { image -> images.forEach { image ->
when { when {
image.isRemote() -> RemoteChatImage(image, maxWidth) image.isRemote() -> RemoteChatImage(image, maxWidth)
image.isInlineDataImage() -> DataUrlChatImage(image, maxWidth)
// A server-local file the agent referenced — fetch it through // A server-local file the agent referenced — fetch it through
// /media/by-path and render inline; on failure the notice shows // /media/by-path and render inline; on failure the notice shows
// the ACTUAL reason (for debugging) instead of a generic message. // the ACTUAL reason (for debugging) instead of a generic message.
@@ -253,6 +280,94 @@ fun ChatInlineImages(
} }
} }
private sealed interface DataUrlImagePhase {
data object Loading : DataUrlImagePhase
data class Loaded(
val bitmap: ImageBitmap,
val mime: String,
) : DataUrlImagePhase
data object Rejected : DataUrlImagePhase
}
/** Render the bounded data URLs emitted by upstream `_resolve_media_to_data_urls`. */
@Composable
private fun DataUrlChatImage(image: ChatInlineImage, maxWidth: Dp) {
var phase by remember(image.src) { mutableStateOf<DataUrlImagePhase>(DataUrlImagePhase.Loading) }
var viewerOpen by remember(image.src) { mutableStateOf(false) }
val blurMode = LocalMediaBlurMode.current
var revealed by remember(image.src) { mutableStateOf(false) }
LaunchedEffect(image.src) {
phase = withInlineImageDecodeLock {
val decoded = decodeInlineImageDataUrl(image.src)
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(decoded.bytes, 0, decoded.bytes.size, bounds)
val sample = inlineImageSampleSize(bounds.outWidth, bounds.outHeight)
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
val bitmap = BitmapFactory.decodeByteArray(
decoded.bytes,
0,
decoded.bytes.size,
BitmapFactory.Options().apply {
inSampleSize = sample
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888
},
)
?.asImageBitmap() ?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
DataUrlImagePhase.Loaded(bitmap, decoded.mime)
}
}
when (val current = phase) {
DataUrlImagePhase.Loading -> Box(
modifier = Modifier
.widthIn(max = maxWidth)
.height(120.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) }
DataUrlImagePhase.Rejected -> UnrenderableImageNotice(
image.copy(src = "inline image"),
reason = "Unsupported or oversized inline image.",
)
is DataUrlImagePhase.Loaded -> {
if (viewerOpen) {
ChatImageViewer(
source = ChatImageViewerSource.Bitmap(
bitmap = current.bitmap,
displayName = image.alt.ifBlank { "image" },
mime = current.mime,
// Decode the already-retained data URL only when the
// user requests Save/Share; don't keep a second 5 MiB
// byte array beside every thumbnail.
bytesProvider = { decodeInlineImageDataUrlOffMain(image.src)?.bytes },
),
onDismiss = { viewerOpen = false },
sensitive = image.sensitive,
initiallyRevealed = revealed,
)
}
InlineImageColumn(image, maxWidth) {
BlurredMedia(
blurred = !revealed && shouldBlurImage(blurMode, image.sensitive),
onReveal = { revealed = true },
) {
androidx.compose.foundation.Image(
bitmap = current.bitmap,
contentDescription = image.alt.ifBlank { "Generated image" },
contentScale = ContentScale.Fit,
modifier = Modifier
.widthIn(max = maxWidth)
.heightIn(max = 360.dp)
.clip(RoundedCornerShape(12.dp))
.clickable { viewerOpen = true },
)
}
}
}
}
}
@Composable @Composable
private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) { private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) {
var viewerOpen by remember { mutableStateOf(false) } var viewerOpen by remember { mutableStateOf(false) }
@@ -74,7 +74,7 @@ import kotlinx.coroutines.delay
* !isStreaming && hasContent -> SEND // Send arrow, primary (Relay) * !isStreaming && hasContent -> SEND // Send arrow, primary (Relay)
* !isStreaming -> VOICE // GraphicEq, primary * !isStreaming -> VOICE // GraphicEq, primary
* isStreaming && !hasContent -> STOP // Stop in a Danger-outlined circle * isStreaming && !hasContent -> STOP // Stop in a Danger-outlined circle
* canSteer (gateway transport) -> STEER // Send glyph, tertiary (Cyan) * canCorrect (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
* else -> QUEUE // Send glyph + clock badge, tertiary * else -> QUEUE // Send glyph + clock badge, tertiary
* ``` * ```
*/ */
@@ -120,7 +120,7 @@ data class ChatInputPickerControl(
* the limit) only when length > [charLimit] - 200 — supportingText * the limit) only when length > [charLimit] - 200 — supportingText
* reflows the bar, the overline doesn't. * reflows the bar, the overline doesn't.
* - [caption] renders a single relayMetadataStyle line above the bar * - [caption] renders a single relayMetadataStyle line above the bar
* (steer/queue hinting during streaming-with-text); Cyan when the slot * (correct/queue hinting during streaming-with-text); Cyan when the slot
* is STEER, muted otherwise. Null collapses the row. * is STEER, muted otherwise. Null collapses the row.
* - Voice: GraphicEq glyph ("voice session", not "record"); when * - Voice: GraphicEq glyph ("voice session", not "record"); when
* ![voiceReady] the button stays FULL alpha with a 6dp Amber dot badge * ![voiceReady] the button stays FULL alpha with a 6dp Amber dot badge
@@ -191,7 +191,7 @@ fun ChatInputBar(
} }
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
// Caption row — steer/queue hinting, single line, no buttons. // Caption row — correct/queue hinting, single line, no buttons.
AnimatedVisibility(visible = caption != null) { AnimatedVisibility(visible = caption != null) {
Text( Text(
text = caption ?: lastCaption.orEmpty(), text = caption ?: lastCaption.orEmpty(),
@@ -0,0 +1,172 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.AttachmentRenderMode
/**
* Stable, presentation-only summary for a message's attachment group.
*
* Attachment bytes, fetch state, retry callbacks, and persistence remain owned
* by the existing attachment pipeline; this helper only chooses the compact
* label shown while that pipeline is folded away.
*/
internal data class AttachmentGroupSummary(
val count: Int,
val firstName: String?,
val firstType: AttachmentRenderMode,
val remainingCount: Int,
)
internal fun attachmentGroupSummary(attachments: List<Attachment>): AttachmentGroupSummary? {
val first = attachments.firstOrNull() ?: return null
return AttachmentGroupSummary(
count = attachments.size,
firstName = first.fileName?.trim()?.takeIf(String::isNotEmpty),
firstType = first.renderMode,
remainingCount = (attachments.size - 1).coerceAtLeast(0),
)
}
/**
* Slack-style disclosure for all attachments belonging to one message.
*
* The fold state is saveable and keyed by the message's stable Compose
* identity, so attachment lifecycle updates do not unexpectedly reopen a
* group the user collapsed. The compact header always remains available,
* making preview, retry, download, and file actions recoverable with one tap.
*/
@Composable
internal fun CollapsibleAttachmentGroup(
messageKey: String,
attachments: List<Attachment>,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val summary = attachmentGroupSummary(attachments) ?: return
var expanded by rememberSaveable(messageKey) { mutableStateOf(true) }
val stateLabel = stringResource(
if (expanded) R.string.attachment_group_expanded else R.string.attachment_group_collapsed,
)
val actionLabel = stringResource(
if (expanded) R.string.attachment_group_collapse else R.string.attachment_group_expand,
)
val typeLabel = stringResource(summary.firstType.labelResource())
val detail = when {
summary.firstName != null && summary.remainingCount > 0 ->
stringResource(
R.string.attachment_group_named_more,
summary.firstName,
typeLabel,
summary.remainingCount,
)
summary.firstName != null ->
stringResource(R.string.attachment_group_named, summary.firstName, typeLabel)
summary.remainingCount > 0 ->
stringResource(R.string.attachment_group_typed_more, typeLabel, summary.remainingCount)
else -> typeLabel
}
Column(
modifier = modifier
.fillMaxWidth()
.animateContentSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Surface(
onClick = { expanded = !expanded },
modifier = Modifier
.fillMaxWidth()
.testTag("attachment-group-toggle-$messageKey")
.semantics(mergeDescendants = true) {
contentDescription = actionLabel
role = Role.Button
stateDescription = stateLabel
},
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = pluralStringResource(
R.plurals.attachment_group_count,
summary.count,
summary.count,
),
style = MaterialTheme.typography.labelLarge,
)
Text(
text = detail,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
}
}
AnimatedVisibility(visible = expanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.testTag("attachment-group-content-$messageKey"),
) {
content()
}
}
}
}
private fun AttachmentRenderMode.labelResource(): Int = when (this) {
AttachmentRenderMode.IMAGE -> R.string.attachment_type_image
AttachmentRenderMode.VIDEO -> R.string.attachment_type_video
AttachmentRenderMode.AUDIO -> R.string.attachment_type_audio
AttachmentRenderMode.PDF -> R.string.attachment_type_pdf
AttachmentRenderMode.TEXT -> R.string.attachment_type_text
AttachmentRenderMode.GENERIC -> R.string.attachment_type_file
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
package com.hermesandroid.relay.ui.components
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
data class ConnectionSetupTimelineStep(
val title: String,
val detail: String,
)
/** Compact completed-state timeline shared by connection and auth flows. */
@Composable
fun ConnectionSetupTimeline(
steps: List<ConnectionSetupTimelineStep>,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
steps.forEachIndexed { index, step ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(28.dp)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(17.dp),
)
}
if (index != steps.lastIndex) {
Spacer(
modifier = Modifier
.size(width = 2.dp, height = 34.dp)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)),
)
}
}
Column(
modifier = Modifier.padding(bottom = if (index == steps.lastIndex) 0.dp else 10.dp),
) {
Text(step.title, style = MaterialTheme.typography.titleSmall)
Text(
step.detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lan import androidx.compose.material.icons.filled.Lan
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Shield
@@ -29,6 +31,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
@@ -44,8 +47,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection import com.hermesandroid.relay.data.Connection
@@ -55,9 +60,12 @@ import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.data.isEncryptedOverlayRoute import com.hermesandroid.relay.data.isEncryptedOverlayRoute
import com.hermesandroid.relay.data.isKnownRole import com.hermesandroid.relay.data.isKnownRole
import com.hermesandroid.relay.data.isTlsUrl import com.hermesandroid.relay.data.isTlsUrl
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.network.shared.RouteProbeOutcome import com.hermesandroid.relay.network.shared.RouteProbeOutcome
import com.hermesandroid.relay.viewmodel.ConnectionViewModel import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URI
/** /**
* ADR 24 — per-endpoint visibility + override card for the Connection * ADR 24 — per-endpoint visibility + override card for the Connection
@@ -72,9 +80,9 @@ import kotlinx.coroutines.launch
* on app start, tried first on every resolve; cleared by * on app start, tried first on every resolve; cleared by
* [onClearPreferred]. * [onClearPreferred].
* *
* Verbose by design — Bailey explicitly asked for per-row visibility, so we * Each route stays visible, while its Dashboard/API/Relay topology uses
* do NOT collapse into a master row. The card itself is wrapped in a * progressive disclosure: the active route opens by default and fallback
* [SettingsExpandableCard] by the caller for page layout hygiene. * routes keep a compact surface-and-port summary.
* *
* Not visible on legacy installs: when [endpoints] is empty we render a * Not visible on legacy installs: when [endpoints] is empty we render a
* helpful one-liner instead of an empty card, so freshly-upgraded users * helpful one-liner instead of an empty card, so freshly-upgraded users
@@ -106,6 +114,9 @@ fun EndpointsCard(
* resolver's cache-key scheme. * resolver's cache-key scheme.
*/ */
outcomeFor: (EndpointCandidate) -> RouteProbeOutcome? = { null }, outcomeFor: (EndpointCandidate) -> RouteProbeOutcome? = { null },
/** Auth state applies only to the currently active Dashboard route. */
dashboardAuthenticated: Boolean? = null,
dashboardSignInRequired: Boolean = false,
/** /**
* Route management — the standard path's manual equivalent of a v3 QR's * Route management — the standard path's manual equivalent of a v3 QR's
* `endpoints` array. Null callbacks hide the corresponding affordance. * `endpoints` array. Null callbacks hide the corresponding affordance.
@@ -170,11 +181,17 @@ fun EndpointsCard(
candidate = candidate, candidate = candidate,
isActive = activeEndpoint != null && isActive = activeEndpoint != null &&
activeEndpoint.role.equals(candidate.role, ignoreCase = true) && activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) && activeEndpoint.routeAuthority() == candidate.routeAuthority(),
activeEndpoint.api.port == candidate.api.port,
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true, isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
isProbing = isProbing, isProbing = isProbing,
outcome = outcomeFor(candidate), outcome = outcomeFor(candidate),
dashboardAuthenticated = dashboardAuthenticated.takeIf { activeEndpoint != null &&
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
activeEndpoint.routeAuthority() == candidate.routeAuthority()
},
dashboardSignInRequired = dashboardSignInRequired && activeEndpoint != null &&
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
activeEndpoint.routeAuthority() == candidate.routeAuthority(),
onUseNow = { onUseNow(candidate) }, onUseNow = { onUseNow(candidate) },
onPrefer = { onPreferEndpoint(candidate) }, onPrefer = { onPreferEndpoint(candidate) },
onClearPrefer = onClearPreferred, onClearPrefer = onClearPreferred,
@@ -219,6 +236,8 @@ private fun EndpointRow(
isPreferred: Boolean, isPreferred: Boolean,
isProbing: Boolean = false, isProbing: Boolean = false,
outcome: RouteProbeOutcome? = null, outcome: RouteProbeOutcome? = null,
dashboardAuthenticated: Boolean? = null,
dashboardSignInRequired: Boolean = false,
onUseNow: () -> Unit, onUseNow: () -> Unit,
onPrefer: () -> Unit, onPrefer: () -> Unit,
onClearPrefer: () -> Unit, onClearPrefer: () -> Unit,
@@ -230,6 +249,7 @@ private fun EndpointRow(
var menuOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) }
var pinDialogText by remember { mutableStateOf<String?>(null) } var pinDialogText by remember { mutableStateOf<String?>(null) }
var confirmRemove by remember { mutableStateOf(false) } var confirmRemove by remember { mutableStateOf(false) }
var detailsExpanded by remember(isActive) { mutableStateOf(isActive) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val noPinRecordedText = stringResource(R.string.endpoints_no_pin_recorded) val noPinRecordedText = stringResource(R.string.endpoints_no_pin_recorded)
@@ -278,17 +298,6 @@ private fun EndpointRow(
) )
} }
} }
// Full URL, scheme included: http vs https decides whether
// the health probe TLS-handshakes, so two rows that both
// read "host:8642" can behave completely differently. The
// scheme must be visible to be debuggable.
Text(
text = candidate.api.url +
(candidate.relay.transportHint?.let { " · $it" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
when { when {
isProbing -> Text( isProbing -> Text(
text = stringResource(R.string.endpoints_checking), text = stringResource(R.string.endpoints_checking),
@@ -307,6 +316,47 @@ private fun EndpointRow(
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
val dashboardSurfaceUrl = candidate.dashboard?.url
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
val dashboardLabel = stringResource(R.string.active_section_dashboard)
val apiLabel = stringResource(R.string.active_section_api_server)
val relayLabel = stringResource(R.string.active_section_relay)
val surfaceSummary = listOfNotNull(
dashboardSurfaceUrl?.let { "$dashboardLabel ${displayPort(it)}" },
candidate.api?.url?.let { "$apiLabel ${displayPort(it)}" },
candidate.relay?.url?.let { "$relayLabel ${displayPort(it)}" },
).joinToString(" · ")
if (surfaceSummary.isNotBlank()) {
Row(
modifier = Modifier
.padding(top = 4.dp)
.clip(RoundedCornerShape(6.dp))
.clickable { detailsExpanded = !detailsExpanded }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = surfaceSummary,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = if (detailsExpanded) {
Icons.Filled.ExpandLess
} else {
Icons.Filled.ExpandMore
},
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
}
} }
// 3-dot overflow menu — actions per-row so the card stays flat // 3-dot overflow menu — actions per-row so the card stays flat
@@ -392,6 +442,15 @@ private fun EndpointRow(
} }
} }
} }
if (detailsExpanded) {
RouteSurfaceMap(
candidate = candidate,
dashboardAuthenticated = dashboardAuthenticated,
dashboardSignInRequired = dashboardSignInRequired,
modifier = Modifier.padding(start = 26.dp, end = 4.dp, top = 8.dp),
)
}
} }
if (confirmRemove && onRemove != null) { if (confirmRemove && onRemove != null) {
@@ -400,7 +459,7 @@ private fun EndpointRow(
title = { Text(stringResource(R.string.endpoints_remove_route_title, candidate.displayLabel())) }, title = { Text(stringResource(R.string.endpoints_remove_route_title, candidate.displayLabel())) },
text = { text = {
Text( Text(
text = stringResource(R.string.endpoints_remove_route_body, "${candidate.api.host}:${candidate.api.port}"), text = stringResource(R.string.endpoints_remove_route_body, candidate.routeAuthority().orEmpty()),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
}, },
@@ -421,7 +480,7 @@ private fun EndpointRow(
pinDialogText?.let { body -> pinDialogText?.let { body ->
AlertDialog( AlertDialog(
onDismissRequest = { pinDialogText = null }, onDismissRequest = { pinDialogText = null },
title = { Text(stringResource(R.string.endpoints_pin_title, candidate.api.host)) }, title = { Text(stringResource(R.string.endpoints_pin_title, candidate.routeAuthority().orEmpty())) },
text = { text = {
Text( Text(
text = body, text = body,
@@ -436,6 +495,106 @@ private fun EndpointRow(
} }
} }
@Composable
private fun RouteSurfaceMap(
candidate: EndpointCandidate,
dashboardAuthenticated: Boolean? = null,
dashboardSignInRequired: Boolean = false,
modifier: Modifier = Modifier,
) {
val dashboardUrl = candidate.dashboard?.url
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
val apiUrl = candidate.api?.url
val relayUrl = candidate.relay?.url
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f),
shape = RoundedCornerShape(10.dp),
modifier = modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
) {
RouteSurfaceRow(
label = stringResource(R.string.active_section_dashboard),
url = dashboardUrl,
status = when {
dashboardSignInRequired -> stringResource(R.string.active_section_sign_in_required)
dashboardAuthenticated == true -> stringResource(R.string.active_section_signed_in)
dashboardUrl != null -> stringResource(R.string.active_section_configured)
else -> stringResource(R.string.active_section_not_configured)
},
warning = dashboardSignInRequired,
)
RouteSurfaceRow(
label = stringResource(R.string.active_section_api_server),
url = apiUrl,
status = stringResource(
if (apiUrl != null) R.string.active_section_configured
else R.string.active_section_not_configured,
),
)
RouteSurfaceRow(
label = stringResource(R.string.active_section_relay),
url = relayUrl,
status = stringResource(
if (relayUrl != null) R.string.active_section_configured
else R.string.active_section_not_configured,
),
)
}
}
}
@Composable
private fun RouteSurfaceRow(
label: String,
url: String?,
status: String,
warning: Boolean = false,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = url ?: "—",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = status,
style = MaterialTheme.typography.labelSmall,
color = if (warning) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
private fun displayPort(url: String): String {
val uri = runCatching { URI(url) }.getOrNull()
val port = uri?.port?.takeIf { it > 0 } ?: when (uri?.scheme?.lowercase()) {
"https", "wss" -> 443
else -> 80
}
return ":$port"
}
@Composable @Composable
private fun ActiveChip(label: String) { private fun ActiveChip(label: String) {
Row( Row(
@@ -521,7 +680,7 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
* be classified independently before it's the active route. * be classified independently before it's the active route.
*/ */
private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when { private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
isTlsUrl(api.url) -> SurfaceSecurityKind.Tls isTlsUrl(primaryRouteUrl().orEmpty()) -> SurfaceSecurityKind.Tls
isEncryptedOverlayRoute(isTailscaleDetected = false) -> SurfaceSecurityKind.Overlay isEncryptedOverlayRoute(isTailscaleDetected = false) -> SurfaceSecurityKind.Overlay
else -> SurfaceSecurityKind.Plain else -> SurfaceSecurityKind.Plain
} }
@@ -531,21 +690,24 @@ private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can * v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
* set up LAN ↔ Tailscale roaming without the plugin. * set up LAN ↔ Tailscale roaming without the plugin.
* *
* The relay URL is derived from the API URL (same `:8767` convention the * The editor is host-first: Dashboard/Gateway uses `:9119`, direct API
* wizard uses); routes that need a custom relay URL still come from a QR. * fallback uses `:8642`, and an already-enabled Relay uses `:8767`.
* Routes that need custom per-surface hosts or ports still come from a QR.
* *
* @param original null = add a new route; non-null = edit (pre-fills role + * @param original null = add a new route; non-null = edit (pre-fills role +
* URL, keeps the stored priority). * URL, keeps the stored priority).
* @param onSave invoked with (role, apiUrl, resultCallback); the callback * @param onSave invoked with (role, dashboardUrl, resultCallback); the callback
* receives a user-facing error string to render inline, or null on * receives a user-facing error string to render inline, or null on
* success (the dialog then closes itself). * success (the dialog then closes itself).
*/ */
@Composable @Composable
fun RouteEditorDialog( fun RouteEditorDialog(
original: EndpointCandidate?, original: EndpointCandidate?,
onSave: (role: String, apiUrl: String, onResult: (String?) -> Unit) -> Unit, relayEnabled: Boolean = false,
onSave: (role: String, dashboardUrl: String, onResult: (String?) -> Unit) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val uriHandler = LocalUriHandler.current
val knownRoles = listOf("tailscale", "public") val knownRoles = listOf("tailscale", "public")
var selectedRole by remember { var selectedRole by remember {
mutableStateOf( mutableStateOf(
@@ -561,7 +723,7 @@ fun RouteEditorDialog(
original?.role?.takeIf { it.lowercase() !in knownRoles }.orEmpty(), original?.role?.takeIf { it.lowercase() !in knownRoles }.orEmpty(),
) )
} }
var url by remember { mutableStateOf(original?.api?.url.orEmpty()) } var url by remember(original) { mutableStateOf(original?.primaryRouteUrl().orEmpty()) }
var errorText by remember { mutableStateOf<String?>(null) } var errorText by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) } var saving by remember { mutableStateOf(false) }
@@ -615,13 +777,20 @@ fun RouteEditorDialog(
// Live preview of what will actually be saved — scheme and // Live preview of what will actually be saved — scheme and
// port defaults applied — so "what, which port, http or // port defaults applied — so "what, which port, http or
// https?" is answered before Save, not after a failed probe. // https?" is answered before Save, not after a failed probe.
val previewCandidate = remember(url, effectiveRole) { val previewCandidate = remember(url, effectiveRole, relayEnabled) {
url.takeIf { it.isNotBlank() }?.let { url.takeIf { it.isNotBlank() }?.let {
Connection.endpointCandidateFromApiUrl( val dashboardUrl = Connection.normalizeDashboardUrlInput(it)
val apiUrl = Connection.deriveDefaultApiUrl(dashboardUrl)
Connection.endpointCandidateFromDashboardUrl(
role = effectiveRole.ifBlank { "custom" }, role = effectiveRole.ifBlank { "custom" },
priority = original?.priority ?: 1, priority = original?.priority ?: 1,
apiServerUrl = Connection.normalizeApiUrlInput(it), dashboardUrl = dashboardUrl,
relayUrl = "", apiServerUrl = apiUrl,
relayUrl = if (relayEnabled) {
apiUrl?.let(Connection::deriveDefaultRelayUrl)
} else {
null
},
) )
} }
} }
@@ -643,7 +812,7 @@ fun RouteEditorDialog(
url.isBlank() -> url.isBlank() ->
supportingBlank supportingBlank
previewCandidate != null -> previewCandidate != null ->
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.api.url) stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.primaryRouteUrl().orEmpty())
else -> else ->
supportingEnter supportingEnter
}, },
@@ -652,6 +821,21 @@ fun RouteEditorDialog(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
previewCandidate?.let { candidate ->
RouteSurfaceMap(candidate = candidate)
}
if (selectedRole == "tailscale") {
Text(
text = stringResource(R.string.endpoints_tailscale_setup_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(
onClick = { uriHandler.openUri(REMOTE_ACCESS_DOCS_URL) },
) {
Text(stringResource(R.string.endpoints_setup_help))
}
}
} }
}, },
confirmButton = { confirmButton = {
@@ -683,4 +867,7 @@ fun RouteEditorDialog(
) )
} }
private const val REMOTE_ACCESS_DOCS_URL =
"https://hermes-relay.dev/docs/guide/remote-access"
private const val CUSTOM_ROLE = "__custom__" private const val CUSTOM_ROLE = "__custom__"
@@ -0,0 +1,127 @@
package com.hermesandroid.relay.ui.components
import java.util.Base64
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
internal const val INLINE_IMAGE_DATA_MAX_BYTES = 5 * 1024 * 1024
internal const val INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION = 1_024
internal const val INLINE_IMAGE_THUMBNAIL_MAX_PIXELS = 1_500_000L
internal const val INLINE_IMAGE_SOURCE_MAX_DIMENSION = 32_768
internal const val INLINE_IMAGE_DATA_MAX_PER_MESSAGE = 4
internal const val INLINE_IMAGE_DATA_MAX_TOTAL_BYTES = 8 * 1024 * 1024
private const val MAX_HEADER_CHARS = 64
private val inlineImageDecodeMutex = Mutex()
private val DATA_IMAGE_HEADER = Regex(
"^data:(image/(?:png|jpeg|gif|webp|bmp));base64$",
RegexOption.IGNORE_CASE,
)
internal data class DecodedInlineImageData(
val bytes: ByteArray,
val mime: String,
)
/**
* Strict decoder for upstream API-server inline images.
*
* Only the five raster formats upstream emits for this path are accepted. The
* encoded input is bounded before allocation, strict base64 is required, and
* the decoded magic bytes must agree with the declared MIME type.
*/
internal fun decodeInlineImageDataUrl(
source: String,
maxBytes: Int = INLINE_IMAGE_DATA_MAX_BYTES,
): DecodedInlineImageData? {
if (maxBytes <= 0 || !source.startsWith("data:", ignoreCase = true)) return null
val comma = source.indexOf(',')
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
val mime = DATA_IMAGE_HEADER.matchEntire(source.substring(0, comma))
?.groupValues?.get(1)?.lowercase() ?: return null
val encoded = source.substring(comma + 1)
if (encoded.isEmpty()) return null
// ceil(maxBytes / 3) * 4 plus at most two trailing padding characters.
val maxEncoded = ((maxBytes.toLong() + 2L) / 3L) * 4L
if (encoded.length.toLong() > maxEncoded) return null
val bytes = runCatching { Base64.getDecoder().decode(encoded) }.getOrNull() ?: return null
if (bytes.isEmpty() || bytes.size > maxBytes || !matchesImageMagic(mime, bytes)) return null
return DecodedInlineImageData(bytes, mime)
}
/** Conservative decoded-size estimate that does not allocate a byte array. */
internal fun inlineImageDecodedSizeUpperBound(source: String): Long? {
if (!source.startsWith("data:image/", ignoreCase = true)) return null
val comma = source.indexOf(',')
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
val encodedLength = source.length.toLong() - comma - 1L
if (encodedLength <= 0) return null
val padding = when {
source.endsWith("==") -> 2L
source.endsWith('=') -> 1L
else -> 0L
}
return ((encodedLength + 3L) / 4L) * 3L - padding
}
/**
* Serialize inline-image byte/bitmap work and keep it off the caller (usually
* Compose Main) thread. This prevents multiple Base64 and bitmap decode
* allocations from overlapping.
*/
internal suspend fun <T> withInlineImageDecodeLock(
dispatcher: CoroutineDispatcher = Dispatchers.Default,
block: () -> T,
): T = withContext(dispatcher) {
inlineImageDecodeMutex.withLock { block() }
}
internal suspend fun decodeInlineImageDataUrlOffMain(
source: String,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
): DecodedInlineImageData? = withInlineImageDecodeLock(dispatcher) {
decodeInlineImageDataUrl(source)
}
private fun matchesImageMagic(mime: String, bytes: ByteArray): Boolean = when (mime) {
"image/png" -> bytes.size >= 8 && bytes.sliceArray(0..7).contentEquals(
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
)
"image/jpeg" -> bytes.size >= 3 &&
bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte()
"image/webp" -> bytes.size >= 12 &&
bytes.copyOfRange(0, 4).contentEquals("RIFF".encodeToByteArray()) &&
bytes.copyOfRange(8, 12).contentEquals("WEBP".encodeToByteArray())
"image/gif" -> bytes.size >= 6 &&
(bytes.copyOfRange(0, 6).contentEquals("GIF87a".encodeToByteArray()) ||
bytes.copyOfRange(0, 6).contentEquals("GIF89a".encodeToByteArray()))
"image/bmp" -> bytes.size >= 2 && bytes[0] == 'B'.code.toByte() && bytes[1] == 'M'.code.toByte()
else -> false
}
/**
* Power-of-two sample factor that bounds the decoded ARGB thumbnail to roughly
* 6 MiB and 1024 px on either axis. Null rejects invalid/extreme source bounds.
*/
internal fun inlineImageSampleSize(
width: Int,
height: Int,
maxDimension: Int = INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION,
maxPixels: Long = INLINE_IMAGE_THUMBNAIL_MAX_PIXELS,
): Int? {
if (width <= 0 || height <= 0 || maxDimension <= 0 || maxPixels <= 0 ||
width > INLINE_IMAGE_SOURCE_MAX_DIMENSION || height > INLINE_IMAGE_SOURCE_MAX_DIMENSION
) return null
var sample = 1
while (true) {
val sampledWidth = (width + sample - 1L) / sample
val sampledHeight = (height + sample - 1L) / sample
if (sampledWidth <= maxDimension && sampledHeight <= maxDimension &&
sampledWidth <= maxPixels / sampledHeight
) return sample
if (sample >= 1 shl 15) return null
sample *= 2
}
}
@@ -41,8 +41,6 @@ import com.mikepenz.markdown.compose.components.MarkdownComponentModel
import com.mikepenz.markdown.compose.LocalMarkdownColors import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.elements.MarkdownDivider import com.mikepenz.markdown.compose.elements.MarkdownDivider
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeFence
import com.mikepenz.markdown.compose.elements.MarkdownTable import com.mikepenz.markdown.compose.elements.MarkdownTable
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
import com.mikepenz.markdown.compose.elements.MarkdownTableRow import com.mikepenz.markdown.compose.elements.MarkdownTableRow
@@ -69,6 +67,11 @@ fun MarkdownContent(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val isDarkTheme = LocalBrand.current.isDark val isDarkTheme = LocalBrand.current.isDark
val chatBodyStyle = MaterialTheme.typography.bodyMedium.copy(
fontSize = 15.sp,
lineHeight = 21.sp,
color = textColor,
)
val highlightsBuilder = remember(isDarkTheme) { val highlightsBuilder = remember(isDarkTheme) {
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme)) Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
} }
@@ -89,7 +92,8 @@ fun MarkdownContent(
// ~45sp, h3=displaySmall 36sp) — a single `#` becomes a billboard inside the // ~45sp, h3=displaySmall 36sp) — a single `#` becomes a billboard inside the
// ~272dp bubble. Here every level derives from bodyLarge/bodyMedium (so the // ~272dp bubble. Here every level derives from bodyLarge/bodyMedium (so the
// live font-picker still applies) and is capped so the largest heading is // live font-picker still applies) and is capped so the largest heading is
// ~1.4x the 14sp body, matching Discord / GitHub-mobile in-message headings. // proportionate to the 15sp body, matching Discord / GitHub-mobile
// in-message headings.
typography = markdownTypography( typography = markdownTypography(
h1 = MaterialTheme.typography.bodyLarge.copy( h1 = MaterialTheme.typography.bodyLarge.copy(
fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor, fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor,
@@ -110,16 +114,17 @@ fun MarkdownContent(
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp,
color = textColor.copy(alpha = 0.85f), color = textColor.copy(alpha = 0.85f),
), ),
// Prose, list items, and quotes all sit at the 14sp body size so a // Prose, list items, and quotes share a 15sp/21sp reading rhythm.
// paragraph and the bullet list under it share one rhythm — the library // The library default 'text'/list role is bodyLarge (16sp), while
// default 'text'/list role is bodyLarge (16sp), 2sp larger than paragraph. // bodyMedium was previously 14sp and unnecessarily small for long chat.
paragraph = MaterialTheme.typography.bodyMedium.copy(color = textColor), paragraph = chatBodyStyle,
text = MaterialTheme.typography.bodyMedium.copy(color = textColor), text = chatBodyStyle,
bullet = MaterialTheme.typography.bodyMedium.copy(color = textColor), bullet = chatBodyStyle,
ordered = MaterialTheme.typography.bodyMedium.copy(color = textColor), ordered = chatBodyStyle,
list = MaterialTheme.typography.bodyMedium.copy(color = textColor), list = chatBodyStyle,
quote = MaterialTheme.typography.bodyMedium.copy( quote = chatBodyStyle.copy(
fontStyle = FontStyle.Italic, color = textColor.copy(alpha = 0.78f), fontStyle = FontStyle.Italic,
color = textColor.copy(alpha = 0.9f),
), ),
// Inline + fenced code at 13sp (one step under body, not two): monospace // Inline + fenced code at 13sp (one step under body, not two): monospace
// + the tinted chip already signal "code" without also shrinking it, and // + the tinted chip already signal "code" without also shrinking it, and
@@ -127,7 +132,7 @@ fun MarkdownContent(
code = MaterialTheme.typography.bodySmall.copy( code = MaterialTheme.typography.bodySmall.copy(
fontSize = 13.sp, letterSpacing = 0.sp, fontSize = 13.sp, letterSpacing = 0.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = textColor,
), ),
inlineCode = MaterialTheme.typography.bodyMedium.copy( inlineCode = MaterialTheme.typography.bodyMedium.copy(
fontSize = 13.sp, letterSpacing = 0.sp, fontSize = 13.sp, letterSpacing = 0.sp,
@@ -152,7 +157,7 @@ fun MarkdownContent(
), ),
components = markdownComponents( components = markdownComponents(
codeBlock = { codeBlock = {
MarkdownHighlightedCodeBlock( SafeMarkdownHighlightedCodeBlock(
content = it.content, content = it.content,
node = it.node, node = it.node,
highlightsBuilder = highlightsBuilder, highlightsBuilder = highlightsBuilder,
@@ -160,7 +165,7 @@ fun MarkdownContent(
) )
}, },
codeFence = { codeFence = {
MarkdownHighlightedCodeFence( SafeMarkdownHighlightedCodeFence(
content = it.content, content = it.content,
node = it.node, node = it.node,
highlightsBuilder = highlightsBuilder, highlightsBuilder = highlightsBuilder,
@@ -304,7 +309,10 @@ fun StreamingMarkdownContent(
// code and deliberately spaced prose are not altered. // code and deliberately spaced prose are not altered.
text = content.withoutLeadingBlankLines(), text = content.withoutLeadingBlankLines(),
modifier = modifier, modifier = modifier,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 15.sp,
lineHeight = 21.sp,
),
color = textColor, color = textColor,
) )
} else { } else {
@@ -138,6 +138,8 @@ fun MessageBubble(
* happening. * happening.
*/ */
recoveringAnswer: Boolean = false, recoveringAnswer: Boolean = false,
imageGenerationStylePreference: String = "rotate",
imageGenerationRotationIndex: Int = 0,
) { ) {
val isUser = message.role == MessageRole.USER val isUser = message.role == MessageRole.USER
val isSystem = message.role == MessageRole.SYSTEM val isSystem = message.role == MessageRole.SYSTEM
@@ -172,7 +174,7 @@ fun MessageBubble(
val textColor = when (message.role) { val textColor = when (message.role) {
MessageRole.USER -> MaterialTheme.colorScheme.onPrimary MessageRole.USER -> MaterialTheme.colorScheme.onPrimary
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurfaceVariant MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurface
MessageRole.SYSTEM -> MaterialTheme.colorScheme.onTertiaryContainer MessageRole.SYSTEM -> MaterialTheme.colorScheme.onTertiaryContainer
} }
@@ -205,6 +207,28 @@ fun MessageBubble(
extractChatInlineImages(message.content) extractChatInlineImages(message.content)
} }
} }
val showImageGeneration = shouldShowImageGenerationPlaceholder(
toolCalls = message.toolCalls,
isStreaming = message.isStreaming,
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
)
val hasImageGenerationCall = remember(message.toolCalls) {
message.toolCalls.any {
it.name.trim().lowercase() == "image_generate"
}
}
val imageGenerationStartMillis = remember(message.toolCalls) {
imageGenerationStartedAt(message.toolCalls)
}
val imageGenerationVisualStyle = remember(
imageGenerationStylePreference,
imageGenerationRotationIndex,
) {
resolveImageGenerationVisualStyle(
preference = imageGenerationStylePreference,
rotationIndex = imageGenerationRotationIndex,
)
}
// Provide the sensitive-media blur mode to the attachment / inline-image // Provide the sensitive-media blur mode to the attachment / inline-image
// renderers below, sourced as locally as possible (here, not threaded // renderers below, sourced as locally as possible (here, not threaded
@@ -291,6 +315,30 @@ fun MessageBubble(
) )
} }
if (!isUser && !isSystem && showThinking) {
message.moaReferences.forEach { reference ->
ThinkingBlock(
thinkingContent = if (reference.available) {
reference.text
} else {
"Advisor unavailable."
},
isStreaming = false,
headerText = buildString {
append("Advisor ")
append(reference.index)
reference.count?.let { append("/").append(it) }
append(" · ")
append(reference.label)
},
accessibilityLabel = "Mixture of Agents advisor response",
modifier = Modifier
.widthIn(max = maxBubbleWidth)
.padding(bottom = 4.dp),
)
}
}
// Message bubble. // Message bubble.
// //
// Action bubbles (voice/phone origin) wrap the existing Surface in // Action bubbles (voice/phone origin) wrap the existing Surface in
@@ -309,6 +357,7 @@ fun MessageBubble(
val showBubble = isUser || isSystem || val showBubble = isUser || isSystem ||
message.content.isNotBlank() || message.content.isNotBlank() ||
message.isStreaming || message.isStreaming ||
showImageGeneration ||
message.cards.isNotEmpty() || message.cards.isNotEmpty() ||
message.attachments.isNotEmpty() || message.attachments.isNotEmpty() ||
inlineImages.isNotEmpty() inlineImages.isNotEmpty()
@@ -420,7 +469,10 @@ fun MessageBubble(
// Plain text for user and system messages // Plain text for user and system messages
Text( Text(
text = message.content, text = message.content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 15.sp,
lineHeight = 21.sp,
),
color = textColor color = textColor
) )
} else { } else {
@@ -478,35 +530,78 @@ fun MessageBubble(
} }
} }
// Attachments — two or more loaded images collapse into one // Image generation owns the bubble's progress slot. Keep the
// grid + swipe-across gallery. Every other item stays on the // selected progress treatment mounted under the real result,
// unified InboundAttachmentCard path, and layout items retain // then reveal the same collapsible attachment surface without
// their original ChatMessage.attachments indices so retry / // rebuilding the surrounding message bubble.
// manual-fetch callbacks cannot drift after grouping. if (hasImageGenerationCall) {
if (message.attachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
val attachmentItems = remember(message.attachments) { ImageGenerationResultTransition(
attachmentLayoutItems(message.attachments) generating = showImageGeneration,
} startedAtMillis = imageGenerationStartMillis,
attachmentItems.forEach { item -> visualStyle = imageGenerationVisualStyle,
when (item) { ) {
is AttachmentLayoutItem.Gallery -> AttachmentGallery( if (message.attachments.isNotEmpty()) {
attachments = item.attachmentIndices.map(message.attachments::get), CollapsibleAttachmentGroup(
maxWidth = maxBubbleWidth - 24.dp, messageKey = message.uiKey,
modifier = Modifier.padding(vertical = 2.dp), attachments = message.attachments,
) ) {
is AttachmentLayoutItem.Single -> { val attachmentItems = attachmentLayoutItems(message.attachments)
val index = item.attachmentIndex attachmentItems.forEach { item ->
InboundAttachmentCard( when (item) {
attachment = message.attachments[index], is AttachmentLayoutItem.Gallery -> AttachmentGallery(
onRetry = { onAttachmentRetry(message.id, index) }, attachments = item.attachmentIndices.map(message.attachments::get),
onManualFetch = { onAttachmentManualFetch(message.id, index) }, maxWidth = maxBubbleWidth - 24.dp,
maxWidth = maxBubbleWidth - 24.dp, modifier = Modifier.padding(vertical = 2.dp),
modifier = Modifier.padding(vertical = 2.dp), )
) is AttachmentLayoutItem.Single -> {
val index = item.attachmentIndex
InboundAttachmentCard(
attachment = message.attachments[index],
onRetry = { onAttachmentRetry(message.id, index) },
onManualFetch = { onAttachmentManualFetch(message.id, index) },
maxWidth = maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
}
} }
} }
} }
} else if (message.attachments.isNotEmpty()) {
// Two or more loaded images collapse into one grid +
// swipe-across gallery. Every other item stays on the
// unified attachment path, retaining original indices.
Spacer(modifier = Modifier.height(4.dp))
CollapsibleAttachmentGroup(
messageKey = message.uiKey,
attachments = message.attachments,
) {
// Two or more loaded images collapse into one grid +
// swipe-across gallery. Every other item stays on the
// unified attachment path, retaining original indices.
val attachmentItems = attachmentLayoutItems(message.attachments)
attachmentItems.forEach { item ->
when (item) {
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
attachments = item.attachmentIndices.map(message.attachments::get),
maxWidth = maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp),
)
is AttachmentLayoutItem.Single -> {
val index = item.attachmentIndex
InboundAttachmentCard(
attachment = message.attachments[index],
onRetry = { onAttachmentRetry(message.id, index) },
onManualFetch = { onAttachmentManualFetch(message.id, index) },
maxWidth = maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
}
}
} }
// Streaming indicator — only while awaiting the first token. Once // Streaming indicator — only while awaiting the first token. Once
@@ -514,7 +609,11 @@ fun MessageBubble(
// signal, so the pulsing dots stop (Messenger/Telegram drop the // signal, so the pulsing dots stop (Messenger/Telegram drop the
// typing bubble the moment content appears) instead of throbbing // typing bubble the moment content appears) instead of throbbing
// under the text for the whole turn. // under the text for the whole turn.
if (message.isStreaming && message.content.isBlank()) { if (
message.isStreaming &&
message.content.isBlank() &&
!showImageGeneration
) {
// After a few seconds with no content yet, escalate the bare // After a few seconds with no content yet, escalate the bare
// dots to a labeled "Still working…" so a slow first token // dots to a labeled "Still working…" so a slow first token
// never reads as a hang on the SSE / sessions paths. // never reads as a hang on the SSE / sessions paths.
@@ -219,6 +219,16 @@ data class RelayPairing(
val transportHint: String? = null, val transportHint: String? = null,
) )
/** True only when a scoped Relay scan can immediately begin authentication. */
internal fun HermesPairingPayload.hasUsableRelayPairing(): Boolean {
val relay = relay ?: return false
if (relay.code.isBlank()) return false
val uri = runCatching { URI(relay.url.trim()) }.getOrNull() ?: return false
val supportedScheme = uri.scheme.equals("ws", ignoreCase = true) ||
uri.scheme.equals("wss", ignoreCase = true)
return supportedScheme && !uri.host.isNullOrBlank()
}
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
isLenient = true isLenient = true
@@ -258,8 +268,8 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
return try { return try {
// Quick check: must contain a `host` field and be valid JSON. We no // Quick check: must contain a `host` field and be valid JSON. We no
// longer reject based on the `hermes` version int — future v4+ QRs // longer reject based on the `hermes` version int — future v4+ QRs
// should still parse on this phone so Bailey doesn't have to ship a // should still parse so wire-format growth does not require an app
// whole release to keep up with wire-format growth. // release for every compatible payload version.
val obj = json.decodeFromString<JsonObject>(raw) val obj = json.decodeFromString<JsonObject>(raw)
val version = obj["hermes"]?.jsonPrimitive?.intOrNull ?: 1 val version = obj["hermes"]?.jsonPrimitive?.intOrNull ?: 1
if (version < 1) return null if (version < 1) return null
@@ -532,7 +542,8 @@ private fun mapBoxToViewport(
@Composable @Composable
fun QrPairingScanner( fun QrPairingScanner(
onPairingDetected: (HermesPairingPayload) -> Unit, onPairingDetected: (HermesPairingPayload) -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit,
relayOnly: Boolean = false,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@@ -628,7 +639,10 @@ fun QrPairingScanner(
) )
} }
Text( Text(
text = stringResource(R.string.qr_scanner_title), text = stringResource(
if (relayOnly) R.string.qr_scanner_relay_title
else R.string.qr_scanner_title,
),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
@@ -740,7 +754,11 @@ fun QrPairingScanner(
) { ) {
val rawValue = barcode.rawValue ?: continue val rawValue = barcode.rawValue ?: continue
val payload = parseHermesPairingQr(rawValue) val payload = parseHermesPairingQr(rawValue)
if (payload != null && hasDetected.compareAndSet(false, true)) { if (
payload != null &&
(!relayOnly || payload.hasUsableRelayPairing()) &&
hasDetected.compareAndSet(false, true)
) {
lockedPayload = payload lockedPayload = payload
return@addOnSuccessListener return@addOnSuccessListener
} }
@@ -798,13 +816,19 @@ fun QrPairingScanner(
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
) )
Text( Text(
text = stringResource(R.string.qr_scanner_instruction), text = stringResource(
if (relayOnly) R.string.qr_scanner_relay_instruction
else R.string.qr_scanner_instruction,
),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Text( Text(
text = stringResource(R.string.qr_scanner_subtext), text = stringResource(
if (relayOnly) R.string.qr_scanner_relay_subtext
else R.string.qr_scanner_subtext,
),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
@@ -0,0 +1,145 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.compose.LocalMarkdownTypography
import com.mikepenz.markdown.compose.elements.MarkdownCodeBackground
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
import com.mikepenz.markdown.compose.elements.material.MarkdownBasicText
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.BoldHighlight
import dev.snipme.highlights.model.ColorHighlight
import dev.snipme.highlights.model.SyntaxLanguage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.intellij.markdown.ast.ASTNode
@Composable
internal fun SafeMarkdownHighlightedCodeFence(
content: String,
node: ASTNode,
style: TextStyle = LocalMarkdownTypography.current.code,
highlightsBuilder: Highlights.Builder,
showHeader: Boolean = false,
) {
MarkdownCodeFence(content, node, style) { code, language, codeStyle ->
SafeMarkdownHighlightedCode(
code = code,
language = language,
style = codeStyle,
highlightsBuilder = highlightsBuilder,
showHeader = showHeader,
)
}
}
@Composable
internal fun SafeMarkdownHighlightedCodeBlock(
content: String,
node: ASTNode,
style: TextStyle = LocalMarkdownTypography.current.code,
highlightsBuilder: Highlights.Builder,
showHeader: Boolean = false,
) {
MarkdownCodeBlock(content, node, style) { code, language, codeStyle ->
SafeMarkdownHighlightedCode(
code = code,
language = language,
style = codeStyle,
highlightsBuilder = highlightsBuilder,
showHeader = showHeader,
)
}
}
@Composable
private fun SafeMarkdownHighlightedCode(
code: String,
language: String?,
style: TextStyle,
highlightsBuilder: Highlights.Builder,
showHeader: Boolean,
) {
val codeHighlights by produceState(
initialValue = AnnotatedString(code),
key1 = code,
key2 = language,
key3 = highlightsBuilder,
) {
value = withContext(Dispatchers.Default) {
buildSafeHighlightedAnnotatedString(code, language, highlightsBuilder)
}
}
val codeBackgroundCornerSize = LocalMarkdownDimens.current.codeBackgroundCornerSize
MarkdownCodeBackground(
color = LocalMarkdownColors.current.codeBackground,
shape = RoundedCornerShape(codeBackgroundCornerSize),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
showHeader = showHeader,
language = language,
code = code,
) {
MarkdownBasicText(
text = codeHighlights,
style = style,
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(LocalMarkdownPadding.current.codeBlock),
)
}
}
/**
* Converts Highlights ranges into Compose spans without trusting dependency
* offsets. Highlights 1.1.0 can return a reversed multiline-comment range
* when a multiline-comment closing delimiter precedes its opening delimiter.
* Streaming can expose that shape before a code block is complete.
*/
internal fun buildSafeHighlightedAnnotatedString(
code: String,
language: String?,
highlightsBuilder: Highlights.Builder,
): AnnotatedString {
val syntaxLanguage = language?.let(SyntaxLanguage::getByName)
val highlights = highlightsBuilder
.code(code)
.let { if (syntaxLanguage != null) it.language(syntaxLanguage) else it }
.build()
.getHighlights()
return buildAnnotatedString {
append(code)
highlights.forEach { highlight ->
val start = highlight.location.start.coerceIn(0, code.length)
val end = highlight.location.end.coerceIn(0, code.length)
if (start >= end) return@forEach
val style = when (highlight) {
is ColorHighlight -> SpanStyle(color = Color(highlight.rgb).copy(alpha = 1f))
is BoldHighlight -> SpanStyle(fontWeight = FontWeight.Bold)
}
addStyle(style = style, start = start, end = end)
}
}
}
@@ -19,15 +19,15 @@ data class SourceBadge(val label: String, val color: Color)
* The app's own chats — no badge (they're "your" conversations from this app / * The app's own chats — no badge (they're "your" conversations from this app /
* desktop / API, not a distinct gateway lane). * desktop / API, not a distinct gateway lane).
*/ */
private val OWN_CHAT_SOURCES = setOf("tui", "api_server", "cli", "local", "") private val OWN_CHAT_SOURCES = setOf("tui", "api_server", "cli", "local", "webui", "")
/** /**
* Map a session `source` to a drawer badge, or null for the app's own chats and * Map a session `source` to a drawer badge, or null for the app's own chats and
* the phone **Thread** (which renders its own thread-spool chip). External * the phone **Thread** (which renders its own thread-spool chip). External
* gateways — discord / telegram / slack / cron / webhook / web / … — each get a * gateways — discord / telegram / slack / cron / webhook / web / … — each get a
* small colored chip so the drawer reads like the desktop's per-channel tags. * small colored chip so the drawer reads like the desktop's per-channel tags.
* Confirmed live sources: tui, cli, api_server, web, discord, telegram, cron, * Confirmed live sources: tui, cli, api_server, webui, web, discord, telegram,
* webhook, phone. * cron, webhook, phone.
*/ */
fun sourceBadge(source: String?): SourceBadge? { fun sourceBadge(source: String?): SourceBadge? {
val s = source?.trim()?.lowercase() ?: return null val s = source?.trim()?.lowercase() ?: return null
@@ -39,6 +39,8 @@ fun ThinkingBlock(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/** Message timestamp shown right-aligned in the header (null hides it). */ /** Message timestamp shown right-aligned in the header (null hides it). */
timestamp: Long? = null, timestamp: Long? = null,
headerText: String? = null,
accessibilityLabel: String = "Thinking",
) { ) {
var expanded by remember { mutableStateOf(isStreaming) } var expanded by remember { mutableStateOf(isStreaming) }
val locale = LocalLocale.current.platformLocale val locale = LocalLocale.current.platformLocale
@@ -65,13 +67,13 @@ fun ThinkingBlock(
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Psychology, imageVector = Icons.Filled.Psychology,
contentDescription = "Thinking", contentDescription = accessibilityLabel,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary tint = MaterialTheme.colorScheme.tertiary
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = if (isStreaming) "Thinking..." else "Thought process", text = headerText ?: if (isStreaming) "Thinking..." else "Thought process",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary color = MaterialTheme.colorScheme.tertiary
) )
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@@ -73,8 +74,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ChatMessage import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.HermesCardAction
import com.hermesandroid.relay.data.MessageRole import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.ToolCall import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
import com.hermesandroid.relay.ui.LocalSnackbarHost import com.hermesandroid.relay.ui.LocalSnackbarHost
@@ -146,7 +149,8 @@ fun VoiceModeOverlay(
voiceOutputEnabled: Boolean? = null, voiceOutputEnabled: Boolean? = null,
voiceOutputFallbackEnabled: Boolean? = null, voiceOutputFallbackEnabled: Boolean? = null,
onOverlayRequest: () -> Unit = {}, onOverlayRequest: () -> Unit = {},
onCompactModeChange: (Boolean) -> Unit = {}, presentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
onPresentationModeChange: (VoicePresentationMode) -> Unit = {},
// === END PHASE3-voice-mode-transcript === // === END PHASE3-voice-mode-transcript ===
// === v0.4.1 JIT permission-denied chip === // === v0.4.1 JIT permission-denied chip ===
// Tapped when the user clicks the permission-denied chip. Default no-op // Tapped when the user clicks the permission-denied chip. Default no-op
@@ -159,22 +163,21 @@ fun VoiceModeOverlay(
onBackgroundRunCancel: () -> Unit = {}, onBackgroundRunCancel: () -> Unit = {},
onBackgroundRunTap: () -> Unit = {}, onBackgroundRunTap: () -> Unit = {},
onHermesConfirmationAnswer: (String) -> Unit = {}, onHermesConfirmationAnswer: (String) -> Unit = {},
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit =
{ _, _, _ -> },
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit =
{ _, _, _ -> },
// === END v0.4.1 === // === END v0.4.1 ===
) { ) {
val surface = MaterialTheme.colorScheme.surface val surface = MaterialTheme.colorScheme.surface
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
var controlsExpanded by remember { mutableStateOf(false) } var controlsExpanded by remember { mutableStateOf(false) }
var focusMode by remember { mutableStateOf(true) } val focusMode = presentationMode == VoicePresentationMode.Focus
val setFocusMode: (Boolean) -> Unit = { focused -> val setFocusMode: (Boolean) -> Unit = { focused ->
focusMode = focused onPresentationModeChange(
onCompactModeChange(!focused) if (focused) VoicePresentationMode.Focus else VoicePresentationMode.Conversation,
} )
LaunchedEffect(uiState.voiceMode) {
if (!uiState.voiceMode) {
setFocusMode(true)
}
} }
// Voice errors surface ONLY on the overlay's own inline top banner // Voice errors surface ONLY on the overlay's own inline top banner
@@ -193,7 +196,7 @@ fun VoiceModeOverlay(
// chips, pill) didn't handle so stray taps/swipes don't fall // chips, pill) didn't handle so stray taps/swipes don't fall
// through to the chat + session drawer behind it. Children run on // through to the chat + session drawer behind it. Children run on
// the same Main pass leaf-first, so this only catches the gaps. // the same Main pass leaf-first, so this only catches the gaps.
// In compact mode the overlay is intentionally transparent and the // In Conversation the overlay is intentionally transparent and the
// chat stays interactive, so no scrim is installed. // chat stays interactive, so no scrim is installed.
.then( .then(
if (focusMode) { if (focusMode) {
@@ -404,6 +407,11 @@ fun VoiceModeOverlay(
message = msg, message = msg,
showThinking = showThinking, showThinking = showThinking,
expanded = msg.id == latestId || msg.isStreaming, expanded = msg.id == latestId || msg.isStreaming,
onViewConversation = {
onPresentationModeChange(VoicePresentationMode.Conversation)
},
onCardAction = onCardAction,
onCardInput = onCardInput,
) )
} }
if (pendingTranscriptText != null) { if (pendingTranscriptText != null) {
@@ -418,6 +426,11 @@ fun VoiceModeOverlay(
), ),
showThinking = showThinking, showThinking = showThinking,
expanded = true, expanded = true,
onViewConversation = {
onPresentationModeChange(VoicePresentationMode.Conversation)
},
onCardAction = onCardAction,
onCardInput = onCardInput,
) )
} }
} }
@@ -449,7 +462,7 @@ fun VoiceModeOverlay(
} }
} }
// Compact mode: the background-run chip must survive outside focus // Conversation: the background-run chip must survive outside focus
// mode too — a running task with no visible presence reads as lost // mode too — a running task with no visible presence reads as lost
// (the chip previously existed ONLY in the focus layout). // (the chip previously existed ONLY in the focus layout).
AnimatedVisibility( AnimatedVisibility(
@@ -821,6 +834,15 @@ private fun VoiceSessionPill(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (!focusMode) {
ConversationVoiceMicButton(
uiState = uiState,
onMicTap = onMicTap,
onMicRelease = onMicRelease,
onInterrupt = onInterrupt,
onPauseAutoMode = onPauseAutoMode,
)
}
Icon( Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) stringResource(R.string.voice_overlay_collapse_cd) else stringResource(R.string.voice_overlay_expand_cd), contentDescription = if (expanded) stringResource(R.string.voice_overlay_collapse_cd) else stringResource(R.string.voice_overlay_expand_cd),
@@ -855,10 +877,9 @@ private fun VoiceSessionPill(
.padding(top = 10.dp), .padding(top = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
// Moved out of the collapsed header (4a): the current // The expanded body keeps the current interaction mode
// interaction-mode pill plus the inline mic control. The // visible; Conversation's persistent mic stays in the
// compact mic only appears in compact mode, where the // collapsed header so it never disappears.
// full-size bottom mic button is hidden.
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -866,15 +887,6 @@ private fun VoiceSessionPill(
) { ) {
StatusPill(uiState.interactionMode.label()) StatusPill(uiState.interactionMode.label())
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
if (!focusMode) {
CompactVoiceMicButton(
uiState = uiState,
onMicTap = onMicTap,
onMicRelease = onMicRelease,
onInterrupt = onInterrupt,
onPauseAutoMode = onPauseAutoMode,
)
}
} }
Row( Row(
@@ -915,7 +927,11 @@ private fun VoiceSessionPill(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
Text( Text(
if (focusMode) stringResource(R.string.voice_overlay_compact) else stringResource(R.string.voice_overlay_focus), if (focusMode) {
stringResource(R.string.voice_overlay_conversation)
} else {
stringResource(R.string.voice_overlay_focus)
},
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -958,7 +974,7 @@ private fun VoiceSessionPill(
} }
@Composable @Composable
private fun CompactVoiceMicButton( private fun ConversationVoiceMicButton(
uiState: VoiceUiState, uiState: VoiceUiState,
onMicTap: () -> Unit, onMicTap: () -> Unit,
onMicRelease: () -> Unit, onMicRelease: () -> Unit,
@@ -1224,6 +1240,9 @@ private fun CompactTranscriptRow(
message: ChatMessage, message: ChatMessage,
showThinking: Boolean, showThinking: Boolean,
expanded: Boolean, expanded: Boolean,
onViewConversation: () -> Unit,
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit,
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit,
) { ) {
if (message.role == MessageRole.SYSTEM) return if (message.role == MessageRole.SYSTEM) return
@@ -1248,6 +1267,13 @@ private fun CompactTranscriptRow(
message.role == MessageRole.USER -> MaterialTheme.colorScheme.primary message.role == MessageRole.USER -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.secondary else -> MaterialTheme.colorScheme.secondary
} }
val (markdownBody, inlineImages) = remember(message.content, message.role) {
if (message.role == MessageRole.ASSISTANT) {
extractChatInlineImages(message.content)
} else {
message.content to emptyList()
}
}
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -1285,7 +1311,7 @@ private fun CompactTranscriptRow(
} }
when { when {
isVoiceActionBubble && hasText -> MarkdownContent( isVoiceActionBubble && hasText -> MarkdownContent(
content = message.content, content = markdownBody,
textColor = MaterialTheme.colorScheme.onSurface, textColor = MaterialTheme.colorScheme.onSurface,
) )
message.role == MessageRole.USER && hasText -> Text( message.role == MessageRole.USER && hasText -> Text(
@@ -1295,12 +1321,107 @@ private fun CompactTranscriptRow(
maxLines = if (expanded) Int.MAX_VALUE else 3, maxLines = if (expanded) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
hasText -> Text( hasText -> MarkdownContent(
text = message.content, content = markdownBody,
style = MaterialTheme.typography.bodyMedium, textColor = MaterialTheme.colorScheme.onSurface,
)
}
message.cards.forEachIndexed { index, card ->
if (card.actions.isNotEmpty() || card.input != null) {
Spacer(Modifier.height(6.dp))
val cardKey = card.id ?: "idx:$index"
HermesCardBubble(
card = card,
cardKey = cardKey,
dispatches = message.cardDispatches,
onActionTap = { key, action ->
onCardAction(message.id, key, action)
},
onInputSubmit = { key, value ->
onCardInput(message.id, key, value)
},
maxWidth = 360.dp,
)
}
}
if (
message.attachments.isNotEmpty() ||
message.cards.any { it.actions.isEmpty() && it.input == null } ||
inlineImages.isNotEmpty()
) {
Spacer(Modifier.height(6.dp))
VoiceRichResultAffordance(
message = message,
onViewConversation = onViewConversation,
)
}
}
}
@Composable
private fun VoiceRichResultAffordance(
message: ChatMessage,
onViewConversation: () -> Unit,
) {
val inlineImages = remember(message.content) {
extractChatInlineImages(message.content).second
}
val previewModel = remember(message.attachments, inlineImages) {
message.attachments.firstOrNull { it.isImage && !it.cachedUri.isNullOrBlank() }?.cachedUri
?: inlineImages.firstOrNull {
it.src.startsWith("https://") || it.src.startsWith("http://")
}?.src
}
val label = when {
message.attachments.size + inlineImages.size > 1 ->
stringResource(
R.string.voice_overlay_rich_results_count,
message.attachments.size + inlineImages.size,
)
message.attachments.isNotEmpty() || inlineImages.isNotEmpty() ->
stringResource(R.string.voice_overlay_image_ready)
else -> stringResource(R.string.voice_overlay_rich_result_ready)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onViewConversation),
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.48f),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (previewModel != null) {
AsyncImage(
model = previewModel,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp)),
)
} else {
Icon(
imageVector = Icons.Filled.Image,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = if (expanded) Int.MAX_VALUE else 6, modifier = Modifier.weight(1f),
overflow = TextOverflow.Ellipsis, )
Text(
text = stringResource(R.string.voice_overlay_view_conversation),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
) )
} }
} }
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import com.hermesandroid.relay.viewmodel.VoiceState import com.hermesandroid.relay.viewmodel.VoiceState
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.max import kotlin.math.max
@@ -140,6 +141,8 @@ fun VoiceWaveform(
amplitude: Float, amplitude: Float,
state: VoiceState, state: VoiceState,
outputAudioActive: Boolean = false, outputAudioActive: Boolean = false,
height: Dp = 56.dp,
compactBars: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// No downstream smoothing. The VoiceViewModel already runs an // No downstream smoothing. The VoiceViewModel already runs an
@@ -154,6 +157,7 @@ fun VoiceWaveform(
// doesn't look abrupt. // doesn't look abrupt.
val dim = MaterialTheme.colorScheme.onSurfaceVariant val dim = MaterialTheme.colorScheme.onSurfaceVariant
val errorColor = MaterialTheme.colorScheme.error val errorColor = MaterialTheme.colorScheme.error
val compactColor = MaterialTheme.colorScheme.primary
val targetPrimary = when (state) { val targetPrimary = when (state) {
VoiceState.Idle -> dim.copy(alpha = 0.3f) VoiceState.Idle -> dim.copy(alpha = 0.3f)
@@ -198,16 +202,38 @@ fun VoiceWaveform(
) )
val spinnerPhase = rememberProcessingSpinnerPhase(processing) val spinnerPhase = rememberProcessingSpinnerPhase(processing)
Canvas( val canvasModifier = if (compactBars) {
modifier = modifier modifier.height(height)
.fillMaxWidth() } else {
.height(56.dp), modifier.fillMaxWidth().height(height)
) { }
Canvas(modifier = canvasModifier) {
val width = size.width val width = size.width
val height = size.height val height = size.height
if (width <= 0f || height <= 0f) return@Canvas if (width <= 0f || height <= 0f) return@Canvas
val centerY = height / 2f val centerY = height / 2f
if (compactBars) {
val barCount = 10
val gap = 3.dp.toPx()
val barWidth = 2.5.dp.toPx()
val totalWidth = barWidth * barCount + gap * (barCount - 1)
val startX = ((width - totalWidth) / 2f).coerceAtLeast(0f)
repeat(barCount) { index ->
val wave = ((sin(phases[0] + index * 0.82f) + 1f) * 0.5f)
val activeHeight = height * (0.22f + wave * (0.28f + displayAmplitude * 0.5f))
val x = startX + index * (barWidth + gap)
drawRoundRect(
color = compactColor.copy(alpha = if (index < 5) 0.96f else 0.22f),
topLeft = Offset(x, centerY - activeHeight / 2f),
size = Size(barWidth, activeHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidth / 2f, barWidth / 2f),
)
}
return@Canvas
}
val peakPixels = height * PEAK_FRACTION val peakPixels = height * PEAK_FRACTION
val strokePx = STROKE_WIDTH_DP.dp.toPx() val strokePx = STROKE_WIDTH_DP.dp.toPx()
val centerX = width / 2f val centerX = width / 2f
@@ -1,11 +1,10 @@
package com.hermesandroid.relay.ui.onboarding package com.hermesandroid.relay.ui.onboarding
import androidx.compose.foundation.background import androidx.compose.foundation.background
import com.hermesandroid.relay.ui.theme.LocalBrand import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -20,23 +19,21 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.hermesandroid.relay.ui.theme.HermesRelayTheme import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.ui.theme.gradientBorder
@Composable @Composable
fun OnboardingPage( fun OnboardingPage(
@@ -44,7 +41,6 @@ fun OnboardingPage(
title: String, title: String,
description: String, description: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
transparentHero: Boolean = false,
heroContent: @Composable BoxScope.() -> Unit = { heroContent: @Composable BoxScope.() -> Unit = {
FeatureHero( FeatureHero(
icon = icon, icon = icon,
@@ -53,101 +49,44 @@ fun OnboardingPage(
}, },
content: @Composable ColumnScope.() -> Unit = {} content: @Composable ColumnScope.() -> Unit = {}
) { ) {
val isDarkTheme = LocalBrand.current.isDark Column(
val heroShape = RoundedCornerShape(30.dp) modifier = modifier
val bodyShape = RoundedCornerShape(26.dp) .fillMaxWidth()
val heroBrush = Brush.radialGradient( .widthIn(max = 560.dp)
colors = if (isDarkTheme) { .verticalScroll(rememberScrollState())
listOf( .padding(horizontal = 42.dp, vertical = 8.dp),
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.96f), horizontalAlignment = Alignment.CenterHorizontally,
MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.92f), verticalArrangement = Arrangement.spacedBy(12.dp),
MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.98f), ) {
) Box(
} else {
listOf(
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.70f),
MaterialTheme.colorScheme.surface.copy(alpha = 0.98f),
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.60f),
)
}
)
// Short viewports (small phones, large font scale, split screen) shrink or
// drop the hero so the body text fits; the vertical scroll below is the
// safety net when even that isn't enough. The enclosing pager Box centers
// short content, so no Arrangement.Center here — it conflicts with
// verticalScroll when content overflows.
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val heroHeight = when {
maxHeight < 480.dp -> 0.dp
maxHeight < 620.dp -> 160.dp
else -> 232.dp
}
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.widthIn(max = 560.dp) .height(210.dp),
.verticalScroll(rememberScrollState()) contentAlignment = Alignment.Center,
.padding(horizontal = 24.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (heroHeight > 0.dp) { heroContent()
Card(
modifier = Modifier
.fillMaxWidth()
.height(heroHeight)
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
shape = heroShape,
colors = CardDefaults.cardColors(
containerColor = if (transparentHero) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer
)
) {
val heroModifier = Modifier
.fillMaxWidth()
.height(heroHeight)
Box(
modifier = if (transparentHero) heroModifier else heroModifier.background(heroBrush),
contentAlignment = Alignment.Center
) {
heroContent()
}
}
Spacer(modifier = Modifier.height(18.dp))
}
Card(
modifier = Modifier
.fillMaxWidth()
.gradientBorder(shape = bodyShape, isDarkTheme = isDarkTheme),
shape = bodyShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
content()
}
}
} }
Text(
text = title,
style = MaterialTheme.typography.headlineLarge.copy(
fontSize = 38.sp,
lineHeight = 40.sp,
),
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = description,
style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 25.sp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
content()
} }
} }
@@ -156,26 +95,45 @@ private fun FeatureHero(
icon: ImageVector, icon: ImageVector,
title: String, title: String,
) { ) {
val isDarkTheme = LocalBrand.current.isDark
Box( Box(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(136.dp) .size(176.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
MaterialTheme.colorScheme.primary.copy(alpha = if (isDarkTheme) 0.14f else 0.10f) Color(0xFF7B55F6).copy(alpha = 0.07f)
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Box(
imageVector = icon, modifier = Modifier
contentDescription = title, .size(128.dp)
modifier = Modifier.size(74.dp), .clip(CircleShape)
tint = MaterialTheme.colorScheme.primary .background(Color(0xFF7B55F6).copy(alpha = 0.10f)),
) contentAlignment = Alignment.Center,
) {
Surface(
modifier = Modifier.size(86.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.78f),
border = BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f),
),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(46.dp),
tint = Color(0xFF7B55F6),
)
}
}
}
} }
} }
} }
@@ -1,10 +1,9 @@
package com.hermesandroid.relay.ui.onboarding package com.hermesandroid.relay.ui.onboarding
import android.content.Intent import android.Manifest
import android.net.Uri import android.os.Build
import androidx.compose.animation.AnimatedVisibility import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.animation.fadeIn import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -18,19 +17,33 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.MenuBook import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.outlined.Forum import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.RocketLaunch
import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material.icons.outlined.Terminal
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -45,28 +58,64 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.hermesandroid.relay.R import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.SphereState import com.hermesandroid.relay.permissions.AppPermissionStatusProbe
import com.hermesandroid.relay.ui.components.ConnectionWizard import com.hermesandroid.relay.ui.components.ConnectionWizard
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
import com.hermesandroid.relay.ui.theme.HermesRelayTheme import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.ConnectionViewModel import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** Page identifiers for dynamic onboarding flow. */ /** Page identifiers for the standard-first onboarding flow. */
private enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect } internal enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect, Permissions }
internal val standardOnboardingPages = listOf(
OnboardingPage.Welcome,
OnboardingPage.Chat,
OnboardingPage.Manage,
OnboardingPage.Power,
OnboardingPage.Connect,
OnboardingPage.Permissions,
)
internal enum class OnboardingNotificationAction {
RequestPermission,
Finish,
}
internal fun onboardingNotificationAction(
sdkInt: Int,
notificationsPermitted: Boolean,
): OnboardingNotificationAction {
return if (
sdkInt >= Build.VERSION_CODES.TIRAMISU &&
!notificationsPermitted
) {
OnboardingNotificationAction.RequestPermission
} else {
OnboardingNotificationAction.Finish
}
}
private val OnboardingAccent = Color(0xFF7B55F6)
/** /**
* Standard-first onboarding: * Standard-first onboarding:
@@ -112,21 +161,23 @@ fun OnboardingScreen(
*/ */
onTryDemo: () -> Unit = {}, onTryDemo: () -> Unit = {},
) { ) {
val pages = remember { val pages = standardOnboardingPages
buildList {
add(OnboardingPage.Welcome)
add(OnboardingPage.Chat)
add(OnboardingPage.Manage)
add(OnboardingPage.Power)
add(OnboardingPage.Connect)
}
}
val pageCount = pages.size val pageCount = pages.size
val lastPage = pageCount - 1 val lastPage = pageCount - 1
val pagerState = rememberPagerState(pageCount = { pageCount }) val pagerState = rememberPagerState(pageCount = { pageCount })
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var showSkipConfirm by rememberSaveable { mutableStateOf(false) } var showSkipConfirm by rememberSaveable { mutableStateOf(false) }
val context = LocalContext.current
var notificationsPermitted by remember {
mutableStateOf(AppPermissionStatusProbe.snapshot(context).notificationsPermitted)
}
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
notificationsPermitted =
granted || AppPermissionStatusProbe.snapshot(context).notificationsPermitted
}
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -156,22 +207,35 @@ fun OnboardingScreen(
} }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Top bar with Skip — only on informational pages, not the wizard val currentPage = pagerState.currentPage
// (which has its own "Skip for now" affordance). val currentPageType = pages[currentPage]
Row(
modifier = Modifier // The selected welcome frame has no toolbar. Later information
.fillMaxWidth() // pages keep quiet navigation without reserving space above it.
.padding(horizontal = 16.dp, vertical = 12.dp), if (
horizontalArrangement = Arrangement.End, currentPageType == OnboardingPage.Chat ||
verticalAlignment = Alignment.CenterVertically currentPageType == OnboardingPage.Manage ||
currentPageType == OnboardingPage.Power
) { ) {
if (pages[pagerState.currentPage] != OnboardingPage.Connect) { Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(
onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(currentPage - 1) }
},
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.onboarding_back))
}
TextButton(onClick = { showSkipConfirm = true }) { TextButton(onClick = { showSkipConfirm = true }) {
Text( Text(stringResource(R.string.onboarding_skip))
text = stringResource(R.string.onboarding_skip),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} }
} }
@@ -179,7 +243,13 @@ fun OnboardingScreen(
// Pager content // Pager content
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
// Connect and permission setup advance only through their
// explicit actions. This keeps the post-connect setup page
// unreachable until the wizard reports a successful save.
userScrollEnabled =
currentPageType != OnboardingPage.Connect &&
currentPageType != OnboardingPage.Permissions,
) { pageIndex -> ) { pageIndex ->
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -194,243 +264,332 @@ fun OnboardingScreen(
) )
OnboardingPage.Connect -> ConnectPage( OnboardingPage.Connect -> ConnectPage(
connectionViewModel = connectionViewModel, connectionViewModel = connectionViewModel,
onComplete = onComplete, onComplete = {
notificationsPermitted =
AppPermissionStatusProbe.snapshot(context).notificationsPermitted
coroutineScope.launch {
pagerState.animateScrollToPage(
pages.indexOf(OnboardingPage.Permissions),
)
}
},
onManageSignIn = onManageSignIn, onManageSignIn = onManageSignIn,
onSkip = { showSkipConfirm = true }, onSkip = { showSkipConfirm = true },
onTryDemo = onTryDemo, onTryDemo = onTryDemo,
) )
OnboardingPage.Permissions -> PermissionSetupPage(
notificationAction = onboardingNotificationAction(
sdkInt = Build.VERSION.SDK_INT,
notificationsPermitted = notificationsPermitted,
),
onEnableNotifications = {
notificationPermissionLauncher.launch(
Manifest.permission.POST_NOTIFICATIONS,
)
},
onReviewPermissions = onOpenPermissions,
onFinish = onComplete,
)
} }
} }
} }
// Bottom navigation only on informational pages — the wizard // Bottom navigation only on informational pages — the wizard
// owns its own back/pair affordances. // owns its own back/pair affordances.
if (pages[pagerState.currentPage] != OnboardingPage.Connect) { if (
// Short viewports get a tighter footer so more of the pager currentPageType != OnboardingPage.Connect &&
// content stays above the fold; indicator + Back/Next remain currentPageType != OnboardingPage.Permissions
// pinned outside the (scrollable) pager pages either way. ) {
val compactHeight = LocalConfiguration.current.screenHeightDp < 620 val compactHeight = LocalConfiguration.current.screenHeightDp < 620
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 32.dp) .padding(horizontal = 42.dp)
.padding(bottom = if (compactHeight) 16.dp else 48.dp), .padding(bottom = if (compactHeight) 10.dp else 20.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(if (compactHeight) 8.dp else 12.dp),
) { ) {
PageIndicator( GradientOnboardingButton(
pageCount = pageCount, label = when {
currentPage = pagerState.currentPage currentPage == 0 -> stringResource(R.string.onboarding_get_started)
currentPage == lastPage - 1 -> stringResource(R.string.onboarding_connect)
else -> stringResource(R.string.onboarding_next)
},
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage((currentPage + 1).coerceAtMost(lastPage))
}
},
) )
Spacer(modifier = Modifier.height(if (compactHeight) 12.dp else 24.dp)) if (currentPage == 0) {
TextButton(onClick = onTryDemo) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
AnimatedVisibility(
visible = pagerState.currentPage > 0,
enter = fadeIn(),
exit = fadeOut()
) {
TextButton(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
}
) {
Text(text = stringResource(R.string.onboarding_back))
}
}
if (pagerState.currentPage == 0) {
Spacer(modifier = Modifier.width(1.dp))
}
Button(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(
(pagerState.currentPage + 1).coerceAtMost(lastPage)
)
}
}
) {
Text( Text(
text = if (pagerState.currentPage == lastPage - 1) { text = stringResource(R.string.chat_try_demo),
stringResource(R.string.onboarding_connect) color = OnboardingAccent,
} else { style = MaterialTheme.typography.labelLarge,
stringResource(R.string.onboarding_next)
}
) )
} }
} }
SegmentedOnboardingProgress(
pageCount = pageCount,
currentPage = currentPage,
)
Text(
text = stringResource(R.string.onboarding_step_count, currentPage + 1, pageCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.68f),
)
} }
} }
} }
} }
} }
@Composable
private fun GradientOnboardingButton(
label: String,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.clip(RoundedCornerShape(14.dp))
.clickable(onClick = onClick),
color = Color.Transparent,
shape = RoundedCornerShape(14.dp),
) {
Row(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
listOf(Color(0xFF7047F5), Color(0xFF6446F0)),
),
)
.padding(horizontal = 22.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Spacer(Modifier.width(24.dp))
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
)
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp),
)
}
}
}
@Composable
private fun SegmentedOnboardingProgress(
pageCount: Int,
currentPage: Int,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 36.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(pageCount) { index ->
Box(
modifier = Modifier
.weight(1f)
.height(4.dp)
.clip(RoundedCornerShape(999.dp))
.background(
if (index == currentPage) {
OnboardingAccent
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f)
},
),
)
}
}
}
@Composable @Composable
private fun WelcomePage() { private fun WelcomePage() {
val context = LocalContext.current val titleParts = stringResource(R.string.onboarding_welcome_title).split("\n", limit = 2)
OnboardingPage( Column(
icon = Icons.Outlined.RocketLaunch, modifier = Modifier
title = stringResource(R.string.onboarding_welcome_title), .fillMaxSize()
description = stringResource(R.string.onboarding_welcome_description), .verticalScroll(rememberScrollState())
transparentHero = true, .padding(horizontal = 28.dp),
heroContent = { horizontalAlignment = Alignment.CenterHorizontally,
Box(modifier = Modifier.fillMaxSize()) {
LocalAgentAvatar.current.Render(
state = AvatarRenderState(
state = SphereState.Idle,
intensity = 0.12f,
),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 4.dp, vertical = 2.dp),
)
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(16.dp)
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.80f))
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Text(
text = stringResource(R.string.onboarding_welcome_badge),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 6.dp)
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.86f))
.padding(start = 7.dp, end = 12.dp, top = 5.dp, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.onboarding_hermes_logo),
modifier = Modifier.size(30.dp)
)
Text(
text = "Hermes-Relay",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
) { ) {
Column( Box(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
verticalArrangement = Arrangement.spacedBy(8.dp), .fillMaxWidth()
.height(380.dp),
contentAlignment = Alignment.Center,
) { ) {
SetupPathSummary( Image(
label = stringResource(R.string.onboarding_chat_manage_label), painter = painterResource(R.drawable.onboarding_hero_option1),
description = stringResource(R.string.onboarding_chat_manage_description), contentDescription = stringResource(R.string.onboarding_hermes_logo),
) modifier = Modifier.fillMaxSize(),
SetupPathSummary( contentScale = ContentScale.Crop,
label = stringResource(R.string.onboarding_power_tools_label), alignment = BiasAlignment(horizontalBias = 0f, verticalBias = -0.5f),
description = stringResource(R.string.onboarding_power_tools_description),
) )
} }
Text( Text(
text = stringResource(R.string.onboarding_setup_guide_hint), text = buildAnnotatedString {
style = MaterialTheme.typography.bodySmall, append(titleParts.first())
color = MaterialTheme.colorScheme.onSurfaceVariant, if (titleParts.size > 1) append("\n")
withStyle(SpanStyle(color = OnboardingAccent)) {
if (titleParts.size > 1) append(titleParts[1])
}
},
color = MaterialTheme.colorScheme.onBackground,
fontSize = 38.sp,
lineHeight = 40.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(14.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/docs/guide/getting-started"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.onboarding_setup_guide))
}
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.onboarding_hermes_docs))
}
}
Text( Text(
text = stringResource(R.string.onboarding_api_server_docs), text = stringResource(R.string.onboarding_welcome_description),
style = MaterialTheme.typography.bodySmall.copy( style = MaterialTheme.typography.bodyLarge.copy(
textDecoration = TextDecoration.Underline lineHeight = 27.sp,
fontWeight = FontWeight.Normal,
), ),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable { textAlign = TextAlign.Center,
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server")))
}
) )
Spacer(Modifier.height(24.dp))
WelcomeCapabilityRow(
icon = Icons.Filled.Dashboard,
label = stringResource(R.string.onboarding_chat_manage_label),
description = stringResource(R.string.onboarding_chat_manage_description),
)
Spacer(Modifier.height(14.dp))
WelcomeCapabilityRow(
icon = Icons.Filled.Bolt,
label = stringResource(R.string.onboarding_power_tools_label),
description = stringResource(R.string.onboarding_power_tools_description),
)
Spacer(Modifier.height(12.dp))
}
}
@Composable
private fun WelcomeCapabilityRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
description: String,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 47.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(30.dp),
) {
Box {
Surface(
modifier = Modifier.size(58.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
border = androidx.compose.foundation.BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f),
),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = OnboardingAccent,
)
}
}
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(8.dp)
.clip(CircleShape)
.background(Color(0xFF57E389)),
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
} }
@Composable @Composable
private fun SetupPathSummary( private fun SetupPathSummary(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String, label: String,
description: String, description: String,
) { ) {
Surface( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
shape = RoundedCornerShape(16.dp), .fillMaxWidth()
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f), .padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Row( Surface(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), modifier = Modifier.size(48.dp),
verticalAlignment = Alignment.Top, shape = RoundedCornerShape(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
border = androidx.compose.foundation.BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.22f),
),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(25.dp),
tint = OnboardingAccent,
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.width(96.dp),
) )
Text( Text(
text = description, text = description,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
) )
} }
} }
@@ -448,14 +607,17 @@ private fun ChatPage() {
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
SetupPathSummary( SetupPathSummary(
icon = Icons.Filled.Bolt,
label = stringResource(R.string.onboarding_streaming_label), label = stringResource(R.string.onboarding_streaming_label),
description = stringResource(R.string.onboarding_streaming_description), description = stringResource(R.string.onboarding_streaming_description),
) )
SetupPathSummary( SetupPathSummary(
icon = Icons.Filled.Person,
label = stringResource(R.string.onboarding_profiles_label), label = stringResource(R.string.onboarding_profiles_label),
description = stringResource(R.string.onboarding_profiles_description), description = stringResource(R.string.onboarding_profiles_description),
) )
SetupPathSummary( SetupPathSummary(
icon = Icons.Filled.Mic,
label = stringResource(R.string.onboarding_voice_label), label = stringResource(R.string.onboarding_voice_label),
description = stringResource(R.string.onboarding_voice_description), description = stringResource(R.string.onboarding_voice_description),
) )
@@ -475,14 +637,17 @@ private fun ManagePage() {
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
SetupPathSummary( SetupPathSummary(
icon = Icons.Filled.Tune,
label = stringResource(R.string.onboarding_control_label), label = stringResource(R.string.onboarding_control_label),
description = stringResource(R.string.onboarding_control_description), description = stringResource(R.string.onboarding_control_description),
) )
SetupPathSummary( SetupPathSummary(
icon = Icons.Filled.Extension,
label = stringResource(R.string.onboarding_skills_hub_label), label = stringResource(R.string.onboarding_skills_hub_label),
description = stringResource(R.string.onboarding_skills_hub_description), description = stringResource(R.string.onboarding_skills_hub_description),
) )
SetupPathSummary( SetupPathSummary(
icon = Icons.Filled.Lock,
label = stringResource(R.string.onboarding_one_sign_in_label), label = stringResource(R.string.onboarding_one_sign_in_label),
description = stringResource(R.string.onboarding_one_sign_in_description), description = stringResource(R.string.onboarding_one_sign_in_description),
) )
@@ -504,14 +669,17 @@ private fun PowerToolsPage(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
SetupPathSummary( SetupPathSummary(
icon = Icons.Outlined.Terminal,
label = stringResource(R.string.onboarding_terminal_label), label = stringResource(R.string.onboarding_terminal_label),
description = stringResource(R.string.onboarding_terminal_description), description = stringResource(R.string.onboarding_terminal_description),
) )
SetupPathSummary( SetupPathSummary(
icon = Icons.Filled.PhoneAndroid,
label = stringResource(R.string.onboarding_bridge_label), label = stringResource(R.string.onboarding_bridge_label),
description = stringResource(R.string.onboarding_bridge_description), description = stringResource(R.string.onboarding_bridge_description),
) )
SetupPathSummary( SetupPathSummary(
icon = Icons.Filled.GraphicEq,
label = stringResource(R.string.onboarding_realtime_label), label = stringResource(R.string.onboarding_realtime_label),
description = stringResource(R.string.onboarding_realtime_description), description = stringResource(R.string.onboarding_realtime_description),
) )
@@ -531,6 +699,80 @@ private fun PowerToolsPage(
} }
} }
@Composable
internal fun PermissionSetupPage(
notificationAction: OnboardingNotificationAction,
onEnableNotifications: () -> Unit,
onReviewPermissions: () -> Unit,
onFinish: () -> Unit,
) {
OnboardingPage(
icon = Icons.Filled.Security,
title = stringResource(R.string.onboarding_finish_setup_title),
description = stringResource(R.string.onboarding_finish_setup_description),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
SetupPathSummary(
icon = Icons.Filled.CheckCircle,
label = stringResource(R.string.perms_chat_and_manage),
description = stringResource(R.string.onboarding_chat_manage_ready),
)
SetupPathSummary(
icon = Icons.Filled.Notifications,
label = stringResource(R.string.onboarding_chat_alerts),
description = if (
notificationAction == OnboardingNotificationAction.Finish
) {
stringResource(R.string.onboarding_chat_alerts_ready)
} else {
stringResource(R.string.onboarding_chat_alerts_description)
},
)
SetupPathSummary(
icon = Icons.Filled.Tune,
label = stringResource(R.string.onboarding_optional_features),
description = stringResource(R.string.onboarding_optional_features_description),
)
OutlinedButton(
onClick = onReviewPermissions,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.Security,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.onboarding_review_optional_permissions))
}
Spacer(Modifier.height(2.dp))
if (notificationAction == OnboardingNotificationAction.RequestPermission) {
GradientOnboardingButton(
label = stringResource(R.string.onboarding_enable_chat_alerts),
onClick = onEnableNotifications,
)
TextButton(
onClick = onFinish,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.onboarding_not_now))
}
} else {
GradientOnboardingButton(
label = stringResource(R.string.onboarding_finish),
onClick = onFinish,
)
}
}
}
}
@Composable @Composable
private fun ConnectPage( private fun ConnectPage(
connectionViewModel: ConnectionViewModel, connectionViewModel: ConnectionViewModel,
@@ -206,10 +206,16 @@ fun AboutScreen(
val remaining = 7 - versionTapCount val remaining = 7 - versionTapCount
when { when {
remaining <= 0 -> { remaining <= 0 -> {
scope.launch { FeatureFlags.unlockDevOptions(context) }
Toast.makeText(context, devUnlockedMsg, Toast.LENGTH_SHORT).show()
versionTapCount = 0 versionTapCount = 0
onUnlockDeveloperOptions() scope.launch {
FeatureFlags.unlockDevOptions(context)
Toast.makeText(
context,
devUnlockedMsg,
Toast.LENGTH_SHORT,
).show()
onUnlockDeveloperOptions()
}
} }
remaining <= 3 -> { remaining <= 3 -> {
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining) val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
@@ -432,7 +438,7 @@ fun AboutScreen(
// Privacy policy link // Privacy policy link
OutlinedButton( OutlinedButton(
onClick = { onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Codename-11/hermes-relay/blob/main/docs/privacy.md")) val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/privacy.html"))
context.startActivity(intent) context.startActivity(intent)
}, },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@@ -460,6 +460,7 @@ fun AppearanceSettingsScreen(
) { ) {
val animEnabled by connectionViewModel.animationEnabled.collectAsState() val animEnabled by connectionViewModel.animationEnabled.collectAsState()
val animBehindChat by connectionViewModel.animationBehindChat.collectAsState() val animBehindChat by connectionViewModel.animationBehindChat.collectAsState()
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@@ -526,6 +527,48 @@ fun AppearanceSettingsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
HorizontalDivider()
Text(
text = stringResource(R.string.appearance_image_generation_style),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.appearance_image_generation_style_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val imageStyleOptions = listOf(
"rotate" to stringResource(R.string.appearance_image_generation_rotate),
"grid" to stringResource(R.string.appearance_image_generation_grid),
"sphere" to stringResource(R.string.appearance_image_generation_sphere),
"nodes" to stringResource(R.string.appearance_image_generation_nodes),
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
imageStyleOptions.forEach { (id, label) ->
FilterChip(
selected = imageGenerationStyle == id,
onClick = { connectionViewModel.setImageGenerationStyle(id) },
label = { Text(label) },
leadingIcon = if (imageGenerationStyle == id) {
{
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
} else {
null
},
)
}
}
} }
} }
@@ -115,6 +115,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.hermesandroid.relay.R import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.radialNavyBackground import com.hermesandroid.relay.ui.theme.radialNavyBackground
import com.hermesandroid.relay.network.upstream.ApiModelOption
import com.hermesandroid.relay.network.upstream.ChatMode import com.hermesandroid.relay.network.upstream.ChatMode
import com.hermesandroid.relay.network.upstream.GatewayAvailability import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.RelayVoiceClient import com.hermesandroid.relay.network.relay.RelayVoiceClient
@@ -138,6 +139,7 @@ import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import android.content.ClipData import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
@@ -155,7 +157,9 @@ import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.Attachment import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.ChatMessage import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.Connection import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.HermesCardAction
import com.hermesandroid.relay.data.MessageRole import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
import com.hermesandroid.relay.ui.components.AgentInfoSheet import com.hermesandroid.relay.ui.components.AgentInfoSheet
import com.hermesandroid.relay.ui.components.BackgroundTaskCard import com.hermesandroid.relay.ui.components.BackgroundTaskCard
@@ -195,11 +199,15 @@ import com.hermesandroid.relay.ui.components.ThinkingMatrixColor
import com.hermesandroid.relay.ui.components.ThinkingMatrixPattern import com.hermesandroid.relay.ui.components.ThinkingMatrixPattern
import com.hermesandroid.relay.ui.components.SessionDrawerContent import com.hermesandroid.relay.ui.components.SessionDrawerContent
import com.hermesandroid.relay.ui.components.SlashCommand import com.hermesandroid.relay.ui.components.SlashCommand
import com.hermesandroid.relay.ui.components.StreamingDots
import com.hermesandroid.relay.ui.components.SubagentLane import com.hermesandroid.relay.ui.components.SubagentLane
import com.hermesandroid.relay.ui.components.ToolProgressCard import com.hermesandroid.relay.ui.components.ToolProgressCard
import com.hermesandroid.relay.ui.components.isVisibleForToolDisplay
import com.hermesandroid.relay.ui.components.showsImageGenerationPlaceholder
import com.hermesandroid.relay.ui.components.VoiceModeOverlay import com.hermesandroid.relay.ui.components.VoiceModeOverlay
import com.hermesandroid.relay.ui.LocalSnackbarHost import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.showHumanError import com.hermesandroid.relay.ui.showHumanError
import com.hermesandroid.relay.util.HumanErrorAction
import com.hermesandroid.relay.ui.theme.RelayRefresh import com.hermesandroid.relay.ui.theme.RelayRefresh
import kotlin.math.abs import kotlin.math.abs
import com.hermesandroid.relay.ui.theme.relayGridTexture import com.hermesandroid.relay.ui.theme.relayGridTexture
@@ -208,7 +216,10 @@ import androidx.compose.ui.text.style.TextAlign
import kotlin.math.roundToInt import kotlin.math.roundToInt
import com.hermesandroid.relay.viewmodel.ChatViewModel import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ChatConnectState import com.hermesandroid.relay.viewmodel.ChatConnectState
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ConnectionViewModel import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.VoiceViewModel import com.hermesandroid.relay.viewmodel.VoiceViewModel
import com.hermesandroid.relay.voice.VoiceOverlayHost import com.hermesandroid.relay.voice.VoiceOverlayHost
import com.hermesandroid.relay.voice.VoiceOverlaySession import com.hermesandroid.relay.voice.VoiceOverlaySession
@@ -220,6 +231,23 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val DEFAULT_CHAR_LIMIT = 4096 private const val DEFAULT_CHAR_LIMIT = 4096
internal fun resolveChatHeaderSubtitle(
isStreaming: Boolean,
statusText: String,
projectName: String?,
personalityName: String?,
modelName: String?,
): String = if (isStreaming) {
statusText
} else {
listOfNotNull(
projectName?.takeIf { it.isNotBlank() },
personalityName?.takeIf { it.isNotBlank() },
modelName?.takeIf { it.isNotBlank() },
).joinToString(" \u00B7 ").ifBlank { statusText }
}
/** /**
* A same-author run breaks into a new visual group once the gap to the * A same-author run breaks into a new visual group once the gap to the
* neighboring message exceeds this — so a conversation resumed after a pause * neighboring message exceeds this — so a conversation resumed after a pause
@@ -256,6 +284,17 @@ internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?)
previous.messageCount == messageCount && previous.messageCount == messageCount &&
previous.lastMessageUiKey == lastMessageUiKey previous.lastMessageUiKey == lastMessageUiKey
internal fun releaseRetainedLiveTail(
retainedUiKey: String?,
completedUiKey: String?,
): String? = retainedUiKey?.takeUnless { it == completedUiKey }
internal fun tailEndScrollOffset(
tailSizePx: Int,
footerSizePx: Int,
viewportSizePx: Int,
): Int = (tailSizePx + footerSizePx - viewportSizePx).coerceAtLeast(0)
private class ChatTailTransitionRef( private class ChatTailTransitionRef(
var snapshot: ChatScrollSnapshot? = null, var snapshot: ChatScrollSnapshot? = null,
) )
@@ -415,6 +454,8 @@ fun ChatScreen(
voiceViewModel: VoiceViewModel, voiceViewModel: VoiceViewModel,
voiceClient: RelayVoiceClient? = null, voiceClient: RelayVoiceClient? = null,
maxBubbleWidth: Dp = 300.dp, maxBubbleWidth: Dp = 300.dp,
voicePresentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
onVoicePresentationModeChange: (VoicePresentationMode) -> Unit = {},
// Deep-link nudge from Settings → Active Agent card: when `true`, the // Deep-link nudge from Settings → Active Agent card: when `true`, the
// AgentInfoSheet auto-opens on first composition and [onAgentSheetArgConsumed] // AgentInfoSheet auto-opens on first composition and [onAgentSheetArgConsumed]
// fires so the host can clear the nav arg (prevents re-open on tab // fires so the host can clear the nav arg (prevents re-open on tab
@@ -427,6 +468,7 @@ fun ChatScreen(
// don't wire navigation. // don't wire navigation.
onNavigateToConnections: () -> Unit = {}, onNavigateToConnections: () -> Unit = {},
onNavigateToConnect: () -> Unit = onNavigateToConnections, onNavigateToConnect: () -> Unit = onNavigateToConnections,
onRepairConnection: () -> Unit = onNavigateToConnect,
// Offline demo entry, surfaced on the empty-chat "needs connection" card so a // Offline demo entry, surfaced on the empty-chat "needs connection" card so a
// skipped / never-connected first run can explore without a server. null hides it. // skipped / never-connected first run can explore without a server. null hides it.
onTryDemo: (() -> Unit)? = null, onTryDemo: (() -> Unit)? = null,
@@ -441,9 +483,10 @@ fun ChatScreen(
) { ) {
val voiceUiState by voiceViewModel.uiState.collectAsState() val voiceUiState by voiceViewModel.uiState.collectAsState()
val isDemoMode by connectionViewModel.isDemoMode.collectAsState() val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
var voiceCompactMode by remember { mutableStateOf(false) }
val chatAlpha by animateFloatAsState( val chatAlpha by animateFloatAsState(
targetValue = if (voiceUiState.voiceMode && !voiceCompactMode) 0.4f else 1f, targetValue = if (
voiceUiState.voiceMode && voicePresentationMode == VoicePresentationMode.Focus
) 0.4f else 1f,
animationSpec = tween(300), animationSpec = tween(300),
label = "chatAlpha", label = "chatAlpha",
) )
@@ -453,7 +496,13 @@ fun ChatScreen(
val snackbarHost = LocalSnackbarHost.current val snackbarHost = LocalSnackbarHost.current
LaunchedEffect(chatViewModel) { LaunchedEffect(chatViewModel) {
chatViewModel.errorEvents.collect { err -> chatViewModel.errorEvents.collect { err ->
snackbarHost.showHumanError(err) val result = snackbarHost.showHumanError(err)
if (
result == SnackbarResult.ActionPerformed &&
err.action == HumanErrorAction.Repair
) {
onRepairConnection()
}
} }
} }
@@ -521,6 +570,7 @@ fun ChatScreen(
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState() val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
val standardVoiceSignInRouteHint by val standardVoiceSignInRouteHint by
connectionViewModel.standardVoiceSignInRouteHint.collectAsState() connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
val dashboardRouteMovedHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
val apiReachable by connectionViewModel.apiServerReachable.collectAsState() val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
val chatMode by connectionViewModel.chatMode.collectAsState() val chatMode by connectionViewModel.chatMode.collectAsState()
val error by chatViewModel.error.collectAsState() val error by chatViewModel.error.collectAsState()
@@ -539,6 +589,7 @@ fun ChatScreen(
// and is consumed by the AgentInfoSheet for the Profile section. The list // and is consumed by the AgentInfoSheet for the Profile section. The list
// of available profiles itself now lives entirely inside the sheet. // of available profiles itself now lives entirely inside the sheet.
val selectedProfile by connectionViewModel.selectedProfile.collectAsState() val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
// Server-advertised profile catalog — used to locate the "default" profile // Server-advertised profile catalog — used to locate the "default" profile
// so the header can render its description/model when no explicit pick // so the header can render its description/model when no explicit pick
// has been made (the /api/config fallback is more useful than the bare // has been made (the /api/config fallback is more useful than the bare
@@ -547,11 +598,12 @@ fun ChatScreen(
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState() val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState() val activeConnection by connectionViewModel.activeConnection.collectAsState()
val serverModelName by chatViewModel.serverModelName.collectAsState() val serverModelName by chatViewModel.serverModelName.collectAsState()
val availableModels by chatViewModel.availableModels.collectAsState() val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
val modelProviders by chatViewModel.modelProviders.collectAsState() val modelProviders by chatViewModel.modelProviders.collectAsState()
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState() val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState() val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState() val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
val gatewayProjectName by chatViewModel.gatewayProjectName.collectAsState()
val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState() val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState()
val showThinking by connectionViewModel.showThinking.collectAsState() val showThinking by connectionViewModel.showThinking.collectAsState()
val toolDisplay by connectionViewModel.toolDisplay.collectAsState() val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
@@ -583,7 +635,7 @@ fun ChatScreen(
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState() val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
// Whether the NEXT turn would ride the gateway transport — gates the // Whether the NEXT turn would ride the gateway transport — gates the
// "Edit & resend" menu entry (conversation rewind needs the gateway). // "Edit & resend" menu entry (conversation rewind needs the gateway).
// Per-turn steerability comes from [steerableTurn], which also covers // Per-turn correction availability comes from [steerableTurn], which also covers
// the preflight-SSE-fallback window. // the preflight-SSE-fallback window.
val streamingEndpointPref by connectionViewModel.streamingEndpoint.collectAsState() val streamingEndpointPref by connectionViewModel.streamingEndpoint.collectAsState()
val chatServerCapabilities by connectionViewModel.serverCapabilities.collectAsState() val chatServerCapabilities by connectionViewModel.serverCapabilities.collectAsState()
@@ -636,9 +688,24 @@ fun ChatScreen(
// Animation settings // Animation settings
val animationEnabled by connectionViewModel.animationEnabled.collectAsState() val animationEnabled by connectionViewModel.animationEnabled.collectAsState()
val animationBehindChat by connectionViewModel.animationBehindChat.collectAsState() val animationBehindChat by connectionViewModel.animationBehindChat.collectAsState()
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
val thinkingIndicatorStyle by connectionViewModel.thinkingIndicatorStyle.collectAsState() val thinkingIndicatorStyle by connectionViewModel.thinkingIndicatorStyle.collectAsState()
val thinkingMatrixPattern by connectionViewModel.thinkingMatrixPattern.collectAsState() val thinkingMatrixPattern by connectionViewModel.thinkingMatrixPattern.collectAsState()
val thinkingMatrixColor by connectionViewModel.thinkingMatrixColor.collectAsState() val thinkingMatrixColor by connectionViewModel.thinkingMatrixColor.collectAsState()
val imageGenerationOrdinals = remember(messages) {
var nextOrdinal = 0
buildMap {
messages.forEach { message ->
val generationCount = message.toolCalls.count {
it.name.trim().equals("image_generate", ignoreCase = true)
}
if (generationCount > 0) {
put(message.uiKey, nextOrdinal + generationCount - 1)
nextOrdinal += generationCount
}
}
}
}
var ambientMode by remember { mutableStateOf(false) } // clean text-flow mode, hides chat var ambientMode by remember { mutableStateOf(false) } // clean text-flow mode, hides chat
// Clean-mode discoverability hint: a persistent pill shown ONLY on the // Clean-mode discoverability hint: a persistent pill shown ONLY on the
// empty / new-chat view (no messages) — it teaches the long-press entry // empty / new-chat view (no messages) — it teaches the long-press entry
@@ -746,6 +813,25 @@ fun ChatScreen(
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val handleCardAction: (String, String, HermesCardAction) -> Unit =
remember(chatViewModel, context) {
{ messageId, cardKey, action ->
if (action.mode == HermesCardAction.Modes.OPEN_URL) {
chatViewModel.dispatchCardAction(messageId, cardKey, action)
com.hermesandroid.relay.ui.components.handleCardActionExternally(
context,
action,
)
} else {
chatViewModel.dispatchCardAction(messageId, cardKey, action)
}
}
}
val handleCardInput: (String, String, String) -> Unit = remember(chatViewModel) {
{ messageId, cardKey, value ->
chatViewModel.answerAsk(messageId, cardKey, value)
}
}
// Ephemeral notices from the VM (model-switch warnings/errors, etc.) → // Ephemeral notices from the VM (model-switch warnings/errors, etc.) →
// transient snackbar, never a chat bubble. // transient snackbar, never a chat bubble.
@@ -809,8 +895,7 @@ fun ChatScreen(
provider = activeVoiceProvider, provider = activeVoiceProvider,
model = activeVoiceModel, model = activeVoiceModel,
voice = activeVoiceName, voice = activeVoiceName,
profileName = selectedProfile?.description?.takeIf { it.isNotBlank() } profileName = AgentDisplay.profileDisplayName(effectiveProfile),
?: selectedProfile?.name,
configScope = activeVoiceScope, configScope = activeVoiceScope,
outputEnabled = activeVoiceEnabled, outputEnabled = activeVoiceEnabled,
fallbackEnabled = voiceOutputConfig?.fallback_enabled, fallbackEnabled = voiceOutputConfig?.fallback_enabled,
@@ -844,7 +929,6 @@ fun ChatScreen(
if (!voiceUiState.voiceMode) { if (!voiceUiState.voiceMode) {
voiceOverlayHost.hide() voiceOverlayHost.hide()
pendingVoiceOverlayPermission = false pendingVoiceOverlayPermission = false
voiceCompactMode = false
} }
} }
@@ -1089,10 +1173,12 @@ fun ChatScreen(
derivedStateOf { derivedStateOf {
val retainingVisibleTail = retainedLiveTailUiKey != null && val retainingVisibleTail = retainedLiveTailUiKey != null &&
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
val settlingVisibleTail = completionSettlingUiKey != null &&
messages.lastOrNull()?.uiKey == completionSettlingUiKey
messages.isNotEmpty() && messages.isNotEmpty() &&
!isAtBottom && !isAtBottom &&
!programmaticBottomScroll && !programmaticBottomScroll &&
!((isStreaming || retainingVisibleTail) && !((isStreaming || retainingVisibleTail || settlingVisibleTail) &&
smoothAutoScroll && smoothAutoScroll &&
!userScrolledAway) !userScrolledAway)
} }
@@ -1290,34 +1376,99 @@ fun ChatScreen(
) { ) {
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
if (!smoothAutoScroll || userScrolledAway || isUserDragging) { if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
// Retention is only a completion-transition aid. Never leave the
// finalized tail on the plain streaming renderer just because the
// user disabled follow-scroll or is reading above the bottom.
retainedLiveTailUiKey = releaseRetainedLiveTail(
retainedUiKey = retainedLiveTailUiKey,
completedUiKey = settlingKey,
)
completionSettlingUiKey = null completionSettlingUiKey = null
return@LaunchedEffect return@LaunchedEffect
} }
var settledFrames = 0 var settledFrames = 0
repeat(6) { var previousMarkdownTailSize: Int? = null
var previousMarkdownFooterSize: Int? = null
val markdownWasAlreadyReleased = retainedLiveTailUiKey != settlingKey
repeat(60) completionFrame@{
withFrameNanos { } withFrameNanos { }
if (messages.lastOrNull()?.uiKey != settlingKey) { if (messages.lastOrNull()?.uiKey != settlingKey) {
completionSettlingUiKey = null completionSettlingUiKey = null
return@LaunchedEffect return@LaunchedEffect
} }
if (listState.canScrollForward) { if (!markdownWasAlreadyReleased && retainedLiveTailUiKey == settlingKey) {
settledFrames = 0 if (listState.canScrollForward) {
val viewportHeight = listState.layoutInfo.viewportSize.height settledFrames = 0
if (viewportHeight > 0) { val viewportHeight = listState.layoutInfo.viewportSize.height
listState.scroll(MutatePriority.Default) { if (viewportHeight > 0) {
scrollBy(viewportHeight.toFloat()) listState.scroll(MutatePriority.Default) {
scrollBy(viewportHeight.toFloat())
}
} }
return@completionFrame
} }
} else {
settledFrames += 1 settledFrames += 1
if (settledFrames >= 2) { if (settledFrames < 2) return@completionFrame
completionSettlingUiKey = null retainedLiveTailUiKey = releaseRetainedLiveTail(
return@LaunchedEffect retainedUiKey = retainedLiveTailUiKey,
} completedUiKey = settlingKey,
)
settledFrames = 0
return@completionFrame
}
// Once Markdown owns the row, position its measured trailing edge
// explicitly. `canScrollForward` is insufficient here: LazyColumn
// may preserve the leading edge of a tall item while reporting an
// otherwise valid item anchor. Repeating catches deferred parsing,
// highlighted code, and attachment measurement without competing
// with the ordinary streaming-growth coroutine.
val layout = listState.layoutInfo
val tailIndex = messages.size // header item + zero-based messages
val footerIndex = tailIndex + 1
val tailInfo = layout.visibleItemsInfo.firstOrNull { it.index == tailIndex }
val footerInfo = layout.visibleItemsInfo.firstOrNull { it.index == footerIndex }
if (tailInfo == null) {
listState.scrollToItem(tailIndex)
settledFrames = 0
return@completionFrame
}
val viewportHeight = layout.viewportSize.height
if (viewportHeight <= 0) return@completionFrame
val desiredOffset = tailEndScrollOffset(
tailSizePx = tailInfo.size,
footerSizePx = footerInfo?.size ?: 0,
viewportSizePx = viewportHeight,
)
if (desiredOffset == 0) {
listState.scrollToItem(footerIndex)
} else {
listState.scrollToItem(tailIndex, desiredOffset)
}
val footerSize = footerInfo?.size ?: 0
settledFrames = if (
previousMarkdownTailSize == tailInfo.size &&
previousMarkdownFooterSize == footerSize
) {
settledFrames + 1
} else {
0
}
previousMarkdownTailSize = tailInfo.size
previousMarkdownFooterSize = footerSize
if (settledFrames >= 12) {
completionSettlingUiKey = null
return@LaunchedEffect
} }
} }
retainedLiveTailUiKey = releaseRetainedLiveTail(
retainedUiKey = retainedLiveTailUiKey,
completedUiKey = settlingKey,
)
completionSettlingUiKey = null completionSettlingUiKey = null
} }
@@ -1379,23 +1530,6 @@ fun ChatScreen(
} }
} }
// Display profile - the one the header should reflect. Priority:
// 1. explicit user pick (selectedProfile)
// 2. server-advertised profile named "default"
// 3. null (fall back to personality-derived name)
//
// Computed as a derived state so the header cross-fades when the user
// picks a new profile OR when the server's profile catalog finishes
// loading and a "default" entry shows up.
val effectiveProfile by remember(selectedProfile, agentProfiles) {
derivedStateOf {
AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
)
}
}
// Agent display name — used in header and info dialog. // Agent display name — used in header and info dialog.
// //
// Precedence mirrors the messaging-app pattern: show the profile's // Precedence mirrors the messaging-app pattern: show the profile's
@@ -1439,7 +1573,7 @@ fun ChatScreen(
// drawer itself, so the overlay's pointer scrim alone can't block it. // drawer itself, so the overlay's pointer scrim alone can't block it.
gesturesEnabled = !voiceUiState.voiceMode, gesturesEnabled = !voiceUiState.voiceMode,
drawerContent = { drawerContent = {
val drawerTitle = if (selectedProfile != null) { val drawerTitle = if (effectiveProfile != null) {
stringResource(R.string.chat_profile_sessions, agentDisplayName) stringResource(R.string.chat_profile_sessions, agentDisplayName)
} else { } else {
stringResource(R.string.chat_server_default_sessions) stringResource(R.string.chat_server_default_sessions)
@@ -1517,17 +1651,36 @@ fun ChatScreen(
} }
}, },
title = { title = {
val headerApiReachable = apiReachable || isStreaming // Resolve the same Gateway-first, API-fallback runtime
val isConnecting = isChatConnecting || // model used elsewhere; Relay is intentionally unrelated
(!headerApiReachable && chatMode != ChatMode.DISCONNECTED) // to Chat health.
val chatRuntimeStatus = resolveChatRuntimeStatus(
gateway = when (chatGatewayAvailability) {
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
GatewayAvailability.Unknown -> if (
activeConnection?.resolvedDashboardUrl.isNullOrBlank()
) ChatTransportReadiness.NotConfigured else ChatTransportReadiness.Connecting
GatewayAvailability.SignInRequired,
GatewayAvailability.Unreachable,
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
},
apiSse = when {
apiReachable -> ChatTransportReadiness.Ready
activeConnection?.apiServerUrl.isNullOrBlank() -> ChatTransportReadiness.NotConfigured
chatMode != ChatMode.DISCONNECTED -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
},
)
val headerChatReady = chatRuntimeStatus is ChatRuntimeStatus.Connected || isStreaming
val isConnecting = isChatConnecting || chatRuntimeStatus is ChatRuntimeStatus.Connecting
// Once we've been connected this session, a later drop reads as // Once we've been connected this session, a later drop reads as
// "Reconnecting…" (we had it, we're getting it back) rather than // "Reconnecting…" (we had it, we're getting it back) rather than
// a first-time "Connecting…". Honest wording for the WhatsApp- // a first-time "Connecting…". Honest wording for the WhatsApp-
// style subtitle status. // style subtitle status.
var everConnected by remember { mutableStateOf(false) } var everConnected by remember { mutableStateOf(false) }
if (headerApiReachable) everConnected = true if (headerChatReady) everConnected = true
val statusText = when { val statusText = when {
headerApiReachable -> if (isStreaming) { headerChatReady -> if (isStreaming) {
stringResource(R.string.chat_streaming) stringResource(R.string.chat_streaming)
} else { } else {
stringResource(R.string.chat_connected_label) stringResource(R.string.chat_connected_label)
@@ -1540,7 +1693,7 @@ fun ChatScreen(
else -> stringResource(R.string.chat_disconnected_label) else -> stringResource(R.string.chat_disconnected_label)
} }
val statusColor = when { val statusColor = when {
headerApiReachable -> Color(0xFF4CAF50) headerChatReady -> Color(0xFF4CAF50)
isConnecting -> Color(0xFFFFA726) isConnecting -> Color(0xFFFFA726)
else -> MaterialTheme.colorScheme.error else -> MaterialTheme.colorScheme.error
} }
@@ -1578,14 +1731,18 @@ fun ChatScreen(
// yet (server config still loading), fall back to the plain // yet (server config still loading), fall back to the plain
// connection status \u2014 never the literal "None"/"Default" // connection status \u2014 never the literal "None"/"Default"
// personality label. // personality label.
val subtitleText = when { val subtitleText = if (!headerChatReady) {
!headerApiReachable -> statusText statusText
else -> listOfNotNull( } else {
nonDefaultPersonality, resolveChatHeaderSubtitle(
modelName?.takeIf { it.isNotBlank() }, isStreaming = isStreaming,
).joinToString(" \u00B7 ").ifBlank { statusText } statusText = statusText,
projectName = gatewayProjectName,
personalityName = nonDefaultPersonality,
modelName = modelName,
)
} }
val subtitleColor = if (headerApiReachable) { val subtitleColor = if (headerChatReady) {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
} else { } else {
statusColor statusColor
@@ -1643,7 +1800,7 @@ fun ChatScreen(
} }
} }
ConnectionStatusBadge( ConnectionStatusBadge(
isConnected = headerApiReachable, isConnected = headerChatReady,
isConnecting = isConnecting, isConnecting = isConnecting,
modifier = Modifier modifier = Modifier
.size(10.dp) .size(10.dp)
@@ -1716,6 +1873,10 @@ fun ChatScreen(
transitionSpec = { loadedContentTransform() }, transitionSpec = { loadedContentTransform() },
label = "chatHeaderSubtitle", label = "chatHeaderSubtitle",
) { line -> ) { line ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text( Text(
text = line, text = line,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -1723,6 +1884,13 @@ fun ChatScreen(
maxLines = 1, maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
) )
if (isStreaming && animationEnabled) {
StreamingDots(
color = subtitleColor,
modifier = Modifier.clearAndSetSemantics { },
)
}
}
} }
} }
} }
@@ -1924,6 +2092,9 @@ fun ChatScreen(
chatReady = chatReady, chatReady = chatReady,
isLoadingHistory = isLoadingHistory, isLoadingHistory = isLoadingHistory,
isLoadingSessions = isLoadingSessions, isLoadingSessions = isLoadingSessions,
gatewayAvailability = chatGatewayAvailability,
dashboardRouteMovedHint = dashboardRouteMovedHint,
onNavigateToManage = onNavigateToManage,
onNavigateToConnections = onNavigateToConnections, onNavigateToConnections = onNavigateToConnections,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
@@ -1972,7 +2143,7 @@ fun ChatScreen(
// thread itself (not just the header) - // thread itself (not just the header) -
// the desktop's intro. // the desktop's intro.
ChatConnectState.Ready -> ChatConnectState.Ready ->
if (selectedProfile != null) { if (effectiveProfile != null) {
stringResource(R.string.chat_prompt_chat_with, agentDisplayName) stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
} else { } else {
stringResource(R.string.chat_start_conversation) stringResource(R.string.chat_start_conversation)
@@ -2238,30 +2409,17 @@ fun ChatScreen(
isLastInGroup = isLastInGroup, isLastInGroup = isLastInGroup,
retainStreamingLayout = retainLiveLayout, retainStreamingLayout = retainLiveLayout,
recoveringAnswer = recoveringAnswer, recoveringAnswer = recoveringAnswer,
imageGenerationStylePreference = imageGenerationStyle,
imageGenerationRotationIndex =
imageGenerationOrdinals[message.uiKey] ?: 0,
onAttachmentRetry = { msgId, idx -> onAttachmentRetry = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx) chatViewModel.manualFetchAttachment(msgId, idx)
}, },
onAttachmentManualFetch = { msgId, idx -> onAttachmentManualFetch = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx) chatViewModel.manualFetchAttachment(msgId, idx)
}, },
onCardAction = { msgId, cardKey, action -> onCardAction = handleCardAction,
// OPEN_URL is resolved at the UI layer onCardInput = handleCardInput,
// because launching ACTION_VIEW needs a
// Context. Record the dispatch first so
// the card collapses even if launch fails.
if (action.mode == com.hermesandroid.relay.data.HermesCardAction.Modes.OPEN_URL) {
chatViewModel.dispatchCardAction(msgId, cardKey, action)
com.hermesandroid.relay.ui.components.handleCardActionExternally(
context,
action,
)
} else {
chatViewModel.dispatchCardAction(msgId, cardKey, action)
}
},
onCardInput = { msgId, cardKey, value ->
chatViewModel.answerAsk(msgId, cardKey, value)
},
onEditMessage = if ( onEditMessage = if (
isGatewayTransport && isGatewayTransport &&
!isStreaming && !isStreaming &&
@@ -2310,7 +2468,7 @@ fun ChatScreen(
) )
} }
// Steered sends live inside a server-side tool // Legacy steered sends live inside a server-side tool
// result, not a user message — flag the local // result, not a user message — flag the local
// bubble so the scrollback explains itself. // bubble so the scrollback explains itself.
if (message.role == MessageRole.USER && message.id.startsWith("steer-")) { if (message.role == MessageRole.USER && message.id.startsWith("steer-")) {
@@ -2327,12 +2485,21 @@ fun ChatScreen(
} }
} }
if (toolDisplay != "off" && !hasBackgroundTask) { if (!hasBackgroundTask) {
// Subagent children (taskIndex != null) group // Subagent children (taskIndex != null) group
// into lanes after the top-level tool cards; // into lanes after the top-level tool cards;
// the null group renders exactly as before. // the null group renders exactly as before.
val laneGroups = message.toolCalls.groupBy { it.taskIndex } val laneGroups = message.toolCalls.groupBy { it.taskIndex }
laneGroups[null]?.forEach { toolCall -> laneGroups[null]?.forEach { toolCall ->
// Image generation renders inside MessageBubble
// so the progress canvas and final media share
// one stable Surface and transition in place.
if (toolCall.showsImageGenerationPlaceholder()) {
return@forEach
}
if (!toolCall.isVisibleForToolDisplay(toolDisplay)) {
return@forEach
}
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
when (toolDisplay) { when (toolDisplay) {
"compact" -> CompactToolCall(toolCall = toolCall) "compact" -> CompactToolCall(toolCall = toolCall)
@@ -2342,12 +2509,14 @@ fun ChatScreen(
) )
} }
} }
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex -> if (toolDisplay != "off") {
Spacer(modifier = Modifier.height(4.dp)) laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
SubagentLane( Spacer(modifier = Modifier.height(4.dp))
taskIndex = taskIndex, SubagentLane(
calls = laneGroups.getValue(taskIndex), taskIndex = taskIndex,
) calls = laneGroups.getValue(taskIndex),
)
}
} }
} }
} }
@@ -2675,7 +2844,7 @@ fun ChatScreen(
} }
// Input bar — pill field with ONE trailing slot morphing // Input bar — pill field with ONE trailing slot morphing
// Send / Voice / Stop / Steer / Queue. "+" taps the file picker, // Send / Voice / Stop / Correct / Queue. "+" taps the file picker,
// long-press opens the CommandPalette (the dedicated "/" button // long-press opens the CommandPalette (the dedicated "/" button
// is gone — typing "/" still surfaces InlineAutocomplete). // is gone — typing "/" still surfaces InlineAutocomplete).
val hasContent = inputText.isNotBlank() || pendingAttachments.isNotEmpty() val hasContent = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
@@ -2702,13 +2871,16 @@ fun ChatScreen(
val editBusyMessage = stringResource(R.string.chat_edit_busy_snackbar) val editBusyMessage = stringResource(R.string.chat_edit_busy_snackbar)
val stoppedMessage = stringResource(R.string.chat_stopped_snackbar) val stoppedMessage = stringResource(R.string.chat_stopped_snackbar)
val attachmentPlaceholder = stringResource(R.string.chat_attachment_placeholder) val attachmentPlaceholder = stringResource(R.string.chat_attachment_placeholder)
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) { val sseModelOptions = remember(apiModelOptions, agentProfiles, selectedModelOverride) {
(availableModels.mapNotNull(AgentDisplay::displayModelName) + (apiModelOptions +
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } + agentProfiles.mapNotNull { profile ->
listOfNotNull(AgentDisplay.displayModelName(selectedModelOverride))) AgentDisplay.requestModelName(profile.model)?.let { ApiModelOption(it) }
.distinct() } +
listOfNotNull(AgentDisplay.requestModelName(selectedModelOverride)?.let { ApiModelOption(it) }))
.distinctBy { it.id }
} }
val currentModelForInput = AgentDisplay.displayModelName(selectedModelOverride) val currentModelForInput = apiModelOptions.firstOrNull { it.id == selectedModelOverride }?.id
?: AgentDisplay.displayModelName(selectedModelOverride)
?: AgentDisplay.displayModelName(gatewayCurrentModel) ?: AgentDisplay.displayModelName(gatewayCurrentModel)
?: AgentDisplay.displayModelName(effectiveProfile?.model) ?: AgentDisplay.displayModelName(effectiveProfile?.model)
?: AgentDisplay.displayModelName(serverModelName) ?: AgentDisplay.displayModelName(serverModelName)
@@ -2776,13 +2948,26 @@ fun ChatScreen(
) )
} }
} }
val providerModelIds = modelProviders.flatMap { it.models }.toSet()
sseModelOptions.filter { it.id !in providerModelIds }.forEach { model ->
add(
ChatInputPickerOption(
label = AgentDisplay.displayModelName(model.id) ?: model.id,
value = model.id,
group = "Routes",
secondary = model.routeDetail,
selected = selectedModelOverride == model.id,
),
)
}
} else { } else {
sseModelOptions.forEach { model -> sseModelOptions.forEach { model ->
add( add(
ChatInputPickerOption( ChatInputPickerOption(
label = model, label = AgentDisplay.displayModelName(model.id) ?: model.id,
value = model, value = model.id,
selected = selectedModelOverride == model, secondary = model.routeDetail,
selected = selectedModelOverride == model.id,
), ),
) )
} }
@@ -2927,7 +3112,11 @@ fun ChatScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
modelControl = modelControl, modelControl = modelControl,
onModelOptionSelected = { option -> onModelOptionSelected = { option ->
chatViewModel.selectModel(option.value, option.provider) if (option.provider == null && apiModelOptions.any { it.id == option.value }) {
option.value?.let(chatViewModel::selectApiModel)
} else {
chatViewModel.selectModel(option.value, option.provider)
}
}, },
effortControl = effortControl, effortControl = effortControl,
onEffortOptionSelected = { option -> onEffortOptionSelected = { option ->
@@ -2944,7 +3133,11 @@ fun ChatScreen(
onRefresh = { chatViewModel.refreshModelOptions(refresh = true) }, onRefresh = { chatViewModel.refreshModelOptions(refresh = true) },
onSelect = { option -> onSelect = { option ->
showModelSheet = false showModelSheet = false
chatViewModel.selectModel(option.value, option.provider) if (option.provider == null && apiModelOptions.any { it.id == option.value }) {
option.value?.let(chatViewModel::selectApiModel)
} else {
chatViewModel.selectModel(option.value, option.provider)
}
}, },
onDismiss = { showModelSheet = false }, onDismiss = { showModelSheet = false },
) )
@@ -3081,19 +3274,17 @@ fun ChatScreen(
voiceOutputProvider = activeVoiceProvider, voiceOutputProvider = activeVoiceProvider,
voiceOutputModel = activeVoiceModel, voiceOutputModel = activeVoiceModel,
voiceOutputVoice = activeVoiceName, voiceOutputVoice = activeVoiceName,
voiceProfileName = selectedProfile?.description?.takeIf { it.isNotBlank() } voiceProfileName = AgentDisplay.profileDisplayName(effectiveProfile),
?: selectedProfile?.name,
voiceConfigScope = activeVoiceScope, voiceConfigScope = activeVoiceScope,
voiceOutputEnabled = activeVoiceEnabled, voiceOutputEnabled = activeVoiceEnabled,
voiceOutputFallbackEnabled = voiceOutputConfig?.fallback_enabled, voiceOutputFallbackEnabled = voiceOutputConfig?.fallback_enabled,
presentationMode = voicePresentationMode,
onPresentationModeChange = onVoicePresentationModeChange,
onOverlayRequest = showVoiceSystemOverlay, onOverlayRequest = showVoiceSystemOverlay,
// Gear button in the overlay's expanded controls. The overlay // Gear button in the overlay's expanded controls. The overlay
// exits voice mode before invoking this, so navigation lands // exits voice mode before invoking this, so navigation lands
// on Voice Settings with no overlay left on top. // on Voice Settings with no overlay left on top.
onOpenSettings = onNavigateToVoiceSettings, onOpenSettings = onNavigateToVoiceSettings,
onCompactModeChange = { compact ->
voiceCompactMode = compact
},
// === v0.4.1 JIT permission-denied chip === // === v0.4.1 JIT permission-denied chip ===
// Tap deep-links to Settings → Apps → Hermes-Relay → // Tap deep-links to Settings → Apps → Hermes-Relay →
// Permissions for the running package. Use BuildConfig // Permissions for the running package. Use BuildConfig
@@ -3117,6 +3308,8 @@ fun ChatScreen(
onHermesConfirmationAnswer = { answer -> onHermesConfirmationAnswer = { answer ->
voiceViewModel.answerHermesConfirmation(answer) voiceViewModel.answerHermesConfirmation(answer)
}, },
onCardAction = handleCardAction,
onCardInput = handleCardInput,
// === END v0.4.1 === // === END v0.4.1 ===
) )
} }
@@ -3222,6 +3415,9 @@ private fun ChatColdStartLoadingState(
chatReady: Boolean, chatReady: Boolean,
isLoadingHistory: Boolean, isLoadingHistory: Boolean,
isLoadingSessions: Boolean, isLoadingSessions: Boolean,
gatewayAvailability: GatewayAvailability,
dashboardRouteMovedHint: String?,
onNavigateToManage: () -> Unit,
onNavigateToConnections: () -> Unit, onNavigateToConnections: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -3284,14 +3480,51 @@ private fun ChatColdStartLoadingState(
) )
} }
ChatLoadingCommandPanel( val dashboardSignInRequired =
commands = commands, gatewayAvailability == GatewayAvailability.SignInRequired && !apiReachable
onNavigateToConnections = onNavigateToConnections, if (dashboardSignInRequired) {
modifier = Modifier ElevatedCard(
.align(Alignment.BottomCenter) colors = CardDefaults.elevatedCardColors(
.fillMaxWidth() containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
.padding(horizontal = 16.dp, vertical = 16.dp), ),
) modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.dashboard_signin_required_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = dashboardRouteMovedHint?.let { route ->
stringResource(R.string.dashboard_signin_route_hint, route)
} ?: stringResource(R.string.chat_settings_gateway_needs_signin_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = onNavigateToManage,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.voice_settings_sign_in_via_manage))
}
}
}
} else {
ChatLoadingCommandPanel(
commands = commands,
onNavigateToConnections = onNavigateToConnections,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
)
}
} }
} }
@@ -443,7 +443,7 @@ fun ChatSettingsScreen(
HorizontalDivider() HorizontalDivider()
// Turn-complete notification toggle. First enable on // Background chat-alert toggle. First enable on
// API 33+ runs the POST_NOTIFICATIONS request (the // API 33+ runs the POST_NOTIFICATIONS request (the
// BridgeScreen master-toggle precedent); if the user // BridgeScreen master-toggle precedent); if the user
// denies, the notifier silently no-ops at post time. // denies, the notifier silently no-ops at post time.
@@ -8,12 +8,16 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -42,13 +46,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.FeatureFlags import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection
import com.hermesandroid.relay.ui.components.ActiveCardRoutesSection import com.hermesandroid.relay.ui.components.ActiveCardRoutesSection
@@ -60,7 +65,6 @@ import com.hermesandroid.relay.ui.components.SessionInfoSheet
import com.hermesandroid.relay.ui.theme.LocalBrand import com.hermesandroid.relay.ui.theme.LocalBrand
import com.hermesandroid.relay.viewmodel.ConnectionViewModel import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.statusText
/** /**
* Tabbed detail for a single Hermes connection — the level-2 screen the * Tabbed detail for a single Hermes connection — the level-2 screen the
@@ -105,11 +109,6 @@ fun ConnectionDetailScreen(
val connections by connectionViewModel.connections.collectAsState() val connections by connectionViewModel.connections.collectAsState()
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState() val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
val relayUiState by connectionViewModel.relayUiState.collectAsState() val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRow by connectionViewModel.relayRowState.collectAsState()
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
val relayEnabled by FeatureFlags.relayEnabled(context)
.collectAsState(initial = FeatureFlags.isDevBuild)
val connection = connections.firstOrNull { it.id == connectionId } val connection = connections.firstOrNull { it.id == connectionId }
// Connection was removed (e.g. via the overflow menu) — leave the screen. // Connection was removed (e.g. via the overflow menu) — leave the screen.
LaunchedEffect(connection == null) { LaunchedEffect(connection == null) {
@@ -200,6 +199,12 @@ fun ConnectionDetailScreen(
menuExpanded = false menuExpanded = false
onRepair(connectionId) onRepair(connectionId)
}, },
leadingIcon = {
Icon(
imageVector = Icons.Filled.QrCodeScanner,
contentDescription = null,
)
},
) )
if (connection.pairedAt != null) { if (connection.pairedAt != null) {
DropdownMenuItem( DropdownMenuItem(
@@ -265,10 +270,7 @@ fun ConnectionDetailScreen(
ActiveOverview( ActiveOverview(
connectionViewModel = connectionViewModel, connectionViewModel = connectionViewModel,
connection = connection, connection = connection,
relayConfigured = relayConfigured,
relayStatusText = relayRow.statusText(connectedLabel = stringResource(R.string.detail_connected)),
relayUiState = relayUiState, relayUiState = relayUiState,
relayEnabled = relayEnabled,
onReconnect = onReconnect, onReconnect = onReconnect,
onRepair = { onRepair(connectionId) }, onRepair = { onRepair(connectionId) },
onOpenApiInfo = { showApiInfoSheet = true }, onOpenApiInfo = { showApiInfoSheet = true },
@@ -289,18 +291,22 @@ fun ConnectionDetailScreen(
connectionViewModel = connectionViewModel, connectionViewModel = connectionViewModel,
connection = connection, connection = connection,
liveState = relayUiState, liveState = relayUiState,
onEditDashboard = {
selectedTab = tabs.indexOf(DetailTab.Advanced)
},
) )
DetailTab.Advanced -> ActiveCardAdvancedSection( DetailTab.Advanced -> ActiveCardAdvancedSection(
connectionViewModel = connectionViewModel, connectionViewModel = connectionViewModel,
relayEnabled = relayEnabled,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onPairRelay = { onRepair(connectionId) },
onInsecureAckRequested = { showInsecureAckDialog = true }, onInsecureAckRequested = { showInsecureAckDialog = true },
) )
DetailTab.Security -> ActiveCardSecurityPosture( DetailTab.Security -> ActiveCardSecurityPosture(
connectionViewModel = connectionViewModel, connectionViewModel = connectionViewModel,
onNavigateToPairedDevices = onNavigateToPairedDevices, onNavigateToPairedDevices = onNavigateToPairedDevices,
onRevokeRelay = { showRevokeConfirm = true },
) )
} }
@@ -410,10 +416,7 @@ private enum class DetailTab {
private fun ActiveOverview( private fun ActiveOverview(
connectionViewModel: ConnectionViewModel, connectionViewModel: ConnectionViewModel,
connection: Connection, connection: Connection,
relayConfigured: Boolean,
relayStatusText: String,
relayUiState: RelayUiState, relayUiState: RelayUiState,
relayEnabled: Boolean,
onReconnect: () -> Unit, onReconnect: () -> Unit,
onRepair: () -> Unit, onRepair: () -> Unit,
onOpenApiInfo: () -> Unit, onOpenApiInfo: () -> Unit,
@@ -421,48 +424,78 @@ private fun ActiveOverview(
onOpenRelayInfo: () -> Unit, onOpenRelayInfo: () -> Unit,
onOpenSessionInfo: () -> Unit, onOpenSessionInfo: () -> Unit,
) { ) {
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl) val hostname = connection.primaryHost.ifBlank { connection.label }
val headerLine = if (relayConfigured) relayStatusText else stringResource(R.string.detail_standard_prefix) + hostname val dashboardReady = connection.dashboardLastStatus?.reachable == true
Surface( Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Column( Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text( Row(
text = headerLine, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.titleSmall, horizontalArrangement = Arrangement.spacedBy(12.dp),
color = MaterialTheme.colorScheme.onSurface, ) {
maxLines = 1, Icon(
overflow = TextOverflow.Ellipsis, imageVector = Icons.Filled.Dashboard,
) contentDescription = null,
Text( tint = MaterialTheme.colorScheme.primary,
text = hostname, )
style = MaterialTheme.typography.bodySmall, Column(modifier = Modifier.weight(1f)) {
color = MaterialTheme.colorScheme.onSurfaceVariant, Text(
maxLines = 1, text = stringResource(R.string.detail_dashboard_primary),
overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium,
) )
Text(
text = connection.resolvedDashboardUrl.ifBlank { hostname },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
HorizontalDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
tint = if (dashboardReady) Color(0xFF4CAF50) else MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Text(
text = if (dashboardReady) {
stringResource(R.string.detail_core_ready)
} else {
stringResource(R.string.detail_core_configured)
},
style = MaterialTheme.typography.bodyMedium,
color = if (dashboardReady) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurface,
)
}
} }
} }
Text( Text(
text = stringResource(R.string.detail_routes_help), text = stringResource(R.string.detail_overview_summary),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
ActiveCardFeaturesSection( ActiveCardFeaturesSection(
connectionViewModel = connectionViewModel, connectionViewModel = connectionViewModel,
relayEnabled = relayEnabled,
onOpenApiInfo = onOpenApiInfo, onOpenApiInfo = onOpenApiInfo,
onOpenDashboard = onOpenDashboard, onOpenDashboard = onOpenDashboard,
onOpenRelayInfo = onOpenRelayInfo, onOpenRelayInfo = onOpenRelayInfo,
onOpenSessionInfo = onOpenSessionInfo, onOpenSessionInfo = onOpenSessionInfo,
onPairRelay = onRepair,
) )
Row( Row(
@@ -472,9 +505,6 @@ private fun ActiveOverview(
if (relayUiState == RelayUiState.Stale) { if (relayUiState == RelayUiState.Stale) {
Button(onClick = onReconnect) { Text(stringResource(R.string.detail_reconnect)) } Button(onClick = onReconnect) { Text(stringResource(R.string.detail_reconnect)) }
} }
TextButton(onClick = onRepair) {
Text(if (connection.pairedAt == null) stringResource(R.string.detail_pair_relay) else stringResource(R.string.detail_repair))
}
} }
} }
@@ -489,10 +519,10 @@ private fun InactiveOverview(
onSwitch: () -> Unit, onSwitch: () -> Unit,
onRepair: () -> Unit, onRepair: () -> Unit,
) { ) {
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl) val hostname = connection.primaryHost.ifBlank { connection.label }
val statusLine = when { val statusLine = when {
connection.pairedAt != null -> stringResource(R.string.detail_paired_relay_configured) connection.pairedAt != null -> stringResource(R.string.detail_paired_relay_configured)
connection.apiServerUrl.isNotBlank() -> stringResource(R.string.detail_standard_not_paired) connection.capabilities.chatConfigured -> stringResource(R.string.detail_standard_not_paired)
else -> stringResource(R.string.detail_not_configured) else -> stringResource(R.string.detail_not_configured)
} }
@@ -1,6 +1,9 @@
package com.hermesandroid.relay.ui.screens package com.hermesandroid.relay.ui.screens
import android.content.Context import android.content.Context
import android.text.format.DateUtils
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -21,12 +24,24 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.CellTower
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Lan
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -39,21 +54,33 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.RelayUrlDeriver import com.hermesandroid.relay.network.relay.RelayUrlDeriver
import com.hermesandroid.relay.viewmodel.ConnectionViewModel import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.RelayUiState import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.statusText import com.hermesandroid.relay.viewmodel.statusText
import java.util.concurrent.TimeUnit import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** /**
* Level-1 list of Hermes connections. Reachable via Settings → Connections. * Level-1 list of Hermes connections. Reachable via Settings → Connections.
@@ -91,6 +118,15 @@ fun ConnectionsSettingsScreen(
} else { } else {
false false
} }
val startupConnectionId: String? = if (connectionViewModel != null) {
val startupId by connectionViewModel.startupConnectionId.collectAsState()
startupId
} else {
null
}
val scope = rememberCoroutineScope()
var switchingConnectionId by remember { mutableStateOf<String?>(null) }
var justSwitchedConnectionId by remember { mutableStateOf<String?>(null) }
// Kick a WSS reconnect on entry in case the user landed here from a Stale // Kick a WSS reconnect on entry in case the user landed here from a Stale
// chip (same intent as the old inline screen). // chip (same intent as the old inline screen).
@@ -101,7 +137,22 @@ fun ConnectionsSettingsScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.conn_title)) }, title = {
Column {
Text(stringResource(R.string.conn_title))
if (connections.isNotEmpty()) {
Text(
text = pluralStringResource(
R.plurals.conn_server_count,
connections.size,
connections.size,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
@@ -115,13 +166,6 @@ fun ConnectionsSettingsScreen(
), ),
) )
}, },
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onAddConnection,
icon = { Icon(imageVector = Icons.Filled.Add, contentDescription = null) },
text = { Text(stringResource(R.string.conn_add_connection)) },
)
},
) { innerPadding -> ) { innerPadding ->
if (connections.isEmpty()) { if (connections.isEmpty()) {
// Shown in practice only during tests / after a wipe; cold start // Shown in practice only during tests / after a wipe; cold start
@@ -149,6 +193,15 @@ fun ConnectionsSettingsScreen(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (connections.size > 1 && connectionViewModel != null) {
item {
StartupConnectionSelector(
connections = connections,
startupConnectionId = startupConnectionId,
onSelect = connectionViewModel::setStartupConnection,
)
}
}
items(connections, key = { it.id }) { connection -> items(connections, key = { it.id }) { connection ->
val isActive = connection.id == activeConnectionId val isActive = connection.id == activeConnectionId
ConnectionListCard( ConnectionListCard(
@@ -162,10 +215,118 @@ fun ConnectionsSettingsScreen(
connection.hasConfiguredRelay() connection.hasConfiguredRelay()
}, },
onClick = { onOpenConnection(connection.id) }, onClick = { onOpenConnection(connection.id) },
onSwitch = if (!isActive && connectionViewModel != null) {
{
if (switchingConnectionId == null) {
scope.launch {
switchingConnectionId = connection.id
connectionViewModel.switchConnection(connection.id).join()
switchingConnectionId = null
if (connectionViewModel.activeConnectionId.value == connection.id) {
justSwitchedConnectionId = connection.id
delay(800)
if (justSwitchedConnectionId == connection.id) {
justSwitchedConnectionId = null
}
}
}
}
}
} else {
null
},
isSwitching = switchingConnectionId == connection.id,
justSwitched = justSwitchedConnectionId == connection.id,
) )
} }
// Footer spacer so the last card isn't hidden by the FAB. item {
item { Spacer(modifier = Modifier.height(72.dp)) } Button(
onClick = onAddConnection,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
) {
Icon(imageVector = Icons.Filled.Add, contentDescription = null)
Text(
text = stringResource(R.string.conn_add_connection),
modifier = Modifier.padding(start = 8.dp),
)
}
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
}
@Composable
private fun StartupConnectionSelector(
connections: List<Connection>,
startupConnectionId: String?,
onSelect: (String?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selectedLabel = startupConnectionId
?.let { id -> connections.firstOrNull { it.id == id }?.label }
?: stringResource(R.string.conn_startup_last_used)
Box {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = true },
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.AccessTime,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 12.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.conn_startup_title),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(text = selectedLabel, style = MaterialTheme.typography.bodyLarge)
}
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.conn_startup_choose),
)
}
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = {
Column {
Text(stringResource(R.string.conn_startup_last_used))
Text(
text = stringResource(R.string.conn_startup_recommended),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
onClick = {
expanded = false
onSelect(null)
},
)
connections.forEach { connection ->
DropdownMenuItem(
text = { Text(connection.label) },
onClick = {
expanded = false
onSelect(connection.id)
},
)
} }
} }
} }
@@ -185,16 +346,28 @@ private fun ConnectionListCard(
activeConnectionViewModel: ConnectionViewModel?, activeConnectionViewModel: ConnectionViewModel?,
relayConfigured: Boolean, relayConfigured: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onSwitch: (() -> Unit)?,
isSwitching: Boolean,
justSwitched: Boolean,
) { ) {
val context = LocalContext.current val context = LocalContext.current
// Active card: muted indigo wash instead of full-strength primaryContainer — // Active card: muted indigo wash instead of full-strength primaryContainer —
// a card-sized fill of the brand blue overwhelmed body text (2026-06-10 // a card-sized fill of the brand blue overwhelmed body text (2026-06-10
// feedback); small accents keep the vivid blue. // feedback); small accents keep the vivid blue.
val containerColor = if (isActive) { val targetContainerColor = if (isActive || isSwitching || justSwitched) {
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.42f) com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.25f)
} else { } else {
MaterialTheme.colorScheme.surfaceVariant MaterialTheme.colorScheme.surfaceVariant
} }
val containerColor by animateColorAsState(targetValue = targetContainerColor, label = "connectionCardColor")
val borderColor by animateColorAsState(
targetValue = if (isSwitching || justSwitched) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0f)
},
label = "connectionCardBorder",
)
Card( Card(
modifier = Modifier modifier = Modifier
@@ -202,6 +375,7 @@ private fun ConnectionListCard(
.clickable(onClick = onClick), .clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = containerColor), colors = CardDefaults.cardColors(containerColor = containerColor),
border = BorderStroke(if (isSwitching || justSwitched) 1.5.dp else 0.dp, borderColor),
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@@ -209,52 +383,87 @@ private fun ConnectionListCard(
) { ) {
// ── Title row ──────────────────────────────────────────────── // ── Title row ────────────────────────────────────────────────
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(10.dp)
.clip(RoundedCornerShape(50))
.background(
if (isActive) com.hermesandroid.relay.ui.theme.RelayRefresh.Green
else MaterialTheme.colorScheme.primary,
),
)
Text( Text(
text = connection.label, text = connection.label,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f), modifier = Modifier
.weight(1f)
.padding(start = 10.dp),
) )
if (isActive) { if (isActive) {
Badge( Badge(
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary, contentColor = MaterialTheme.colorScheme.onPrimary,
) { ) {
Text(text = stringResource(R.string.conn_active), modifier = Modifier.padding(horizontal = 6.dp)) Text(
text = stringResource(R.string.conn_active).uppercase(),
modifier = Modifier.padding(horizontal = 6.dp),
)
}
}
if (onSwitch != null || isSwitching || justSwitched) {
OutlinedButton(
onClick = { onSwitch?.invoke() },
enabled = !isSwitching && !justSwitched,
) {
when {
isSwitching -> {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text(stringResource(R.string.conn_switching), modifier = Modifier.padding(start = 6.dp))
}
justSwitched -> {
Icon(Icons.Filled.CheckCircle, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringResource(R.string.conn_switched), modifier = Modifier.padding(start = 6.dp))
}
else -> Text(stringResource(R.string.conn_switch))
}
} }
} }
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp),
)
} }
// ── Subtitle: hostname + status ────────────────────────────── // ── Subtitle: hostname + status ──────────────────────────────
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl) val hostname = connection.primaryHost.ifBlank { connection.label }
val hasStandardApi = connection.apiServerUrl.isNotBlank() val dashboardStatus = connection.dashboardLastStatus
val pairedStatus = when { val dashboardSignInRequired =
liveState != null && dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
(connection.pairedAt != null || liveState != RelayUiState.NotConfigured) -> val connectionStatus = when {
liveState.statusText(connectedLabel = stringResource(R.string.conn_connected)) dashboardSignInRequired -> stringResource(R.string.conn_dashboard_sign_in)
connection.pairedAt != null -> formatPairedRelative(context, connection.pairedAt) dashboardStatus?.reachable == true -> stringResource(R.string.conn_dashboard_available)
hasStandardApi -> stringResource(R.string.conn_standard_not_paired) dashboardStatus != null && !dashboardStatus.reachable -> stringResource(R.string.conn_dashboard_offline)
else -> stringResource(R.string.conn_not_paired) connection.resolvedDashboardUrl.isNotBlank() -> stringResource(R.string.conn_dashboard_unchecked)
} else -> stringResource(R.string.conn_dashboard_missing)
val statusColor = if (liveState == RelayUiState.Stale) {
MaterialTheme.colorScheme.tertiary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
} }
Text( Text(
text = "$hostname • $pairedStatus", text = if (isSwitching) {
stringResource(R.string.conn_connecting_to, connection.label)
} else {
"Dashboard · $hostname"
},
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = statusColor, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text(
text = if (isActive) {
stringResource(R.string.conn_last_used_now)
} else {
connection.lastUsedAt?.let { formatUsedRelative(context, it) } ?: connectionStatus
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// ── Steps/timeline summary (every card) ────────────────────── // ── Steps/timeline summary (every card) ──────────────────────
ConnectionSurfaceSummary( ConnectionSurfaceSummary(
@@ -267,6 +476,42 @@ private fun ConnectionListCard(
// detail — the same destination as the card tap. // detail — the same destination as the card tap.
onOpenDashboard = onClick, onOpenDashboard = onClick,
) )
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Lan,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(18.dp)
.padding(end = 2.dp),
)
Text(
text = when {
connection.routeCandidates.isNotEmpty() -> connection.routeCandidates
.sortedBy { it.priority }
.joinToString(" · ") { route ->
route.role.replaceFirstChar { it.titlecase() }
}
connection.resolvedDashboardUrl.isNotBlank() -> stringResource(R.string.conn_dashboard_only_route)
else -> stringResource(R.string.conn_no_routes)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
} }
} }
@@ -304,6 +549,12 @@ private fun ConnectionSurfaceSummary(
} else { } else {
null null
} }
val gatewayAvailability: GatewayAvailability? = if (activeConnectionViewModel != null) {
val availability by activeConnectionViewModel.gatewayAvailability.collectAsState()
availability
} else {
null
}
val standardVoiceAvailability: StandardVoiceAvailability? = val standardVoiceAvailability: StandardVoiceAvailability? =
if (activeConnectionViewModel != null) { if (activeConnectionViewModel != null) {
val availability by activeConnectionViewModel.standardVoiceAvailability.collectAsState() val availability by activeConnectionViewModel.standardVoiceAvailability.collectAsState()
@@ -315,17 +566,41 @@ private fun ConnectionSurfaceSummary(
val dashboardSignInRequired = val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val apiText = when { val chatRuntimeStatus: ChatRuntimeStatus? = if (isActive) {
connection.apiServerUrl.isBlank() -> stringResource(R.string.conn_api_missing) resolveChatRuntimeStatus(
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> stringResource(R.string.conn_api_checking) gateway = when (gatewayAvailability) {
activeApiReachable == true -> stringResource(R.string.conn_api_ready) GatewayAvailability.Ready -> ChatTransportReadiness.Ready
isActive && activeApiReachable == false -> stringResource(R.string.conn_api_offline) GatewayAvailability.Unknown, null -> ChatTransportReadiness.Connecting
else -> stringResource(R.string.conn_api_configured) GatewayAvailability.SignInRequired,
GatewayAvailability.Unreachable,
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
},
apiSse = when {
connection.apiServerUrl.isBlank() -> ChatTransportReadiness.NotConfigured
activeApiReachable == true -> ChatTransportReadiness.Ready
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
activeApiHealth == ConnectionViewModel.HealthStatus.Unreachable -> ChatTransportReadiness.Unavailable
else -> ChatTransportReadiness.Connecting
},
)
} else {
null
} }
val apiTone = when {
activeApiReachable == true -> SummaryTone.Good val chatText = when {
connection.apiServerUrl.isBlank() -> SummaryTone.Warning chatRuntimeStatus is ChatRuntimeStatus.Connected -> stringResource(R.string.conn_api_ready)
isActive && activeApiReachable == false -> SummaryTone.Warning chatRuntimeStatus == ChatRuntimeStatus.Connecting -> stringResource(R.string.conn_api_checking)
gatewayAvailability == GatewayAvailability.SignInRequired -> stringResource(R.string.conn_dashboard_sign_in)
dashboardStatus?.reachable == true && !dashboardSignInRequired -> stringResource(R.string.conn_dashboard_available)
connection.capabilities.chatConfigured -> stringResource(R.string.conn_api_configured)
else -> stringResource(R.string.conn_api_missing)
}
val chatTone = when {
chatRuntimeStatus is ChatRuntimeStatus.Connected -> SummaryTone.Good
chatRuntimeStatus == ChatRuntimeStatus.Connecting -> SummaryTone.Info
gatewayAvailability == GatewayAvailability.SignInRequired -> SummaryTone.Info
dashboardStatus?.reachable == true && !dashboardSignInRequired -> SummaryTone.Good
connection.capabilities.chatConfigured -> SummaryTone.Neutral
else -> SummaryTone.Neutral else -> SummaryTone.Neutral
} }
@@ -374,99 +649,78 @@ private fun ConnectionSurfaceSummary(
else -> SummaryTone.Neutral else -> SummaryTone.Neutral
} }
// A single grouped surface with dot + label + value rows — the Row(
// steps/timeline vocabulary shared with the detail's Features list.
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) { ConnectionStatusChip(Icons.Filled.Chat, stringResource(R.string.conn_chat_label), chatText, chatTone, Modifier.weight(1f))
ConnectionSurfaceRow(label = stringResource(R.string.conn_api_label), value = apiText, tone = apiTone) ConnectionStatusChip(
SurfaceRowDivider() Icons.Filled.Dashboard,
ConnectionSurfaceRow( stringResource(R.string.conn_manage_label),
label = stringResource(R.string.conn_dashboard_label), dashboardText,
value = dashboardText, dashboardTone,
tone = dashboardTone, Modifier.weight(1f),
onClick = if (dashboardSignInRequired) onOpenDashboard else null, if (dashboardSignInRequired) onOpenDashboard else null,
) )
SurfaceRowDivider() ConnectionStatusChip(
ConnectionSurfaceRow( Icons.Filled.GraphicEq,
label = stringResource(R.string.conn_voice_label), stringResource(R.string.conn_voice_label),
value = voiceText, voiceText,
tone = voiceTone, voiceTone,
onClick = if (standardVoiceAvailability == StandardVoiceAvailability.SignInRequired) onOpenDashboard else null, Modifier.weight(1f),
) if (standardVoiceAvailability == StandardVoiceAvailability.SignInRequired) onOpenDashboard else null,
SurfaceRowDivider() )
ConnectionSurfaceRow(label = stringResource(R.string.conn_relay_label), value = relayText, tone = relayTone) ConnectionStatusChip(Icons.Filled.CellTower, stringResource(R.string.conn_relay_label), relayText, relayTone, Modifier.weight(1f))
}
} }
} }
private enum class SummaryTone { Neutral, Good, Info, Warning } private enum class SummaryTone { Neutral, Good, Info, Warning }
/** Hairline divider between summary rows — inset so it reads as a list. */
@Composable @Composable
private fun SurfaceRowDivider() { private fun ConnectionStatusChip(
HorizontalDivider( icon: ImageVector,
modifier = Modifier.padding(horizontal = 12.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
)
}
/**
* One health line: status dot + label + value. The dot carries the tone so
* the value text stays short and the row stays light.
*/
@Composable
private fun ConnectionSurfaceRow(
label: String, label: String,
value: String, value: String,
tone: SummaryTone, tone: SummaryTone,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
val dotColor = when (tone) { val accent = when (tone) {
SummaryTone.Good -> com.hermesandroid.relay.ui.theme.RelayRefresh.Green
SummaryTone.Info -> MaterialTheme.colorScheme.primary
SummaryTone.Warning -> MaterialTheme.colorScheme.error
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
}
val valueColor = when (tone) {
SummaryTone.Good -> com.hermesandroid.relay.ui.theme.RelayRefresh.Green SummaryTone.Good -> com.hermesandroid.relay.ui.theme.RelayRefresh.Green
SummaryTone.Info -> MaterialTheme.colorScheme.primary SummaryTone.Info -> MaterialTheme.colorScheme.primary
SummaryTone.Warning -> MaterialTheme.colorScheme.error SummaryTone.Warning -> MaterialTheme.colorScheme.error
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant
} }
Row( Surface(
modifier = modifier modifier = modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
.fillMaxWidth() color = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) shape = RoundedCornerShape(10.dp),
.padding(horizontal = 8.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Box( Column(
modifier = Modifier modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp),
.size(8.dp) horizontalAlignment = Alignment.CenterHorizontally,
.clip(RoundedCornerShape(50)) verticalArrangement = Arrangement.spacedBy(3.dp),
.background(dotColor), ) {
) Icon(
Text( imageVector = icon,
text = label, contentDescription = null,
style = MaterialTheme.typography.bodyMedium, tint = MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(18.dp),
modifier = Modifier.weight(1f), )
maxLines = 1, Text(
overflow = TextOverflow.Ellipsis, text = label,
) style = MaterialTheme.typography.labelMedium,
Text( color = MaterialTheme.colorScheme.onSurface,
text = value, maxLines = 1,
style = MaterialTheme.typography.bodySmall, )
color = valueColor, Text(
maxLines = 1, text = value,
overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall,
) color = accent,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} }
} }
@@ -477,21 +731,15 @@ private fun Connection.hasConfiguredRelay(): Boolean {
!RelayUrlDeriver.isAutoManagedRelayUrl(trimmedRelayUrl, apiServerUrl) !RelayUrlDeriver.isAutoManagedRelayUrl(trimmedRelayUrl, apiServerUrl)
} }
/** private fun formatUsedRelative(context: Context, usedAtMillis: Long): String {
* Hand-rolled "N minutes ago" formatter for the card subtitle. `DateUtils` val relative = if (System.currentTimeMillis() - usedAtMillis < DateUtils.MINUTE_IN_MILLIS) {
* returns awkward copy ("in 0 minutes") for small deltas. context.getString(R.string.conn_just_now)
*/ } else {
private fun formatPairedRelative(context: Context, pairedAtMillis: Long): String { DateUtils.getRelativeTimeSpanString(
val deltaMs = System.currentTimeMillis() - pairedAtMillis usedAtMillis,
if (deltaMs < 0) return context.getString(R.string.conn_just_paired) System.currentTimeMillis(),
val minutes = TimeUnit.MILLISECONDS.toMinutes(deltaMs) DateUtils.MINUTE_IN_MILLIS,
val hours = TimeUnit.MILLISECONDS.toHours(deltaMs) ).toString()
val days = TimeUnit.MILLISECONDS.toDays(deltaMs)
return when {
minutes < 1L -> context.getString(R.string.conn_just_paired)
minutes < 60L -> context.resources.getQuantityString(R.plurals.conn_paired_minutes_ago, minutes.toInt(), minutes)
hours < 24L -> context.resources.getQuantityString(R.plurals.conn_paired_hours_ago, hours.toInt(), hours)
days < 30L -> context.resources.getQuantityString(R.plurals.conn_paired_days_ago, days.toInt(), days)
else -> context.getString(R.string.conn_paired)
} }
return context.getString(R.string.conn_last_used_format, relative)
} }
@@ -44,11 +44,16 @@ internal enum class DashboardActionKind {
EnableMcp, EnableMcp,
DisableMcp, DisableMcp,
TestMcp, TestMcp,
AuthenticateMcp,
RemoveMcp, RemoveMcp,
InstallMcpCatalog, InstallMcpCatalog,
ViewProfileSoul, ViewProfileSoul,
ActivateProfile, ActivateProfile,
DeleteProfile, DeleteProfile,
EditCustomEndpoint,
ValidateCustomEndpoint,
ActivateCustomEndpoint,
DeleteCustomEndpoint,
// Input-backed kinds — intercepted before runAction and routed to a // Input-backed kinds — intercepted before runAction and routed to a
// text-input or model-picker dialog instead of firing immediately. // text-input or model-picker dialog instead of firing immediately.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,708 @@
package com.hermesandroid.relay.ui.screens
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
import com.hermesandroid.relay.network.upstream.DashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator
import com.hermesandroid.relay.network.upstream.androidDashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* Connection-level Dashboard authentication flow. It is deliberately outside
* Manage so onboarding, connection setup, Voice, Chat, and Manage can all use
* the same cookie/session flow without inheriting Manage's navigation stack.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardSignInScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
onAuthenticated: () -> Unit,
) {
val context = LocalContext.current
val resources = LocalResources.current
val appContext = context.applicationContext
val scope = rememberCoroutineScope()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
val routeHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
val connectionId = activeConnection?.id ?: "default"
var providers by remember(dashboardUrl, connectionId) {
mutableStateOf<List<DashboardAuthProvider>>(emptyList())
}
var loading by remember(dashboardUrl, connectionId) { mutableStateOf(true) }
var actionInFlight by remember { mutableStateOf(false) }
var actionMessage by remember { mutableStateOf<String?>(null) }
var actionIsError by remember { mutableStateOf(false) }
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
var authFlows by remember(dashboardUrl, connectionId) {
mutableStateOf<List<String>>(emptyList())
}
var nativeSignInJob by remember(dashboardUrl, connectionId) { mutableStateOf<Job?>(null) }
var authenticationComplete by remember { mutableStateOf(false) }
val cookieStoreFactory = remember(appContext, connectionId) {
{
connectionViewModel.activeDashboardCookieStore()
?: EncryptedDashboardCookieStore(appContext, connectionId)
}
}
val clientFactory = remember(dashboardUrl, connectionViewModel) {
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
}
suspend fun verifyAndRecord(client: DashboardApiClient): DashboardAuthSession? {
val status = client.getStatus().getOrNull()
val session = client.currentSession().getOrNull()
val ticketAvailable = if (session?.authenticated == true) {
client.requestWsTicket().isSuccess
} else {
null
}
connectionViewModel.recordDashboardStatus(
status = status,
session = session,
reachable = status != null,
gatewayTicketAvailable = ticketAvailable,
)
return session
}
fun finishAuthentication() {
scope.launch {
invalidateDashboardManageCache(appContext.cacheDir)
connectionViewModel.refreshStandardVoice()
connectionViewModel.refreshDashboardProfiles()
authenticationComplete = true
}
}
LaunchedEffect(dashboardUrl, connectionId) {
if (dashboardUrl.isBlank()) {
loading = false
actionMessage = resources.getString(R.string.dashboard_no_url_configured)
return@LaunchedEffect
}
val client = clientFactory()
try {
val status = client.getStatus().getOrElse {
actionMessage = it.message ?: resources.getString(R.string.dashboard_request_failed)
actionIsError = true
return@LaunchedEffect
}
providers = client.getAuthProviders().getOrNull()
?.takeIf { it.isNotEmpty() }
?: status.authProviderDetails
authFlows = status.authFlows
val session = if (status.authRequired) client.currentSession().getOrNull() else null
connectionViewModel.recordDashboardStatus(
status = status,
session = session,
reachable = true,
gatewayTicketAvailable = null,
)
if (!status.authRequired || session?.authenticated == true) {
finishAuthentication()
}
} finally {
loading = false
client.shutdown()
}
}
fun submitPassword(provider: String, username: String, password: String) {
if (actionInFlight || dashboardUrl.isBlank()) return
actionInFlight = true
actionMessage = null
actionIsError = false
scope.launch {
val client = clientFactory()
try {
val result = client.loginPassword(provider, username, password)
val session = if (result.isSuccess) verifyAndRecord(client) else null
if (result.isSuccess && session?.authenticated == true) {
finishAuthentication()
} else {
actionMessage = result.exceptionOrNull()?.message
?: resources.getString(R.string.dashboard_signin_no_session)
actionIsError = true
}
} catch (e: Exception) {
actionMessage = e.message ?: resources.getString(R.string.dashboard_signin_failed)
actionIsError = true
} finally {
actionInFlight = false
client.shutdown()
}
}
}
fun startRedirectSignIn(provider: DashboardAuthProvider) {
if (actionInFlight || dashboardUrl.isBlank()) return
if (
androidDashboardRedirectAuthMode(provider.name, authFlows) ==
DashboardRedirectAuthMode.WebView
) {
oauthProvider = provider
return
}
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
actionMessage = resources.getString(R.string.dashboard_native_signin_requires_https)
actionIsError = true
return
}
val authClient = connectionViewModel.nativeDashboardAuthClientForActive(dashboardUrl)
if (authClient == null) {
actionMessage = resources.getString(R.string.dashboard_native_signin_unavailable)
actionIsError = true
return
}
actionInFlight = true
actionIsError = false
actionMessage = resources.getString(R.string.dashboard_native_signin_opening)
nativeSignInJob = scope.launch {
try {
NativeDashboardSignInCoordinator(authClient).signIn(provider.name) { authorizationUrl ->
withContext(Dispatchers.Main.immediate) {
launchNativeDashboardAuthorization(context, authorizationUrl)
}
}
val client = clientFactory()
val session = try {
verifyAndRecord(client)
} finally {
client.shutdown()
}
if (session?.authenticated == true) {
actionMessage = session.provider?.let {
resources.getString(R.string.dashboard_signed_in_with, it)
} ?: resources.getString(R.string.dashboard_signed_in)
actionIsError = false
finishAuthentication()
} else {
actionMessage = resources.getString(R.string.dashboard_signin_no_session)
actionIsError = true
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Exception) {
actionMessage = error.message
?: resources.getString(R.string.dashboard_signin_failed)
actionIsError = true
} finally {
actionInFlight = false
nativeSignInJob = null
}
}
}
DisposableEffect(dashboardUrl, connectionId) {
onDispose { nativeSignInJob?.cancel() }
}
oauthProvider?.let { provider ->
DashboardOAuthScreen(
dashboardUrl = dashboardUrl,
provider = provider,
cookieStoreFactory = cookieStoreFactory,
clientFactory = clientFactory,
onDismiss = { oauthProvider = null },
onAuthenticated = { session ->
oauthProvider = null
scope.launch {
val client = clientFactory()
try {
verifyAndRecord(client)
} finally {
client.shutdown()
}
actionMessage = session.provider?.let {
resources.getString(R.string.dashboard_signed_in_with, it)
} ?: resources.getString(R.string.dashboard_signed_in)
finishAuthentication()
}
},
onError = {
actionMessage = it
actionIsError = true
},
)
return
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.dashboard_sign_in)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.dashboard_back),
)
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
if (authenticationComplete) {
DashboardAuthenticationComplete(onContinue = onAuthenticated)
} else if (loading) {
CircularProgressIndicator()
} else {
DashboardSignInForm(
dashboardUrl = dashboardUrl,
routeHint = routeHint,
providers = providers,
actionInFlight = actionInFlight,
actionMessage = actionMessage,
actionIsError = actionIsError,
nativeSignInInFlight = nativeSignInJob != null,
onSignIn = ::submitPassword,
onOAuthSignIn = ::startRedirectSignIn,
onCancelNativeSignIn = {
actionMessage = resources.getString(R.string.dashboard_native_signin_cancelled)
actionIsError = false
nativeSignInJob?.cancel()
},
)
}
}
}
}
@Composable
private fun DashboardAuthenticationComplete(onContinue: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.cw_step_3_3),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.cw_dashboard_connected_title),
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = stringResource(R.string.cw_dashboard_connected_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ConnectionSetupTimeline(
steps = listOf(
ConnectionSetupTimelineStep(
stringResource(R.string.cw_timeline_discovered),
stringResource(R.string.cw_timeline_discovered_detail),
),
ConnectionSetupTimelineStep(
stringResource(R.string.cw_timeline_access),
stringResource(R.string.cw_timeline_authenticated),
),
ConnectionSetupTimelineStep(
stringResource(R.string.cw_timeline_ready),
stringResource(R.string.cw_timeline_ready_detail),
),
),
)
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.cw_continue))
}
}
}
@Composable
private fun DashboardSignInForm(
dashboardUrl: String,
routeHint: String?,
providers: List<DashboardAuthProvider>,
actionInFlight: Boolean,
actionMessage: String?,
actionIsError: Boolean,
nativeSignInInFlight: Boolean,
onSignIn: (String, String, String) -> Unit,
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
onCancelNativeSignIn: () -> Unit,
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val passwordProvider = providers.firstOrNull { it.supportsPassword }
val redirectProviders = providers.filter { it.isRedirectProvider }
Text(
text = stringResource(R.string.dashboard_signin_required_title),
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = stringResource(R.string.dashboard_signin_required_body, dashboardUrl),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
routeHint?.let {
Text(
text = stringResource(R.string.dashboard_signin_route_hint, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
redirectProviders.forEach { provider ->
Button(
onClick = { onOAuthSignIn(provider) },
enabled = !actionInFlight,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
}
}
if (nativeSignInInFlight) {
Button(
onClick = onCancelNativeSignIn,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.dashboard_cancel))
}
}
if (passwordProvider != null || providers.isEmpty()) {
if (redirectProviders.isNotEmpty()) HorizontalDivider()
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.dashboard_username)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.dashboard_password)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
Button(
onClick = { onSignIn(passwordProvider?.name ?: "basic", username, password) },
enabled = !actionInFlight && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text(if (actionInFlight) stringResource(R.string.dashboard_signing_in) else stringResource(R.string.dashboard_sign_in))
}
}
actionMessage?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = if (actionIsError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DashboardOAuthScreen(
dashboardUrl: String,
provider: DashboardAuthProvider,
cookieStoreFactory: () -> DashboardCookieStore,
clientFactory: () -> DashboardApiClient,
onDismiss: () -> Unit,
onAuthenticated: (DashboardAuthSession) -> Unit,
onError: (String) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val initialStatus = stringResource(R.string.dashboard_oauth_initial_status)
val verifyingStatus = stringResource(R.string.dashboard_oauth_verifying)
val notAcceptedStatus = stringResource(R.string.dashboard_oauth_not_accepted)
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
var checking by remember { mutableStateOf(false) }
var pageProgress by remember { mutableStateOf(0) }
var webView by remember { mutableStateOf<WebView?>(null) }
val loginUrl = remember(dashboardUrl, provider.name) {
DashboardApiClient.authLoginUrl(
baseUrl = dashboardUrl,
provider = provider.name,
next = DashboardApiClient.authLandingPath(dashboardUrl),
)
}
fun handleNavigation(url: String?) {
val loadedUrl = url?.takeIf { it.isNotBlank() } ?: return
when (dashboardWebViewAuthNavigation(dashboardUrl, loadedUrl)) {
DashboardWebViewAuthNavigation.Continue -> return
DashboardWebViewAuthNavigation.RejectLoopbackCallback -> {
statusText = notAcceptedStatus
onError(notAcceptedStatus)
return
}
DashboardWebViewAuthNavigation.ImportAndVerify -> Unit
}
val manager = CookieManager.getInstance()
manager.flush()
val imported = importDashboardCookieHeader(
cookieStoreFactory(),
loadedUrl,
manager.getCookie(loadedUrl),
)
if (checking || imported == 0) return
checking = true
statusText = verifyingStatus
scope.launch {
val client = clientFactory()
try {
val session = client.currentSession().getOrNull()
if (session?.authenticated == true) onAuthenticated(session) else {
checking = false
statusText = notAcceptedStatus
}
} catch (e: Exception) {
checking = false
val message = e.message ?: verifyFailedStatus
statusText = message
onError(message)
} finally {
client.shutdown()
}
}
}
BackHandler(onBack = onDismiss)
DisposableEffect(Unit) {
onDispose {
webView?.stopLoading()
webView?.destroy()
webView = null
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
text = stringResource(
R.string.dashboard_signin_with_provider,
provider.displayName ?: provider.name,
),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = statusText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
},
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.dashboard_back),
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
if (pageProgress in 0..99) {
LinearProgressIndicator(
progress = { pageProgress / 100f },
modifier = Modifier.fillMaxWidth(),
)
}
AndroidView(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
factory = { viewContext ->
CookieManager.getInstance().setAcceptCookie(true)
WebView(viewContext).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView, newProgress: Int) {
pageProgress = newProgress
}
}
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean {
val target = request.url.toString()
if (
dashboardWebViewAuthNavigation(dashboardUrl, target) ==
DashboardWebViewAuthNavigation.RejectLoopbackCallback
) {
handleNavigation(target)
return true
}
return false
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
super.onReceivedError(view, request, error)
if (request.isForMainFrame) {
val message = error.description?.toString()
?.takeIf { it.isNotBlank() }
?: verifyFailedStatus
statusText = message
onError(message)
}
}
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
handleNavigation(url)
}
}
webView = this
loadUrl(loginUrl)
}
},
)
}
}
}
internal enum class DashboardWebViewAuthNavigation {
Continue,
ImportAndVerify,
RejectLoopbackCallback,
}
/**
* Android redirect providers use the dashboard's cookie/OIDC flow. A foreign
* loopback callback belongs to the desktop native-PKCE contract and must never
* be followed, imported, or treated as an authenticated Android return.
*/
internal fun dashboardWebViewAuthNavigation(
dashboardUrl: String,
loadedUrl: String,
): DashboardWebViewAuthNavigation {
val dashboard = dashboardUrl.trim().trimEnd('/').toHttpUrlOrNull()
?: return DashboardWebViewAuthNavigation.Continue
val loaded = loadedUrl.trim().toHttpUrlOrNull()
?: return DashboardWebViewAuthNavigation.Continue
val sameOrigin = dashboard.scheme == loaded.scheme &&
dashboard.host.equals(loaded.host, ignoreCase = true) &&
dashboard.port == loaded.port
if (!sameOrigin) {
val foreignLoopback = loaded.scheme == "http" &&
loaded.host in setOf("127.0.0.1", "localhost", "::1") &&
loaded.encodedPath == "/callback"
return if (foreignLoopback) {
DashboardWebViewAuthNavigation.RejectLoopbackCallback
} else {
DashboardWebViewAuthNavigation.Continue
}
}
val basePath = dashboard.encodedPath.trimEnd('/')
val relativePath = loaded.encodedPath
.removePrefix(basePath)
.ifBlank { "/" }
return if (
relativePath.equals("/login", ignoreCase = true) ||
relativePath.equals("/auth/login", ignoreCase = true)
) {
DashboardWebViewAuthNavigation.Continue
} else {
// Includes the public /auth/callback response: import its cookies at
// root scope, then verify the resulting session through /api/auth/me.
DashboardWebViewAuthNavigation.ImportAndVerify
}
}
@@ -34,13 +34,11 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -64,8 +62,7 @@ import kotlinx.coroutines.launch
/** /**
* Dedicated Developer Options screen. Gated behind the tap-version-7x * Dedicated Developer Options screen. Gated behind the tap-version-7x
* unlock. Hosts feature flags (voice/terminal/bridge/etc.), data management * unlock. Hosts experimental labs, test tools, and data-management utilities.
* (clear session / wipe caches), and any experimental toggles.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -73,13 +70,12 @@ fun DeveloperSettingsScreen(
connectionViewModel: ConnectionViewModel, connectionViewModel: ConnectionViewModel,
onBack: () -> Unit, onBack: () -> Unit,
onNavigateToRealtimeVoice: () -> Unit = {}, onNavigateToRealtimeVoice: () -> Unit = {},
onNavigateToImageGenerationLab: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val isDarkTheme = LocalBrand.current.isDark val isDarkTheme = LocalBrand.current.isDark
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
// Data management local state — unfolded from the private // Data management local state — unfolded from the private
// DataManagementSection helper in the old SettingsScreen. // DataManagementSection helper in the old SettingsScreen.
var showResetDialog by remember { mutableStateOf(false) } var showResetDialog by remember { mutableStateOf(false) }
@@ -184,8 +180,17 @@ fun DeveloperSettingsScreen(
) )
} }
IconButton(onClick = { IconButton(onClick = {
connectionViewModel.resetOnboarding() connectionViewModel.resetOnboarding { success ->
Toast.makeText(context, context.getString(R.string.dev_settings_onboarding_reset_toast), Toast.LENGTH_SHORT).show() Toast.makeText(
context,
if (success) {
context.getString(R.string.dev_settings_onboarding_reset_toast)
} else {
context.getString(R.string.dev_settings_reset_failed)
},
Toast.LENGTH_SHORT,
).show()
}
}) { }) {
Icon( Icon(
imageVector = Icons.Filled.RestartAlt, imageVector = Icons.Filled.RestartAlt,
@@ -299,42 +304,6 @@ fun DeveloperSettingsScreen(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// Relay features toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
imageVector = Icons.Filled.Science,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Text(
text = stringResource(R.string.dev_settings_relay_features),
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = stringResource(R.string.dev_settings_relay_features_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = relayEnabled,
onCheckedChange = { scope.launch { FeatureFlags.setRelayEnabled(context, it) } }
)
}
HorizontalDivider()
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -372,6 +341,47 @@ fun DeveloperSettingsScreen(
HorizontalDivider() HorizontalDivider()
if (FeatureFlags.isDevBuild) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = Icons.Filled.Science,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary,
)
Text(
text = stringResource(R.string.dev_settings_image_generation_lab),
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
text = stringResource(R.string.dev_settings_image_generation_lab_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onNavigateToImageGenerationLab) {
Icon(
imageVector = Icons.Filled.Science,
contentDescription = stringResource(
R.string.dev_settings_open_image_generation_lab_cd
),
)
}
}
HorizontalDivider()
}
// Lock developer options // Lock developer options
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -390,9 +400,15 @@ fun DeveloperSettingsScreen(
) )
} }
IconButton(onClick = { IconButton(onClick = {
scope.launch { FeatureFlags.lockDevOptions(context) } scope.launch {
Toast.makeText(context, context.getString(R.string.dev_settings_locked_toast), Toast.LENGTH_SHORT).show() FeatureFlags.lockDevOptions(context)
onBack() Toast.makeText(
context,
context.getString(R.string.dev_settings_locked_toast),
Toast.LENGTH_SHORT,
).show()
onBack()
}
}) { }) {
Icon( Icon(
imageVector = Icons.Filled.Lock, imageVector = Icons.Filled.Lock,
@@ -520,8 +536,16 @@ fun DeveloperSettingsScreen(
onClick = { onClick = {
showExportDialog = false showExportDialog = false
connectionViewModel.exportSettings { json -> connectionViewModel.exportSettings { json ->
backupJson = json if (json == null) {
exportLauncher.launch("hermes-relay-sensitive-backup.json") Toast.makeText(
context,
context.getString(R.string.dev_settings_export_failed),
Toast.LENGTH_SHORT,
).show()
} else {
backupJson = json
exportLauncher.launch("hermes-relay-sensitive-backup.json")
}
} }
} }
) { ) {
@@ -577,8 +601,17 @@ fun DeveloperSettingsScreen(
TextButton( TextButton(
onClick = { onClick = {
showResetDialog = false showResetDialog = false
connectionViewModel.resetAppData() connectionViewModel.resetAppData { success ->
Toast.makeText(context, context.getString(R.string.dev_settings_app_data_reset_toast), Toast.LENGTH_SHORT).show() Toast.makeText(
context,
if (success) {
context.getString(R.string.dev_settings_app_data_reset_toast)
} else {
context.getString(R.string.dev_settings_reset_failed)
},
Toast.LENGTH_SHORT,
).show()
}
} }
) { ) {
Text(stringResource(R.string.dev_settings_reset_action), color = MaterialTheme.colorScheme.error) Text(stringResource(R.string.dev_settings_reset_action), color = MaterialTheme.colorScheme.error)

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