Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ebdf55501 | ||
|
|
71a2b3a7fb | ||
|
|
08545ed32d | ||
|
|
e791c6410b | ||
|
|
8c8c3975f2 | ||
|
|
41601d67ab | ||
|
|
366b424615 | ||
|
|
5cd9baaaab | ||
|
|
8acba9b353 | ||
|
|
26a612f088 | ||
|
|
65e48084cb | ||
|
|
1074ecc24f | ||
|
|
6dd6ce2d13 | ||
|
|
f2a23e32aa | ||
|
|
b60c5d9eeb | ||
|
|
9e201e54d7 | ||
|
|
630cc6d316 | ||
|
|
e16205d82a | ||
|
|
4834fcbdf5 | ||
|
|
676c37e5ca | ||
|
|
9b6fed9bdd | ||
|
|
4d90eef3d8 | ||
|
|
478323893a | ||
|
|
fcddeeb810 | ||
|
|
49002b7141 | ||
|
|
326eb47df3 | ||
|
|
ef1abdae3f | ||
|
|
eece12a815 | ||
|
|
0cdea3ad33 | ||
|
|
a8ca61297d | ||
|
|
5762cdf8af | ||
|
|
dff633c902 | ||
|
|
45d8a73609 | ||
|
|
390a4dd8d8 |
+36
-8
@@ -6,28 +6,56 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [Android 1.13.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Android and Relay add top-level provider usage and limit settings.** Codex credential pools, Nous balances, and OpenCode Go account windows share one provider-neutral screen with Summary, Expanded, and Hidden Settings presentation modes plus per-provider landing-page visibility. The authenticated Relay Dashboard plugin resolves the active Codex credential directly from the live session; paired standalone clients retain an explicitly enabled Relay fallback. The UI identifies Relay-plugin-enhanced data and explains which capabilities require the matching plugin. Provider credentials remain host-side.
|
||||
- **Desktop releases now include a Linux ARM64 CLI artifact.** The one-line installer, updater, checksums, release publication, architecture validation, and platform documentation all recognize the same `linux-arm64` binary.
|
||||
- **The public site now shows the real Windows CLI UI and guides each surface through first use.** Deterministic public-safe screenshots cover connection, host access, activity, computer control, and updates; Android and CLI paths now carry users from install through prerequisites, pairing, verification, and a concrete first-success action before the long-form reference material.
|
||||
- **Provider usage and limits are available from top-level Settings.** Codex credential pools, Nous balances, and OpenCode Go account windows share one provider-neutral screen with Summary, Expanded, and Hidden presentation modes. Provider credentials remain on the Hermes host.
|
||||
- **Android Bot Mode provides one messenger-style workspace across saved Hermes gateways.** Bots and read-only group rooms aggregate without changing the foreground connection, Bot Chats retain exact gateway/profile ownership, and unavailable gateways keep clearly marked last-known roster entries.
|
||||
- **Android Assistant screen context.** Compatible unlocked assistant-button invocations can open Hermes, begin listening, and include bounded visible text plus an available screenshot in the first Standard voice turn. Ordinary wake and keyguard invocations remain screen-context free.
|
||||
- **Android Supervised Mode presents a parent-controlled, profile-pinned chat surface.** Parents can limit attachments, Standard voice, generated media, conversation history, actions, and technical metadata while device authentication protects full settings. Hermes-Relay can identify and revoke a paired supervised client without becoming the policy enforcement boundary.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release and candidate names use one public product hierarchy.** Future releases use `Hermes-Relay Android`, `Hermes-Relay Plugin`, or `Hermes-Relay CLI+UI` display names, while isolated Android review and release-candidate installs use `HR Candidate`, without changing immutable tags, package identities, updater contracts, or artifact filenames.
|
||||
- **Review candidates are an explicit PR opt-in with one trusted handoff comment.** Maintainers can apply `review-candidate` for exact-head Android and Relay bundles; a separate reporter updates the PR with the artifact, expiry, source SHA, and bounded review instructions without executing fork code with write permission.
|
||||
- **Unlabeled PR updates no longer receive false candidate-failure comments.** The trusted reporter ignores skipped review-bundle workflow shells before reading artifacts or writing to a PR.
|
||||
- **Android releases and review candidates use clear public product names.** Stable builds use `Hermes-Relay Android`, while isolated review installs use `HR Candidate` without changing package identities or update contracts.
|
||||
- **Review candidates are explicit and source-pinned.** Maintainers can opt a PR into a matched Android and Relay bundle with checksums, expiry, source SHA, and bounded review instructions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Unlabeled PR updates no longer receive false candidate-failure comments.** The trusted reporter ignores skipped review-bundle workflow shells before reading artifacts or writing to a PR.
|
||||
- **Android chats no longer retain a stale busy composer.** A completed Gateway bubble settles automatically when its exact session has no live or detached turn, new-chat navigation clears stale visible ownership, and Stop remains an immediate escape hatch. (#416, #418)
|
||||
- **README and Google Play onboarding now match the Dashboard-first product path.** Public setup copy names the two separate Dashboard QR actions, treats the API server as an advanced fallback, explains the encouraged Hermes-Relay extension without implying Play includes Device Control, and ships one current deterministic Android screenshot set.
|
||||
- **Desktop install and update discovery remains reliable in a multi-surface release repository.** Every resolver paginates GitHub releases before choosing the SemVer maximum, Windows cooperative updates clean their released backup, unsigned preview installers retain the normal SmartScreen warning, and release smoke tests preserve real exit codes.
|
||||
- **Android fresh chats no longer inherit a stale busy composer.** New-chat navigation settles visible streaming ownership even when a Gateway turn has already lost its live handle, and Stop remains an immediate escape hatch after the terminal bubble has settled. (#416, #418)
|
||||
- **The Android Sphere remains gently animated while visibly idle.** New chats and the ambient Sphere behind messages now use a low-cost layer breath, while hidden/backgrounded and motion-disabled surfaces stay still and active agent/voice states retain their full procedural animation.
|
||||
- **Android retries Windows-hosted `MEDIA:` attachments through Relay's by-path route.** A document deferred on cellular no longer treats `C:\...` as an opaque media token and reports it as expired.
|
||||
|
||||
## [Plugin 1.10.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Relay provides normalized provider usage without exposing credentials.** The authenticated Dashboard route resolves the active Codex pool entry, structured Nous balances, and OpenCode Go windows on the Hermes host; explicitly enabled paired clients receive the same provider-neutral schema.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Plugin releases use the `Hermes-Relay Plugin` public name.** The display name is aligned with Android and CLI+UI while the `server-v*` compatibility tag remains unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Relay profile discovery follows `HERMES_HOME` by default.** Custom Hermes installations surface their real default profile and persist Relay sessions beside the active config while retaining the explicit `RELAY_HERMES_CONFIG` override.
|
||||
|
||||
## [0.4.0-beta.5] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Desktop releases now include a Linux ARM64 CLI artifact.** The one-line installer, updater, checksums, release publication, architecture validation, and platform documentation all recognize the same `linux-arm64` binary.
|
||||
- **The public site now shows the real Windows CLI UI and guides each surface through first use.** Deterministic public-safe screenshots cover connection, host access, activity, computer control, and updates.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Desktop releases use the `Hermes-Relay CLI+UI` public name.** The beta keeps its existing `desktop-v*` tag and updater contract.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Desktop install and update discovery remains reliable in a multi-surface release repository.** Every resolver paginates GitHub releases before choosing the SemVer maximum, Windows cooperative updates clean their released backup, unsigned preview installers retain the normal SmartScreen warning, and release smoke tests preserve real exit codes.
|
||||
- **Desktop daemon connections recover instead of exiting after an interrupted Relay socket.** Healthy daemons retry through Relay restarts and repeated failed reconnect attempts, oversized desktop-tool results fail within a bounded response instead of closing the shared WebSocket, and terminal failures leave an accurate stopped status for the tray.
|
||||
- **Desktop computer control follows Hermes' current CUA Driver contract.** CUA Driver 0.20 and newer are accepted when their manifest, daemon/MCP arguments, required tools, and canonical path remain compatible, and Windows sessions use the manifest-declared direct standard-mode runtime instead of a potentially stale machine-wide daemon. Current 0.21 installations no longer fall back solely because of an obsolete upper version pin or daemon contract.
|
||||
|
||||
|
||||
+11
-4
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay CLI+UI v__VERSION__
|
||||
|
||||
**Release Date:** 2026-08-22
|
||||
**Release Date:** 2026-08-25
|
||||
|
||||
This release makes the Desktop connector resilient through Relay interruptions,
|
||||
aligns Windows computer control with current CUA Driver releases, and adds a
|
||||
native Linux ARM64 build.
|
||||
This beta makes the Desktop connector resilient through Relay interruptions,
|
||||
aligns Windows computer control with current CUA Driver releases, adds a native
|
||||
Linux ARM64 build, and hardens installation and update discovery.
|
||||
|
||||
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64; the management UI is Windows-only.
|
||||
|
||||
@@ -14,6 +14,13 @@ native Linux ARM64 build.
|
||||
|
||||
- **Linux ARM64 is a first-class release target.** The one-line installer,
|
||||
updater, checksums, and release artifacts now cover both Linux x64 and arm64.
|
||||
- **The public site shows the real Windows CLI UI.** Deterministic screenshots
|
||||
cover connections, host access, activity, computer control, and updates.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Public naming is aligned.** Releases use `Hermes-Relay CLI+UI` while the
|
||||
beta keeps its existing `desktop-v*` tag and updater contract.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+10
-5
@@ -1,21 +1,26 @@
|
||||
# Hermes-Relay Plugin v__VERSION__
|
||||
|
||||
**Release Date:** August 21, 2026
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Summary
|
||||
|
||||
This release makes delayed phone delivery and active Bridge access easier to understand. Relay now identifies messages flushed after reconnect, emits one completion signal for the backlog, and reports permanent, timed, and unlimited phone capabilities through status surfaces.
|
||||
This release adds a provider-neutral account-usage surface for Android and Dashboard clients. Relay resolves Codex credential pools, structured Nous balances, and OpenCode Go windows on the Hermes host without returning provider credentials.
|
||||
|
||||
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## Added
|
||||
|
||||
- **Reconnect backlog context.** Messages flushed from the bounded offline queue carry an explicit delayed-delivery marker, followed by one ordered completion event with the delivered count.
|
||||
- **Granular phone capability status.** Relay status and `android_phone_status` report permanent, timed, and unlimited Bridge capabilities alongside existing Android permissions and safety state.
|
||||
- **Provider-neutral usage snapshots.** Authenticated Dashboard clients can resolve the exact active Codex pool entry, Nous balances, and OpenCode Go account windows through one normalized schema.
|
||||
- **Bounded paired-client fallback.** Operators may explicitly enable the Relay usage route for paired standalone clients while credentials remain host-side.
|
||||
|
||||
## Changed
|
||||
|
||||
- **Phone surfacing semantics are explicit.** Default delivery persists to Threads and notifies, Inbox delivery remains silent, and Session delivery targets an available active conversation before falling back to a notification.
|
||||
- **Usage capabilities are explicit.** Responses identify Relay-enhanced credential pools, structured balances, and provider adapters instead of implying unsupported upstream data.
|
||||
- **Public product naming is aligned.** Releases use `Hermes-Relay Plugin` while retaining the `server-v*` tag and installation contract.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Custom Hermes homes resolve correctly.** Relay profile discovery and session persistence follow `HERMES_HOME` by default while preserving the explicit `RELAY_HERMES_CONFIG` override.
|
||||
|
||||
## Install / update
|
||||
|
||||
|
||||
+20
-10
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.12.1
|
||||
# Hermes-Relay Android v1.13.0
|
||||
|
||||
**Release Date:** August 22, 2026
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.12.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.13.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,18 +12,28 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch makes Android sharing and recovery dependable. Shared links, text, images, and files open as complete reviewable drafts; connection renewal no longer stalls; offline and history failures are visible; and secure-storage recovery appears in Diagnostics.
|
||||
This feature release adds Bot Mode across saved Hermes gateways, provider usage and limits, and bounded Assistant screen context. It also settles stale Gateway composer state, improves onboarding, and keeps idle Sphere motion efficient.
|
||||
|
||||
## Added
|
||||
|
||||
- Use Bot Mode as one messenger-style workspace across saved Hermes gateways, with exact gateway/profile ownership and read-only group rooms.
|
||||
- Review Codex credential pools, Nous balances, and OpenCode Go windows from one provider-neutral Usage & limits screen.
|
||||
- Start a compatible unlocked Assistant invocation with bounded visible text and an available screenshot in the first Standard voice turn.
|
||||
|
||||
## Changed
|
||||
|
||||
- Follow the Dashboard-first setup path with current screenshots and clearer separation between standard Hermes and optional Relay extensions.
|
||||
- Use clear `Hermes-Relay Android` and isolated `HR Candidate` product names without changing package identities or update behavior.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Open shared links, text, images, files, and mixed or multi-item shares as a fresh reviewable draft without sending automatically.
|
||||
- Keep Add and Renew connection setup on the correct connection-scoped authentication store, with bounded Retry or Cancel recovery instead of an indefinite preparation screen.
|
||||
- Surface unavailable chat routes and profile-history failures clearly instead of silently dropping Send or presenting missing history as an empty conversation.
|
||||
- Report Android Keystore fallback, encrypted-store recovery, and temporary credential storage in Diagnostics without exposing credentials.
|
||||
- Settle orphaned Gateway busy state automatically while preserving active or detached turns owned by another session.
|
||||
- Keep the visible idle Sphere gently animated without running hidden, backgrounded, or motion-disabled loops.
|
||||
- Retry Windows-hosted `MEDIA:` attachments through the Relay by-path route instead of treating drive-letter paths as expired tokens.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.12.1** (versionCode **48**).
|
||||
- App version: **1.13.0** (versionCode **49**).
|
||||
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
|
||||
- The optional Relay plugin is not required for standard Android chat, sharing, session continuity, or Gateway recovery.
|
||||
- The optional Relay plugin enhances provider usage, media retry, and device surfaces but remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Shared links, text, images, and files now open as complete reviewable drafts without sending automatically. Add and Renew connection setup no longer stalls. Offline chat and profile-history failures surface clear recovery guidance instead of doing nothing or showing empty history. Diagnostics now reports secure-storage fallback and recovery without exposing credentials.
|
||||
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
|
||||
|
||||
@@ -1 +1 @@
|
||||
共享链接、文本、图片和文件现在会作为完整、可检查的草稿打开,不会自动发送。添加或续订连接时不再卡在准备阶段。离线聊天和配置文件历史记录失败会显示明确的恢复提示,而不是无响应或显示空历史记录。诊断现在会报告安全存储降级与恢复,且不会暴露凭据。
|
||||
Bot 模式现在可将已保存 Hermes 网关中的机器人汇集到一个消息式工作区。设置新增统一的 Codex、Nous 和 OpenCode Go 用量视图。兼容的助手启动可在首个语音回合中包含受限的可见文本和可用截图。Gateway 聊天会自动清除过期的忙碌状态,引导更清晰,空闲 Sphere 动画也更省电。
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.13.0",
|
||||
"title": "Bots, usage, and reliable chat",
|
||||
"date": "2026-08-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Talk across saved gateways",
|
||||
"bullets": [
|
||||
"Use Bot Mode as one messenger-style workspace for bots and read-only groups across saved Hermes gateways.",
|
||||
"Keep every Bot Chat bound to its exact gateway and profile without changing the foreground connection."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Understand account limits",
|
||||
"bullets": [
|
||||
"Review Codex credential pools, Nous balances, and OpenCode Go windows from one provider-neutral Usage and limits screen.",
|
||||
"Choose Summary, Expanded, or Hidden presentation while provider credentials remain on the Hermes host."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Keep chat and voice in context",
|
||||
"bullets": [
|
||||
"Settle orphaned Gateway busy state automatically while preserving another session's active or detached turn.",
|
||||
"Include bounded visible text and an available screenshot in the first compatible Assistant voice turn."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.12.1",
|
||||
"title": "Sharing and recovery that work",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
v1.12.1 - Sharing and recovery that work
|
||||
v1.13.0 - Bots, usage, and reliable chat
|
||||
|
||||
* Open shared links, text, images, and files as a reviewable draft without auto-sending.
|
||||
* Add or renew a connection without getting stuck during secure setup.
|
||||
* See clear recovery guidance when chat or profile history is unavailable.
|
||||
* Find secret-free secure-storage fallback and recovery evidence in Diagnostics.
|
||||
* Use Bot Mode across saved Hermes gateways without changing the foreground connection.
|
||||
* Review Codex, Nous, and OpenCode Go usage from one provider-neutral screen.
|
||||
* Include bounded visible text and an available screenshot in compatible Assistant turns.
|
||||
* Keep the composer accurate when Gateway completion frames and visible bubbles settle separately.
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.hermesandroid.relay.data.replaceHermesReachCredential
|
||||
import com.hermesandroid.relay.data.sameBrokerAuthority
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.isSafeProfileUiMeta
|
||||
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
@@ -18,6 +19,8 @@ import com.hermesandroid.relay.network.shared.InvalidCredentialException
|
||||
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
@@ -53,6 +56,39 @@ sealed class AuthState {
|
||||
data class Failed(val reason: String) : AuthState()
|
||||
}
|
||||
|
||||
internal fun relaySupervisedModePayload(policy: SupervisedModePolicy): JsonObject {
|
||||
if (!policy.isActive) return buildJsonObject { put("active", false) }
|
||||
val capabilities = buildList {
|
||||
add("text_chat")
|
||||
if (policy.capabilities.newChat) add("new_chat")
|
||||
if (policy.capabilities.cancelResponse) add("cancel")
|
||||
if (policy.capabilities.steerResponse) add("steer")
|
||||
if (policy.capabilities.attachments) add("attachments")
|
||||
if (policy.capabilities.voice) add("voice")
|
||||
if (policy.capabilities.generatedImages) add("generated_images")
|
||||
if (policy.capabilities.shareGeneratedImages) add("share_images")
|
||||
if (policy.capabilities.copyResponses) add("copy")
|
||||
if (policy.capabilities.retryResponse) add("retry")
|
||||
if (policy.capabilities.quoteReplies) add("quote_reply")
|
||||
if (policy.visibility.resolved().showTimestamps) add("timestamps")
|
||||
}.take(12)
|
||||
return buildJsonObject {
|
||||
put("active", true)
|
||||
put("profile_label", policy.pinnedProfileName.orEmpty().take(80))
|
||||
put("capabilities", JsonArray(capabilities.map(::JsonPrimitive)))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun relaySupervisedModeUpdateEnvelope(
|
||||
policy: SupervisedModePolicy,
|
||||
): Envelope = Envelope(
|
||||
channel = "system",
|
||||
type = "supervised.update",
|
||||
payload = buildJsonObject {
|
||||
put("supervised_mode", relaySupervisedModePayload(policy))
|
||||
},
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectionAuthSecrets(
|
||||
val sessionToken: String? = null,
|
||||
@@ -120,6 +156,60 @@ class AuthManager(
|
||||
private val eagerHydrate: Boolean = true,
|
||||
) : ChannelMultiplexer.ChannelHandler {
|
||||
|
||||
@Volatile
|
||||
private var supervisedMode: SupervisedModePolicy = SupervisedModePolicy()
|
||||
|
||||
@Volatile
|
||||
private var supervisedMetadataReconnectFallback: (() -> Unit)? = null
|
||||
private var pendingSupervisedUpdateId: String? = null
|
||||
private var supervisedUpdateFallbackJob: Job? = null
|
||||
|
||||
/**
|
||||
* Update the public client-mode tag sent on Relay auth. This does not grant
|
||||
* authority: Relay labels enforcement_owner=android_client and the Android
|
||||
* policy remains the enforcing surface.
|
||||
*/
|
||||
fun updateSupervisedMode(policy: SupervisedModePolicy) {
|
||||
if (supervisedMode == policy) return
|
||||
supervisedMode = policy
|
||||
if (_authState.value is AuthState.Paired) sendSupervisedModeUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the narrow compatibility path used when an older Relay ignores
|
||||
* `system/supervised.update`. Reopening the authenticated socket causes
|
||||
* the current policy to travel through the legacy `system/auth` payload.
|
||||
*/
|
||||
fun setSupervisedMetadataReconnectFallback(callback: () -> Unit) {
|
||||
supervisedMetadataReconnectFallback = callback
|
||||
}
|
||||
|
||||
private fun sendSupervisedModeUpdate() {
|
||||
val envelope = relaySupervisedModeUpdateEnvelope(supervisedMode)
|
||||
pendingSupervisedUpdateId = envelope.id
|
||||
supervisedUpdateFallbackJob?.cancel()
|
||||
multiplexer.send(envelope)
|
||||
supervisedUpdateFallbackJob = scope.launch {
|
||||
delay(SUPERVISED_UPDATE_ACK_TIMEOUT_MS)
|
||||
if (pendingSupervisedUpdateId == envelope.id) {
|
||||
pendingSupervisedUpdateId = null
|
||||
Log.i(TAG, "supervised.update unsupported or unacknowledged; refreshing Relay socket")
|
||||
supervisedMetadataReconnectFallback?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun settleSupervisedModeUpdate(envelope: Envelope, unsupported: Boolean) {
|
||||
if (envelope.id != pendingSupervisedUpdateId) return
|
||||
pendingSupervisedUpdateId = null
|
||||
supervisedUpdateFallbackJob?.cancel()
|
||||
supervisedUpdateFallbackJob = null
|
||||
if (unsupported) {
|
||||
Log.i(TAG, "supervised.update rejected; refreshing Relay socket for compatibility")
|
||||
supervisedMetadataReconnectFallback?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AuthManager"
|
||||
private const val KEY_SESSION_TOKEN = "session_token"
|
||||
@@ -134,6 +224,7 @@ class AuthManager(
|
||||
// migration has run, so we never rebuild the legacy keyset to re-check.
|
||||
private const val KEY_LEGACY_MIGRATED = "legacy_migrated"
|
||||
private const val PAIRING_CODE_LENGTH = 6
|
||||
private const val SUPERVISED_UPDATE_ACK_TIMEOUT_MS = 2_000L
|
||||
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
|
||||
|
||||
/**
|
||||
@@ -835,6 +926,10 @@ class AuthManager(
|
||||
put("device_form_factor", "phone")
|
||||
}
|
||||
|
||||
private fun JsonObjectBuilder.putSupervisedMode() {
|
||||
put("supervised_mode", relaySupervisedModePayload(supervisedMode))
|
||||
}
|
||||
|
||||
private fun relayDeviceName(): String {
|
||||
val configured = runCatching {
|
||||
Settings.Global.getString(context.contentResolver, "device_name")
|
||||
@@ -890,6 +985,7 @@ class AuthManager(
|
||||
put("device_id", deviceId)
|
||||
putRelayDeviceIdentity()
|
||||
putRelayClientSupports()
|
||||
putSupervisedMode()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@@ -906,6 +1002,7 @@ class AuthManager(
|
||||
put("device_id", deviceId)
|
||||
putRelayDeviceIdentity()
|
||||
putRelayClientSupports()
|
||||
putSupervisedMode()
|
||||
pendingTtlSeconds?.let { put("ttl_seconds", it) }
|
||||
pendingGrants?.let { grants ->
|
||||
val obj = buildJsonObject {
|
||||
@@ -985,6 +1082,8 @@ class AuthManager(
|
||||
when (envelope.type) {
|
||||
"auth.ok" -> handleAuthOk(envelope)
|
||||
"auth.fail" -> handleAuthFail(envelope)
|
||||
"supervised.updated" -> settleSupervisedModeUpdate(envelope, unsupported = false)
|
||||
"error" -> settleSupervisedModeUpdate(envelope, unsupported = true)
|
||||
// `profiles.updated` push — sent by the v0.7.1+ relay on
|
||||
// the "pairing" channel whenever its in-memory profile
|
||||
// snapshot changes (file-watcher, SIGHUP, or a manual
|
||||
@@ -1129,6 +1228,11 @@ class AuthManager(
|
||||
get() = _authState.value is AuthState.Paired
|
||||
|
||||
private fun handleAuthOk(envelope: Envelope) {
|
||||
// A successful auth always carries the latest client report, including
|
||||
// after the compatibility reconnect used for older Relay versions.
|
||||
pendingSupervisedUpdateId = null
|
||||
supervisedUpdateFallbackJob?.cancel()
|
||||
supervisedUpdateFallbackJob = null
|
||||
scope.launch {
|
||||
try {
|
||||
val payload = envelope.payload
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import com.hermesandroid.relay.ui.theme.AppThemes
|
||||
|
||||
/**
|
||||
* Parent-configured restrictions for the official Android client.
|
||||
*
|
||||
* This policy deliberately describes a client presentation mode, not a server
|
||||
* authorization boundary. The pinned profile is expected to have already been
|
||||
* configured with the appropriate server-side tool and content restrictions.
|
||||
*/
|
||||
@Serializable
|
||||
data class SupervisedModePolicy(
|
||||
val enabled: Boolean = false,
|
||||
val pinnedProfileName: String? = null,
|
||||
val capabilities: SupervisedCapabilities = SupervisedCapabilities(),
|
||||
val appearance: SupervisedAppearance = SupervisedAppearance(),
|
||||
val visibility: SupervisedVisibility = SupervisedVisibility(),
|
||||
val parentAccess: SupervisedParentAccess = SupervisedParentAccess(),
|
||||
) {
|
||||
/** A saved policy is usable only when it names a concrete Hermes profile. */
|
||||
val isConfigured: Boolean
|
||||
get() = !pinnedProfileName.isNullOrBlank()
|
||||
|
||||
/** Consumers should use this instead of treating [enabled] alone as sufficient. */
|
||||
val isActive: Boolean
|
||||
get() = enabled && isConfigured
|
||||
|
||||
internal fun normalized(): SupervisedModePolicy = copy(
|
||||
pinnedProfileName = pinnedProfileName?.trim()?.takeIf { it.isNotEmpty() },
|
||||
capabilities = capabilities.normalized(),
|
||||
appearance = appearance.normalized(),
|
||||
parentAccess = parentAccess.normalized(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Actions and content types the supervised chat surface may expose. */
|
||||
@Serializable
|
||||
data class SupervisedCapabilities(
|
||||
val attachments: Boolean = false,
|
||||
val voice: Boolean = false,
|
||||
val generatedImages: Boolean = true,
|
||||
val conversationHistory: Boolean = false,
|
||||
val newChat: Boolean = true,
|
||||
val cancelResponse: Boolean = true,
|
||||
val steerResponse: Boolean = true,
|
||||
val retryResponse: Boolean = true,
|
||||
val copyResponses: Boolean = true,
|
||||
val quoteReplies: Boolean = true,
|
||||
val editAndResend: Boolean = false,
|
||||
val shareGeneratedImages: Boolean = false,
|
||||
val sessionActions: SupervisedSessionActions = SupervisedSessionActions(),
|
||||
val attachmentMaxCount: Int = DEFAULT_ATTACHMENT_MAX_COUNT,
|
||||
val attachmentMaxFileMb: Int = DEFAULT_ATTACHMENT_MAX_FILE_MB,
|
||||
val attachmentCategories: Set<SupervisedAttachmentCategory> = setOf(
|
||||
SupervisedAttachmentCategory.Images,
|
||||
),
|
||||
) {
|
||||
internal fun normalized(): SupervisedCapabilities = copy(
|
||||
attachmentMaxCount = attachmentMaxCount.coerceIn(1, MAX_ATTACHMENT_COUNT),
|
||||
attachmentMaxFileMb = attachmentMaxFileMb.coerceIn(1, MAX_ATTACHMENT_FILE_MB),
|
||||
attachmentCategories = attachmentCategories.ifEmpty {
|
||||
setOf(SupervisedAttachmentCategory.Images)
|
||||
},
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_ATTACHMENT_MAX_COUNT = 4
|
||||
const val DEFAULT_ATTACHMENT_MAX_FILE_MB = 10
|
||||
const val MAX_ATTACHMENT_COUNT = 10
|
||||
const val MAX_ATTACHMENT_FILE_MB = 100
|
||||
}
|
||||
}
|
||||
|
||||
/** Appearance applied only while the supervised root is locked. */
|
||||
@Serializable
|
||||
data class SupervisedAppearance(
|
||||
val appThemeId: String = AppThemes.DEFAULT_ID,
|
||||
val themePreference: String = "auto",
|
||||
val showPet: Boolean = false,
|
||||
val allowProfileIconChanges: Boolean = false,
|
||||
val allowBackgroundChanges: Boolean = false,
|
||||
) {
|
||||
internal fun normalized(): SupervisedAppearance = copy(
|
||||
appThemeId = AppThemes.byId(appThemeId).id,
|
||||
themePreference = themePreference.takeIf { it in VALID_THEME_PREFERENCES } ?: "auto",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val VALID_THEME_PREFERENCES = setOf("auto", "light", "dark")
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable operations available from a supervised conversation-history row. */
|
||||
@Serializable
|
||||
data class SupervisedSessionActions(
|
||||
val pin: Boolean = false,
|
||||
val rename: Boolean = false,
|
||||
val archive: Boolean = false,
|
||||
val delete: Boolean = false,
|
||||
val shareTranscript: Boolean = false,
|
||||
) {
|
||||
val enabledCount: Int
|
||||
get() = listOf(pin, rename, archive, delete, shareTranscript).count { it }
|
||||
|
||||
val allEnabled: Boolean
|
||||
get() = enabledCount == TOTAL
|
||||
|
||||
val noneEnabled: Boolean
|
||||
get() = enabledCount == 0
|
||||
|
||||
fun withAll(enabled: Boolean): SupervisedSessionActions = SupervisedSessionActions(
|
||||
pin = enabled,
|
||||
rename = enabled,
|
||||
archive = enabled,
|
||||
delete = enabled,
|
||||
shareTranscript = enabled,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val TOTAL = 5
|
||||
}
|
||||
}
|
||||
|
||||
enum class SupervisedSessionAction {
|
||||
Pin,
|
||||
Rename,
|
||||
Archive,
|
||||
Delete,
|
||||
ShareTranscript,
|
||||
}
|
||||
|
||||
fun SupervisedModePolicy.allowsSessionAction(action: SupervisedSessionAction): Boolean {
|
||||
if (!enabled) return true
|
||||
if (!capabilities.conversationHistory) return false
|
||||
return when (action) {
|
||||
SupervisedSessionAction.Pin -> capabilities.sessionActions.pin
|
||||
SupervisedSessionAction.Rename -> capabilities.sessionActions.rename
|
||||
SupervisedSessionAction.Archive -> capabilities.sessionActions.archive
|
||||
SupervisedSessionAction.Delete -> capabilities.sessionActions.delete
|
||||
SupervisedSessionAction.ShareTranscript -> capabilities.sessionActions.shareTranscript
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SupervisedAttachmentCategory {
|
||||
@SerialName("images")
|
||||
Images,
|
||||
|
||||
@SerialName("documents")
|
||||
Documents,
|
||||
|
||||
@SerialName("audio")
|
||||
Audio,
|
||||
|
||||
@SerialName("video")
|
||||
Video,
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls which metadata and conversation affordances are rendered.
|
||||
*
|
||||
* [Simple] is the quiet default. [Transparent] is a useful preset for older or
|
||||
* technical users, while [Custom] tells the UI to honor every stored toggle.
|
||||
*/
|
||||
@Serializable
|
||||
data class SupervisedVisibility(
|
||||
val preset: SupervisedVisibilityPreset = SupervisedVisibilityPreset.Simple,
|
||||
val showAgentIdentity: Boolean = true,
|
||||
val showModelName: Boolean = false,
|
||||
val showProfileName: Boolean = false,
|
||||
val showConnectionStatus: Boolean = true,
|
||||
val showTechnicalRoute: Boolean = false,
|
||||
val showTimestamps: Boolean = true,
|
||||
val showToolNames: Boolean = false,
|
||||
val showToolDetails: Boolean = false,
|
||||
val showWorkingStatus: Boolean = true,
|
||||
val showReasoning: Boolean = false,
|
||||
val showUsage: Boolean = false,
|
||||
) {
|
||||
/** Resolve presets to the concrete flags consumed by chat presentation. */
|
||||
fun resolved(): SupervisedVisibility = when (preset) {
|
||||
SupervisedVisibilityPreset.Simple -> SIMPLE
|
||||
SupervisedVisibilityPreset.Transparent -> TRANSPARENT
|
||||
SupervisedVisibilityPreset.Custom -> this
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SIMPLE = SupervisedVisibility(preset = SupervisedVisibilityPreset.Simple)
|
||||
|
||||
val TRANSPARENT = SupervisedVisibility(
|
||||
preset = SupervisedVisibilityPreset.Transparent,
|
||||
showModelName = true,
|
||||
showProfileName = true,
|
||||
showTechnicalRoute = true,
|
||||
showToolNames = true,
|
||||
showUsage = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SupervisedVisibilityPreset {
|
||||
@SerialName("simple")
|
||||
Simple,
|
||||
|
||||
@SerialName("transparent")
|
||||
Transparent,
|
||||
|
||||
@SerialName("custom")
|
||||
Custom,
|
||||
}
|
||||
|
||||
/** Device-authentication and automatic relock behavior for parent access. */
|
||||
@Serializable
|
||||
data class SupervisedParentAccess(
|
||||
/** Reserved for forward-compatible persistence; normalization never permits an auth bypass. */
|
||||
val requireDeviceAuthentication: Boolean = true,
|
||||
val relockOnBackground: Boolean = true,
|
||||
val timeoutMinutes: Int = DEFAULT_TIMEOUT_MINUTES,
|
||||
) {
|
||||
internal fun normalized(): SupervisedParentAccess = copy(
|
||||
requireDeviceAuthentication = true,
|
||||
timeoutMinutes = timeoutMinutes.coerceIn(MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_TIMEOUT_MINUTES = 5
|
||||
const val MIN_TIMEOUT_MINUTES = 1
|
||||
const val MAX_TIMEOUT_MINUTES = 60
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Persists one independent [SupervisedModePolicy] per Hermes connection. */
|
||||
class SupervisedModeStore private constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
private val serializer = MapSerializer(String.serializer(), SupervisedModePolicy.serializer())
|
||||
|
||||
fun policyFlow(connectionId: String): Flow<SupervisedModePolicy> =
|
||||
dataStore.data.map { preferences ->
|
||||
val decoded = decode(preferences[KEY_POLICIES])
|
||||
if (decoded.corrupt) {
|
||||
// A malformed persisted policy must never silently reopen the
|
||||
// unrestricted app. Enabled + unconfigured renders the
|
||||
// supervised recovery surface until an authenticated user
|
||||
// repairs or clears the policy.
|
||||
SupervisedModePolicy(enabled = true)
|
||||
} else {
|
||||
decoded.policies[connectionId]?.normalized() ?: SupervisedModePolicy()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPolicy(connectionId: String, policy: SupervisedModePolicy) {
|
||||
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
|
||||
dataStore.edit { preferences ->
|
||||
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
|
||||
policies[connectionId] = policy.normalized()
|
||||
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePolicy(
|
||||
connectionId: String,
|
||||
transform: (SupervisedModePolicy) -> SupervisedModePolicy,
|
||||
) {
|
||||
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
|
||||
dataStore.edit { preferences ->
|
||||
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
|
||||
val current = policies[connectionId]?.normalized() ?: SupervisedModePolicy()
|
||||
policies[connectionId] = transform(current).normalized()
|
||||
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setEnabled(connectionId: String, enabled: Boolean) {
|
||||
updatePolicy(connectionId) { it.copy(enabled = enabled) }
|
||||
}
|
||||
|
||||
suspend fun clear(connectionId: String) {
|
||||
dataStore.edit { preferences ->
|
||||
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
|
||||
policies.remove(connectionId)
|
||||
if (policies.isEmpty()) {
|
||||
preferences.remove(KEY_POLICIES)
|
||||
} else {
|
||||
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear supervised policies without disturbing unrelated app settings. */
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { preferences -> preferences.remove(KEY_POLICIES) }
|
||||
}
|
||||
|
||||
private fun decode(raw: String?): DecodeResult {
|
||||
if (raw.isNullOrBlank()) return DecodeResult(emptyMap(), corrupt = false)
|
||||
return try {
|
||||
DecodeResult(json.decodeFromString(serializer, raw), corrupt = false)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Unable to decode supervised-mode policies; failing closed", error)
|
||||
DecodeResult(emptyMap(), corrupt = true)
|
||||
}
|
||||
}
|
||||
|
||||
private data class DecodeResult(
|
||||
val policies: Map<String, SupervisedModePolicy>,
|
||||
val corrupt: Boolean,
|
||||
)
|
||||
|
||||
internal companion object {
|
||||
private const val TAG = "SupervisedModeStore"
|
||||
private val KEY_POLICIES = stringPreferencesKey("supervised_mode_policies_v1")
|
||||
|
||||
fun forTesting(dataStore: DataStore<Preferences>): SupervisedModeStore =
|
||||
SupervisedModeStore(dataStore)
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ class ChannelMultiplexer {
|
||||
)
|
||||
send(pong)
|
||||
}
|
||||
"auth.ok", "auth.fail" -> {
|
||||
"auth.ok", "auth.fail", "supervised.updated", "error" -> {
|
||||
// Delegate to system handler if registered
|
||||
handlers["system"]?.onMessage(envelope)
|
||||
}
|
||||
|
||||
@@ -442,6 +442,35 @@ class ConnectionManager(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen the current authenticated Relay socket without discarding pair
|
||||
* state. Used only as a compatibility fallback when an older Relay does
|
||||
* not acknowledge a post-auth metadata update; the replacement socket's
|
||||
* normal `system/auth` frame carries the latest metadata.
|
||||
*/
|
||||
fun reconnectForAuthenticatedMetadataUpdate(): Boolean {
|
||||
val targetUrl = serverUrl?.takeIf { it.isNotBlank() } ?: return false
|
||||
if (isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
) {
|
||||
Log.i(TAG, "metadata reconnect: preserving active rate-limit backoff")
|
||||
return false
|
||||
}
|
||||
val previousSocket = webSocket
|
||||
if (previousSocket == null) {
|
||||
connect(targetUrl)
|
||||
} else {
|
||||
doConnect(
|
||||
targetUrl,
|
||||
previousSocketToClose = previousSocket,
|
||||
replaceReason = "Relay metadata compatibility refresh",
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [connect] but bypasses the resolver — used by the network-
|
||||
* change callback when we've already picked a winner and just want to
|
||||
|
||||
@@ -48,6 +48,9 @@ interface VoiceAudioClient {
|
||||
val effectiveRoute: VoiceAudioRoute
|
||||
get() = route
|
||||
|
||||
/** Temporary client-policy override; the shared router honors it before user prefs. */
|
||||
fun setRouteOverride(route: VoiceAudioRoute?) = Unit
|
||||
|
||||
suspend fun transcribe(audioFile: File): Result<String>
|
||||
suspend fun synthesize(text: String): Result<File>
|
||||
|
||||
@@ -82,8 +85,15 @@ class AutoVoiceAudioClient(
|
||||
private val standardReadyProvider: () -> Boolean,
|
||||
private val relayReadyProvider: () -> Boolean,
|
||||
) : VoiceAudioClient {
|
||||
@Volatile
|
||||
private var routeOverride: VoiceAudioRoute? = null
|
||||
|
||||
override fun setRouteOverride(route: VoiceAudioRoute?) {
|
||||
routeOverride = route
|
||||
}
|
||||
|
||||
override val route: VoiceAudioRoute
|
||||
get() = routeProvider()
|
||||
get() = routeOverride ?: routeProvider()
|
||||
|
||||
/**
|
||||
* Resolve the configured preference to the backend a call would land on:
|
||||
@@ -92,7 +102,7 @@ class AutoVoiceAudioClient(
|
||||
* decide whether standard-only limitations (global TTS) currently apply.
|
||||
*/
|
||||
override val effectiveRoute: VoiceAudioRoute
|
||||
get() = when (routeProvider()) {
|
||||
get() = when (route) {
|
||||
VoiceAudioRoute.Standard -> VoiceAudioRoute.Standard
|
||||
VoiceAudioRoute.Relay -> VoiceAudioRoute.Relay
|
||||
VoiceAudioRoute.Auto ->
|
||||
@@ -114,7 +124,7 @@ class AutoVoiceAudioClient(
|
||||
private suspend fun <T> runWithSelectedRoute(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
return when (routeProvider()) {
|
||||
return when (route) {
|
||||
VoiceAudioRoute.Standard -> {
|
||||
if (!standardReadyProvider()) {
|
||||
Result.failure(
|
||||
|
||||
@@ -797,6 +797,11 @@ class GatewayChatClient(
|
||||
*/
|
||||
fun hasActiveTurn(): Boolean = activeTurn?.ended == false || backgroundTurns.isNotEmpty()
|
||||
|
||||
/** True only when [storedId] still owns a foreground or deliberately detached turn. */
|
||||
fun hasActiveTurnForSession(storedId: String): Boolean =
|
||||
(activeTurn?.ended == false && storedSessionId == storedId) ||
|
||||
backgroundTurns.values.any { it.storedSessionId == storedId }
|
||||
|
||||
/** Live id to persist beside a durable stored id while a turn is active. */
|
||||
fun currentLiveSessionId(storedId: String): String? =
|
||||
liveSessionId?.takeIf { storedSessionId == storedId }
|
||||
|
||||
@@ -133,6 +133,8 @@ import com.hermesandroid.relay.data.CandidateBuild
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.SupervisedModeStore
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
@@ -144,6 +146,7 @@ import com.hermesandroid.relay.util.HumanError
|
||||
import kotlinx.coroutines.delay
|
||||
import com.hermesandroid.relay.ui.onboarding.OnboardingScreen
|
||||
import com.hermesandroid.relay.ui.screens.AboutScreen
|
||||
import com.hermesandroid.relay.ui.screens.AdvancedSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.AnalyticsScreen
|
||||
import com.hermesandroid.relay.ui.screens.AppearanceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.CustomThemeScreen
|
||||
@@ -170,6 +173,8 @@ import com.hermesandroid.relay.ui.screens.PermissionsStatusScreen
|
||||
import com.hermesandroid.relay.ui.screens.ProfileInspectorScreen
|
||||
import com.hermesandroid.relay.ui.screens.RealtimeVoiceTestScreen
|
||||
import com.hermesandroid.relay.ui.screens.SettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.SupervisedControlsScreen
|
||||
import com.hermesandroid.relay.ui.screens.SupervisedAppearanceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.UsageLimitsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginPageScreen
|
||||
@@ -531,6 +536,17 @@ sealed class Screen(
|
||||
// the plural `ConnectionsSettings` subpage. See `ConnectionsSettings`
|
||||
// above for the surviving route.)
|
||||
data object ChatSettings : Screen("settings/chat", "Chat", Icons.Filled.Settings)
|
||||
data object AdvancedSettings : Screen("settings/advanced", "Advanced", Icons.Filled.Settings)
|
||||
data object SupervisedAppearanceSettings : Screen(
|
||||
"settings/supervised/appearance",
|
||||
"Appearance",
|
||||
Icons.Filled.Settings,
|
||||
)
|
||||
data object SupervisedControls : Screen(
|
||||
"settings/supervised",
|
||||
"Supervised mode",
|
||||
Icons.Filled.Settings,
|
||||
)
|
||||
data object ProviderUsage : Screen("settings/usage", "Usage & limits", Icons.Filled.Settings)
|
||||
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
|
||||
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
|
||||
@@ -589,6 +605,24 @@ sealed class Screen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupervisedStartupLoadingScreen() {
|
||||
HermesRelayTheme(themePreference = "dark") {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Loading protected settings…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayApp() {
|
||||
val applicationContext = LocalContext.current.applicationContext
|
||||
@@ -603,7 +637,10 @@ fun RelayApp() {
|
||||
LaunchedEffect(processRuntime) {
|
||||
processRuntime.ensureInitialized()
|
||||
}
|
||||
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) return
|
||||
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) {
|
||||
SupervisedStartupLoadingScreen()
|
||||
return
|
||||
}
|
||||
|
||||
val voiceClient: RelayVoiceClient = processRuntime.relayVoiceClient
|
||||
val voicePreferences = processRuntime.voicePreferences
|
||||
@@ -700,6 +737,72 @@ fun RelayApp() {
|
||||
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val connectionStoreHydrated by
|
||||
connectionViewModel.connectionStore.isHydrated.collectAsState()
|
||||
val supervisedModeStore = remember(applicationContext) {
|
||||
SupervisedModeStore(applicationContext)
|
||||
}
|
||||
val supervisedPolicyState = produceState<Pair<String?, SupervisedModePolicy>?>(
|
||||
initialValue = null,
|
||||
key1 = activeConnectionId,
|
||||
key2 = supervisedModeStore,
|
||||
) {
|
||||
val connectionId = activeConnectionId
|
||||
if (connectionId == null) {
|
||||
value = null to SupervisedModePolicy()
|
||||
} else {
|
||||
supervisedModeStore.policyFlow(connectionId).collect { policy ->
|
||||
value = connectionId to policy
|
||||
}
|
||||
}
|
||||
}
|
||||
val ownedSupervisedPolicyState = supervisedPolicyState.value
|
||||
?.takeIf { (ownerConnectionId, _) -> ownerConnectionId == activeConnectionId }
|
||||
// Fail closed across process restoration. activeConnectionId starts as
|
||||
// null while ConnectionStore reads DataStore, so null alone cannot prove
|
||||
// this is a fresh install with no supervised policy to restore.
|
||||
if (!isRelayNavigationHydrated(
|
||||
connectionStoreHydrated = connectionStoreHydrated,
|
||||
activeConnectionId = activeConnectionId,
|
||||
supervisedPolicyHydrated = ownedSupervisedPolicyState != null,
|
||||
)
|
||||
) {
|
||||
SupervisedStartupLoadingScreen()
|
||||
return
|
||||
}
|
||||
val supervisedPolicy = ownedSupervisedPolicyState?.second ?: SupervisedModePolicy()
|
||||
val supervisedPinnedProfile = supervisedPolicy.pinnedProfileName?.let { name ->
|
||||
agentProfiles.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
}
|
||||
val supervisedProfileConfirmed = !supervisedPolicy.enabled || (
|
||||
profileSelectionSettled &&
|
||||
supervisedPinnedProfile != null &&
|
||||
selectedProfile?.name.equals(supervisedPinnedProfile.name, ignoreCase = true)
|
||||
)
|
||||
val chatSupervisedPolicy = if (supervisedPolicy.enabled && !supervisedProfileConfirmed) {
|
||||
supervisedPolicy.copy(pinnedProfileName = null)
|
||||
} else supervisedPolicy
|
||||
var parentAccessUnlocked by remember(activeConnectionId) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(
|
||||
activeConnectionId,
|
||||
supervisedPolicy,
|
||||
agentProfiles,
|
||||
selectedProfile,
|
||||
profileSelectionSettled,
|
||||
) {
|
||||
chatViewModel.updateSupervisedModePolicy(chatSupervisedPolicy)
|
||||
connectionViewModel.authManager.updateSupervisedMode(chatSupervisedPolicy)
|
||||
if (!supervisedPolicy.enabled) {
|
||||
parentAccessUnlocked = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val pinned = supervisedPinnedProfile ?: return@LaunchedEffect
|
||||
if (!selectedProfile?.name.equals(pinned.name, ignoreCase = true)) {
|
||||
connectionViewModel.selectProfile(pinned)
|
||||
chatViewModel.activateGatewayProfile(pinned)
|
||||
}
|
||||
}
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
@@ -784,6 +887,22 @@ fun RelayApp() {
|
||||
val appearanceAccent by connectionViewModel.appearanceAccent.collectAsState()
|
||||
val appearanceShape by connectionViewModel.appearanceShape.collectAsState()
|
||||
val activeCustomTheme by connectionViewModel.activeCustomTheme.collectAsState()
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
val parentAccessForCurrentRoute = parentAccessUnlocked &&
|
||||
!shouldRelockParentAccess(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessUnlocked,
|
||||
route = currentRoute,
|
||||
)
|
||||
val resolvedTheme = resolveSupervisedTheme(
|
||||
policy = supervisedPolicy,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
globalAppThemeId = appThemeId,
|
||||
globalThemePreference = themePreference,
|
||||
)
|
||||
val supervisedAppearanceLocked = supervisedPolicy.enabled && !parentAccessForCurrentRoute
|
||||
|
||||
// Resolve the active sphere skin (built-in / adaptive / user-loaded) and
|
||||
// publish it + the full available set so every MorphingSphere picks it up
|
||||
@@ -800,10 +919,10 @@ fun RelayApp() {
|
||||
value = SphereRegistry.builtIns +
|
||||
withContext(Dispatchers.IO) { SphereSkinLoader.loadUserSkins(sphereContext) }
|
||||
}
|
||||
val activeSphereSkin = remember(sphereSkinId, appThemeId, availableSphereSkins) {
|
||||
val activeSphereSkin = remember(sphereSkinId, resolvedTheme.appThemeId, availableSphereSkins) {
|
||||
SphereRegistry.resolve(
|
||||
selectedId = sphereSkinId,
|
||||
themeDefaultSkinId = AppThemes.byId(appThemeId).defaultSphereSkinId,
|
||||
themeDefaultSkinId = AppThemes.byId(resolvedTheme.appThemeId).defaultSphereSkinId,
|
||||
available = availableSphereSkins,
|
||||
)
|
||||
}
|
||||
@@ -938,37 +1057,19 @@ fun RelayApp() {
|
||||
),
|
||||
)
|
||||
HermesRelayTheme(
|
||||
appThemeId = appThemeId,
|
||||
themePreference = themePreference,
|
||||
appThemeId = resolvedTheme.appThemeId,
|
||||
themePreference = resolvedTheme.themePreference,
|
||||
fontScale = fontScale,
|
||||
appFontId = appFontId,
|
||||
accentHex = appearanceAccent,
|
||||
accentHex = appearanceAccent.takeIf { resolvedTheme.useGlobalCustomTheme },
|
||||
shapeId = appearanceShape,
|
||||
customTheme = activeCustomTheme,
|
||||
customTheme = activeCustomTheme.takeIf { resolvedTheme.useGlobalCustomTheme },
|
||||
) {
|
||||
// Surface a crash report from a previous session, if any. Renders a
|
||||
// platform Dialog (own window) so tree position is z-order-agnostic;
|
||||
// it just needs to be inside the theme for Material colors.
|
||||
CrashReportGate()
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
|
||||
// Collect navigation requests posted by external launchers (e.g., the
|
||||
// BridgeForegroundService notification's "Settings" action). The
|
||||
// service sets EXTRA_NAV_ROUTE on its launch intent → MainActivity's
|
||||
// onCreate / onNewIntent reads it and pumps it onto NavRouteRequest →
|
||||
// we forward each emission to the NavController. Single observer at
|
||||
// the app root so every screen benefits.
|
||||
LaunchedEffect(navController) {
|
||||
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
|
||||
// Wire the proactive "session" surfacing once: a message with
|
||||
// surfacing="session" is injected into the active chat conversation.
|
||||
// ChatViewModel isn't available where ConnectionViewModel builds the
|
||||
@@ -1039,8 +1140,68 @@ fun RelayApp() {
|
||||
// restart cleanly lands back in setup.
|
||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
// The unlock remains useful while moving between parent-only settings,
|
||||
// but never follows an enrolled device user back into supervised chat.
|
||||
// Cross-layer requests (notifications, services, deep links) use the
|
||||
// route-scoped unlock. As soon as Chat is current, the parent grant is
|
||||
// ineffective even before the state-clearing effect runs.
|
||||
LaunchedEffect(
|
||||
navController,
|
||||
supervisedPolicy.enabled,
|
||||
parentAccessForCurrentRoute,
|
||||
) {
|
||||
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
|
||||
if (
|
||||
supervisedPolicy.enabled &&
|
||||
!isSupervisedRouteAllowed(route, parentAccessForCurrentRoute)
|
||||
) return@collect
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(
|
||||
supervisedPolicy.enabled,
|
||||
parentAccessForCurrentRoute,
|
||||
currentRoute,
|
||||
) {
|
||||
if (shouldRedirectSupervisedRoute(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
currentRoute = currentRoute,
|
||||
)
|
||||
) {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute) {
|
||||
if (shouldRelockParentAccess(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute)) {
|
||||
parentAccessUnlocked = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(parentAccessUnlocked, supervisedPolicy.parentAccess.timeoutMinutes) {
|
||||
if (parentAccessUnlocked) {
|
||||
delay(supervisedPolicy.parentAccess.timeoutMinutes * 60_000L)
|
||||
parentAccessUnlocked = false
|
||||
}
|
||||
}
|
||||
DisposableEffect(lifecycleOwner, supervisedPolicy.enabled, parentAccessUnlocked) {
|
||||
val relockObserver = LifecycleEventObserver { _, event ->
|
||||
if (
|
||||
event == Lifecycle.Event.ON_PAUSE &&
|
||||
supervisedPolicy.enabled &&
|
||||
parentAccessUnlocked &&
|
||||
supervisedPolicy.parentAccess.relockOnBackground
|
||||
) {
|
||||
parentAccessUnlocked = false
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(relockObserver)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(relockObserver) }
|
||||
}
|
||||
val suppressGlobalChrome = shouldSuppressGlobalChrome(
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
isDemoMode = isDemoMode,
|
||||
@@ -1719,6 +1880,8 @@ fun RelayApp() {
|
||||
!suppressGlobalChrome &&
|
||||
!isKeyboardVisible &&
|
||||
!showStartupSphere &&
|
||||
(!supervisedPolicy.enabled ||
|
||||
supervisedPolicy.visibility.resolved().showTechnicalRoute) &&
|
||||
shouldShowConnectionFooter(voiceUiState.voiceMode, voicePresentationMode)
|
||||
) {
|
||||
val footerRoute = resolveFooterRouteCandidate(
|
||||
@@ -1793,12 +1956,16 @@ fun RelayApp() {
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
val routeContentAllowed = isSupervisedRouteContentAllowed(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
currentRoute = currentRoute,
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
composable(Screen.Onboarding.route) {
|
||||
// The wizard inside OnboardingScreen now owns credential
|
||||
@@ -1885,15 +2052,43 @@ fun RelayApp() {
|
||||
// sheet.
|
||||
val openAgentSheetArg = backStackEntry.arguments
|
||||
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
|
||||
val requestedSessionId = backStackEntry.arguments
|
||||
val rawRequestedSessionId = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_SESSION_ID)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val requestedProfileRoute = backStackEntry.arguments
|
||||
val rawRequestedProfileRoute = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_PROFILE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val requestedProactiveChatId = backStackEntry.arguments
|
||||
val rawRequestedProactiveChatId = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_PROACTIVE_CHAT_ID)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
// Nav/deep-link arguments are not ownership evidence. The
|
||||
// supervised drawer uses profile-scoped session rows
|
||||
// directly; external args stay discarded until an
|
||||
// owner-aware source can explicitly prove the binding.
|
||||
val sanitizedRouteArgs = sanitizeSupervisedChatRouteArgs(
|
||||
policy = supervisedPolicy,
|
||||
args = SupervisedChatRouteArgs(
|
||||
sessionId = rawRequestedSessionId,
|
||||
profile = rawRequestedProfileRoute,
|
||||
proactiveChatId = rawRequestedProactiveChatId,
|
||||
),
|
||||
pinnedProfileOwnershipProven = false,
|
||||
)
|
||||
val requestedSessionId = sanitizedRouteArgs.sessionId
|
||||
val requestedProfileRoute = sanitizedRouteArgs.profile
|
||||
val requestedProactiveChatId = sanitizedRouteArgs.proactiveChatId
|
||||
LaunchedEffect(
|
||||
supervisedPolicy.enabled,
|
||||
rawRequestedSessionId,
|
||||
rawRequestedProfileRoute,
|
||||
rawRequestedProactiveChatId,
|
||||
) {
|
||||
if (supervisedPolicy.enabled) {
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROACTIVE_CHAT_ID, null)
|
||||
}
|
||||
}
|
||||
val proactiveInboxEntries by connectionViewModel.inboxMessages.collectAsState()
|
||||
val phoneThreadChatIds by connectionViewModel.phoneThreadChatIds.collectAsState()
|
||||
LaunchedEffect(
|
||||
@@ -2056,6 +2251,7 @@ fun RelayApp() {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
supervisedPolicy = chatSupervisedPolicy,
|
||||
onNavigateToBotMode = {
|
||||
navController.navigate(Screen.BotMode.route) { launchSingleTop = true }
|
||||
},
|
||||
@@ -2376,6 +2572,22 @@ fun RelayApp() {
|
||||
SettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
supervisedPolicy = supervisedPolicy,
|
||||
parentAccessUnlocked = parentAccessUnlocked,
|
||||
onRequestParentAccess = { parentAccessUnlocked = true },
|
||||
onUpdateSupervisedPolicy = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
connectionSwitchScope.launch {
|
||||
supervisedModeStore.setPolicy(connectionId, policy)
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateToAdvancedSettings = {
|
||||
navController.navigate(Screen.AdvancedSettings.route)
|
||||
},
|
||||
onNavigateToSupervisedAppearance = {
|
||||
navController.navigate(Screen.SupervisedAppearanceSettings.route)
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
// (The `onNavigateToChatWithAgentSheet` callback that
|
||||
// used to live here was removed 2026-04-21. Tapping
|
||||
@@ -2450,6 +2662,62 @@ fun RelayApp() {
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.AdvancedSettings.route) {
|
||||
if (!parentAccessUnlocked && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
AdvancedSettingsScreen(
|
||||
supervisedPolicy = supervisedPolicy,
|
||||
onNavigateToSupervisedControls = {
|
||||
navController.navigate(Screen.SupervisedControls.route)
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.SupervisedAppearanceSettings.route) {
|
||||
if (!supervisedPolicy.enabled && !parentAccessUnlocked) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
SupervisedAppearanceSettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
policy = supervisedPolicy,
|
||||
onPolicyChange = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
connectionSwitchScope.launch {
|
||||
supervisedModeStore.setPolicy(connectionId, policy)
|
||||
}
|
||||
}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.SupervisedControls.route) {
|
||||
if (!parentAccessUnlocked && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
SupervisedControlsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
policy = supervisedPolicy,
|
||||
profiles = agentProfiles.filterNot { it.isDefault },
|
||||
onPolicyChange = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
connectionSwitchScope.launch {
|
||||
supervisedModeStore.setPolicy(connectionId, policy)
|
||||
}
|
||||
}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
onReturnToSupervisedView = {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(Screen.Chat.route) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.ProviderUsage.route) {
|
||||
UsageLimitsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
@@ -2927,7 +3195,8 @@ fun RelayApp() {
|
||||
composable(Screen.About.route) {
|
||||
AboutScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
onBack = { navController.popBackStack() },
|
||||
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessUnlocked,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
@@ -3030,6 +3299,12 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!routeContentAllowed) {
|
||||
// Keep the graph mounted so the redirect can complete, but
|
||||
// cover restored parent-only content with an opaque fail-closed surface.
|
||||
SupervisedStartupLoadingScreen()
|
||||
}
|
||||
}
|
||||
} // end bridge-return wrapper column
|
||||
} // end CompositionLocalProvider
|
||||
}
|
||||
@@ -3042,6 +3317,7 @@ fun RelayApp() {
|
||||
val petSurfaceOwner = petSurfaceOwnerForRoute(currentRoute)
|
||||
val petActivity = petCompanionCoordinator.activityFor(petSurfaceOwner)
|
||||
val showFloatingPet = activeFloatingPet != null &&
|
||||
shouldShowPetInSupervisedMode(supervisedPolicy, parentAccessForCurrentRoute) &&
|
||||
floatingPetAllowedOnRoute(currentRoute) &&
|
||||
!petActivity.hidden &&
|
||||
!suppressGlobalChrome &&
|
||||
@@ -3072,6 +3348,7 @@ fun RelayApp() {
|
||||
),
|
||||
animationEnabled = animationEnabled,
|
||||
appForeground = appIsForeground,
|
||||
interactive = !supervisedAppearanceLocked,
|
||||
route = roamingRoute,
|
||||
visitRequest = petCompanionCoordinator.pendingVisitRequest,
|
||||
onVisitRequestConsumed = petCompanionCoordinator::clearVisitRequest,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
|
||||
internal data class ResolvedSupervisedTheme(
|
||||
val appThemeId: String,
|
||||
val themePreference: String,
|
||||
val useGlobalCustomTheme: Boolean,
|
||||
)
|
||||
|
||||
/** Keep the supervised palette isolated from the parent's ordinary app theme. */
|
||||
internal fun resolveSupervisedTheme(
|
||||
policy: SupervisedModePolicy,
|
||||
parentAccessUnlocked: Boolean,
|
||||
globalAppThemeId: String,
|
||||
globalThemePreference: String,
|
||||
): ResolvedSupervisedTheme = if (policy.enabled && !parentAccessUnlocked) {
|
||||
ResolvedSupervisedTheme(
|
||||
appThemeId = policy.appearance.appThemeId,
|
||||
themePreference = policy.appearance.themePreference,
|
||||
useGlobalCustomTheme = false,
|
||||
)
|
||||
} else {
|
||||
ResolvedSupervisedTheme(
|
||||
appThemeId = globalAppThemeId,
|
||||
themePreference = globalThemePreference,
|
||||
useGlobalCustomTheme = true,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun shouldShowPetInSupervisedMode(
|
||||
policy: SupervisedModePolicy,
|
||||
parentAccessUnlocked: Boolean,
|
||||
): Boolean = !policy.enabled || parentAccessUnlocked || policy.appearance.showPet
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.ConnectionStore
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
|
||||
/** Allowlist applied to external, deep-link, and programmatic navigation. */
|
||||
internal fun isSupervisedRouteAllowed(route: String?, parentAccessUnlocked: Boolean): Boolean {
|
||||
if (parentAccessUnlocked) return true
|
||||
val normalized = route?.substringBefore('?') ?: return false
|
||||
return normalized == "chat" ||
|
||||
normalized == Screen.Settings.route ||
|
||||
normalized == Screen.SupervisedAppearanceSettings.route
|
||||
}
|
||||
|
||||
/** Do not inspect or mutate a NavController until its first destination exists. */
|
||||
internal fun shouldRedirectSupervisedRoute(
|
||||
supervisedEnabled: Boolean,
|
||||
parentAccessUnlocked: Boolean,
|
||||
currentRoute: String?,
|
||||
): Boolean = currentRoute != null &&
|
||||
supervisedEnabled &&
|
||||
!isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
|
||||
|
||||
/** A null route is Navigation's pre-graph bootstrap state, not a forbidden destination. */
|
||||
internal fun isSupervisedRouteContentAllowed(
|
||||
supervisedEnabled: Boolean,
|
||||
parentAccessUnlocked: Boolean,
|
||||
currentRoute: String?,
|
||||
): Boolean = currentRoute == null ||
|
||||
!supervisedEnabled ||
|
||||
isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
|
||||
|
||||
/**
|
||||
* Cold-start gate for the app navigation graph.
|
||||
*
|
||||
* A null active connection is also the seed value used while [ConnectionStore]
|
||||
* is reading DataStore. Callers must therefore wait for the store's explicit
|
||||
* hydration signal before treating null as "no connection" and composing the
|
||||
* unrestricted onboarding/settings graph.
|
||||
*/
|
||||
internal fun isRelayNavigationHydrated(
|
||||
connectionStoreHydrated: Boolean,
|
||||
activeConnectionId: String?,
|
||||
supervisedPolicyHydrated: Boolean,
|
||||
): Boolean = connectionStoreHydrated &&
|
||||
(activeConnectionId == null || supervisedPolicyHydrated)
|
||||
|
||||
/** A parent unlock never follows the user back into the supervised chat root. */
|
||||
internal fun shouldRelockParentAccess(
|
||||
supervisedEnabled: Boolean,
|
||||
parentAccessUnlocked: Boolean,
|
||||
route: String?,
|
||||
): Boolean = supervisedEnabled &&
|
||||
parentAccessUnlocked &&
|
||||
route?.substringBefore('?') == "chat"
|
||||
|
||||
/**
|
||||
* External chat route arguments are untrusted. A session may be restored only
|
||||
* after an owner-aware source has proved that it belongs to the pinned profile.
|
||||
*/
|
||||
internal fun mayRestoreSupervisedSessionRoute(
|
||||
policy: SupervisedModePolicy,
|
||||
requestedSessionId: String?,
|
||||
requestedProfile: String?,
|
||||
pinnedProfileOwnershipProven: Boolean,
|
||||
): Boolean = policy.isActive &&
|
||||
policy.capabilities.conversationHistory &&
|
||||
pinnedProfileOwnershipProven &&
|
||||
!requestedSessionId.isNullOrBlank() &&
|
||||
!requestedProfile.isNullOrBlank() &&
|
||||
requestedProfile.equals(policy.pinnedProfileName, ignoreCase = true)
|
||||
|
||||
internal data class SupervisedChatRouteArgs(
|
||||
val sessionId: String? = null,
|
||||
val profile: String? = null,
|
||||
val proactiveChatId: String? = null,
|
||||
)
|
||||
|
||||
/** Strip external chat targeting before any destination effect can dispatch it. */
|
||||
internal fun sanitizeSupervisedChatRouteArgs(
|
||||
policy: SupervisedModePolicy,
|
||||
args: SupervisedChatRouteArgs,
|
||||
pinnedProfileOwnershipProven: Boolean,
|
||||
): SupervisedChatRouteArgs {
|
||||
if (!policy.enabled) return args
|
||||
val allowSession = mayRestoreSupervisedSessionRoute(
|
||||
policy = policy,
|
||||
requestedSessionId = args.sessionId,
|
||||
requestedProfile = args.profile,
|
||||
pinnedProfileOwnershipProven = pinnedProfileOwnershipProven,
|
||||
)
|
||||
return if (allowSession) {
|
||||
args.copy(proactiveChatId = null)
|
||||
} else {
|
||||
SupervisedChatRouteArgs()
|
||||
}
|
||||
}
|
||||
|
||||
/** A disabled policy may become active only after an enrolled credential succeeds. */
|
||||
internal fun mayEnableSupervisedMode(
|
||||
policy: SupervisedModePolicy,
|
||||
deviceSecure: Boolean,
|
||||
deviceCredentialConfirmed: Boolean,
|
||||
): Boolean = !policy.enabled &&
|
||||
policy.isConfigured &&
|
||||
deviceSecure &&
|
||||
deviceCredentialConfirmed
|
||||
@@ -125,6 +125,7 @@ fun AttachmentGallery(
|
||||
if (attachments.size < 2) return
|
||||
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
val revealed = remember { mutableStateMapOf<String, Boolean>() }
|
||||
@@ -189,7 +190,7 @@ fun AttachmentGallery(
|
||||
)
|
||||
}
|
||||
|
||||
if (!blurred) {
|
||||
if (!blurred && exportAllowed) {
|
||||
SaveOverlayButton(
|
||||
onClick = {
|
||||
scope.launch { saveAttachment(context, attachment) }
|
||||
@@ -201,7 +202,7 @@ fun AttachmentGallery(
|
||||
}
|
||||
|
||||
AttachmentActionsMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = menuExpanded && exportAllowed,
|
||||
onDismiss = { menuExpanded = false },
|
||||
context = context,
|
||||
scope = scope,
|
||||
|
||||
@@ -312,6 +312,8 @@ fun AttachmentViewer(
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current ||
|
||||
attachment.renderMode != AttachmentRenderMode.IMAGE
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
@@ -405,6 +407,7 @@ fun AttachmentViewer(
|
||||
title = title,
|
||||
busy = busy,
|
||||
actionsEnabled = !blurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
@@ -448,6 +451,7 @@ internal fun AttachmentGalleryViewer(
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
@@ -584,6 +588,7 @@ internal fun AttachmentGalleryViewer(
|
||||
title = toolbarTitle,
|
||||
busy = busy,
|
||||
actionsEnabled = !currentBlurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
@@ -613,6 +618,7 @@ private fun MediaViewerToolbar(
|
||||
title: String,
|
||||
busy: Boolean,
|
||||
actionsEnabled: Boolean = true,
|
||||
exportAllowed: Boolean = true,
|
||||
onShare: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onOpenExternal: () -> Unit,
|
||||
@@ -653,11 +659,13 @@ private fun MediaViewerToolbar(
|
||||
) {
|
||||
Icon(Icons.Filled.OpenInNew, contentDescription = stringResource(R.string.attachment_open_externally_a11y))
|
||||
}
|
||||
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
|
||||
}
|
||||
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
|
||||
if (exportAllowed) {
|
||||
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
|
||||
}
|
||||
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ fun ChatFailurePanel(
|
||||
onDetails: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
showDetails: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
@@ -72,8 +73,10 @@ fun ChatFailurePanel(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onDetails) {
|
||||
Text(stringResource(R.string.chat_failure_details))
|
||||
if (showDetails) {
|
||||
TextButton(onClick = onDetails) {
|
||||
Text(stringResource(R.string.chat_failure_details))
|
||||
}
|
||||
}
|
||||
if (failure.recoverable) {
|
||||
TextButton(onClick = onRetry) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -42,6 +43,9 @@ import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.util.MediaSaver
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Whether the current conversation policy permits copying image bytes out of the app. */
|
||||
val LocalImageExportAllowed = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* What the [ChatImageViewer] displays and how it obtains bytes for Save/Share.
|
||||
*
|
||||
@@ -104,6 +108,7 @@ fun ChatImageViewer(
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -156,60 +161,72 @@ fun ChatImageViewer(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
|
||||
val cdShare = stringResource(R.string.cd_share)
|
||||
val cdSave = stringResource(R.string.cd_save)
|
||||
val cdClose = stringResource(R.string.cd_close_viewer)
|
||||
val errorMsg = context.getString(R.string.image_viewer_error)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
busy = false
|
||||
if (bytes == null) {
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = cdShare)
|
||||
}
|
||||
val savedFmt = context.getString(R.string.image_viewer_saved)
|
||||
val failedFmt = context.getString(R.string.image_viewer_failed)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
if (bytes == null) {
|
||||
if (exportAllowed) {
|
||||
val cdShare = stringResource(R.string.cd_share)
|
||||
val cdSave = stringResource(R.string.cd_save)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
busy = false
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
if (bytes == null) {
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = cdShare)
|
||||
}
|
||||
val savedFmt = context.getString(R.string.image_viewer_saved)
|
||||
val failedFmt = context.getString(R.string.image_viewer_failed)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
toast(context, savedFmt.format(result.location))
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
toast(context, failedFmt.format(result.message))
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
busy = false
|
||||
toast(context, savedFmt.format(result.location))
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
toast(context, failedFmt.format(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = cdSave)
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = cdSave)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDismiss, colors = tint) {
|
||||
Icon(Icons.Filled.Close, contentDescription = cdClose)
|
||||
|
||||
@@ -483,6 +483,7 @@ fun FloatingPetCompanion(
|
||||
compact: Boolean,
|
||||
animationEnabled: Boolean,
|
||||
appForeground: Boolean,
|
||||
interactive: Boolean = true,
|
||||
route: String?,
|
||||
visitRequest: PetVisitRequest?,
|
||||
onVisitRequestConsumed: (String) -> Unit,
|
||||
@@ -2318,13 +2319,14 @@ fun FloatingPetCompanion(
|
||||
}
|
||||
.pointerInput(
|
||||
pet.id,
|
||||
interactive,
|
||||
safeBounds,
|
||||
roamingRails,
|
||||
settledHabitat,
|
||||
positioned,
|
||||
surfaceScrolling,
|
||||
) {
|
||||
if (!floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
|
||||
if (!interactive || !floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
|
||||
return@pointerInput
|
||||
}
|
||||
detectDragGesturesAfterLongPress(
|
||||
@@ -2399,16 +2401,16 @@ fun FloatingPetCompanion(
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
enabled = floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
|
||||
enabled = interactive && floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
|
||||
) {
|
||||
tapReactionNonce += 1
|
||||
setMenuExpanded(true)
|
||||
}
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = Role.Button
|
||||
if (interactive) role = Role.Button
|
||||
contentDescription = companionDescription
|
||||
stateDescription = stateLabel
|
||||
customActions = buildList {
|
||||
customActions = if (interactive) buildList {
|
||||
add(CustomAccessibilityAction(moveStartLabel) {
|
||||
onPlacementChanged(placement.copy(edge = PetLogicalEdge.Start)); true
|
||||
})
|
||||
@@ -2438,7 +2440,7 @@ fun FloatingPetCompanion(
|
||||
add(CustomAccessibilityAction(resetLabel) { onResetPlacement(); true })
|
||||
add(CustomAccessibilityAction(appearanceLabel) { onOpenAppearance(); true })
|
||||
add(CustomAccessibilityAction(hideLabel) { onHide(); true })
|
||||
}
|
||||
} else emptyList()
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -2482,7 +2484,7 @@ fun FloatingPetCompanion(
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = interactive && menuExpanded,
|
||||
onDismissRequest = { setMenuExpanded(false) },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
|
||||
+18
-13
@@ -270,6 +270,7 @@ private fun ImageRender(
|
||||
maxWidth: Dp
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// Decode OFF the main thread — a large inbound image would otherwise block
|
||||
// composition. Null while decoding (placeholder); decodeFailed → file card.
|
||||
@@ -356,14 +357,14 @@ private fun ImageRender(
|
||||
}
|
||||
// One-tap save overlay — hidden while the blur cover is up so it
|
||||
// doesn't sit over the "tap to reveal" prompt.
|
||||
if (!blurred) {
|
||||
if (!blurred && exportAllowed) {
|
||||
SaveOverlayButton(
|
||||
onClick = { scope.launch { saveAttachment(context, attachment) } },
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp),
|
||||
)
|
||||
}
|
||||
AttachmentActionsMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = menuExpanded && exportAllowed,
|
||||
onDismiss = { menuExpanded = false },
|
||||
context = context,
|
||||
scope = scope,
|
||||
@@ -380,6 +381,8 @@ private fun FileCardRender(
|
||||
maxWidth: Dp
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current ||
|
||||
attachment.renderMode != AttachmentRenderMode.IMAGE
|
||||
val scope = rememberCoroutineScope()
|
||||
val (emoji, typeLabel) = emojiAndLabelFor(attachment.renderMode, attachment.contentType)
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
@@ -463,21 +466,23 @@ private fun FileCardRender(
|
||||
}
|
||||
}
|
||||
// Visible one-tap save affordance (B2).
|
||||
IconButton(
|
||||
onClick = { scope.launch { saveAttachment(context, attachment) } },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = stringResource(R.string.inbound_attach_cd_save),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
if (exportAllowed) {
|
||||
IconButton(
|
||||
onClick = { scope.launch { saveAttachment(context, attachment) } },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = stringResource(R.string.inbound_attach_cd_save),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AttachmentActionsMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = menuExpanded && exportAllowed,
|
||||
onDismiss = { menuExpanded = false },
|
||||
context = context,
|
||||
scope = scope,
|
||||
|
||||
@@ -94,6 +94,14 @@ import java.util.Date
|
||||
internal const val CHAT_PET_IDENTITY_OBSTACLE_PREFIX = "chat-message-identity:"
|
||||
private val MESSAGE_REACTIONS = listOf("❤️", "👍", "👎", "😂", "‼️", "❓")
|
||||
|
||||
internal fun assistantImageContent(
|
||||
content: String,
|
||||
showImages: Boolean,
|
||||
): Pair<String, List<ChatInlineImage>> {
|
||||
val (body, images) = extractChatInlineImages(content)
|
||||
return body to if (showImages) images else emptyList()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
@@ -101,6 +109,13 @@ fun MessageBubble(
|
||||
modifier: Modifier = Modifier,
|
||||
maxBubbleWidth: Dp = 300.dp,
|
||||
showThinking: Boolean = true,
|
||||
showAgentIdentity: Boolean = true,
|
||||
showTimestamps: Boolean = true,
|
||||
showWorkingStatus: Boolean = true,
|
||||
showUsage: Boolean = true,
|
||||
showTechnicalBadges: Boolean = true,
|
||||
showAssistantImages: Boolean = true,
|
||||
allowAssistantImageExport: Boolean = true,
|
||||
isFirstInGroup: Boolean = true,
|
||||
isLastInGroup: Boolean = true,
|
||||
onCopyMessage: (String) -> Unit = {},
|
||||
@@ -238,18 +253,24 @@ fun MessageBubble(
|
||||
// content so they render as real images (remote URLs via Coil) or a
|
||||
// graceful inline notice — not the blank element the markdown renderer
|
||||
// emits for an image link. User/system bubbles keep their raw content.
|
||||
val (markdownBody, inlineImages) = remember(visibleMessageContent, isUser, isSystem) {
|
||||
val (markdownBody, inlineImages) = remember(
|
||||
visibleMessageContent,
|
||||
isUser,
|
||||
isSystem,
|
||||
showAssistantImages,
|
||||
) {
|
||||
if (isUser || isSystem) {
|
||||
visibleMessageContent to emptyList()
|
||||
} else {
|
||||
extractChatInlineImages(visibleMessageContent)
|
||||
assistantImageContent(visibleMessageContent, showAssistantImages)
|
||||
}
|
||||
}
|
||||
val showImageGeneration = shouldShowImageGenerationPlaceholder(
|
||||
val showImageGeneration = showAssistantImages && showWorkingStatus && shouldShowImageGenerationPlaceholder(
|
||||
toolCalls = message.toolCalls,
|
||||
isStreaming = message.isStreaming,
|
||||
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
|
||||
)
|
||||
val actionContent = if (!isUser && !isSystem) markdownBody else visibleMessageContent
|
||||
val streamingStatusLabel = if (
|
||||
!isUser &&
|
||||
!isSystem &&
|
||||
@@ -297,7 +318,10 @@ fun MessageBubble(
|
||||
val blurRepo = remember(context) { MediaSettingsRepository(context.applicationContext) }
|
||||
val blurMode by blurRepo.blurMode.collectAsState(initial = BlurMode.FLAGGED)
|
||||
|
||||
CompositionLocalProvider(LocalMediaBlurMode provides blurMode) {
|
||||
CompositionLocalProvider(
|
||||
LocalMediaBlurMode provides blurMode,
|
||||
LocalImageExportAllowed provides allowAssistantImageExport,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = alignment,
|
||||
@@ -305,7 +329,7 @@ fun MessageBubble(
|
||||
// Keep sender identity in the first-message label rather than a
|
||||
// persistent leading column. Long responses and every follow-up in the
|
||||
// group therefore retain the full bubble-width allowance.
|
||||
if (!isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
|
||||
if (showAgentIdentity && !isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
@@ -336,7 +360,7 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUser && !isSystem && message.badges.isNotEmpty()) {
|
||||
if (showTechnicalBadges && !isUser && !isSystem && message.badges.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
@@ -415,7 +439,7 @@ fun MessageBubble(
|
||||
// is rendered directly in the conversation
|
||||
// lane below, without an opaque bubble. Cards and attachments still own
|
||||
// a normal bubble even when response prose has not arrived yet.
|
||||
streamingStatusLabel?.let { streamingStatus ->
|
||||
streamingStatusLabel?.takeIf { showWorkingStatus }?.let { streamingStatus ->
|
||||
StandaloneStreamingStatus(
|
||||
status = streamingStatus,
|
||||
accessibilityDescription = a11yDescription,
|
||||
@@ -508,7 +532,7 @@ fun MessageBubble(
|
||||
text = { Text(stringResource(R.string.msg_bubble_copy)) },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onCopyMessage(visibleMessageContent)
|
||||
onCopyMessage(actionContent)
|
||||
},
|
||||
)
|
||||
if (onQuoteMessage != null) {
|
||||
@@ -516,7 +540,7 @@ fun MessageBubble(
|
||||
text = { Text(stringResource(R.string.msg_bubble_quote)) },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onQuoteMessage(message.copy(content = visibleMessageContent))
|
||||
onQuoteMessage(message.copy(content = actionContent))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -531,7 +555,7 @@ fun MessageBubble(
|
||||
},
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onSpeakMessage?.invoke(visibleMessageContent)
|
||||
onSpeakMessage?.invoke(actionContent)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -601,7 +625,7 @@ fun MessageBubble(
|
||||
) {
|
||||
showMessageActions = true
|
||||
} else {
|
||||
onCopyMessage(visibleMessageContent)
|
||||
onCopyMessage(actionContent)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -798,7 +822,7 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
val hasTokenUsage = !isUser &&
|
||||
val hasTokenUsage = showUsage && !isUser &&
|
||||
(message.inputTokens != null || message.outputTokens != null)
|
||||
|
||||
// Timestamp — only on the LAST bubble of a same-author run so a
|
||||
@@ -808,13 +832,13 @@ fun MessageBubble(
|
||||
// This row is reserved from the first streaming frame. Completion
|
||||
// can reveal both timestamp and token usage without adding a new
|
||||
// footer line or changing the bubble's measured height.
|
||||
if (isLastInGroup) {
|
||||
if (isLastInGroup && (showTimestamps || hasTokenUsage)) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
if (showTimestamps) Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
|
||||
@@ -883,15 +907,15 @@ fun MessageBubble(
|
||||
showEdit = showEditAction,
|
||||
onCopy = {
|
||||
showInlineActions = false
|
||||
onCopyMessage(visibleMessageContent)
|
||||
onCopyMessage(actionContent)
|
||||
},
|
||||
onQuote = {
|
||||
showInlineActions = false
|
||||
onQuoteMessage?.invoke(message.copy(content = visibleMessageContent))
|
||||
onQuoteMessage?.invoke(message.copy(content = actionContent))
|
||||
},
|
||||
onSpeak = {
|
||||
showInlineActions = false
|
||||
onSpeakMessage?.invoke(visibleMessageContent)
|
||||
onSpeakMessage?.invoke(actionContent)
|
||||
},
|
||||
onStopSpeaking = {
|
||||
showInlineActions = false
|
||||
|
||||
@@ -105,6 +105,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.SupervisedSessionActions
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
|
||||
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
|
||||
@@ -203,6 +204,8 @@ fun SessionDrawerContent(
|
||||
animationEnabled: Boolean = true,
|
||||
autoTitlesSupported: Boolean = true,
|
||||
archiveSupported: Boolean = true,
|
||||
supervisedSessionActions: SupervisedSessionActions? = null,
|
||||
newChatEnabled: Boolean = true,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
/** Opens the separate Bot Mode messenger workspace; never changes drawer filters. */
|
||||
onOpenBotMode: (() -> Unit)? = null,
|
||||
@@ -278,8 +281,10 @@ fun SessionDrawerContent(
|
||||
val scopedRows = (sessions + provisionalSessions).map { ProfileSessionRow(activeProfileName, it) }
|
||||
val sourceRows = if (showAllProfiles) allProfileSessions else scopedRows
|
||||
val sourceSessions = sourceRows.map { it.session }
|
||||
val showThreads = threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) }
|
||||
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, archiveSupported)
|
||||
val showThreads = supervisedSessionActions == null &&
|
||||
(threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) })
|
||||
val effectiveArchiveSupported = archiveSupported && supervisedSessionActions?.archive != false
|
||||
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, effectiveArchiveSupported)
|
||||
// External gateway sources present (discord/telegram/cron/…) for the source
|
||||
// filter dropdown. Own chats (tui/api_server) + phone Threads aren't listed.
|
||||
val presentSources = sourceSessions
|
||||
@@ -390,7 +395,7 @@ fun SessionDrawerContent(
|
||||
)
|
||||
// Source filter — show/hide gateway sources (default hides the
|
||||
// noisy cron+webhook). Only when external sources are present.
|
||||
if (onToggleSourceHidden != null && presentSources.isNotEmpty()) {
|
||||
if (supervisedSessionActions == null && onToggleSourceHidden != null && presentSources.isNotEmpty()) {
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { sourceFilterOpen = true },
|
||||
@@ -451,7 +456,7 @@ fun SessionDrawerContent(
|
||||
// Threads affordance — a clean thread-spool that toggles the Threads
|
||||
// filter. Shown only when the Threads capability is active (or a Thread is
|
||||
// already present), so an ordinary no-relay drawer is visually unchanged.
|
||||
if (showThreads) {
|
||||
if (supervisedSessionActions == null && showThreads) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
filter = if (filter == SessionDrawerFilter.Threads) {
|
||||
@@ -522,7 +527,8 @@ fun SessionDrawerContent(
|
||||
onNewChat()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = newChatEnabled,
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -587,8 +593,10 @@ fun SessionDrawerContent(
|
||||
}
|
||||
SessionDrawerFilter.entries
|
||||
.filter { item ->
|
||||
(item != SessionDrawerFilter.Threads || showThreads) &&
|
||||
(item != SessionDrawerFilter.Archive || archiveSupported)
|
||||
(item != SessionDrawerFilter.Threads ||
|
||||
(supervisedSessionActions == null && showThreads)) &&
|
||||
(item != SessionDrawerFilter.Archive ||
|
||||
effectiveArchiveSupported)
|
||||
}
|
||||
.forEach { item ->
|
||||
FilterChip(
|
||||
@@ -620,17 +628,19 @@ fun SessionDrawerContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = { customizeOpen = true },
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.drawer_customize_sessions))
|
||||
if (supervisedSessionActions == null) {
|
||||
TextButton(
|
||||
onClick = { customizeOpen = true },
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.drawer_customize_sessions))
|
||||
}
|
||||
}
|
||||
// "+ New Thread" — Discord-style user-created thread, shown when the
|
||||
// Threads filter is active. The first message opens the conversation.
|
||||
@@ -764,13 +774,20 @@ fun SessionDrawerContent(
|
||||
showUpdated = viewOptions.showUpdated,
|
||||
showTokens = viewOptions.showTokens,
|
||||
showCost = viewOptions.showCost,
|
||||
actionsEnabled = !provisional,
|
||||
actionsEnabled = !provisional && (
|
||||
supervisedSessionActions == null ||
|
||||
supervisedSessionActions.pin ||
|
||||
supervisedSessionActions.rename ||
|
||||
supervisedSessionActions.delete ||
|
||||
(supervisedSessionActions.archive && archiveSupported)
|
||||
),
|
||||
isActive = !showAllProfiles && session.sessionId == currentSessionId,
|
||||
activityState = activityState,
|
||||
animationEnabled = animationEnabled && isOpen,
|
||||
pinned = session.pinned,
|
||||
archived = session.archived,
|
||||
archiveSupported = archiveSupported,
|
||||
supervisedSessionActions = supervisedSessionActions,
|
||||
onClick = {
|
||||
if (showAllProfiles) {
|
||||
onSelectProfileSession?.invoke(row.profile, session.sessionId)
|
||||
@@ -1326,6 +1343,7 @@ private fun SessionItem(
|
||||
pinned: Boolean,
|
||||
archived: Boolean,
|
||||
archiveSupported: Boolean,
|
||||
supervisedSessionActions: SupervisedSessionActions?,
|
||||
onClick: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onToggleArchived: () -> Unit,
|
||||
@@ -1493,7 +1511,7 @@ private fun SessionItem(
|
||||
expanded = menuOpen,
|
||||
onDismissRequest = { menuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions?.pin != false) DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
if (pinned) {
|
||||
@@ -1519,7 +1537,7 @@ private fun SessionItem(
|
||||
onTogglePinned()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions == null) DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_copy_session_id)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.ContentCopy, contentDescription = null)
|
||||
@@ -1529,7 +1547,7 @@ private fun SessionItem(
|
||||
onCopySessionId()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions?.rename != false) DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.drawer_rename)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Edit, contentDescription = null)
|
||||
@@ -1539,7 +1557,7 @@ private fun SessionItem(
|
||||
onRename()
|
||||
},
|
||||
)
|
||||
if (archiveSupported) {
|
||||
if (archiveSupported && supervisedSessionActions?.archive != false) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
|
||||
leadingIcon = {
|
||||
@@ -1559,7 +1577,7 @@ private fun SessionItem(
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions?.delete != false) DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.drawer_delete),
|
||||
|
||||
@@ -87,6 +87,8 @@ fun AboutScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onUnlockDeveloperOptions: () -> Unit = {},
|
||||
/** Supervised clients may read About without gaining a settings mutation backdoor. */
|
||||
allowDeveloperUnlock: Boolean = true,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -218,7 +220,7 @@ fun AboutScreen(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
.clickable(enabled = allowDeveloperUnlock) {
|
||||
if (devOptionsUnlocked) return@clickable
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastTapTime > 2000) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
|
||||
/** Optional and specialized features kept off the primary Settings surface. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdvancedSettingsScreen(
|
||||
supervisedPolicy: SupervisedModePolicy,
|
||||
onNavigateToSupervisedControls: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.settings_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
title = { Text(stringResource(R.string.settings_advanced)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_advanced_intro),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.settings_supervised_mode),
|
||||
subtitle = when {
|
||||
supervisedPolicy.isActive -> stringResource(
|
||||
R.string.settings_supervised_on_profile,
|
||||
supervisedPolicy.pinnedProfileName.orEmpty(),
|
||||
)
|
||||
supervisedPolicy.isConfigured -> stringResource(
|
||||
R.string.settings_supervised_ready_profile,
|
||||
supervisedPolicy.pinnedProfileName.orEmpty(),
|
||||
)
|
||||
else -> stringResource(R.string.settings_supervised_desc)
|
||||
},
|
||||
badge = supervisedPolicy.takeIf { it.isActive }?.let {
|
||||
SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_supervised_on),
|
||||
tone = SettingsStatusTone.Good,
|
||||
)
|
||||
},
|
||||
onClick = onNavigateToSupervisedControls,
|
||||
isDarkTheme = isDarkTheme,
|
||||
petPerchKey = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-18
@@ -1487,26 +1487,27 @@ private fun AppearanceSummaryRow(
|
||||
|
||||
/** Representative, theme-live chat sample so presets are judged in context. */
|
||||
@Composable
|
||||
private fun AppearanceLivePreview(
|
||||
internal fun AppearanceLivePreview(
|
||||
palette: BrandPalette,
|
||||
shapeScale: AppearanceShapeScale,
|
||||
restricted: Boolean = false,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalBrand provides palette,
|
||||
LocalAppearanceShapeScale provides shapeScale,
|
||||
) {
|
||||
MaterialTheme(colorScheme = palette.toColorScheme(), shapes = shapeScale.asMaterialShapes()) {
|
||||
AppearanceLivePreviewContent()
|
||||
AppearanceLivePreviewContent(restricted = restricted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppearanceLivePreviewContent() {
|
||||
private fun AppearanceLivePreviewContent(restricted: Boolean) {
|
||||
val backgroundEnabled = LocalBackgroundVisualizationEnabled.current
|
||||
val backgroundAvatar = LocalAgentAvatar.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().height(294.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(if (restricted) 258.dp else 294.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
@@ -1648,14 +1649,16 @@ private fun AppearanceLivePreviewContent() {
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_preview_tool_meta),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||
color = LocalBrand.current.green,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
if (!restricted) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_preview_tool_meta),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||
color = LocalBrand.current.green,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.padding(start = 6.dp).size(38.dp), contentAlignment = Alignment.Center) {
|
||||
@@ -1678,10 +1681,12 @@ private fun AppearanceLivePreviewContent() {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Add, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
if (!restricted) {
|
||||
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.appearance_preview_message_placeholder),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||
@@ -1691,7 +1696,7 @@ private fun AppearanceLivePreviewContent() {
|
||||
Icon(Icons.Filled.GraphicEq, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
if (!restricted) Surface(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
shape = appearanceRoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
@@ -1785,7 +1790,7 @@ private fun FontOptionRow(
|
||||
* are added.
|
||||
*/
|
||||
@Composable
|
||||
private fun ThemeSwatchChip(
|
||||
internal fun ThemeSwatchChip(
|
||||
appTheme: AppTheme,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
@@ -147,6 +148,7 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
@@ -180,6 +182,10 @@ import com.hermesandroid.relay.data.PhysicalKeyboardEnterBehavior
|
||||
import com.hermesandroid.relay.data.ProfilePresentationPolicy
|
||||
import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.SupervisedSessionAction
|
||||
import com.hermesandroid.relay.data.allowsSessionAction
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
@@ -726,8 +732,40 @@ fun ChatScreen(
|
||||
// existing test/preview call sites keep compiling.
|
||||
onNavigateToVoiceSettings: () -> Unit = {},
|
||||
onNavigateToProfileInspector: (String) -> Unit = {},
|
||||
supervisedPolicy: SupervisedModePolicy = SupervisedModePolicy(),
|
||||
onNavigateToBotMode: () -> Unit = {},
|
||||
) {
|
||||
val supervised = supervisedPolicy.enabled
|
||||
val supervisedVisibility = supervisedPolicy.visibility.resolved()
|
||||
LaunchedEffect(supervisedPolicy) {
|
||||
voiceViewModel.updateSupervisedModePolicy(supervisedPolicy)
|
||||
}
|
||||
if (supervised && !supervisedPolicy.isActive) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Supervised chat unavailable") },
|
||||
actions = {
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"The supervised profile is unavailable. Parent access is required to update this connection.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val responseSpeechActive by voiceViewModel.responseSpeechActive.collectAsState()
|
||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||
@@ -750,7 +788,6 @@ fun ChatScreen(
|
||||
LaunchedEffect(voiceUiState.voiceMode) {
|
||||
if (!voiceUiState.voiceMode) voicePresentationOverride = null
|
||||
}
|
||||
|
||||
// Route classified chat errors (media cache, streaming failures, …) to
|
||||
// the app-wide snackbar. Same pattern every VM-bound screen uses.
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
@@ -812,7 +849,22 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
|
||||
val messages by chatViewModel.messages.collectAsState()
|
||||
val rawMessages by chatViewModel.messages.collectAsState()
|
||||
val messages = remember(rawMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
|
||||
if (!supervised) rawMessages
|
||||
else rawMessages.map { message ->
|
||||
if (message.role == MessageRole.ASSISTANT) {
|
||||
message.copy(
|
||||
attachments = if (supervisedPolicy.capabilities.generatedImages) {
|
||||
message.attachments.filter { it.isImage }
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
cards = emptyList(),
|
||||
)
|
||||
} else message
|
||||
}
|
||||
}
|
||||
val messageReactionsSupported by chatViewModel.messageReactionsSupported.collectAsState()
|
||||
val newestReactableMessageKeys = remember(messages) {
|
||||
setOfNotNull(
|
||||
@@ -839,10 +891,17 @@ fun ChatScreen(
|
||||
// Stable voice can use the standard Hermes dashboard audio routes or the
|
||||
// optional Relay voice routes. Gate the mic on either route being usable;
|
||||
// availability picks the actionable toast when neither is.
|
||||
val voiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
val connectionVoiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val voiceReady = if (supervised) {
|
||||
supervisedPolicy.capabilities.voice &&
|
||||
standardVoiceAvailability ==
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Ready
|
||||
} else {
|
||||
connectionVoiceReady
|
||||
}
|
||||
val chatSpeakResponseActionsEnabled =
|
||||
shouldOfferChatSpeakAction(voiceReady, voiceUiState.state)
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val standardVoiceSignInRouteHint by
|
||||
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
|
||||
val dashboardRouteMovedHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
|
||||
@@ -964,8 +1023,15 @@ fun ChatScreen(
|
||||
?: sessionModelState.pickerModel?.let { model ->
|
||||
modelProviders.singleOrNull { model in it.models }?.slug
|
||||
}
|
||||
val showThinking by connectionViewModel.showThinking.collectAsState()
|
||||
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
||||
val configuredShowThinking by connectionViewModel.showThinking.collectAsState()
|
||||
val configuredToolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
||||
val showThinking = configuredShowThinking &&
|
||||
(!supervised || supervisedVisibility.showReasoning)
|
||||
val toolDisplay = if (!supervised) configuredToolDisplay else when {
|
||||
supervisedVisibility.showToolDetails -> "detailed"
|
||||
supervisedVisibility.showToolNames -> "compact"
|
||||
else -> "off"
|
||||
}
|
||||
val smoothAutoScroll by connectionViewModel.smoothAutoScroll.collectAsState()
|
||||
val closeDrawerOnSend by connectionViewModel.closeDrawerOnSend.collectAsState()
|
||||
val keepComposerFocusedOnSend by
|
||||
@@ -984,7 +1050,10 @@ fun ChatScreen(
|
||||
// marker so the user knows approvals are off without opening the agent drawer.
|
||||
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
|
||||
val pendingAttachments by chatViewModel.pendingAttachments.collectAsState()
|
||||
val maxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
|
||||
val configuredMaxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
|
||||
val maxAttachmentMb = if (supervised) {
|
||||
minOf(configuredMaxAttachmentMb, supervisedPolicy.capabilities.attachmentMaxFileMb)
|
||||
} else configuredMaxAttachmentMb
|
||||
val charLimit by connectionViewModel.maxMessageLength.collectAsState()
|
||||
|
||||
// === Gateway desktop-parity state ===
|
||||
@@ -993,6 +1062,9 @@ fun ChatScreen(
|
||||
val contextWindow by chatViewModel.contextWindow.collectAsState()
|
||||
// Injected-context audit sheet (opened by tapping the context meter).
|
||||
var showContextSheet by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(supervised) {
|
||||
if (supervised) showContextSheet = false
|
||||
}
|
||||
val steerableTurn by chatViewModel.steerableTurn.collectAsState()
|
||||
val steerNotice by chatViewModel.steerNotice.collectAsState()
|
||||
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
|
||||
@@ -1988,9 +2060,9 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val showAutocomplete by remember(filteredCommands, inputText) {
|
||||
val showAutocomplete by remember(filteredCommands, inputText, supervised) {
|
||||
derivedStateOf {
|
||||
inputText.startsWith("/") && filteredCommands.isNotEmpty()
|
||||
!supervised && inputText.startsWith("/") && filteredCommands.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2262,7 +2334,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
val selectedProfileKey = AgentDisplay.profileSessionKey(selectedProfile?.name)
|
||||
val profileShelfAvailable = ProfilePresentationPolicy.shouldShowShelf(
|
||||
val profileShelfAvailable = !supervised && ProfilePresentationPolicy.shouldShowShelf(
|
||||
profiles = agentProfiles,
|
||||
presentation = profilePresentation,
|
||||
selectedKey = selectedProfileKey,
|
||||
@@ -2289,7 +2361,7 @@ fun ChatScreen(
|
||||
// Material routes scrim taps through the drawer's gesture handler.
|
||||
// Keep it enabled so tapping outside always dismisses the drawer; the
|
||||
// voice overlay already owns input while voice mode is visible.
|
||||
gesturesEnabled = true,
|
||||
gesturesEnabled = !supervised || supervisedPolicy.capabilities.conversationHistory,
|
||||
drawerContent = {
|
||||
val drawerProfileName = explicitBindingProfileName ?: effectiveProfile?.name
|
||||
val drawerTitle = if (drawerProfileName != null) {
|
||||
@@ -2333,7 +2405,9 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
SessionDrawerContent(
|
||||
sessions = sessions,
|
||||
sessions = if (
|
||||
supervised && !supervisedPolicy.capabilities.conversationHistory
|
||||
) emptyList() else sessions,
|
||||
currentSessionId = currentSessionId,
|
||||
scopeTitle = drawerTitle,
|
||||
scopeSubtitle = drawerSubtitle,
|
||||
@@ -2344,14 +2418,19 @@ fun ChatScreen(
|
||||
animationEnabled = animationEnabled,
|
||||
autoTitlesSupported = serverAutoTitles,
|
||||
archiveSupported = sessionArchivingSupported,
|
||||
supervisedSessionActions = supervisedPolicy.capabilities.sessionActions
|
||||
.takeIf { supervised },
|
||||
newChatEnabled = !supervised || supervisedPolicy.capabilities.newChat,
|
||||
onRefresh = { chatViewModel.refreshSessions() },
|
||||
onOpenBotMode = {
|
||||
scope.launch { drawerState.close() }
|
||||
onNavigateToBotMode()
|
||||
},
|
||||
onNewChat = {
|
||||
chatViewModel.createNewChat()
|
||||
scope.launch { drawerState.close() }
|
||||
if (!supervised || supervisedPolicy.capabilities.newChat) {
|
||||
chatViewModel.createNewChat()
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
},
|
||||
onNewDefaultChat = {
|
||||
if (isProfileLocked) return@SessionDrawerContent
|
||||
@@ -2377,6 +2456,9 @@ fun ChatScreen(
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onDeleteSession = { sessionId ->
|
||||
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Delete)) {
|
||||
return@SessionDrawerContent
|
||||
}
|
||||
val connectionId = activeConnection?.id
|
||||
val profileId = explicitBindingProfileName ?: selectedProfile?.name
|
||||
chatViewModel.deleteSession(sessionId) {
|
||||
@@ -2390,10 +2472,21 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onRenameSession = { sessionId, title ->
|
||||
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Rename)) {
|
||||
return@SessionDrawerContent
|
||||
}
|
||||
chatViewModel.renameSession(sessionId, title)
|
||||
},
|
||||
onSetSessionPinned = chatViewModel::setSessionPinned,
|
||||
onSetSessionArchived = chatViewModel::setSessionArchived,
|
||||
onSetSessionPinned = { sessionId, pinned ->
|
||||
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Pin)) {
|
||||
chatViewModel.setSessionPinned(sessionId, pinned)
|
||||
}
|
||||
},
|
||||
onSetSessionArchived = { sessionId, archived ->
|
||||
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Archive)) {
|
||||
chatViewModel.setSessionArchived(sessionId, archived)
|
||||
}
|
||||
},
|
||||
onCopySessionId = { sessionId ->
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
@@ -2423,7 +2516,7 @@ fun ChatScreen(
|
||||
onToggleSourceHidden = { source, hidden ->
|
||||
connectionViewModel.setSourceHidden(source, hidden)
|
||||
},
|
||||
allProfilesSupported = !isProfileLocked &&
|
||||
allProfilesSupported = !supervised && !isProfileLocked &&
|
||||
!activeConnection?.resolvedDashboardUrl.isNullOrBlank(),
|
||||
allProfileSessions = allProfileSessions,
|
||||
allProfileSessionsLoading = allProfileSessionsLoading,
|
||||
@@ -2585,8 +2678,14 @@ fun ChatScreen(
|
||||
// Top bar — messaging app style with avatar, name, model subtitle
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
|
||||
if (!supervised || supervisedPolicy.capabilities.conversationHistory) {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
|
||||
}
|
||||
} else if (supervisedPolicy.capabilities.newChat) {
|
||||
IconButton(onClick = { chatViewModel.createNewChat() }) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = "New chat")
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
@@ -2618,8 +2717,10 @@ fun ChatScreen(
|
||||
// style subtitle status.
|
||||
var everConnected by remember { mutableStateOf(false) }
|
||||
if (headerChatReady) everConnected = true
|
||||
val showStreamingState = isStreaming &&
|
||||
(!supervised || supervisedVisibility.showWorkingStatus)
|
||||
val statusText = when {
|
||||
headerChatReady -> if (isStreaming) {
|
||||
headerChatReady -> if (showStreamingState) {
|
||||
stringResource(R.string.chat_streaming)
|
||||
} else {
|
||||
stringResource(R.string.chat_connected_label)
|
||||
@@ -2669,6 +2770,14 @@ fun ChatScreen(
|
||||
// personality label.
|
||||
val subtitleText = if (!headerChatReady) {
|
||||
statusText
|
||||
} else if (supervised) {
|
||||
buildList {
|
||||
if (supervisedVisibility.showProfileName) {
|
||||
conversationProfile?.name?.takeIf { it.isNotBlank() }?.let(::add)
|
||||
}
|
||||
if (supervisedVisibility.showModelName && !modelName.isNullOrBlank()) add(modelName)
|
||||
if (isEmpty() && supervisedVisibility.showConnectionStatus) add(statusText)
|
||||
}.joinToString(" · ")
|
||||
} else {
|
||||
resolveChatHeaderSubtitle(
|
||||
isStreaming = isStreaming,
|
||||
@@ -2687,7 +2796,7 @@ fun ChatScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
.clickable(enabled = !supervised) {
|
||||
if (profileShelfAvailable) {
|
||||
showProfileShelf = !showProfileShelf
|
||||
} else {
|
||||
@@ -2711,7 +2820,7 @@ fun ChatScreen(
|
||||
// Avatar — a plain 40dp circle whose letter swaps to the
|
||||
// active agent (profile or personality). No overlay ring:
|
||||
// the letter itself is the indicator.
|
||||
Box(modifier = Modifier.size(40.dp)) {
|
||||
if (!supervised || supervisedVisibility.showAgentIdentity) Box(modifier = Modifier.size(40.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
shape = CircleShape,
|
||||
@@ -2761,14 +2870,16 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
ConnectionStatusBadge(
|
||||
isConnected = headerChatReady,
|
||||
isConnecting = isConnecting,
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
size = 10.dp
|
||||
)
|
||||
if (!supervised || supervisedVisibility.showConnectionStatus) {
|
||||
ConnectionStatusBadge(
|
||||
isConnected = headerChatReady,
|
||||
isConnecting = isConnecting,
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
size = 10.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Name + single-line subtitle.
|
||||
@@ -2809,7 +2920,13 @@ fun ChatScreen(
|
||||
} else {
|
||||
Column {
|
||||
Text(
|
||||
text = if (agentDisplayName.isNotBlank()) agentDisplayName else stringResource(R.string.chat_agent_default),
|
||||
text = if (supervised && !supervisedVisibility.showAgentIdentity) {
|
||||
stringResource(R.string.screen_chat_label)
|
||||
} else if (agentDisplayName.isNotBlank()) {
|
||||
agentDisplayName
|
||||
} else {
|
||||
stringResource(R.string.chat_agent_default)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
@@ -2846,7 +2963,7 @@ fun ChatScreen(
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
if (isStreaming && animationEnabled) {
|
||||
if (showStreamingState && animationEnabled) {
|
||||
StreamingDots(
|
||||
color = subtitleColor,
|
||||
modifier = Modifier.clearAndSetSemantics { },
|
||||
@@ -2867,7 +2984,7 @@ fun ChatScreen(
|
||||
// full explanation (global mode / --yolo / per-session)
|
||||
// lives. Keeps the risk visible without eating subtitle
|
||||
// width on every turn.
|
||||
if (yoloEnabled == true) {
|
||||
if (!supervised && yoloEnabled == true) {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Bolt,
|
||||
contentDescription = stringResource(R.string.cd_approvals_off),
|
||||
@@ -2885,12 +3002,14 @@ fun ChatScreen(
|
||||
// tappable → Connections, so the affordance moved with the
|
||||
// info. Dropping it here declutters the actions row and frees
|
||||
// width for the title subtitle.)
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = stringResource(R.string.cd_terminal),
|
||||
onClick = onNavigateToTerminal,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
if (!supervised) {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = stringResource(R.string.cd_terminal),
|
||||
onClick = onNavigateToTerminal,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Tune,
|
||||
contentDescription = stringResource(R.string.cd_settings),
|
||||
@@ -2903,7 +3022,11 @@ fun ChatScreen(
|
||||
// Settings — which is what was squeezing the title subtitle.
|
||||
// Session identity is useful before the first message; sharing only appears
|
||||
// once the conversation has content.
|
||||
if (messages.isNotEmpty() || !currentSessionId.isNullOrBlank()) {
|
||||
if (
|
||||
(!supervised && (messages.isNotEmpty() || !currentSessionId.isNullOrBlank())) ||
|
||||
(supervised && messages.isNotEmpty() &&
|
||||
supervisedPolicy.allowsSessionAction(SupervisedSessionAction.ShareTranscript))
|
||||
) {
|
||||
var showOverflowMenu by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
RelayChromeIconButton(
|
||||
@@ -2916,7 +3039,7 @@ fun ChatScreen(
|
||||
expanded = showOverflowMenu,
|
||||
onDismissRequest = { showOverflowMenu = false },
|
||||
) {
|
||||
currentSessionId?.takeIf { it.isNotBlank() }?.let { sessionId ->
|
||||
currentSessionId?.takeIf { !supervised && it.isNotBlank() }?.let { sessionId ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(copySessionIdLabel) },
|
||||
leadingIcon = {
|
||||
@@ -2941,7 +3064,7 @@ fun ChatScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (messages.isNotEmpty()) {
|
||||
if (!supervised && messages.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_search_conversation)) },
|
||||
leadingIcon = {
|
||||
@@ -2965,6 +3088,22 @@ fun ChatScreen(
|
||||
shareConversation(context, messages)
|
||||
},
|
||||
)
|
||||
} else if (
|
||||
messages.isNotEmpty() &&
|
||||
supervisedPolicy.allowsSessionAction(
|
||||
SupervisedSessionAction.ShareTranscript,
|
||||
)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_share_conversation)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Share, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
showOverflowMenu = false
|
||||
shareConversation(context, messages)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3006,13 +3145,15 @@ fun ChatScreen(
|
||||
// and the mode strip — slim bar + `NN% · used/max` token readout,
|
||||
// color-graded by fullness. Composes to nothing until the server
|
||||
// reports a context_max for the session.
|
||||
ContextMeterBar(
|
||||
usedFraction = contextUsage,
|
||||
usedTokens = contextWindow?.usedTokens,
|
||||
maxTokens = contextWindow?.maxTokens,
|
||||
onClick = { showContextSheet = true },
|
||||
)
|
||||
if (showContextSheet) {
|
||||
if (!supervised || supervisedVisibility.showUsage) {
|
||||
ContextMeterBar(
|
||||
usedFraction = contextUsage,
|
||||
usedTokens = contextWindow?.usedTokens,
|
||||
maxTokens = contextWindow?.maxTokens,
|
||||
onClick = if (supervised) null else ({ showContextSheet = true }),
|
||||
)
|
||||
}
|
||||
if (!supervised && showContextSheet) {
|
||||
// Live audit of the exact extra context the agent will be
|
||||
// injected with on the next turn (transparency / auditability).
|
||||
InjectedContextSheet(
|
||||
@@ -3073,7 +3214,31 @@ fun ChatScreen(
|
||||
},
|
||||
label = "chatEmptyStatePhaseTransition",
|
||||
) { targetConnectState ->
|
||||
if (targetConnectState == ChatConnectState.Connecting) {
|
||||
if (supervised && targetConnectState != ChatConnectState.Ready) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (supervisedVisibility.showConnectionStatus) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (targetConnectState == ChatConnectState.Connecting) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
Text(
|
||||
text = if (targetConnectState == ChatConnectState.Connecting) {
|
||||
stringResource(R.string.chat_connecting_dots)
|
||||
} else {
|
||||
stringResource(R.string.chat_disconnected_label)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (targetConnectState == ChatConnectState.Connecting) {
|
||||
ChatColdStartLoadingState(
|
||||
animationEnabled = animationEnabled,
|
||||
streamingIntensity = streamingIntensity,
|
||||
@@ -3113,7 +3278,10 @@ fun ChatScreen(
|
||||
Spacer(modifier = Modifier.weight(0.15f))
|
||||
|
||||
// ASCII sphere (constrained to square aspect)
|
||||
if (LocalBackgroundVisualizationEnabled.current) {
|
||||
if (
|
||||
LocalBackgroundVisualizationEnabled.current &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -3141,7 +3309,10 @@ fun ChatScreen(
|
||||
// thread itself (not just the header) -
|
||||
// the desktop's intro.
|
||||
ChatConnectState.Ready ->
|
||||
if (effectiveProfile != null) {
|
||||
if (
|
||||
effectiveProfile != null &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
|
||||
} else {
|
||||
stringResource(R.string.chat_start_conversation)
|
||||
@@ -3159,7 +3330,11 @@ fun ChatScreen(
|
||||
val profileBlurb = effectiveProfile?.description
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals(agentDisplayName, ignoreCase = true) }
|
||||
if (targetConnectState == ChatConnectState.Ready && profileBlurb != null) {
|
||||
if (
|
||||
targetConnectState == ChatConnectState.Ready &&
|
||||
profileBlurb != null &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = profileBlurb,
|
||||
@@ -3276,6 +3451,7 @@ fun ChatScreen(
|
||||
// Ambient avatar behind messages
|
||||
if (
|
||||
LocalBackgroundVisualizationEnabled.current &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity) &&
|
||||
animationBehindChat &&
|
||||
!ambientMode
|
||||
) {
|
||||
@@ -3297,8 +3473,13 @@ fun ChatScreen(
|
||||
// /media/by-path route when a relay session is paired,
|
||||
// instead of degrading to the "image is on the server"
|
||||
// notice. Null when no relay (standard no-plugin) → notice.
|
||||
val relayServerImageResolver = remember(chatViewModel) {
|
||||
RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
|
||||
val relayServerImageResolver = remember(
|
||||
chatViewModel,
|
||||
supervised,
|
||||
supervisedPolicy.capabilities.generatedImages,
|
||||
) {
|
||||
if (supervised && !supervisedPolicy.capabilities.generatedImages) null
|
||||
else RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
|
||||
}
|
||||
val thinkingIndicatorConfig = remember(
|
||||
thinkingIndicatorStyle,
|
||||
@@ -3353,6 +3534,7 @@ fun ChatScreen(
|
||||
items(messages.size, key = { messages[it].uiKey }) { index ->
|
||||
val message = messages[index]
|
||||
val processNotification = message.hermesProcessNotificationOrNull()
|
||||
?.takeIf { !supervised || supervisedVisibility.showToolNames }
|
||||
|
||||
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
|
||||
// no attachments). Attachments keep the bubble alive for inbound media;
|
||||
@@ -3377,7 +3559,10 @@ fun ChatScreen(
|
||||
messages[index + 1].timestamp - message.timestamp > GROUP_GAP_MS
|
||||
|
||||
// Date separator
|
||||
if (index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp)) {
|
||||
if (
|
||||
(!supervised || supervisedVisibility.showTimestamps) &&
|
||||
(index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp))
|
||||
) {
|
||||
DateSeparator(timestamp = message.timestamp)
|
||||
}
|
||||
|
||||
@@ -3389,7 +3574,9 @@ fun ChatScreen(
|
||||
message.attachments.isNotEmpty() ||
|
||||
message.cards.isNotEmpty()
|
||||
|
||||
message.backgroundTask?.let { task ->
|
||||
message.backgroundTask
|
||||
?.takeIf { !supervised || supervisedVisibility.showWorkingStatus }
|
||||
?.let { task ->
|
||||
val taskModifier = Modifier.padding(
|
||||
top = if (isFirstInGroup) 6.dp else 2.dp,
|
||||
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
|
||||
@@ -3447,6 +3634,14 @@ fun ChatScreen(
|
||||
},
|
||||
maxBubbleWidth = maxBubbleWidth,
|
||||
showThinking = showThinking,
|
||||
showAgentIdentity = !supervised || supervisedVisibility.showAgentIdentity,
|
||||
showTimestamps = !supervised || supervisedVisibility.showTimestamps,
|
||||
showWorkingStatus = !supervised || supervisedVisibility.showWorkingStatus,
|
||||
showUsage = !supervised || supervisedVisibility.showUsage,
|
||||
showTechnicalBadges = !supervised || supervisedVisibility.showTechnicalRoute,
|
||||
showAssistantImages = !supervised || supervisedPolicy.capabilities.generatedImages,
|
||||
allowAssistantImageExport = !supervised ||
|
||||
supervisedPolicy.capabilities.shareGeneratedImages,
|
||||
isFirstInGroup = isFirstInGroup,
|
||||
isLastInGroup = isLastInGroup,
|
||||
recoveringAnswer = recoveringAnswer,
|
||||
@@ -3459,9 +3654,9 @@ fun ChatScreen(
|
||||
onAttachmentManualFetch = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onCardAction = handleCardAction,
|
||||
onCardInput = handleCardInput,
|
||||
onSessionReference = { reference ->
|
||||
onCardAction = if (supervised) ({ _, _, _ -> }) else handleCardAction,
|
||||
onCardInput = if (supervised) ({ _, _, _ -> }) else handleCardInput,
|
||||
onSessionReference = if (supervised) null else { reference ->
|
||||
val target = agentProfiles.firstOrNull {
|
||||
it.name.equals(reference.profile, ignoreCase = true)
|
||||
}
|
||||
@@ -3479,6 +3674,7 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onReact = if (
|
||||
!supervised &&
|
||||
isGatewayTransport &&
|
||||
messageReactionsSupported &&
|
||||
!message.isStreaming &&
|
||||
@@ -3492,6 +3688,7 @@ fun ChatScreen(
|
||||
null
|
||||
},
|
||||
onEditMessage = if (
|
||||
(!supervised || supervisedPolicy.capabilities.editAndResend) &&
|
||||
isGatewayTransport &&
|
||||
!isStreaming &&
|
||||
message.role == MessageRole.USER &&
|
||||
@@ -3510,10 +3707,14 @@ fun ChatScreen(
|
||||
null
|
||||
},
|
||||
animationEnabled = animationEnabled,
|
||||
onQuoteMessage = { quoted ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
quotedMessage = quoted
|
||||
},
|
||||
onQuoteMessage = if (
|
||||
!supervised || supervisedPolicy.capabilities.quoteReplies
|
||||
) {
|
||||
{ quoted ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
quotedMessage = quoted
|
||||
}
|
||||
} else null,
|
||||
onNavigateToMessage = { messageId ->
|
||||
val targetIndex = messages.indexOfFirst { it.id == messageId }
|
||||
if (targetIndex >= 0) {
|
||||
@@ -3524,7 +3725,10 @@ fun ChatScreen(
|
||||
scope.launch { listState.animateScrollToItem(targetIndex + 1) }
|
||||
}
|
||||
},
|
||||
onSpeakMessage = if (chatSpeakResponseActionsEnabled) {
|
||||
onSpeakMessage = if (
|
||||
chatSpeakResponseActionsEnabled &&
|
||||
(!supervised || supervisedPolicy.capabilities.voice)
|
||||
) {
|
||||
{ text -> voiceViewModel.speakResponse(text) }
|
||||
} else {
|
||||
null
|
||||
@@ -3535,6 +3739,9 @@ fun ChatScreen(
|
||||
null
|
||||
},
|
||||
onCopyMessage = { text ->
|
||||
if (supervised && !supervisedPolicy.capabilities.copyResponses) {
|
||||
return@MessageBubble
|
||||
}
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
// The new Clipboard API is suspend-based, so the
|
||||
// setClipEntry call has to live inside a coroutine.
|
||||
@@ -4003,7 +4210,8 @@ fun ChatScreen(
|
||||
// Gateway redirect is text-only. Attachment-bearing follow-ups must
|
||||
// retain their files in the session-owned queue instead of showing
|
||||
// a correction action that cannot carry them.
|
||||
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty()
|
||||
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty() &&
|
||||
(!supervised || supervisedPolicy.capabilities.steerResponse)
|
||||
val trailing = when {
|
||||
!isStreaming && hasContent -> ChatInputTrailing.SEND
|
||||
!isStreaming -> ChatInputTrailing.VOICE
|
||||
@@ -4136,7 +4344,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val modelControl = modelPickerOptions.takeIf { it.isNotEmpty() }?.let {
|
||||
val modelControl = modelPickerOptions.takeIf { !supervised && it.isNotEmpty() }?.let {
|
||||
ChatInputPickerControl(
|
||||
value = compactModelChipLabel(currentModelForInput, modelDefaultLabel),
|
||||
contentDescription = stringResource(R.string.cd_select_model),
|
||||
@@ -4187,6 +4395,7 @@ fun ChatScreen(
|
||||
// is definitively unreachable (SSE-only) — the agent sheet carries the
|
||||
// disabled-with-reason version there.
|
||||
val effortControl = if (
|
||||
!supervised &&
|
||||
chatGatewayAvailability != GatewayAvailability.Unreachable &&
|
||||
effortAvailability.supported != false &&
|
||||
effortPickerOptions.isNotEmpty()
|
||||
@@ -4203,7 +4412,13 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
visibleChatFailure?.let { failure ->
|
||||
val failureRouteLabel = when (failure.route) {
|
||||
val displayFailure = if (!supervised) failure else failure.copy(
|
||||
model = failure.model.takeIf { supervisedVisibility.showModelName },
|
||||
provider = failure.provider.takeIf { supervisedVisibility.showTechnicalRoute },
|
||||
)
|
||||
val failureRouteLabel = if (
|
||||
supervised && !supervisedVisibility.showTechnicalRoute
|
||||
) "" else when (failure.route) {
|
||||
ChatFailureRoute.GATEWAY ->
|
||||
stringResource(R.string.chat_failure_route_gateway)
|
||||
ChatFailureRoute.API_FALLBACK ->
|
||||
@@ -4211,23 +4426,28 @@ fun ChatScreen(
|
||||
null -> ""
|
||||
}
|
||||
ChatFailurePanel(
|
||||
failure = failure,
|
||||
failure = displayFailure,
|
||||
routeLabel = failureRouteLabel,
|
||||
onDetails = { showChatFailureDetails = true },
|
||||
onRetry = { chatViewModel.retryLastMessage() },
|
||||
onRetry = {
|
||||
if (!supervised || supervisedPolicy.capabilities.retryResponse) {
|
||||
chatViewModel.retryLastMessage()
|
||||
}
|
||||
},
|
||||
onDismiss = chatViewModel::dismissChatFailure,
|
||||
showDetails = !supervised || supervisedVisibility.showTechnicalRoute,
|
||||
)
|
||||
if (showChatFailureDetails) {
|
||||
ChatFailureDetailsDialog(
|
||||
failure = failure,
|
||||
failure = displayFailure,
|
||||
routeLabel = failureRouteLabel,
|
||||
onCopy = {
|
||||
val details = buildString {
|
||||
append(failureRouteLabel)
|
||||
failure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
failure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
displayFailure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
displayFailure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
append("\n\n")
|
||||
append(failure.rawError)
|
||||
append(displayFailure.rawError)
|
||||
}
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
@@ -4309,6 +4529,9 @@ fun ChatScreen(
|
||||
)
|
||||
},
|
||||
onStop = {
|
||||
if (supervised && !supervisedPolicy.capabilities.cancelResponse) {
|
||||
return@ChatInputBar
|
||||
}
|
||||
chatViewModel.cancelStream()
|
||||
// Firm haptic (LongPress — TextHandleMove was near-
|
||||
// imperceptible) plus a "Stopped" badge stamped on the turn
|
||||
@@ -4324,14 +4547,44 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onAttachPhotos = {
|
||||
photoPickerLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
val allowed = !supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Images in
|
||||
supervisedPolicy.capabilities.attachmentCategories &&
|
||||
pendingAttachments.size < supervisedPolicy.capabilities.attachmentMaxCount
|
||||
)
|
||||
if (allowed) {
|
||||
photoPickerLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
}
|
||||
},
|
||||
onAttachFiles = { filePickerLauncher.launch(arrayOf("*/*")) },
|
||||
onAttachCamera = requestCameraCapture,
|
||||
onPasteImage = pasteImageFromClipboard,
|
||||
onLongPressAttach = { showCommandPalette = true },
|
||||
onAttachFiles = {
|
||||
if (!supervised || supervisedPolicy.capabilities.attachments) {
|
||||
val mimeTypes = if (!supervised) arrayOf("*/*") else buildList {
|
||||
val categories = supervisedPolicy.capabilities.attachmentCategories
|
||||
if (SupervisedAttachmentCategory.Images in categories) add("image/*")
|
||||
if (SupervisedAttachmentCategory.Audio in categories) add("audio/*")
|
||||
if (SupervisedAttachmentCategory.Video in categories) add("video/*")
|
||||
if (SupervisedAttachmentCategory.Documents in categories) {
|
||||
add("text/*")
|
||||
add("application/pdf")
|
||||
}
|
||||
}.toTypedArray()
|
||||
if (mimeTypes.isNotEmpty()) filePickerLauncher.launch(mimeTypes)
|
||||
}
|
||||
},
|
||||
onAttachCamera = if (!supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Images in
|
||||
supervisedPolicy.capabilities.attachmentCategories
|
||||
)) requestCameraCapture else ({ }),
|
||||
onPasteImage = if (!supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Images in
|
||||
supervisedPolicy.capabilities.attachmentCategories
|
||||
)) pasteImageFromClipboard else ({ }),
|
||||
onLongPressAttach = { if (!supervised) showCommandPalette = true },
|
||||
charLimit = charLimit,
|
||||
caption = turnStatus ?: inputCaption,
|
||||
voiceReady = voiceReady,
|
||||
@@ -4343,8 +4596,13 @@ fun ChatScreen(
|
||||
submitEnabled = pendingAttachments.none {
|
||||
it.state == com.hermesandroid.relay.data.AttachmentState.LOADING
|
||||
},
|
||||
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS
|
||||
.takeIf { convertLargePastesToAttachments },
|
||||
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS.takeIf {
|
||||
convertLargePastesToAttachments && (!supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Documents in
|
||||
supervisedPolicy.capabilities.attachmentCategories
|
||||
))
|
||||
},
|
||||
onLargePaste = { pastedText ->
|
||||
val owner = activeComposerDraftKey ?: composerDraftKey
|
||||
val sizeBytes = pastedText.toByteArray(Charsets.UTF_8).size.toLong()
|
||||
@@ -4662,7 +4920,7 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
// Command palette bottom sheet
|
||||
if (showCommandPalette) {
|
||||
if (showCommandPalette && !supervised) {
|
||||
CommandPalette(
|
||||
commands = allCommands,
|
||||
onSelect = { cmd ->
|
||||
@@ -4690,7 +4948,7 @@ fun ChatScreen(
|
||||
// personality, connection summary). Replaces the old AlertDialog and the
|
||||
// two top-bar chips (ProfilePicker + PersonalityPicker). Tap target is
|
||||
// the title Row in the TopAppBar above.
|
||||
if (showAgentInfo) {
|
||||
if (showAgentInfo && !supervised) {
|
||||
AgentInfoSheet(
|
||||
connectionViewModel = connectionViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
|
||||
@@ -101,6 +101,7 @@ import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.ProviderUsageLandingMode
|
||||
import com.hermesandroid.relay.data.ProviderUsagePreferences
|
||||
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
@@ -157,6 +158,13 @@ fun SettingsScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
/** Header back affordance — Settings is a pushed destination, not a tab. */
|
||||
onBack: (() -> Unit)? = null,
|
||||
supervisedPolicy: SupervisedModePolicy? = null,
|
||||
parentAccessUnlocked: Boolean = false,
|
||||
/** Called only after the restricted surface completes device authentication. */
|
||||
onRequestParentAccess: () -> Unit = {},
|
||||
onUpdateSupervisedPolicy: (SupervisedModePolicy) -> Unit = {},
|
||||
onNavigateToAdvancedSettings: () -> Unit = {},
|
||||
onNavigateToSupervisedAppearance: () -> Unit = {},
|
||||
// Needed by the Active Agent summary card at the top of the screen — it
|
||||
// reads the current personality pick so the subtitle can render
|
||||
// `connection · model · personality` without re-reading ChatViewModel
|
||||
@@ -206,6 +214,21 @@ fun SettingsScreen(
|
||||
// discoverable before a pair-and-pick happens.
|
||||
onNavigateToProfileInspector: (profileName: String) -> Unit,
|
||||
) {
|
||||
// Keep the restricted root when an enabled policy becomes temporarily
|
||||
// unusable (for example, its profile was renamed). Parent authentication,
|
||||
// not a configuration error, is what unlocks the full settings surface.
|
||||
if (supervisedPolicy?.enabled == true && !parentAccessUnlocked) {
|
||||
SupervisedSettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
policy = supervisedPolicy,
|
||||
onPolicyChange = onUpdateSupervisedPolicy,
|
||||
onBack = onBack,
|
||||
onNavigateToAppearance = onNavigateToSupervisedAppearance,
|
||||
onParentAccessGranted = onRequestParentAccess,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
@@ -497,6 +520,7 @@ fun SettingsScreen(
|
||||
modifier = Modifier.settingsPetSurface("settings-card:profile-lock"),
|
||||
)
|
||||
|
||||
|
||||
// ── Quick Controls ─────────────────────────────────────────
|
||||
// The switches flipped most often, pinned to the top-level Settings
|
||||
// landing instead of buried in a sub-screen. Persistent connection is
|
||||
@@ -666,6 +690,24 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.settings_advanced),
|
||||
subtitle = when {
|
||||
supervisedPolicy?.isActive == true -> "On · ${supervisedPolicy.pinnedProfileName}"
|
||||
supervisedPolicy?.isConfigured == true -> "Ready · ${supervisedPolicy.pinnedProfileName}"
|
||||
else -> stringResource(R.string.settings_advanced_desc)
|
||||
},
|
||||
badge = supervisedPolicy?.takeIf { it.isActive }?.let {
|
||||
SettingsStatusPillModel(
|
||||
label = "On",
|
||||
tone = SettingsStatusTone.Good,
|
||||
)
|
||||
},
|
||||
onClick = onNavigateToAdvancedSettings,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Analytics,
|
||||
title = stringResource(R.string.settings_analytics),
|
||||
@@ -1280,12 +1322,12 @@ private fun ProfileLockOptionRow(
|
||||
}
|
||||
}
|
||||
|
||||
private data class SettingsStatusPillModel(
|
||||
internal data class SettingsStatusPillModel(
|
||||
val label: String,
|
||||
val tone: SettingsStatusTone = SettingsStatusTone.Neutral,
|
||||
)
|
||||
|
||||
private enum class SettingsStatusTone {
|
||||
internal enum class SettingsStatusTone {
|
||||
Neutral,
|
||||
Good,
|
||||
Info,
|
||||
@@ -1483,18 +1525,22 @@ private fun SettingsSectionHeader(
|
||||
* mega-SettingsScreen.
|
||||
*/
|
||||
@Composable
|
||||
private fun SettingsCategoryRow(
|
||||
internal fun SettingsCategoryRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
badge: SettingsStatusPillModel? = null,
|
||||
petPerchKey: String = title,
|
||||
petPerchKey: String? = title,
|
||||
) {
|
||||
val surfaceModifier = if (petPerchKey != null) {
|
||||
Modifier.settingsPetSurface("settings-category:$petPerchKey")
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.settingsPetSurface("settings-category:$petPerchKey")
|
||||
modifier = surfaceModifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(
|
||||
shape = appearanceRoundedCornerShape(12.dp),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,10 @@ import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.SupervisedSessionAction
|
||||
import com.hermesandroid.relay.data.allowsSessionAction
|
||||
import com.hermesandroid.relay.data.ToolCallEvent
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
@@ -274,6 +278,24 @@ internal fun voiceTurnTransportRejection(
|
||||
}
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
/**
|
||||
* Active Android-only supervision policy. RelayApp replaces this snapshot
|
||||
* whenever the active connection changes. Enforcement belongs here as well
|
||||
* as in Compose so alternate UI entry points cannot bypass the restrictions.
|
||||
*/
|
||||
@Volatile
|
||||
private var supervisedModePolicy: SupervisedModePolicy = SupervisedModePolicy()
|
||||
|
||||
fun updateSupervisedModePolicy(policy: SupervisedModePolicy) {
|
||||
supervisedModePolicy = policy
|
||||
if (policy.enabled) {
|
||||
_pendingAttachments.update { attachments ->
|
||||
attachments.filterIndexed { index, attachment ->
|
||||
isAttachmentAllowedBySupervision(attachment, index)
|
||||
}.take(policy.capabilities.attachmentMaxCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var apiClient: HermesApiClient? = null
|
||||
private var chatHandler: ChatHandler? = null
|
||||
@@ -396,6 +418,7 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
private var firstTokenNotified = false
|
||||
private var toolHistoryJob: Job? = null
|
||||
private var gatewayComposerSettlementJob: Job? = null
|
||||
private var backgroundProcessSessionJob: Job? = null
|
||||
private var connectionSwitchJob: Job? = null
|
||||
private var sessionRefreshJob: Job? = null
|
||||
@@ -666,7 +689,10 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun addAttachment(attachment: Attachment) {
|
||||
_pendingAttachments.update { it + attachment }
|
||||
_pendingAttachments.update { current ->
|
||||
if (!isAttachmentAllowedBySupervision(attachment, current.size)) current
|
||||
else current + attachment
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAttachment(index: Int) {
|
||||
@@ -676,11 +702,20 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun replacePendingAttachments(attachments: List<Attachment>) {
|
||||
_pendingAttachments.value = attachments.toList()
|
||||
val policy = supervisedModePolicy
|
||||
_pendingAttachments.value = if (!policy.enabled) {
|
||||
attachments.toList()
|
||||
} else {
|
||||
attachments.filter { isAttachmentAllowedBySupervision(it, 0) }
|
||||
.take(policy.capabilities.attachmentMaxCount)
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceAttachment(composerId: String, attachment: Attachment) {
|
||||
_pendingAttachments.update { attachments ->
|
||||
if (!isAttachmentAllowedBySupervision(attachment, (attachments.size - 1).coerceAtLeast(0))) {
|
||||
return@update attachments.filterNot { it.composerId == composerId }
|
||||
}
|
||||
var replaced = false
|
||||
val updated = attachments.map { current ->
|
||||
if (current.composerId == composerId) {
|
||||
@@ -710,6 +745,23 @@ class ChatViewModel : ViewModel() {
|
||||
_pendingAttachments.value = emptyList()
|
||||
}
|
||||
|
||||
private fun isAttachmentAllowedBySupervision(attachment: Attachment, existingCount: Int): Boolean {
|
||||
val policy = supervisedModePolicy
|
||||
if (!policy.enabled) return true
|
||||
val capabilities = policy.capabilities
|
||||
if (!policy.isActive || !capabilities.attachments) return false
|
||||
if (existingCount >= capabilities.attachmentMaxCount) return false
|
||||
val maxBytes = capabilities.attachmentMaxFileMb.toLong() * 1024L * 1024L
|
||||
if ((attachment.fileSize ?: 0L) > maxBytes) return false
|
||||
val category = when {
|
||||
attachment.contentType.startsWith("image/") -> SupervisedAttachmentCategory.Images
|
||||
attachment.contentType.startsWith("audio/") -> SupervisedAttachmentCategory.Audio
|
||||
attachment.contentType.startsWith("video/") -> SupervisedAttachmentCategory.Video
|
||||
else -> SupervisedAttachmentCategory.Documents
|
||||
}
|
||||
return category in capabilities.attachmentCategories
|
||||
}
|
||||
|
||||
// Server-side personality selection
|
||||
private val _selectedPersonality = MutableStateFlow("default")
|
||||
val selectedPersonality: StateFlow<String> = _selectedPersonality.asStateFlow()
|
||||
@@ -3403,6 +3455,8 @@ class ChatViewModel : ViewModel() {
|
||||
if (this.chatHandler !== chatHandler) {
|
||||
checkpointStatusJob?.cancel()
|
||||
checkpointStatusJob = null
|
||||
gatewayComposerSettlementJob?.cancel()
|
||||
gatewayComposerSettlementJob = null
|
||||
}
|
||||
this.chatHandler = chatHandler
|
||||
ensureCheckpointObservers()
|
||||
@@ -3454,6 +3508,26 @@ class ChatViewModel : ViewModel() {
|
||||
scheduleCheckpointWrite()
|
||||
}
|
||||
}
|
||||
gatewayComposerSettlementJob?.cancel()
|
||||
gatewayComposerSettlementJob = viewModelScope.launch {
|
||||
chatHandler.messages.collect { messages ->
|
||||
val storedSessionId = chatHandler.currentSessionId.value ?: return@collect
|
||||
val client = gatewayClient ?: return@collect
|
||||
if (
|
||||
streamingEndpoint == "gateway" &&
|
||||
chatHandler.isStreaming.value &&
|
||||
messages.none { it.isStreaming || it.isThinkingStreaming } &&
|
||||
!client.hasActiveTurnForSession(storedSessionId)
|
||||
) {
|
||||
// A terminal bubble with no matching live or detached
|
||||
// Gateway owner is an orphaned handler-wide busy bit. Clear
|
||||
// it without disturbing a different session's active turn.
|
||||
chatHandler.clearStreamingStatus()
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4087,6 +4161,7 @@ class ChatViewModel : ViewModel() {
|
||||
onReady: ((String?) -> Unit)? = null,
|
||||
onFailure: (() -> Unit)? = null,
|
||||
) {
|
||||
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.newChat) return
|
||||
val handler = chatHandler ?: return
|
||||
recordPreResetEvidence(handler, "new_chat")
|
||||
clearOpenedSessionOwner()
|
||||
@@ -4419,6 +4494,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun deleteSession(sessionId: String, onDeleted: () -> Unit = {}) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Delete)) return
|
||||
val handler = chatHandler ?: return
|
||||
val client = apiClient
|
||||
if (streamingEndpoint != "gateway" && client == null) return
|
||||
@@ -4483,6 +4559,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun renameSession(sessionId: String, newTitle: String) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Rename)) return
|
||||
val handler = chatHandler ?: return
|
||||
val client = apiClient
|
||||
if (streamingEndpoint != "gateway" && client == null) return
|
||||
@@ -4527,6 +4604,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun setSessionPinned(sessionId: String, pinned: Boolean) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Pin)) return
|
||||
val expectedContextKey = activeProfileContextKey
|
||||
val profileName = currentSessionProfileName()
|
||||
mutateSessionFlag(
|
||||
@@ -4545,6 +4623,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun setSessionArchived(sessionId: String, archived: Boolean) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Archive)) return
|
||||
if (!_sessionArchivingSupported.value) {
|
||||
emitError(
|
||||
UnsupportedOperationException("Archive and restore require Dashboard sessions"),
|
||||
@@ -4613,6 +4692,22 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
if (text.isBlank()) return
|
||||
supervisedMessageBlockReason(supervisedModePolicy, text)?.let { reason ->
|
||||
chatHandler?.addSystemNotice(reason)
|
||||
return
|
||||
}
|
||||
if (supervisedModePolicy.enabled) {
|
||||
val attachments = _pendingAttachments.value
|
||||
if (attachments.any { attachment ->
|
||||
!isAttachmentAllowedBySupervision(attachment, attachments.indexOf(attachment))
|
||||
}
|
||||
) {
|
||||
chatHandler?.addSystemNotice(
|
||||
"One or more attachments are unavailable under the supervised policy.",
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
recordRecentPrompt(text)
|
||||
|
||||
// Demo / Explore mode: there is no server, but a silently dead Send
|
||||
@@ -4881,6 +4976,10 @@ class ChatViewModel : ViewModel() {
|
||||
action: com.hermesandroid.relay.data.HermesCardAction,
|
||||
) {
|
||||
val handler = chatHandler ?: return
|
||||
if (supervisedModePolicy.enabled) {
|
||||
handler.addSystemNotice("This action is unavailable in supervised mode.")
|
||||
return
|
||||
}
|
||||
// Ask answers route straight to the gateway respond RPCs —
|
||||
// answerAsk records its own (sanitized) dispatch stamp, so don't
|
||||
// double-stamp here.
|
||||
@@ -4919,6 +5018,10 @@ class ChatViewModel : ViewModel() {
|
||||
ask: GatewayAsk,
|
||||
restored: ChatTurnAskCheckpoint? = null,
|
||||
) {
|
||||
if (supervisedModePolicy.enabled) {
|
||||
denySupervisedInteraction(handler, ask)
|
||||
return
|
||||
}
|
||||
val sessionId = handler.currentSessionId.value
|
||||
val contextKey = activeProfileContextKey
|
||||
val existing = _pendingAsk.value
|
||||
@@ -5047,6 +5150,33 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId?.let { maybeNotifyInteraction(it, ask) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervised Chat never exposes approval, clarification, sudo, or secret
|
||||
* inputs. Settle the upstream interaction immediately with its safest
|
||||
* negative/empty response; if that cannot be confirmed, interrupt the turn
|
||||
* so a hidden card cannot leave the session waiting indefinitely.
|
||||
*/
|
||||
private fun denySupervisedInteraction(handler: ChatHandler, ask: GatewayAsk) {
|
||||
val gateway = gatewayClient
|
||||
if (gateway == null) {
|
||||
handler.addSystemNotice("An interactive request was blocked by supervised mode.")
|
||||
cancelStream()
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val response: Result<GatewayAskResponse>? = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> gateway.respondApproval(choice = "deny")
|
||||
GatewayAsk.Kind.CLARIFY -> ask.requestId?.let {
|
||||
gateway.respondClarify(it, "This supervised client cannot answer interactive requests.")
|
||||
}
|
||||
GatewayAsk.Kind.SUDO -> ask.requestId?.let { gateway.respondSudo(it, "") }
|
||||
GatewayAsk.Kind.SECRET -> ask.requestId?.let { gateway.respondSecret(it, "") }
|
||||
}
|
||||
handler.addSystemNotice("An interactive request was denied by supervised mode.")
|
||||
if (response == null || response.isFailure) cancelStream()
|
||||
}
|
||||
}
|
||||
|
||||
/** Render only upstream-supported approval values; old servers retain Approve/Deny. */
|
||||
private fun approvalActions(ask: GatewayAsk): List<HermesCardAction> {
|
||||
val advertised = ask.choices.orEmpty()
|
||||
@@ -8852,6 +8982,9 @@ class ChatViewModel : ViewModel() {
|
||||
* so we shouldn't see duplicate calls here.
|
||||
*/
|
||||
fun onMediaAttachmentRequested(messageId: String, token: String) {
|
||||
if (supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return
|
||||
val handler = chatHandler ?: return
|
||||
val relay = relayHttpClient
|
||||
val repo = mediaSettingsRepo
|
||||
@@ -8909,6 +9042,9 @@ class ChatViewModel : ViewModel() {
|
||||
* token and uses [RelayHttpClient.fetchMedia].
|
||||
*/
|
||||
fun manualFetchAttachment(messageId: String, attachmentIndex: Int) {
|
||||
if (supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return
|
||||
val handler = chatHandler ?: return
|
||||
val relay = relayHttpClient ?: return
|
||||
val repo = mediaSettingsRepo ?: return
|
||||
@@ -8982,6 +9118,9 @@ class ChatViewModel : ViewModel() {
|
||||
* it into the markdown-image renderer, which previously ignored the relay.
|
||||
*/
|
||||
suspend fun resolveServerImage(serverPath: String): ServerImageResult {
|
||||
if (supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return ServerImageResult.Failure("Generated images are disabled in supervised mode")
|
||||
val relay = relayHttpClient
|
||||
?: return ServerImageResult.Failure("Relay not configured on this connection")
|
||||
// fetchMediaByPath returns Result<MediaBytes>; fold it ONCE, right here,
|
||||
@@ -9024,6 +9163,11 @@ class ChatViewModel : ViewModel() {
|
||||
expectedRole: MessageRole,
|
||||
unavailableMessage: String,
|
||||
) {
|
||||
if (
|
||||
expectedRole == MessageRole.ASSISTANT &&
|
||||
supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return
|
||||
val handler = chatHandler ?: return
|
||||
val relay = relayHttpClient
|
||||
val repo = mediaSettingsRepo
|
||||
|
||||
@@ -2749,6 +2749,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
private fun installAuthManager(am: AuthManager) {
|
||||
am.setActiveEndpointProvider { connectionManager.activeRelayEndpoint.value }
|
||||
am.setSupervisedMetadataReconnectFallback {
|
||||
connectionManager.reconnectForAuthenticatedMetadataUpdate()
|
||||
}
|
||||
authManager = am
|
||||
// Push into the flow so the flatMapLatest chains on authState /
|
||||
// pairingCode / currentPairedSession repoint to the new manager.
|
||||
@@ -3591,6 +3594,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// ConnectionStore's EncryptedSharedPrefs.
|
||||
profileController.profileSelectionStore.clear(connectionId)
|
||||
profileController.profileLockStore.clear(connectionId)
|
||||
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
|
||||
.clear(connectionId)
|
||||
profileController.profilePresentationStore.clear(connectionId)
|
||||
profileController.profileSessionStore.clearConnection(connectionId)
|
||||
profileController.profileDisplayAliasStore.clearConnection(connectionId)
|
||||
@@ -3630,6 +3635,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
|
||||
init {
|
||||
authManager.setSupervisedMetadataReconnectFallback {
|
||||
connectionManager.reconnectForAuthenticatedMetadataUpdate()
|
||||
}
|
||||
// Wire multiplexer to connection manager (for relay/bridge/terminal)
|
||||
multiplexer.setSendCallback { envelope ->
|
||||
connectionManager.send(envelope)
|
||||
@@ -4096,6 +4104,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
connectionStore.removeConnection(duplicate.id)
|
||||
profileController.profileSelectionStore.clear(duplicate.id)
|
||||
profileController.profileLockStore.clear(duplicate.id)
|
||||
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
|
||||
.clear(duplicate.id)
|
||||
profileController.profilePresentationStore.clear(duplicate.id)
|
||||
profileController.profileSessionStore.clearConnection(duplicate.id)
|
||||
}
|
||||
@@ -7190,6 +7200,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
check(dataManager.resetAppData()) { "App data store reset failed" }
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
|
||||
.clearAll()
|
||||
profileController.profilePresentationStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
|
||||
/**
|
||||
* Fail-closed dispatch policy for Android Supervised Mode.
|
||||
*
|
||||
* This intentionally runs before demo handling, route selection, slash.exec,
|
||||
* command.dispatch, steering, and queueing. Kotlin's default trim recognizes
|
||||
* Unicode whitespace, preventing an indented slash command from bypassing the
|
||||
* client restriction.
|
||||
*/
|
||||
internal fun supervisedMessageBlockReason(
|
||||
policy: SupervisedModePolicy,
|
||||
text: String,
|
||||
): String? {
|
||||
if (!policy.enabled) return null
|
||||
if (!policy.isConfigured) {
|
||||
return "Supervised mode is unavailable until the parent selects a profile."
|
||||
}
|
||||
if (text.trimStart().startsWith('/')) {
|
||||
return "Slash commands are unavailable in supervised mode."
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import com.hermesandroid.relay.data.DEFAULT_VOICE_STOP_PHRASES
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
@@ -279,6 +281,23 @@ internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
|
||||
*/
|
||||
enum class InteractionMode { TapToTalk, HoldToTalk, Continuous }
|
||||
|
||||
internal fun isVoiceCommandAllowed(
|
||||
action: VoiceCommandAction,
|
||||
policy: SupervisedModePolicy,
|
||||
): Boolean {
|
||||
if (!policy.enabled) return true
|
||||
val capabilities: SupervisedCapabilities = policy.capabilities
|
||||
return when (action) {
|
||||
VoiceCommandAction.StartNewChat -> capabilities.newChat
|
||||
VoiceCommandAction.StopResponse,
|
||||
VoiceCommandAction.CancelBackgroundTask -> capabilities.cancelResponse
|
||||
VoiceCommandAction.EndVoiceChat,
|
||||
VoiceCommandAction.PauseContinuousListening,
|
||||
VoiceCommandAction.ResumeContinuousListening,
|
||||
VoiceCommandAction.RepeatBackgroundAnswer -> capabilities.voice
|
||||
}
|
||||
}
|
||||
|
||||
internal fun InteractionMode.storageValue(): String = when (this) {
|
||||
InteractionMode.TapToTalk -> "tap"
|
||||
InteractionMode.HoldToTalk -> "hold"
|
||||
@@ -723,6 +742,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var voicePreferences: VoicePreferencesRepository? = null
|
||||
private var voicePreferencesJob: Job? = null
|
||||
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
|
||||
private var supervisedModePolicy: SupervisedModePolicy = SupervisedModePolicy()
|
||||
private var voiceStopPhrases: List<String> = DEFAULT_VOICE_STOP_PHRASES
|
||||
private var finalAnswerOnly: Boolean = false
|
||||
private var realtimeTraceDetails: Boolean = false
|
||||
@@ -1380,6 +1400,28 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the active Android client policy at the voice coordinator boundary. */
|
||||
fun updateSupervisedModePolicy(policy: SupervisedModePolicy) {
|
||||
supervisedModePolicy = policy
|
||||
val supervised = policy.enabled
|
||||
voiceAudioClient?.setRouteOverride(if (supervised) VoiceAudioRoute.Standard else null)
|
||||
if (supervised) {
|
||||
if (voiceEngineMode == VoiceEngineMode.RealtimeAgent) closeRealtimeSession()
|
||||
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput
|
||||
_voiceStats.update {
|
||||
it.copy(
|
||||
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput.storageValue,
|
||||
)
|
||||
}
|
||||
if (!policy.capabilities.voice && _uiState.value.voiceMode) exitVoiceMode()
|
||||
} else {
|
||||
val prefs = voicePreferences ?: return
|
||||
viewModelScope.launch {
|
||||
prefs.settings.firstOrNull()?.let { applyVoiceSettingsSnapshot(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistInteractionMode(mode: InteractionMode) {
|
||||
val prefs = voicePreferences ?: return
|
||||
viewModelScope.launch {
|
||||
@@ -1485,7 +1527,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
) {
|
||||
closeRealtimeSession()
|
||||
}
|
||||
voiceEngineMode = nextEngineMode
|
||||
voiceEngineMode = if (supervisedModePolicy.enabled) {
|
||||
VoiceEngineMode.HermesVoiceOutput
|
||||
} else {
|
||||
nextEngineMode
|
||||
}
|
||||
voiceStopPhrases = settings.stopPhrases
|
||||
finalAnswerOnly = settings.finalAnswerOnly
|
||||
realtimeTraceDetails = settings.realtimeTraceDetails
|
||||
@@ -1506,7 +1552,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
it.copy(
|
||||
vadThresholdMs = settings.silenceThresholdMs,
|
||||
interactionMode = settings.interactionMode,
|
||||
voiceEngineMode = settings.engineMode,
|
||||
voiceEngineMode = voiceEngineMode.storageValue,
|
||||
realtimeModel = settings.realtimeModel,
|
||||
realtimeVoice = settings.realtimeVoice,
|
||||
)
|
||||
@@ -1540,6 +1586,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
activationId: String? = null,
|
||||
expectScreenContext: Boolean = false,
|
||||
) {
|
||||
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.voice) return
|
||||
val freshEntry = !_uiState.value.voiceMode
|
||||
val orphanedRun = _uiState.value
|
||||
.takeIf { freshEntry }
|
||||
@@ -2430,6 +2477,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (!canSpeakSettledResponse(state, providerRealtimeAgentTurnActive.get())) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
supervisedModePolicy.enabled &&
|
||||
voiceAudioClient?.effectiveRoute != VoiceAudioRoute.Standard
|
||||
) return false
|
||||
|
||||
val spoken = sanitizeForTts(text)
|
||||
if (spoken.isBlank()) return false
|
||||
@@ -3007,6 +3058,17 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isVoiceCommandAllowed(action, supervisedModePolicy)) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Idle,
|
||||
outputAudioActive = false,
|
||||
responseText = "That voice action is disabled by Parent controls.",
|
||||
)
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
Log.i(TAG, "Hands-free voice command action=$action source=${if (fromRealtime) "realtime" else "stt"}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
@@ -3076,12 +3138,23 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
setError("Voice pipeline not initialized")
|
||||
return
|
||||
}
|
||||
if (
|
||||
supervisedModePolicy.enabled &&
|
||||
audioClient.effectiveRoute != VoiceAudioRoute.Standard
|
||||
) {
|
||||
setError("Supervised voice requires the Standard Hermes voice route")
|
||||
return
|
||||
}
|
||||
currentTurnPcm = inputPcm
|
||||
currentTurnPcmSampleRate = inputSampleRate
|
||||
resetBrokeredToolSpeechState()
|
||||
resetRealtimeSpeechCoalescer()
|
||||
resetTtsTurnStats()
|
||||
val engineModeForTurn = voiceEngineMode
|
||||
val engineModeForTurn = if (supervisedModePolicy.enabled) {
|
||||
VoiceEngineMode.HermesVoiceOutput
|
||||
} else {
|
||||
voiceEngineMode
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"Processing voice input engine=${engineModeForTurn.storageValue} " +
|
||||
@@ -3230,7 +3303,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// ever mis-edited. If/when a `BuildFlavor.bridgeTier3` compile-
|
||||
// time constant exists we should still short-circuit here for
|
||||
// clarity, but today the factory already does the right thing.
|
||||
val bridgeHandler = voiceBridgeIntentHandler
|
||||
val bridgeHandler = voiceBridgeIntentHandler.takeUnless { supervisedModePolicy.enabled }
|
||||
|
||||
// === PHASE3-voice-cancel-midcountdown ===
|
||||
// Voice-in-voice cancel: if a destructive action is currently
|
||||
@@ -4906,7 +4979,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
private fun shouldPreferRealtimeVoice(): Boolean =
|
||||
voiceOutputAvailable != false &&
|
||||
!supervisedModePolicy.enabled &&
|
||||
voiceOutputAvailable != false &&
|
||||
realtimePcmPlayer != null &&
|
||||
voiceClient != null &&
|
||||
// Use the RESOLVED route: AutoVoiceAudioClient.effectiveRoute maps
|
||||
|
||||
@@ -657,6 +657,14 @@
|
||||
<string name="settings_analytics_desc">Estatísticas de uso, TTFT, tokens e integridade</string>
|
||||
<string name="settings_diagnostics">Diagnóstico</string>
|
||||
<string name="settings_diagnostics_desc">Verificações de status e atividade recente da API, do relay, da sessão e da voz</string>
|
||||
<string name="settings_advanced">Avançado</string>
|
||||
<string name="settings_advanced_desc">Modo supervisionado e outros recursos opcionais</string>
|
||||
<string name="settings_advanced_intro">Recursos opcionais e especializados ficam aqui para manter a tela principal de Configurações organizada.</string>
|
||||
<string name="settings_supervised_mode">Modo supervisionado</string>
|
||||
<string name="settings_supervised_desc">Escolha um perfil e os recursos de chat permitidos</string>
|
||||
<string name="settings_supervised_on">Ativado</string>
|
||||
<string name="settings_supervised_on_profile">Ativado · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Pronto · %1$s</string>
|
||||
<string name="settings_developer_options">Opções do desenvolvedor</string>
|
||||
<string name="settings_developer_options_desc">Flags de recursos, gerenciamento de dados e opções experimentais</string>
|
||||
<string name="settings_whats_new">Novidades</string>
|
||||
|
||||
@@ -698,6 +698,14 @@
|
||||
<string name="settings_analytics_desc">使用统计、TTFT、token、健康状态</string>
|
||||
<string name="settings_diagnostics">诊断</string>
|
||||
<string name="settings_diagnostics_desc">状态检查,以及最近的 API、Relay、会话和语音活动</string>
|
||||
<string name="settings_advanced">高级</string>
|
||||
<string name="settings_advanced_desc">受监督模式和其他可选功能</string>
|
||||
<string name="settings_advanced_intro">可选和专用功能集中在此,以保持主设置界面简洁。</string>
|
||||
<string name="settings_supervised_mode">受监督模式</string>
|
||||
<string name="settings_supervised_desc">选择配置文件和允许的聊天功能</string>
|
||||
<string name="settings_supervised_on">已开启</string>
|
||||
<string name="settings_supervised_on_profile">已开启 · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">已就绪 · %1$s</string>
|
||||
<string name="settings_developer_options">开发者选项</string>
|
||||
<string name="settings_developer_options_desc">功能标志、数据管理、实验性</string>
|
||||
<string name="settings_whats_new">新功能</string>
|
||||
|
||||
@@ -698,6 +698,14 @@
|
||||
<string name="settings_analytics_desc">Nutzungsstatistiken, TTFT, Token, Status</string>
|
||||
<string name="settings_diagnostics">Diagnose</string>
|
||||
<string name="settings_diagnostics_desc">Statusprüfungen sowie letzte API-, Relay-, Sitzungs- und Sprachaktivitäten</string>
|
||||
<string name="settings_advanced">Erweitert</string>
|
||||
<string name="settings_advanced_desc">Beaufsichtigter Modus und weitere optionale Funktionen</string>
|
||||
<string name="settings_advanced_intro">Optionale und spezielle Funktionen befinden sich hier, damit die Haupteinstellungen übersichtlich bleiben.</string>
|
||||
<string name="settings_supervised_mode">Beaufsichtigter Modus</string>
|
||||
<string name="settings_supervised_desc">Profil und erlaubte Chatfunktionen auswählen</string>
|
||||
<string name="settings_supervised_on">Ein</string>
|
||||
<string name="settings_supervised_on_profile">Ein · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Bereit · %1$s</string>
|
||||
<string name="settings_developer_options">Entwickleroptionen</string>
|
||||
<string name="settings_developer_options_desc">Funktionsschalter, Datenverwaltung, Experimente</string>
|
||||
<string name="settings_whats_new">Neuigkeiten</string>
|
||||
|
||||
@@ -625,6 +625,14 @@
|
||||
<string name="settings_analytics_desc">Estadísticas de uso, TTFT, tokens, salud</string>
|
||||
<string name="settings_diagnostics">Diagnóstico</string>
|
||||
<string name="settings_diagnostics_desc">Verificaciones de estado, además de actividad reciente de API, relay, sesión y voz</string>
|
||||
<string name="settings_advanced">Avanzado</string>
|
||||
<string name="settings_advanced_desc">Modo supervisado y otras funciones opcionales</string>
|
||||
<string name="settings_advanced_intro">Las funciones opcionales y especializadas están aquí para mantener despejada la pantalla principal de Ajustes.</string>
|
||||
<string name="settings_supervised_mode">Modo supervisado</string>
|
||||
<string name="settings_supervised_desc">Elige un perfil y las funciones de chat permitidas</string>
|
||||
<string name="settings_supervised_on">Activado</string>
|
||||
<string name="settings_supervised_on_profile">Activado · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Listo · %1$s</string>
|
||||
<string name="settings_developer_options">Opciones de desarrollador</string>
|
||||
<string name="settings_developer_options_desc">Indicadores de funciones, gestión de datos, experimental.</string>
|
||||
<string name="settings_whats_new">Novedades</string>
|
||||
|
||||
@@ -698,6 +698,14 @@
|
||||
<string name="settings_analytics_desc">使用状況統計、TTFT、トークン、ヘルス</string>
|
||||
<string name="settings_diagnostics">診断</string>
|
||||
<string name="settings_diagnostics_desc">ステータス チェック、および最近の API、Relay、セッション、および音声アクティビティ</string>
|
||||
<string name="settings_advanced">詳細設定</string>
|
||||
<string name="settings_advanced_desc">監督モードとその他のオプション機能</string>
|
||||
<string name="settings_advanced_intro">メインの設定画面をシンプルに保つため、オプション機能と専門機能はここにまとめられています。</string>
|
||||
<string name="settings_supervised_mode">監督モード</string>
|
||||
<string name="settings_supervised_desc">プロファイルと許可するチャット機能を選択</string>
|
||||
<string name="settings_supervised_on">オン</string>
|
||||
<string name="settings_supervised_on_profile">オン · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">準備完了 · %1$s</string>
|
||||
<string name="settings_developer_options">開発者向けオプション</string>
|
||||
<string name="settings_developer_options_desc">機能フラグ、データ管理、実験的</string>
|
||||
<string name="settings_whats_new">新着情報</string>
|
||||
|
||||
@@ -668,6 +668,14 @@
|
||||
<string name="settings_analytics_desc">Статистика использования, TTFT, токены, состояние</string>
|
||||
<string name="settings_diagnostics">Диагностика</string>
|
||||
<string name="settings_diagnostics_desc">Проверка состояния, а также недавняя активность API, Relay, сессий и голосовых данных</string>
|
||||
<string name="settings_advanced">Дополнительно</string>
|
||||
<string name="settings_advanced_desc">Режим с контролем и другие дополнительные функции</string>
|
||||
<string name="settings_advanced_intro">Дополнительные и специальные функции собраны здесь, чтобы не перегружать главный экран настроек.</string>
|
||||
<string name="settings_supervised_mode">Режим с контролем</string>
|
||||
<string name="settings_supervised_desc">Выберите профиль и разрешённые функции чата</string>
|
||||
<string name="settings_supervised_on">Вкл.</string>
|
||||
<string name="settings_supervised_on_profile">Вкл. · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Готово · %1$s</string>
|
||||
<string name="settings_developer_options">Настройки разработчика</string>
|
||||
<string name="settings_developer_options_desc">Флаги функций, управление данными, экспериментальные</string>
|
||||
<string name="settings_whats_new">Что нового</string>
|
||||
|
||||
@@ -736,6 +736,14 @@
|
||||
<string name="settings_analytics_desc">Usage stats, TTFT, tokens, health</string>
|
||||
<string name="settings_diagnostics">Diagnostics</string>
|
||||
<string name="settings_diagnostics_desc">Status checks, plus recent API, relay, session, and voice activity</string>
|
||||
<string name="settings_advanced">Advanced</string>
|
||||
<string name="settings_advanced_desc">Supervised mode and other optional features</string>
|
||||
<string name="settings_advanced_intro">Optional and specialized features live here to keep the main Settings screen focused.</string>
|
||||
<string name="settings_supervised_mode">Supervised mode</string>
|
||||
<string name="settings_supervised_desc">Choose a profile and approved chat features</string>
|
||||
<string name="settings_supervised_on">On</string>
|
||||
<string name="settings_supervised_on_profile">On · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Ready · %1$s</string>
|
||||
<string name="settings_developer_options">Developer options</string>
|
||||
<string name="settings_developer_options_desc">Feature flags, data management, experimental</string>
|
||||
<string name="settings_whats_new">What\'s New</string>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.hermesandroid.relay.auth
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedModeAuthPayloadTest {
|
||||
@Test fun `active policy reports only public capability ids`() {
|
||||
val payload = relaySupervisedModePayload(
|
||||
SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(attachments = true, voice = true),
|
||||
),
|
||||
)
|
||||
assertTrue(payload.getValue("active").jsonPrimitive.boolean)
|
||||
assertEquals("willow", payload.getValue("profile_label").jsonPrimitive.content)
|
||||
val capabilities = payload.getValue("capabilities").jsonArray.map { it.jsonPrimitive.content }
|
||||
assertTrue("text_chat" in capabilities)
|
||||
assertTrue("attachments" in capabilities)
|
||||
assertTrue("voice" in capabilities)
|
||||
assertFalse(capabilities.any { it.contains("model") || it.contains("tool") })
|
||||
}
|
||||
|
||||
@Test fun `inactive update explicitly clears Relay tag`() {
|
||||
val payload = relaySupervisedModePayload(SupervisedModePolicy())
|
||||
assertFalse(payload.getValue("active").jsonPrimitive.boolean)
|
||||
assertEquals(setOf("active"), payload.keys)
|
||||
}
|
||||
|
||||
@Test fun `live update uses typed correlated system envelope`() {
|
||||
val envelope = relaySupervisedModeUpdateEnvelope(
|
||||
SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(voice = true),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("system", envelope.channel)
|
||||
assertEquals("supervised.update", envelope.type)
|
||||
assertTrue(envelope.id.isNotBlank())
|
||||
val mode = envelope.payload.getValue("supervised_mode")
|
||||
.jsonObject
|
||||
assertTrue(mode.getValue("active").jsonPrimitive.boolean)
|
||||
assertEquals("willow", mode.getValue("profile_label").jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.mutablePreferencesOf
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedModeStoreTest {
|
||||
|
||||
@Test
|
||||
fun freshConnectionUsesRestrictiveDefaults() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
|
||||
val policy = store.policyFlow("connection-a").first()
|
||||
|
||||
assertFalse(policy.enabled)
|
||||
assertFalse(policy.isConfigured)
|
||||
assertFalse(policy.isActive)
|
||||
assertFalse(policy.capabilities.attachments)
|
||||
assertFalse(policy.capabilities.voice)
|
||||
assertFalse(policy.visibility.resolved().showModelName)
|
||||
assertFalse(policy.visibility.resolved().showTechnicalRoute)
|
||||
assertTrue(policy.parentAccess.requireDeviceAuthentication)
|
||||
assertEquals(5, policy.parentAccess.timeoutMinutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun policyRoundTripsWithCapabilitiesLimitsAndVisibility() = runTest {
|
||||
val dataStore = InMemorySupervisedPreferencesDataStore()
|
||||
val store = SupervisedModeStore.forTesting(dataStore)
|
||||
val saved = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = " willow ",
|
||||
capabilities = SupervisedCapabilities(
|
||||
attachments = true,
|
||||
voice = true,
|
||||
attachmentMaxCount = 6,
|
||||
attachmentMaxFileMb = 20,
|
||||
attachmentCategories = setOf(
|
||||
SupervisedAttachmentCategory.Images,
|
||||
SupervisedAttachmentCategory.Documents,
|
||||
),
|
||||
sessionActions = SupervisedSessionActions(
|
||||
pin = true,
|
||||
rename = true,
|
||||
shareTranscript = true,
|
||||
),
|
||||
),
|
||||
appearance = SupervisedAppearance(
|
||||
appThemeId = "rose",
|
||||
themePreference = "dark",
|
||||
showPet = true,
|
||||
allowProfileIconChanges = true,
|
||||
allowBackgroundChanges = true,
|
||||
),
|
||||
visibility = SupervisedVisibility(
|
||||
preset = SupervisedVisibilityPreset.Custom,
|
||||
showAgentIdentity = true,
|
||||
showModelName = true,
|
||||
showToolNames = true,
|
||||
),
|
||||
)
|
||||
|
||||
store.setPolicy("connection-a", saved)
|
||||
val restored = SupervisedModeStore.forTesting(dataStore).policyFlow("connection-a").first()
|
||||
|
||||
assertTrue(restored.isActive)
|
||||
assertEquals("willow", restored.pinnedProfileName)
|
||||
assertEquals(6, restored.capabilities.attachmentMaxCount)
|
||||
assertEquals(20, restored.capabilities.attachmentMaxFileMb)
|
||||
assertEquals(saved.capabilities.attachmentCategories, restored.capabilities.attachmentCategories)
|
||||
assertEquals(saved.capabilities.sessionActions, restored.capabilities.sessionActions)
|
||||
assertEquals("rose", restored.appearance.appThemeId)
|
||||
assertEquals("dark", restored.appearance.themePreference)
|
||||
assertTrue(restored.appearance.showPet)
|
||||
assertTrue(restored.appearance.allowProfileIconChanges)
|
||||
assertTrue(restored.appearance.allowBackgroundChanges)
|
||||
assertEquals(SupervisedVisibilityPreset.Custom, restored.visibility.preset)
|
||||
assertTrue(restored.visibility.showModelName)
|
||||
assertTrue(restored.visibility.showToolNames)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionsAreIsolatedAndClearRemovesOnlyTarget() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
|
||||
store.setPolicy("connection-b", SupervisedModePolicy(true, "juniper"))
|
||||
|
||||
store.clear("connection-a")
|
||||
|
||||
assertFalse(store.policyFlow("connection-a").first().enabled)
|
||||
assertEquals("juniper", store.policyFlow("connection-b").first().pinnedProfileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateAndSetEnabledPreserveOtherPolicyFields() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
store.setPolicy(
|
||||
"connection-a",
|
||||
SupervisedModePolicy(
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(voice = true),
|
||||
),
|
||||
)
|
||||
|
||||
store.setEnabled("connection-a", true)
|
||||
store.updatePolicy("connection-a") {
|
||||
it.copy(visibility = it.visibility.copy(preset = SupervisedVisibilityPreset.Transparent))
|
||||
}
|
||||
|
||||
val policy = store.policyFlow("connection-a").first()
|
||||
assertTrue(policy.isActive)
|
||||
assertTrue(policy.capabilities.voice)
|
||||
assertEquals(SupervisedVisibilityPreset.Transparent, policy.visibility.preset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidLimitsAreNormalizedAndEmptyCategoriesFallBackToImages() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
store.setPolicy(
|
||||
"connection-a",
|
||||
SupervisedModePolicy(
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(
|
||||
attachmentMaxCount = Int.MAX_VALUE,
|
||||
attachmentMaxFileMb = -1,
|
||||
attachmentCategories = emptySet(),
|
||||
),
|
||||
parentAccess = SupervisedParentAccess(
|
||||
requireDeviceAuthentication = false,
|
||||
timeoutMinutes = 0,
|
||||
),
|
||||
appearance = SupervisedAppearance(
|
||||
appThemeId = "missing-theme",
|
||||
themePreference = "sepia",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val policy = store.policyFlow("connection-a").first()
|
||||
assertEquals(SupervisedCapabilities.MAX_ATTACHMENT_COUNT, policy.capabilities.attachmentMaxCount)
|
||||
assertEquals(1, policy.capabilities.attachmentMaxFileMb)
|
||||
assertEquals(setOf(SupervisedAttachmentCategory.Images), policy.capabilities.attachmentCategories)
|
||||
assertTrue(policy.parentAccess.requireDeviceAuthentication)
|
||||
assertEquals(SupervisedParentAccess.MIN_TIMEOUT_MINUTES, policy.parentAccess.timeoutMinutes)
|
||||
assertEquals("hermes-relay", policy.appearance.appThemeId)
|
||||
assertEquals("auto", policy.appearance.themePreference)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun simplePresetResolvesToSafeValuesEvenIfStoredFlagsDiffer() {
|
||||
val visibility = SupervisedVisibility(
|
||||
preset = SupervisedVisibilityPreset.Simple,
|
||||
showModelName = true,
|
||||
showTechnicalRoute = true,
|
||||
showReasoning = true,
|
||||
).resolved()
|
||||
|
||||
assertFalse(visibility.showModelName)
|
||||
assertFalse(visibility.showTechnicalRoute)
|
||||
assertFalse(visibility.showReasoning)
|
||||
assertTrue(visibility.showAgentIdentity)
|
||||
assertTrue(visibility.showConnectionStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedPersistedPolicyFailsClosed() = runTest {
|
||||
val policyKey = androidx.datastore.preferences.core.stringPreferencesKey(
|
||||
"supervised_mode_policies_v1",
|
||||
)
|
||||
val dataStore = InMemorySupervisedPreferencesDataStore(
|
||||
mutablePreferencesOf(policyKey to "{not-valid-json"),
|
||||
)
|
||||
|
||||
val policy = SupervisedModeStore.forTesting(dataStore)
|
||||
.policyFlow("connection-a")
|
||||
.first()
|
||||
|
||||
assertTrue(policy.enabled)
|
||||
assertFalse(policy.isConfigured)
|
||||
assertFalse(policy.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearAllDoesNotClearUnrelatedPreferences() = runTest {
|
||||
val unrelatedKey = androidx.datastore.preferences.core.stringPreferencesKey("unrelated")
|
||||
val dataStore = InMemorySupervisedPreferencesDataStore(
|
||||
mutablePreferencesOf(unrelatedKey to "kept"),
|
||||
)
|
||||
val store = SupervisedModeStore.forTesting(dataStore)
|
||||
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
|
||||
|
||||
store.clearAll()
|
||||
|
||||
assertFalse(store.policyFlow("connection-a").first().enabled)
|
||||
assertEquals("kept", dataStore.data.first()[unrelatedKey])
|
||||
}
|
||||
}
|
||||
|
||||
private class InMemorySupervisedPreferencesDataStore(
|
||||
initial: Preferences = emptyPreferences(),
|
||||
) : DataStore<Preferences> {
|
||||
private val state = MutableStateFlow(initial)
|
||||
override val data: Flow<Preferences> = state
|
||||
|
||||
override suspend fun updateData(
|
||||
transform: suspend (t: Preferences) -> Preferences,
|
||||
): Preferences {
|
||||
val updated = transform(state.value)
|
||||
state.value = updated
|
||||
return updated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedSessionPolicyTest {
|
||||
@Test fun `session action summary derives none mixed and all`() {
|
||||
val none = SupervisedSessionActions()
|
||||
val mixed = none.copy(rename = true, delete = true)
|
||||
val all = none.withAll(true)
|
||||
|
||||
assertTrue(none.noneEnabled)
|
||||
assertEquals(2, mixed.enabledCount)
|
||||
assertFalse(mixed.noneEnabled)
|
||||
assertFalse(mixed.allEnabled)
|
||||
assertTrue(all.allEnabled)
|
||||
assertEquals(SupervisedSessionActions.TOTAL, all.enabledCount)
|
||||
}
|
||||
|
||||
@Test fun `supervised history and granular flag are both required`() {
|
||||
val base = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(
|
||||
conversationHistory = true,
|
||||
sessionActions = SupervisedSessionActions(rename = true),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(base.allowsSessionAction(SupervisedSessionAction.Rename))
|
||||
assertFalse(base.allowsSessionAction(SupervisedSessionAction.Delete))
|
||||
assertFalse(
|
||||
base.copy(
|
||||
capabilities = base.capabilities.copy(conversationHistory = false),
|
||||
).allowsSessionAction(SupervisedSessionAction.Rename),
|
||||
)
|
||||
assertTrue(SupervisedModePolicy().allowsSessionAction(SupervisedSessionAction.Delete))
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.hermesandroid.relay.network.relay
|
||||
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChannelMultiplexerSupervisedUpdateTest {
|
||||
@Test fun `supervised update acknowledgement reaches system auth handler`() {
|
||||
val multiplexer = ChannelMultiplexer()
|
||||
val received = mutableListOf<Envelope>()
|
||||
multiplexer.registerHandler("system") { received += it }
|
||||
|
||||
val acknowledgement = Envelope(
|
||||
channel = "system",
|
||||
type = "supervised.updated",
|
||||
id = "update-1",
|
||||
)
|
||||
multiplexer.route(acknowledgement)
|
||||
|
||||
assertEquals(listOf(acknowledgement), received)
|
||||
}
|
||||
|
||||
@Test fun `correlated system error reaches system auth handler`() {
|
||||
val multiplexer = ChannelMultiplexer()
|
||||
val received = mutableListOf<Envelope>()
|
||||
multiplexer.registerHandler("system") { received += it }
|
||||
|
||||
val error = Envelope(channel = "system", type = "error", id = "update-2")
|
||||
multiplexer.route(error)
|
||||
|
||||
assertEquals(listOf(error), received)
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AutoVoiceAudioClientSupervisionTest {
|
||||
@Test
|
||||
fun `route override forces standard even when auto prefers ready relay`() = runTest {
|
||||
val standard = FakeVoiceClient(VoiceAudioRoute.Standard, "standard")
|
||||
val relay = FakeVoiceClient(VoiceAudioRoute.Relay, "relay")
|
||||
val router = AutoVoiceAudioClient(
|
||||
standardClient = standard,
|
||||
relayClient = relay,
|
||||
routeProvider = { VoiceAudioRoute.Auto },
|
||||
standardReadyProvider = { true },
|
||||
relayReadyProvider = { true },
|
||||
)
|
||||
|
||||
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
|
||||
router.setRouteOverride(VoiceAudioRoute.Standard)
|
||||
assertEquals(VoiceAudioRoute.Standard, router.effectiveRoute)
|
||||
assertEquals("standard", router.transcribe(File("voice.wav")).getOrThrow())
|
||||
router.setRouteOverride(null)
|
||||
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
|
||||
}
|
||||
|
||||
private class FakeVoiceClient(
|
||||
override val route: VoiceAudioRoute,
|
||||
private val transcript: String,
|
||||
) : VoiceAudioClient {
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = Result.success(transcript)
|
||||
override suspend fun synthesize(text: String): Result<File> = Result.success(File("voice.mp3"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedAppearance
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedAppearancePolicyTest {
|
||||
private val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
appearance = SupervisedAppearance(
|
||||
appThemeId = "rose",
|
||||
themePreference = "dark",
|
||||
showPet = false,
|
||||
),
|
||||
)
|
||||
|
||||
@Test fun `locked supervised root uses only its own theme`() {
|
||||
val resolved = resolveSupervisedTheme(policy, false, "midnight", "light")
|
||||
|
||||
assertEquals("rose", resolved.appThemeId)
|
||||
assertEquals("dark", resolved.themePreference)
|
||||
assertFalse(resolved.useGlobalCustomTheme)
|
||||
}
|
||||
|
||||
@Test fun `parent access restores ordinary app theme`() {
|
||||
val resolved = resolveSupervisedTheme(policy, true, "midnight", "light")
|
||||
|
||||
assertEquals("midnight", resolved.appThemeId)
|
||||
assertEquals("light", resolved.themePreference)
|
||||
assertTrue(resolved.useGlobalCustomTheme)
|
||||
}
|
||||
|
||||
@Test fun `pet visibility follows supervised policy only while locked`() {
|
||||
assertFalse(shouldShowPetInSupervisedMode(policy, false))
|
||||
assertTrue(shouldShowPetInSupervisedMode(policy, true))
|
||||
assertTrue(
|
||||
shouldShowPetInSupervisedMode(
|
||||
policy.copy(appearance = policy.appearance.copy(showPet = true)),
|
||||
false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `enabled recovery policy stays on restricted appearance defaults`() {
|
||||
val recovery = SupervisedModePolicy(enabled = true)
|
||||
val resolved = resolveSupervisedTheme(recovery, false, "rose", "dark")
|
||||
|
||||
assertEquals("hermes-relay", resolved.appThemeId)
|
||||
assertEquals("auto", resolved.themePreference)
|
||||
assertFalse(resolved.useGlobalCustomTheme)
|
||||
assertFalse(shouldShowPetInSupervisedMode(recovery, false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedNavigationPolicyTest {
|
||||
@Test fun `locked surface permits only approved destinations`() {
|
||||
assertTrue(isSupervisedRouteAllowed("chat?sessionId=owned", false))
|
||||
assertTrue(isSupervisedRouteAllowed("settings", false))
|
||||
assertTrue(isSupervisedRouteAllowed(Screen.SupervisedAppearanceSettings.route, false))
|
||||
// The full Appearance destination includes profile/avatar/pet controls.
|
||||
// The supervised Settings root owns its own allowlisted theme controls.
|
||||
assertFalse(isSupervisedRouteAllowed("settings/appearance", false))
|
||||
assertFalse(isSupervisedRouteAllowed("settings/about", false))
|
||||
assertFalse(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, false))
|
||||
assertFalse(isSupervisedRouteAllowed("manage", false))
|
||||
assertFalse(isSupervisedRouteAllowed("settings/developer", false))
|
||||
assertFalse(isSupervisedRouteAllowed("settings/supervised", false))
|
||||
assertFalse(isSupervisedRouteAllowed(null, false))
|
||||
}
|
||||
|
||||
@Test fun `parent unlock permits full navigation`() {
|
||||
assertTrue(isSupervisedRouteAllowed("manage", true))
|
||||
assertTrue(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, true))
|
||||
}
|
||||
|
||||
@Test fun `supervised redirect waits until the navigation graph has a route`() {
|
||||
assertFalse(shouldRedirectSupervisedRoute(true, false, null))
|
||||
assertTrue(isSupervisedRouteContentAllowed(true, false, null))
|
||||
assertFalse(shouldRedirectSupervisedRoute(true, false, Screen.Chat.route))
|
||||
assertTrue(isSupervisedRouteContentAllowed(true, false, Screen.Chat.route))
|
||||
assertTrue(shouldRedirectSupervisedRoute(true, false, Screen.AdvancedSettings.route))
|
||||
assertFalse(isSupervisedRouteContentAllowed(true, false, Screen.AdvancedSettings.route))
|
||||
assertFalse(shouldRedirectSupervisedRoute(true, true, Screen.AdvancedSettings.route))
|
||||
assertTrue(isSupervisedRouteContentAllowed(true, true, Screen.AdvancedSettings.route))
|
||||
}
|
||||
|
||||
@Test fun `navigation waits for connection store before trusting null active id`() {
|
||||
assertFalse(isRelayNavigationHydrated(false, null, false))
|
||||
assertTrue(isRelayNavigationHydrated(true, null, false))
|
||||
assertFalse(isRelayNavigationHydrated(true, "home", false))
|
||||
assertTrue(isRelayNavigationHydrated(true, "home", true))
|
||||
}
|
||||
|
||||
@Test fun `parent access relocks as soon as chat becomes current`() {
|
||||
assertTrue(shouldRelockParentAccess(true, true, "chat?sessionId=ignored"))
|
||||
assertFalse(shouldRelockParentAccess(true, true, "settings/supervised"))
|
||||
assertFalse(shouldRelockParentAccess(false, true, "chat"))
|
||||
assertFalse(shouldRelockParentAccess(true, false, "chat"))
|
||||
}
|
||||
|
||||
@Test fun `supervised route session requires history pinned profile and trusted ownership proof`() {
|
||||
val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(conversationHistory = true),
|
||||
)
|
||||
|
||||
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "willow", false))
|
||||
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "parent", true))
|
||||
assertTrue(mayRestoreSupervisedSessionRoute(policy, "session-1", "WILLOW", true))
|
||||
assertFalse(
|
||||
mayRestoreSupervisedSessionRoute(
|
||||
policy.copy(capabilities = policy.capabilities.copy(conversationHistory = false)),
|
||||
"session-1",
|
||||
"willow",
|
||||
true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `supervised external route discards session profile and proactive targets`() {
|
||||
val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(conversationHistory = true),
|
||||
)
|
||||
val external = SupervisedChatRouteArgs(
|
||||
sessionId = "parent-session",
|
||||
profile = "willow",
|
||||
proactiveChatId = "phone",
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
sanitizeSupervisedChatRouteArgs(policy, external, false) ==
|
||||
SupervisedChatRouteArgs(),
|
||||
)
|
||||
assertTrue(
|
||||
sanitizeSupervisedChatRouteArgs(policy, external, true) ==
|
||||
external.copy(proactiveChatId = null),
|
||||
)
|
||||
assertTrue(
|
||||
sanitizeSupervisedChatRouteArgs(SupervisedModePolicy(), external, false) == external,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `first enable requires configured policy secure screen and successful device credential`() {
|
||||
val configured = SupervisedModePolicy(pinnedProfileName = "willow")
|
||||
|
||||
assertFalse(
|
||||
mayEnableSupervisedMode(
|
||||
configured,
|
||||
deviceSecure = false,
|
||||
deviceCredentialConfirmed = true,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
mayEnableSupervisedMode(
|
||||
configured,
|
||||
deviceSecure = true,
|
||||
deviceCredentialConfirmed = false,
|
||||
),
|
||||
)
|
||||
assertFalse(mayEnableSupervisedMode(SupervisedModePolicy(), true, true))
|
||||
assertTrue(mayEnableSupervisedMode(configured, true, true))
|
||||
assertFalse(mayEnableSupervisedMode(configured.copy(enabled = true), true, true))
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedImagePresentationTest {
|
||||
@Test
|
||||
fun `disabled assistant images strip markdown without exposing a fetchable source`() {
|
||||
val content = "Here it is  and "
|
||||
|
||||
val (body, images) = assistantImageContent(content, showImages = false)
|
||||
|
||||
assertEquals("Here it is and", body)
|
||||
assertTrue(images.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled assistant images preserve all supported sources`() {
|
||||
val content = " "
|
||||
|
||||
val (_, images) = assistantImageContent(content, showImages = true)
|
||||
|
||||
assertEquals(listOf("https://example.com/a.png", "/tmp/b.png"), images.map { it.src })
|
||||
}
|
||||
}
|
||||
+41
-2
@@ -1005,10 +1005,13 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
|
||||
@Test
|
||||
fun stopClearsStaleBusyStateAfterTerminalBubbleAlreadySettled() {
|
||||
// Exercise Stop's route-independent fallback without the Gateway
|
||||
// orphan-state observer settling this synthetic state first.
|
||||
viewModel.streamingEndpoint = "sessions"
|
||||
handler.onTextDelta("stale-answer", "Finished answer")
|
||||
handler.onTurnComplete("stale-answer")
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertFalse(handler.messages.value.single().isStreaming)
|
||||
assertTrue(handler.isStreaming.value)
|
||||
|
||||
viewModel.cancelStream()
|
||||
|
||||
@@ -1016,10 +1019,46 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertNull(handler.turnStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedGatewayBubbleAutomaticallySettlesComposerWithoutInput() {
|
||||
handler.onTextDelta("completed-answer", "Finished answer")
|
||||
handler.onTurnComplete("completed-answer")
|
||||
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
|
||||
assertFalse(handler.messages.value.single().isStreaming)
|
||||
assertFalse(gatewayClient.hasActiveTurnForSession(STORED_SESSION_ID))
|
||||
assertTrue(gatewayHarness.rpcLog.none { it.first == "prompt.submit" })
|
||||
assertTrue(gatewayHarness.rpcLog.none { it.first == "session.interrupt" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedBubbleKeepsComposerBusyWhileGatewaySessionStillOwnsTheRun() {
|
||||
viewModel.sendMessage("Continue through another assistant turn")
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
awaitCondition { gatewayClient.hasActiveTurnForSession(STORED_SESSION_ID) }
|
||||
val assistantId = handler.messages.value.single { it.role == MessageRole.ASSISTANT }.id
|
||||
|
||||
handler.onTextDelta(assistantId, "First assistant message")
|
||||
handler.onTurnComplete(assistantId)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertTrue(gatewayClient.hasActiveTurnForSession(STORED_SESSION_ID))
|
||||
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Final assistant message") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newChatClearsStaleBusyStateWhenNoLiveGatewayTurnRemains() {
|
||||
handler.onTextDelta("stale-answer", "Finished answer")
|
||||
handler.onTurnComplete("stale-answer")
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertFalse(gatewayClient.hasActiveTurn())
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedChatPolicyTest {
|
||||
@Test
|
||||
fun `normal mode preserves slash commands`() {
|
||||
assertNull(supervisedMessageBlockReason(SupervisedModePolicy(), " /model"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled policy fails closed without pinned profile`() {
|
||||
assertEquals(
|
||||
"Supervised mode is unavailable until the parent selects a profile.",
|
||||
supervisedMessageBlockReason(SupervisedModePolicy(enabled = true), "hello"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active policy blocks slash commands after unicode whitespace`() {
|
||||
val policy = SupervisedModePolicy(enabled = true, pinnedProfileName = "willow")
|
||||
assertEquals(
|
||||
"Slash commands are unavailable in supervised mode.",
|
||||
supervisedMessageBlockReason(policy, "\u2003\t /model hidden"),
|
||||
)
|
||||
assertNull(supervisedMessageBlockReason(policy, "please explain /model"))
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.voice.VoiceCommandAction
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedVoiceCommandPolicyTest {
|
||||
@Test
|
||||
fun `normal mode preserves every voice command`() {
|
||||
VoiceCommandAction.entries.forEach { action ->
|
||||
assertTrue(isVoiceCommandAllowed(action, SupervisedModePolicy()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `supervised mode gates new chat and cancellation independently`() {
|
||||
val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(
|
||||
voice = true,
|
||||
newChat = false,
|
||||
cancelResponse = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.StartNewChat, policy))
|
||||
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.StopResponse, policy))
|
||||
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.CancelBackgroundTask, policy))
|
||||
assertTrue(isVoiceCommandAllowed(VoiceCommandAction.EndVoiceChat, policy))
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"hermes-relay": "bin/hermes-relay.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"description": "Thin-client CLI for Hermes-Relay — talk to a remote Hermes agent over WSS with pairing auth, stream-renders tool calls and responses to plain stdout.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Regenerated from package.json by gen:version script. Do not edit by hand.
|
||||
export const VERSION = "0.4.0-beta.4" as const
|
||||
export const VERSION = "0.4.0-beta.5" as const
|
||||
|
||||
Generated
+1
-1
@@ -1232,7 +1232,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermes-relay-tray"
|
||||
version = "0.4.0-beta.4"
|
||||
version = "0.4.0-beta.5"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hermes-relay-tray"
|
||||
version = "0.4.0-beta.4"
|
||||
version = "0.4.0-beta.5"
|
||||
description = "Compact Windows management UI for Hermes-Relay CLI"
|
||||
authors = ["Axiom Labs"]
|
||||
edition = "2021"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@hermes-relay/tray-ui",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hermes-relay/tray-ui",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hermes-relay/tray-ui",
|
||||
"private": true,
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermes-Relay CLI UI",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"identifier": "com.axiomlabs.hermes-relay-tray",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+119
-2
@@ -1,6 +1,6 @@
|
||||
# Hermes-Relay — Decisions & Implementation Guide
|
||||
|
||||
> Updated: 2026-04-06
|
||||
> Updated: 2026-08-24
|
||||
>
|
||||
> Read this before SPEC.md — it tells you what to build, what was deferred, and why.
|
||||
|
||||
@@ -3692,7 +3692,124 @@ be considered later without being silently introduced now.
|
||||
|
||||
---
|
||||
|
||||
## ADR 66 — Android Bot Mode is a separate upstream-owned messaging workspace
|
||||
## ADR 66 — Android Supervised Mode is a parent-controlled client policy
|
||||
|
||||
**Status:** Implemented in code; physical managed-device certification pending (2026-08-24).
|
||||
|
||||
**Context.** Some operators prepare a deliberately restricted Hermes profile
|
||||
for use through a parent-supervised Android client. The profile remains the
|
||||
authority for its model, prompt, tools, provider credentials, content behavior,
|
||||
and server-side data. Hermes-Relay should help a parent present a smaller,
|
||||
proctored phone interface without representing that interface as end-to-end
|
||||
child security or as a server-enforced account type.
|
||||
|
||||
**Decision.** Android will treat Supervised Mode as an opt-in, locally enforced
|
||||
policy pinned to one existing Connection and one existing Hermes profile. The
|
||||
parent is responsible for preparing and reviewing that profile before enabling
|
||||
the mode. Entering, changing, or leaving the parent policy requires Android
|
||||
device authentication. That prompt authenticates an enrolled device user, not
|
||||
a distinct server-side parent identity. While the policy is active, the app restores directly
|
||||
into a restricted root and never renders the ordinary app behind an
|
||||
authentication prompt. A missing Connection, missing profile, malformed policy,
|
||||
failed authentication, process restart, or restored route that cannot prove its
|
||||
owner fails closed to the restricted surface.
|
||||
|
||||
The ordinary Chat screen stays visually quiet. It does not carry a persistent
|
||||
"supervised" banner. Its existing Settings action opens only approved
|
||||
preferences; a separate **Parent access** row authenticates before showing the
|
||||
policy editor or full application settings. Backgrounding, inactivity, process
|
||||
recreation, and leaving parent settings relock parent access according to the
|
||||
policy. Deep links, notification actions, restored navigation, shortcuts, and
|
||||
programmatic routes pass the same gate.
|
||||
|
||||
The parent policy controls capabilities rather than imposing a special
|
||||
attachment count. Initial capabilities are text chat, new chat, cancel, steer,
|
||||
attachments, standard voice, generated-media viewing, save/share media, copy,
|
||||
retry, quote/reply, and edit/resend. Attachments and voice are independently
|
||||
enabled. When attachments are enabled, Android retains the normal supported
|
||||
attachment flow and its existing size/type limits unless the parent selects a
|
||||
stricter limit; disabling attachments removes every picker, paste-to-file,
|
||||
camera/share-to-chat, and restored-draft entry point. Disabling voice removes
|
||||
capture, voice intents, and voice settings from the restricted surface. Provider
|
||||
credentials remain on the configured Hermes host under the existing standard
|
||||
voice contract.
|
||||
|
||||
The restricted composer does not expose the command palette, slash
|
||||
autocomplete, server command catalog, or command-generated action cards. A
|
||||
leading slash is rejected locally rather than dispatched; approved outcomes
|
||||
such as New chat and Cancel remain explicit typed UI actions. Approval,
|
||||
clarification, secret, and elevated-access requests are denied or skipped
|
||||
immediately with a bounded notice. The supervised user cannot authorize them;
|
||||
a parent may retry from the authenticated full client.
|
||||
|
||||
Restricted Settings contains only parent-approved, non-authoritative choices,
|
||||
such as a supervised-only theme, text size, language, haptics, accessibility,
|
||||
message presentation, sensitive-media blur, and permitted voice playback
|
||||
preferences. Connections, Manage, profiles, models, personalities, reasoning,
|
||||
approvals, tools, plugins, Terminal, TUI, Bridge, Device Control, notification
|
||||
companion, diagnostics, logs, files, credentials, developer controls, Relay
|
||||
management, and other sessions are absent rather than shown disabled.
|
||||
|
||||
The parent may allow the configured floating pet and may independently let the
|
||||
supervised user change the phone-local profile icon or an already-installed chat
|
||||
background. The parent retains those appearance controls when supervised-user
|
||||
changes are disabled. Conversation history and its mutations are separate
|
||||
permissions: pin, rename, archive/restore, transcript sharing, and delete are
|
||||
individually allowlisted, while technical session identifiers and cross-profile
|
||||
administration remain hidden. Delete retains its confirmation step.
|
||||
|
||||
The parent also chooses what Chat discloses. **Simple** is the default: agent
|
||||
name/avatar plus generic Connected, Working, and Reconnecting states; it hides
|
||||
model, profile, provider/route, context, token/usage, reasoning, and tool detail.
|
||||
**Transparent** may add timestamps, bounded usage/context information, and
|
||||
approved activity labels without exposing arguments, results, paths, or
|
||||
credentials. **Custom** exposes the individual visibility switches. Model name
|
||||
and profile name default off in every new policy. Required errors, safety
|
||||
notices, parent-action states, and connection failures cannot be hidden by a
|
||||
cosmetic visibility choice.
|
||||
|
||||
Session selection is limited to the pinned profile. New chat creates a new
|
||||
conversation for that profile; history visibility, transcript retention, and
|
||||
conversation actions follow the parent policy. Ending Supervised Mode may clear
|
||||
local drafts, pending media, and restricted caches, but does not imply deletion
|
||||
of server-owned session history. Server history remains available through the
|
||||
parent's ordinary authenticated Hermes surfaces.
|
||||
|
||||
When the optional Relay plugin is paired, Android reports a bounded
|
||||
client-declared `supervised` tag and a non-sensitive policy summary with its
|
||||
ordinary device identity. Relay and its UI may display that tag and allow the
|
||||
operator to revoke the paired Relay session through the existing revocation
|
||||
model. The tag is informational: Relay does not interpret or enforce the Android
|
||||
policy, pin a profile, filter Gateway traffic, or certify the client. Revoking
|
||||
the Relay session removes Relay-backed capabilities but cannot revoke a direct
|
||||
Dashboard/Gateway session or remotely disable an Android-only policy. Without
|
||||
Relay pairing, Supervised Mode remains usable and locally enforced.
|
||||
|
||||
**Security and product boundary.** This mode restricts the official Android UI,
|
||||
not the Hermes agent or server. It cannot secure another client, a modified APK,
|
||||
direct server access, server-side tools, provider output, or a parent account
|
||||
whose credentials are available elsewhere. It is not a substitute for profile
|
||||
hardening, provider safety controls, parental review, operating-system controls,
|
||||
or applicable legal obligations. Public language uses **Supervised Mode** or
|
||||
**parent-controlled client**, not "child account," "safe for children," or
|
||||
"server enforced."
|
||||
|
||||
**Verification gate.** Implementation requires policy, authentication,
|
||||
navigation, process-death, deep-link, notification, capability, attachment,
|
||||
voice, session-ownership, Relay-tag, and revocation tests. Physical testing must
|
||||
cover the exact Android build on a managed/restricted device, including relock,
|
||||
restart, offline recovery, and attempts to escape the restricted root. Until
|
||||
that evidence exists, documentation and release notes must call the feature
|
||||
planned or experimental and must not call it child-ready.
|
||||
|
||||
**Consequences.** The project gains a generalized, low-noise supervised client
|
||||
without creating a new Hermes account type or making Relay a chat authorization
|
||||
proxy. Parents receive clear local controls and optional paired-device
|
||||
visibility, while server ownership and the limits of client-side enforcement
|
||||
remain explicit.
|
||||
---
|
||||
|
||||
## ADR 67 — Android Bot Mode is a separate upstream-owned messaging workspace
|
||||
|
||||
**Status:** Accepted (2026-08-24).
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
|
||||
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
|
||||
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
|
||||
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
|
||||
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
|
||||
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
|
||||
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"sourceFingerprint": {
|
||||
"algorithm": "sha256",
|
||||
"normalization": "text-lf-v1",
|
||||
"digest": "07cf8f3be17b2029f79bcbd4c291ce81986e8ce6e2ed08aad261bf8c80ce41f5",
|
||||
"digest": "8cf00c04da9e80621b439563145be49ede9539ac95ab903b1903763004e370bc",
|
||||
"files": [
|
||||
"desktop/tray/ui/App.tsx",
|
||||
"desktop/tray/ui/main.tsx",
|
||||
|
||||
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.12.1 - Sharing and recovery that work
|
||||
v1.13.0 - Bots, usage, and reliable chat
|
||||
|
||||
Shared links, text, images, and files now open as complete reviewable drafts without sending automatically. Add and Renew connection setup no longer stalls. Offline chat and profile-history failures surface clear recovery guidance instead of doing nothing or showing empty history. Diagnostics now reports secure-storage fallback and recovery without exposing credentials.
|
||||
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
|
||||
```
|
||||
## Category
|
||||
|
||||
|
||||
+5
-1
@@ -7,7 +7,7 @@ Android's declarative plugin surface is specified in
|
||||
|
||||
**Status:** v1.0.0 stable. The default path supports chat, Manage, and voice on vanilla upstream Hermes without installing the Relay plugin. Relay is additive: terminal, bridge/device control, notification companion, remote access, extra/provider-native voice, desktop tooling, and dashboard Relay management. Historical phase notes remain in this file for context; the current route ownership source of truth is [`docs/upstream-surface-matrix.md`](upstream-surface-matrix.md).
|
||||
**Repo:** [Codename-11/hermes-relay](https://github.com/Codename-11/hermes-relay)
|
||||
**Updated:** 2026-08-22
|
||||
**Updated:** 2026-08-24
|
||||
|
||||
---
|
||||
|
||||
@@ -46,6 +46,10 @@ token, terminal/bridge grants, and optional network candidates.
|
||||
4. **Clean UX** — Material 3, minimal setup, and clear route identity for Vanilla Hermes vs Relay.
|
||||
5. **Offline-aware** — graceful degradation when connection drops. Auto-reconnect with exponential backoff.
|
||||
6. **Server-side state** — the app is a thin client. Sessions, history, memory, profiles, and dashboard state live on the Hermes server.
|
||||
7. **Supervision is a client policy** — Android may offer a parent-controlled,
|
||||
profile-pinned restricted interface, but it does not claim to make the
|
||||
selected Hermes profile, server, or agent child-safe. See ADR 66 and the
|
||||
[Supervised Mode guide](../user-docs/guide/supervised-mode.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.12.1"
|
||||
appVersionCode = "48"
|
||||
appVersionName = "1.13.0"
|
||||
appVersionCode = "49"
|
||||
agp = "9.3.2"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.08.00"
|
||||
|
||||
Vendored
+7
-7
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
"label": "Relay",
|
||||
"description": "Paired devices, bridge activity, media inspection, and remote access for hermes-relay",
|
||||
"icon": "Activity",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"tab": {
|
||||
"path": "/relay",
|
||||
"position": "after:skills"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.12",
|
||||
"qrcode": "^1.5.4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
const MAX_PROFILE_LABEL_LENGTH = 80;
|
||||
const MAX_CAPABILITY_LENGTH = 32;
|
||||
const MAX_CAPABILITIES = 12;
|
||||
const MAX_VISIBLE_CAPABILITIES = 4;
|
||||
|
||||
const CAPABILITY_LABELS = {
|
||||
text_chat: "Text chat",
|
||||
attachments: "Attachments",
|
||||
voice: "Voice",
|
||||
generated_images: "Generated images",
|
||||
new_chat: "New chat",
|
||||
cancel: "Cancel",
|
||||
steer: "Steer",
|
||||
share_images: "Share images",
|
||||
copy: "Copy",
|
||||
retry: "Retry",
|
||||
quote_reply: "Quote & reply",
|
||||
timestamps: "Timestamps",
|
||||
};
|
||||
|
||||
function boundedText(value, maxLength) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return null;
|
||||
return normalized.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function capabilityLabel(value) {
|
||||
const normalized = boundedText(value, MAX_CAPABILITY_LENGTH);
|
||||
if (!normalized) return null;
|
||||
const key = normalized.toLowerCase();
|
||||
return CAPABILITY_LABELS[key] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize optional, client-reported supervised-mode metadata for display.
|
||||
* This deliberately requires active === true and never treats the report as a
|
||||
* Relay authorization policy.
|
||||
*/
|
||||
export function supervisedSessionDisplay(session) {
|
||||
const raw = session && session.supervised_mode;
|
||||
if (
|
||||
!raw ||
|
||||
typeof raw !== "object" ||
|
||||
Array.isArray(raw) ||
|
||||
raw.active !== true ||
|
||||
raw.enforcement_owner !== "android_client"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profileLabel = boundedText(raw.profile_label, MAX_PROFILE_LABEL_LENGTH);
|
||||
const source = Array.isArray(raw.capabilities) ? raw.capabilities : [];
|
||||
const capabilities = [];
|
||||
const seen = new Set();
|
||||
for (const entry of source.slice(0, MAX_CAPABILITIES)) {
|
||||
const label = capabilityLabel(entry);
|
||||
if (!label) continue;
|
||||
const key = label.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
capabilities.push(label);
|
||||
}
|
||||
|
||||
const visibleCapabilities = capabilities.slice(0, MAX_VISIBLE_CAPABILITIES);
|
||||
const remainingCapabilityCount = Math.max(0, capabilities.length - visibleCapabilities.length);
|
||||
|
||||
return {
|
||||
profileLabel,
|
||||
visibleCapabilities,
|
||||
remainingCapabilityCount,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../lib/api.js";
|
||||
import { relativeTime, ttlCountdown, uptime, shortToken } from "../lib/formatters.js";
|
||||
import { formatSessionExpiry } from "../lib/session-expiry.mjs";
|
||||
import { supervisedSessionDisplay } from "../lib/supervised-session.mjs";
|
||||
import PairDialog from "../components/PairDialog.jsx";
|
||||
import {
|
||||
Alert,
|
||||
@@ -596,6 +597,7 @@ export default function RelayManagement({ autoRefresh }) {
|
||||
|
||||
const ov = overview || {};
|
||||
const list = sessions || [];
|
||||
const hasSupervisedSession = list.some((session) => supervisedSessionDisplay(session));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -643,6 +645,13 @@ export default function RelayManagement({ autoRefresh }) {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasSupervisedSession ? (
|
||||
<div className="mb-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
Supervised mode is reported and enforced by the Android client, not by Relay. Relay
|
||||
shows the client's reported settings here so a paired device can be identified and
|
||||
revoked.
|
||||
</div>
|
||||
) : null}
|
||||
{!autoRefresh ? (
|
||||
<div className="mb-3">
|
||||
<Button size="sm" variant="outline" onClick={load}>
|
||||
@@ -685,13 +694,21 @@ export default function RelayManagement({ autoRefresh }) {
|
||||
const grants = extractGrants(s);
|
||||
const type = classifySession(s, grants);
|
||||
const transport = sessionTransport(s);
|
||||
const supervised = supervisedSessionDisplay(s);
|
||||
const deviceDetail = [s.device_model, s.device_platform]
|
||||
.filter((value) => value && value !== "unknown")
|
||||
.join(" · ");
|
||||
return (
|
||||
<TableRow key={tokenPrefix || idx}>
|
||||
<TableCell className="font-medium">
|
||||
<div>{label}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{label}</span>
|
||||
{supervised ? (
|
||||
<Badge variant="secondary" className="w-fit text-xs">
|
||||
Supervised
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="font-mono text-xs font-normal text-muted-foreground">
|
||||
{tokenPrefix ? shortToken(tokenPrefix, 12) : "no token prefix"}
|
||||
</div>
|
||||
@@ -700,6 +717,22 @@ export default function RelayManagement({ autoRefresh }) {
|
||||
{deviceDetail}
|
||||
</div>
|
||||
) : null}
|
||||
{supervised && supervised.profileLabel ? (
|
||||
<div className="text-xs font-normal text-muted-foreground">
|
||||
Pinned profile: {supervised.profileLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{supervised && supervised.visibleCapabilities.length > 0 ? (
|
||||
<div
|
||||
className="max-w-xs text-xs font-normal text-muted-foreground"
|
||||
title="Capabilities reported by the Android client"
|
||||
>
|
||||
Client allows: {supervised.visibleCapabilities.join(" · ")}
|
||||
{supervised.remainingCapabilityCount > 0
|
||||
? ` · +${supervised.remainingCapabilityCount} more`
|
||||
: ""}
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { supervisedSessionDisplay } from "../src/lib/supervised-session.mjs";
|
||||
|
||||
test("leaves legacy and inactive sessions unchanged", () => {
|
||||
assert.equal(supervisedSessionDisplay({ device_name: "Pixel" }), null);
|
||||
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: false } }), null);
|
||||
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: "true" } }), null);
|
||||
assert.equal(
|
||||
supervisedSessionDisplay({ supervised_mode: { active: true, enforcement_owner: "relay" } }),
|
||||
null,
|
||||
);
|
||||
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: true } }), null);
|
||||
assert.equal(supervisedSessionDisplay({ supervised_mode: [] }), null);
|
||||
});
|
||||
|
||||
test("formats active client-reported metadata", () => {
|
||||
assert.deepEqual(
|
||||
supervisedSessionDisplay({
|
||||
supervised_mode: {
|
||||
active: true,
|
||||
profile_label: " Willow ",
|
||||
capabilities: ["attachments", "voice", "generated_images", "new_chat"],
|
||||
enforcement_owner: "android_client",
|
||||
},
|
||||
}),
|
||||
{
|
||||
profileLabel: "Willow",
|
||||
visibleCapabilities: ["Attachments", "Voice", "Generated images", "New chat"],
|
||||
remainingCapabilityCount: 0,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("bounds, sanitizes, and deduplicates untrusted display values", () => {
|
||||
const result = supervisedSessionDisplay({
|
||||
supervised_mode: {
|
||||
active: true,
|
||||
enforcement_owner: "android_client",
|
||||
profile_label: `Willow\u0000 ${"x".repeat(100)}`,
|
||||
capabilities: [
|
||||
"voice",
|
||||
"VOICE",
|
||||
"unknown_capability",
|
||||
"text_chat",
|
||||
"generated_images",
|
||||
"cancel",
|
||||
"steer",
|
||||
"attachments",
|
||||
"new_chat",
|
||||
"share_images",
|
||||
"copy",
|
||||
"retry",
|
||||
"quote_reply",
|
||||
"timestamps",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.profileLabel.length, 80);
|
||||
assert.deepEqual(
|
||||
result.visibleCapabilities,
|
||||
["Voice", "Text chat", "Generated images", "Cancel"],
|
||||
);
|
||||
assert.equal(result.remainingCapabilityCount, 6);
|
||||
});
|
||||
|
||||
test("tolerates malformed optional members", () => {
|
||||
assert.deepEqual(
|
||||
supervisedSessionDisplay({
|
||||
supervised_mode: {
|
||||
active: true,
|
||||
enforcement_owner: "android_client",
|
||||
profile_label: 42,
|
||||
capabilities: "voice",
|
||||
},
|
||||
}),
|
||||
{ profileLabel: null, visibleCapabilities: [], remainingCapabilityCount: 0 },
|
||||
);
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name: hermes-relay
|
||||
manifest_version: 1
|
||||
version: 1.9.0
|
||||
version: 1.10.0
|
||||
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
|
||||
author: Axiom Labs
|
||||
# All three are OPTIONAL — only needed if you use the relay's extra /
|
||||
|
||||
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
|
||||
# CLI+UI releases use desktop/package.json and desktop-v* tags. The /health endpoint
|
||||
# reports this plugin version, and stale values make live diagnosis harder than
|
||||
# it should be.
|
||||
__version__ = "1.9.0"
|
||||
__version__ = "1.10.0"
|
||||
|
||||
from .server import create_app, main # noqa: E402 — must come after __version__
|
||||
|
||||
|
||||
+107
-1
@@ -97,6 +97,29 @@ _PAIRING_CODE_TTL = 600.0
|
||||
DEFAULT_REFRESH_TTL_SECONDS: float = 180 * 24 * 3600 # 180 days
|
||||
_REFRESH_TOKEN_BYTES = 32
|
||||
|
||||
# Client-reported supervised-mode metadata is intentionally small and
|
||||
# non-authoritative. Relay stores it only so paired-device surfaces can show
|
||||
# the operator which Android client is presenting a restricted UI. The
|
||||
# Android client remains the enforcement owner.
|
||||
SUPERVISED_PROFILE_LABEL_MAX_LENGTH = 80
|
||||
SUPERVISED_CAPABILITY_MAX_COUNT = 12
|
||||
SUPERVISED_CAPABILITIES: frozenset[str] = frozenset(
|
||||
{
|
||||
"attachments",
|
||||
"cancel",
|
||||
"copy",
|
||||
"generated_images",
|
||||
"new_chat",
|
||||
"quote_reply",
|
||||
"retry",
|
||||
"share_images",
|
||||
"steer",
|
||||
"text_chat",
|
||||
"timestamps",
|
||||
"voice",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── Data models ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -114,6 +137,58 @@ def _refresh_token_hash(token: str) -> str:
|
||||
return sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupervisedMode:
|
||||
"""Bounded, client-reported metadata for paired-device display only."""
|
||||
|
||||
active: bool = False
|
||||
profile_label: str = ""
|
||||
capabilities: tuple[str, ...] = ()
|
||||
|
||||
def to_public_dict(self) -> dict[str, Any]:
|
||||
"""Return the stable public wire shape for an active report."""
|
||||
return {
|
||||
"active": True,
|
||||
"profile_label": self.profile_label,
|
||||
"capabilities": list(self.capabilities),
|
||||
"enforcement_owner": "android_client",
|
||||
}
|
||||
|
||||
|
||||
def parse_supervised_mode(value: Any) -> SupervisedMode:
|
||||
"""Validate untrusted supervised-mode metadata.
|
||||
|
||||
Missing, inactive, malformed, oversized, or unknown values all normalize
|
||||
to ordinary mode. Capability values are allowlisted so this public summary
|
||||
cannot become a side channel for model, tool, path, or arbitrary client
|
||||
data.
|
||||
"""
|
||||
ordinary = SupervisedMode()
|
||||
if not isinstance(value, dict) or value.get("active") is not True:
|
||||
return ordinary
|
||||
|
||||
raw_label = value.get("profile_label")
|
||||
raw_capabilities = value.get("capabilities", [])
|
||||
if not isinstance(raw_label, str) or not isinstance(raw_capabilities, list):
|
||||
return ordinary
|
||||
|
||||
if not raw_label.isprintable():
|
||||
return ordinary
|
||||
label = raw_label.strip()
|
||||
if not label or len(label) > SUPERVISED_PROFILE_LABEL_MAX_LENGTH:
|
||||
return ordinary
|
||||
if len(raw_capabilities) > SUPERVISED_CAPABILITY_MAX_COUNT:
|
||||
return ordinary
|
||||
|
||||
capabilities: list[str] = []
|
||||
for candidate in raw_capabilities:
|
||||
if not isinstance(candidate, str) or candidate not in SUPERVISED_CAPABILITIES:
|
||||
return ordinary
|
||||
if candidate not in capabilities:
|
||||
capabilities.append(candidate)
|
||||
return SupervisedMode(True, label, tuple(capabilities))
|
||||
|
||||
|
||||
def _default_grants(ttl_seconds: float, now: float) -> dict[str, float]:
|
||||
"""Compute default per-channel grants given an overall session TTL.
|
||||
|
||||
@@ -229,6 +304,7 @@ class Session:
|
||||
refresh_token: str | None = field(default=None, repr=False, compare=False)
|
||||
device_model: str = "unknown"
|
||||
device_platform: str = "unknown"
|
||||
supervised_mode: SupervisedMode = field(default_factory=SupervisedMode)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.expires_at == 0.0:
|
||||
@@ -290,6 +366,7 @@ class TrustedDevice:
|
||||
device_form_factor: str = "unknown"
|
||||
device_model: str = "unknown"
|
||||
device_platform: str = "unknown"
|
||||
supervised_mode: SupervisedMode = field(default_factory=SupervisedMode)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.expires_at == 0.0:
|
||||
@@ -458,7 +535,7 @@ def _session_to_json(session: Session) -> dict[str, Any]:
|
||||
return "never"
|
||||
return v
|
||||
|
||||
return {
|
||||
payload = {
|
||||
"token": session.token,
|
||||
"device_name": session.device_name,
|
||||
"device_id": session.device_id,
|
||||
@@ -473,6 +550,9 @@ def _session_to_json(session: Session) -> dict[str, Any]:
|
||||
"device_platform": session.device_platform,
|
||||
"first_seen": session.first_seen,
|
||||
}
|
||||
if session.supervised_mode.active:
|
||||
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
|
||||
return payload
|
||||
|
||||
|
||||
def _session_from_json(payload: dict[str, Any]) -> Session | None:
|
||||
@@ -508,6 +588,7 @@ def _session_from_json(payload: dict[str, Any]) -> Session | None:
|
||||
device_model = str(payload.get("device_model", "unknown"))
|
||||
device_platform = str(payload.get("device_platform", "unknown"))
|
||||
first_seen = float(payload.get("first_seen", created_at))
|
||||
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -525,6 +606,7 @@ def _session_from_json(payload: dict[str, Any]) -> Session | None:
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
first_seen=first_seen,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -550,6 +632,8 @@ def _trusted_device_to_json(device: TrustedDevice) -> dict[str, Any]:
|
||||
}
|
||||
if device.grants is not None:
|
||||
payload["grants"] = dict(device.grants)
|
||||
if device.supervised_mode.active:
|
||||
payload["supervised_mode"] = device.supervised_mode.to_public_dict()
|
||||
return payload
|
||||
|
||||
|
||||
@@ -586,6 +670,7 @@ def _trusted_device_from_json(payload: dict[str, Any]) -> TrustedDevice | None:
|
||||
device_form_factor = str(payload.get("device_form_factor", "unknown"))
|
||||
device_model = str(payload.get("device_model", "unknown"))
|
||||
device_platform = str(payload.get("device_platform", "unknown"))
|
||||
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -606,6 +691,7 @@ def _trusted_device_from_json(payload: dict[str, Any]) -> TrustedDevice | None:
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -883,6 +969,7 @@ class SessionManager:
|
||||
device_form_factor: str = "unknown",
|
||||
device_model: str = "unknown",
|
||||
device_platform: str = "unknown",
|
||||
supervised_mode: SupervisedMode | None = None,
|
||||
issue_refresh_token: bool = False,
|
||||
) -> Session:
|
||||
"""Create a new session for an authenticated device.
|
||||
@@ -913,6 +1000,9 @@ class SessionManager:
|
||||
device_platform:
|
||||
Optional operating-system/platform metadata retained for device
|
||||
details. Neither field participates in authorization.
|
||||
supervised_mode:
|
||||
Optional bounded report of Android's supervised client state.
|
||||
Informational only; Relay does not enforce the reported policy.
|
||||
issue_refresh_token:
|
||||
When True, also create a persisted trusted-device credential and
|
||||
attach the raw one-time refresh token to the returned
|
||||
@@ -922,6 +1012,7 @@ class SessionManager:
|
||||
"""
|
||||
if ttl_seconds is None:
|
||||
ttl_seconds = DEFAULT_TTL_SECONDS
|
||||
supervised_mode = supervised_mode or SupervisedMode()
|
||||
|
||||
now = time.time()
|
||||
if ttl_seconds == 0:
|
||||
@@ -981,6 +1072,7 @@ class SessionManager:
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
|
||||
token = str(uuid.uuid4())
|
||||
@@ -997,6 +1089,7 @@ class SessionManager:
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
first_seen=now,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
@@ -1051,6 +1144,7 @@ class SessionManager:
|
||||
device_form_factor=session.device_form_factor,
|
||||
device_model=session.device_model,
|
||||
device_platform=session.device_platform,
|
||||
supervised_mode=session.supervised_mode,
|
||||
)
|
||||
session.refresh_token = refresh_token
|
||||
self._save_to_disk()
|
||||
@@ -1067,6 +1161,7 @@ class SessionManager:
|
||||
device_form_factor: str = "unknown",
|
||||
device_model: str = "unknown",
|
||||
device_platform: str = "unknown",
|
||||
supervised_mode: SupervisedMode | None = None,
|
||||
) -> Session | None:
|
||||
"""Mint a replacement session from a trusted-device refresh token.
|
||||
|
||||
@@ -1121,6 +1216,8 @@ class SessionManager:
|
||||
trusted.device_model = device_model
|
||||
if device_platform and device_platform != "unknown":
|
||||
trusted.device_platform = device_platform
|
||||
if supervised_mode is not None:
|
||||
trusted.supervised_mode = supervised_mode
|
||||
self._trusted_devices[new_refresh_hash] = trusted
|
||||
|
||||
session = self.create_session(
|
||||
@@ -1133,6 +1230,7 @@ class SessionManager:
|
||||
device_form_factor=trusted.device_form_factor,
|
||||
device_model=trusted.device_model,
|
||||
device_platform=trusted.device_platform,
|
||||
supervised_mode=trusted.supervised_mode,
|
||||
issue_refresh_token=False,
|
||||
)
|
||||
session.refresh_token = new_refresh_token
|
||||
@@ -1153,6 +1251,7 @@ class SessionManager:
|
||||
device_platform: str | None = None,
|
||||
client_surface: str | None = None,
|
||||
device_form_factor: str | None = None,
|
||||
supervised_mode: SupervisedMode | None = None,
|
||||
) -> None:
|
||||
"""Adopt identity metadata from a valid reconnecting client.
|
||||
|
||||
@@ -1180,6 +1279,10 @@ class SessionManager:
|
||||
setattr(session, field_name, value)
|
||||
changed = True
|
||||
|
||||
if supervised_mode is not None and session.supervised_mode != supervised_mode:
|
||||
session.supervised_mode = supervised_mode
|
||||
changed = True
|
||||
|
||||
for trusted in self._trusted_devices.values():
|
||||
if trusted.device_id != session.device_id:
|
||||
continue
|
||||
@@ -1192,6 +1295,9 @@ class SessionManager:
|
||||
if getattr(trusted, field_name) != value:
|
||||
setattr(trusted, field_name, value)
|
||||
changed = True
|
||||
if supervised_mode is not None and trusted.supervised_mode != supervised_mode:
|
||||
trusted.supervised_mode = supervised_mode
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
self._save_to_disk()
|
||||
|
||||
+62
-2
@@ -53,6 +53,7 @@ from .auth import (
|
||||
RateLimiter,
|
||||
Session,
|
||||
SessionManager,
|
||||
parse_supervised_mode,
|
||||
)
|
||||
from .channels.bridge import BridgeError, BridgeHandler
|
||||
from .channels.chat import ChatHandler
|
||||
@@ -887,7 +888,7 @@ def _session_to_dict(session: Session, current_token: str | None) -> dict[str, A
|
||||
return None if math.isinf(ts) else ts
|
||||
|
||||
grants_out = {k: _norm(v) for k, v in session.grants.items()}
|
||||
return {
|
||||
payload = {
|
||||
"token_prefix": session.token[:8],
|
||||
"device_name": session.device_name,
|
||||
"device_id": session.device_id,
|
||||
@@ -903,6 +904,9 @@ def _session_to_dict(session: Session, current_token: str | None) -> dict[str, A
|
||||
"device_platform": session.device_platform,
|
||||
"is_current": current_token is not None and session.token == current_token,
|
||||
}
|
||||
if session.supervised_mode.active:
|
||||
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
|
||||
return payload
|
||||
|
||||
|
||||
def _require_bearer_session(
|
||||
@@ -1012,6 +1016,18 @@ async def handle_sessions_revoke(request: web.Request) -> web.Response:
|
||||
target = matches[0]
|
||||
revoked_self = current_token is not None and target.token == current_token
|
||||
server.sessions.revoke_session(target.token)
|
||||
# Revocation ends already-connected Relay sockets as well as preventing
|
||||
# future authentication. This does not enforce the Android supervised
|
||||
# policy; it revokes the ordinary paired Relay session that reported it.
|
||||
revoked_sockets = [
|
||||
ws for ws, token in server._clients.items() if token == target.token
|
||||
]
|
||||
for ws in revoked_sockets:
|
||||
if not ws.closed:
|
||||
await ws.close(
|
||||
code=aiohttp.WSCloseCode.POLICY_VIOLATION,
|
||||
message=b"Relay session revoked",
|
||||
)
|
||||
route_credential_id = credential_id_for(target.token)
|
||||
server.secure_link_route_credentials.pop(route_credential_id, None)
|
||||
if server.secure_link_connector is not None:
|
||||
@@ -1185,7 +1201,7 @@ async def handle_provider_usage(request: web.Request) -> web.Response:
|
||||
request.query.get("profile"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise web.HTTPBadRequest(text=str(exc)) from exc
|
||||
raise web.HTTPBadRequest(text="invalid or unknown profile") from exc
|
||||
return web.json_response(
|
||||
await collect_provider_usage(
|
||||
profile_home=profile_home,
|
||||
@@ -4209,6 +4225,8 @@ def _build_auth_ok_payload(
|
||||
}
|
||||
if session.refresh_token:
|
||||
payload["refresh_token"] = session.refresh_token
|
||||
if session.supervised_mode.active:
|
||||
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
|
||||
if route_credential is not None:
|
||||
payload["route_credential"] = route_credential
|
||||
return payload
|
||||
@@ -4310,6 +4328,11 @@ async def _authenticate(
|
||||
device_platform = str(
|
||||
payload.get("device_platform", "unknown") or "unknown"
|
||||
).strip()
|
||||
# Every authentication is a fresh client report. Missing or invalid
|
||||
# metadata explicitly returns the paired session to ordinary mode rather
|
||||
# than leaving a stale supervised badge behind after the client disables
|
||||
# the mode or downgrades.
|
||||
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
|
||||
|
||||
# Pairing policy is attached by a loopback-only operator flow. Clients
|
||||
# may still send ttl_seconds / grants for wire compatibility, but those
|
||||
@@ -4338,6 +4361,7 @@ async def _authenticate(
|
||||
device_form_factor=(
|
||||
device_form_factor if "device_form_factor" in payload else None
|
||||
),
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
if (
|
||||
not refresh_token_attempt
|
||||
@@ -4370,6 +4394,7 @@ async def _authenticate(
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
if session is not None:
|
||||
server.rate_limiter.record_success(remote_ip)
|
||||
@@ -4403,6 +4428,7 @@ async def _authenticate(
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
server.rate_limiter.record_success(remote_ip)
|
||||
@@ -4584,6 +4610,40 @@ async def _handle_system(
|
||||
elif msg_type == "pong":
|
||||
# Client responding to our ping — nothing to do
|
||||
pass
|
||||
elif msg_type == "supervised.update":
|
||||
# Informational update from an already-authenticated Android client.
|
||||
# The socket's paired session is the only ownership input: payload
|
||||
# fields cannot select or modify another session. Relay deliberately
|
||||
# does not enforce the reported client policy.
|
||||
token = server._clients.get(ws)
|
||||
session = server.sessions.get_session(token) if token else None
|
||||
if session is None:
|
||||
await _send_system(
|
||||
ws,
|
||||
"error",
|
||||
{"message": "Authenticated Relay session is no longer valid"},
|
||||
msg_id,
|
||||
)
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
|
||||
server.sessions.update_session_device_metadata(
|
||||
session,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
applied: dict[str, Any] = {
|
||||
"active": False,
|
||||
"enforcement_owner": "android_client",
|
||||
}
|
||||
if supervised_mode.active:
|
||||
applied = supervised_mode.to_public_dict()
|
||||
await _send_system(
|
||||
ws,
|
||||
"supervised.updated",
|
||||
{"supervised_mode": applied},
|
||||
msg_id,
|
||||
)
|
||||
else:
|
||||
logger.debug("Unhandled system message type: %s", msg_type)
|
||||
|
||||
|
||||
@@ -258,6 +258,19 @@ class ProviderUsageEndpointTests(AioHTTPTestCase):
|
||||
response = await self.client.get("/usage/providers")
|
||||
self.assertEqual(response.status, 401)
|
||||
|
||||
async def test_invalid_profile_error_does_not_reflect_request_input(self) -> None:
|
||||
token = await self._mint()
|
||||
response = await self.client.get(
|
||||
"/usage/providers?profile=../private-token",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
body = await response.text()
|
||||
self.assertEqual(response.status, 400 if self.usage_enabled else 404)
|
||||
if self.usage_enabled:
|
||||
self.assertEqual(body, "invalid or unknown profile")
|
||||
self.assertNotIn("private-token", body)
|
||||
|
||||
@mock.patch(
|
||||
"plugin.relay.server.collect_provider_usage",
|
||||
new=mock.AsyncMock(return_value={"schema_version": 1, "providers": []}),
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Client-reported supervised-mode metadata is bounded and informational."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from plugin.relay.auth import (
|
||||
SUPERVISED_CAPABILITY_MAX_COUNT,
|
||||
SUPERVISED_PROFILE_LABEL_MAX_LENGTH,
|
||||
SessionManager,
|
||||
SupervisedMode,
|
||||
parse_supervised_mode,
|
||||
)
|
||||
from plugin.relay.config import RelayConfig
|
||||
from plugin.relay.server import RelayServer, _build_auth_ok_payload, create_app
|
||||
|
||||
|
||||
class SupervisedModeParsingTests(unittest.TestCase):
|
||||
def test_valid_report_is_normalized_and_deduplicated(self) -> None:
|
||||
parsed = parse_supervised_mode(
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": " Learning ",
|
||||
"capabilities": ["text_chat", "voice", "voice"],
|
||||
"enforcement_owner": "server", # client cannot override it
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
parsed,
|
||||
SupervisedMode(True, "Learning", ("text_chat", "voice")),
|
||||
)
|
||||
self.assertEqual(parsed.to_public_dict()["enforcement_owner"], "android_client")
|
||||
|
||||
def test_missing_inactive_and_malformed_reports_are_ordinary(self) -> None:
|
||||
invalid = (
|
||||
None,
|
||||
[],
|
||||
{"active": False, "profile_label": "Learning"},
|
||||
{"active": "true", "profile_label": "Learning"},
|
||||
{"active": True, "profile_label": 7},
|
||||
{"active": True, "profile_label": "Learning", "capabilities": {}},
|
||||
{"active": True, "profile_label": "Learning\n", "capabilities": []},
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["model:gpt-private"],
|
||||
},
|
||||
)
|
||||
for value in invalid:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(parse_supervised_mode(value), SupervisedMode())
|
||||
|
||||
def test_oversized_report_is_ordinary(self) -> None:
|
||||
self.assertEqual(
|
||||
parse_supervised_mode(
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "x" * (SUPERVISED_PROFILE_LABEL_MAX_LENGTH + 1),
|
||||
"capabilities": [],
|
||||
}
|
||||
),
|
||||
SupervisedMode(),
|
||||
)
|
||||
self.assertEqual(
|
||||
parse_supervised_mode(
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["voice"] * (SUPERVISED_CAPABILITY_MAX_COUNT + 1),
|
||||
}
|
||||
),
|
||||
SupervisedMode(),
|
||||
)
|
||||
|
||||
|
||||
class SupervisedModePersistenceTests(unittest.TestCase):
|
||||
def test_active_report_and_trusted_device_survive_restart_and_refresh(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "sessions.json"
|
||||
mode = SupervisedMode(
|
||||
True,
|
||||
"Learning",
|
||||
("text_chat", "attachments", "voice"),
|
||||
)
|
||||
manager = SessionManager(persistence_path=path)
|
||||
session = manager.create_session(
|
||||
"Managed phone",
|
||||
"managed-phone-id",
|
||||
supervised_mode=mode,
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
refresh_token = session.refresh_token
|
||||
assert refresh_token is not None
|
||||
|
||||
reloaded = SessionManager(persistence_path=path)
|
||||
restored = reloaded.get_session(session.token)
|
||||
self.assertIsNotNone(restored)
|
||||
assert restored is not None
|
||||
self.assertEqual(restored.supervised_mode, mode)
|
||||
|
||||
reloaded._sessions.clear()
|
||||
replacement = reloaded.refresh_session(
|
||||
refresh_token,
|
||||
device_name="Managed phone",
|
||||
device_id="managed-phone-id",
|
||||
)
|
||||
self.assertIsNotNone(replacement)
|
||||
assert replacement is not None
|
||||
self.assertEqual(replacement.supervised_mode, mode)
|
||||
|
||||
def test_legacy_and_invalid_disk_rows_load_as_ordinary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "sessions.json"
|
||||
manager = SessionManager(persistence_path=path)
|
||||
session = manager.create_session("Legacy phone", "legacy-id")
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
payload["sessions"][0]["supervised_mode"] = {
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["unknown_future_value"],
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
reloaded = SessionManager(persistence_path=path)
|
||||
restored = reloaded.get_session(session.token)
|
||||
self.assertIsNotNone(restored)
|
||||
assert restored is not None
|
||||
self.assertEqual(restored.supervised_mode, SupervisedMode())
|
||||
|
||||
payload["sessions"][0].pop("supervised_mode")
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
legacy = SessionManager(persistence_path=path).get_session(session.token)
|
||||
self.assertIsNotNone(legacy)
|
||||
assert legacy is not None
|
||||
self.assertEqual(legacy.supervised_mode, SupervisedMode())
|
||||
|
||||
def test_auth_ok_only_emits_active_client_report(self) -> None:
|
||||
server = RelayServer(RelayConfig())
|
||||
ordinary = server.sessions.create_session("Phone", "ordinary-id")
|
||||
self.assertNotIn("supervised_mode", _build_auth_ok_payload(ordinary, server))
|
||||
|
||||
managed = server.sessions.create_session(
|
||||
"Managed phone",
|
||||
"managed-id",
|
||||
supervised_mode=SupervisedMode(True, "Learning", ("voice",)),
|
||||
)
|
||||
report = _build_auth_ok_payload(managed, server)["supervised_mode"]
|
||||
self.assertEqual(report["profile_label"], "Learning")
|
||||
self.assertEqual(report["capabilities"], ["voice"])
|
||||
self.assertEqual(report["enforcement_owner"], "android_client")
|
||||
|
||||
|
||||
class SupervisedModeSessionRoutesTests(AioHTTPTestCase):
|
||||
async def get_application(self) -> web.Application:
|
||||
return create_app(RelayConfig())
|
||||
|
||||
async def test_list_exposes_active_report_and_omits_ordinary_report(self) -> None:
|
||||
ordinary = self.app["server"].sessions.create_session("Phone", "ordinary-id")
|
||||
self.app["server"].sessions.create_session(
|
||||
"Managed phone",
|
||||
"managed-id",
|
||||
supervised_mode=SupervisedMode(
|
||||
True, "Learning", ("text_chat", "attachments")
|
||||
),
|
||||
)
|
||||
|
||||
response = await self.client.get(
|
||||
"/sessions", headers={"Authorization": f"Bearer {ordinary.token}"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
rows = {row["device_name"]: row for row in (await response.json())["sessions"]}
|
||||
self.assertNotIn("supervised_mode", rows["Phone"])
|
||||
self.assertEqual(
|
||||
rows["Managed phone"]["supervised_mode"],
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["text_chat", "attachments"],
|
||||
"enforcement_owner": "android_client",
|
||||
},
|
||||
)
|
||||
|
||||
async def test_pairing_auth_records_and_returns_client_report(self) -> None:
|
||||
response = await self.client.post(
|
||||
"/pairing/register", json={"code": "MODE01"}
|
||||
)
|
||||
self.assertEqual(response.status, 200, await response.text())
|
||||
|
||||
socket = await self.client.ws_connect("/ws")
|
||||
await socket.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "auth",
|
||||
"payload": {
|
||||
"pairing_code": "MODE01",
|
||||
"device_name": "Managed phone",
|
||||
"device_id": "managed-auth-id",
|
||||
"client_surface": "android",
|
||||
"supervised_mode": {
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["text_chat", "voice"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
envelope = await socket.receive_json()
|
||||
await socket.close()
|
||||
|
||||
self.assertEqual(envelope["type"], "auth.ok")
|
||||
report = envelope["payload"]["supervised_mode"]
|
||||
self.assertEqual(report["profile_label"], "Learning")
|
||||
self.assertEqual(report["capabilities"], ["text_chat", "voice"])
|
||||
self.assertEqual(report["enforcement_owner"], "android_client")
|
||||
stored = self.app["server"].sessions.get_session(
|
||||
envelope["payload"]["session_token"]
|
||||
)
|
||||
self.assertIsNotNone(stored)
|
||||
assert stored is not None
|
||||
self.assertTrue(stored.supervised_mode.active)
|
||||
|
||||
reconnect = await self.client.ws_connect("/ws")
|
||||
await reconnect.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "auth",
|
||||
"payload": {
|
||||
"session_token": stored.token,
|
||||
"device_id": "managed-auth-id",
|
||||
},
|
||||
}
|
||||
)
|
||||
ordinary_envelope = await reconnect.receive_json()
|
||||
await reconnect.close()
|
||||
self.assertEqual(ordinary_envelope["type"], "auth.ok")
|
||||
self.assertNotIn("supervised_mode", ordinary_envelope["payload"])
|
||||
refreshed = self.app["server"].sessions.get_session(stored.token)
|
||||
self.assertIsNotNone(refreshed)
|
||||
assert refreshed is not None
|
||||
self.assertFalse(refreshed.supervised_mode.active)
|
||||
|
||||
async def test_authenticated_live_update_is_owned_acked_and_persisted(self) -> None:
|
||||
manager = self.app["server"].sessions
|
||||
original = SupervisedMode(True, "Learning", ("text_chat", "voice"))
|
||||
session = manager.create_session(
|
||||
"Managed phone",
|
||||
"managed-live-id",
|
||||
supervised_mode=original,
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
other = manager.create_session(
|
||||
"Other phone",
|
||||
"other-id",
|
||||
supervised_mode=SupervisedMode(True, "Other", ("text_chat",)),
|
||||
)
|
||||
|
||||
socket = await self.client.ws_connect("/ws")
|
||||
await socket.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "auth",
|
||||
"payload": {
|
||||
"session_token": session.token,
|
||||
"device_id": session.device_id,
|
||||
"supervised_mode": original.to_public_dict(),
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertEqual((await socket.receive_json())["type"], "auth.ok")
|
||||
|
||||
await socket.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "supervised.update",
|
||||
"id": "update-active",
|
||||
"payload": {
|
||||
# Must be ignored: ownership comes from the authenticated
|
||||
# socket, not any selector supplied in the update body.
|
||||
"session_token": other.token,
|
||||
"supervised_mode": {
|
||||
"active": True,
|
||||
"profile_label": "School",
|
||||
"capabilities": ["text_chat", "attachments"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
ack = await socket.receive_json()
|
||||
self.assertEqual(ack["type"], "supervised.updated")
|
||||
self.assertEqual(ack["id"], "update-active")
|
||||
self.assertEqual(
|
||||
ack["payload"]["supervised_mode"],
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "School",
|
||||
"capabilities": ["text_chat", "attachments"],
|
||||
"enforcement_owner": "android_client",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
manager.get_session(session.token).supervised_mode,
|
||||
SupervisedMode(True, "School", ("text_chat", "attachments")),
|
||||
)
|
||||
self.assertEqual(manager.get_session(other.token).supervised_mode.profile_label, "Other")
|
||||
self.assertTrue(
|
||||
any(
|
||||
device.device_id == session.device_id
|
||||
and device.supervised_mode.profile_label == "School"
|
||||
for device in manager._trusted_devices.values()
|
||||
)
|
||||
)
|
||||
|
||||
await socket.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "supervised.update",
|
||||
"id": "update-inactive",
|
||||
"payload": {"supervised_mode": {"active": False}},
|
||||
}
|
||||
)
|
||||
cleared = await socket.receive_json()
|
||||
await socket.close()
|
||||
self.assertEqual(cleared["type"], "supervised.updated")
|
||||
self.assertEqual(cleared["id"], "update-inactive")
|
||||
self.assertEqual(
|
||||
cleared["payload"]["supervised_mode"],
|
||||
{"active": False, "enforcement_owner": "android_client"},
|
||||
)
|
||||
self.assertFalse(manager.get_session(session.token).supervised_mode.active)
|
||||
self.assertTrue(
|
||||
all(
|
||||
not device.supervised_mode.active
|
||||
for device in manager._trusted_devices.values()
|
||||
if device.device_id == session.device_id
|
||||
)
|
||||
)
|
||||
|
||||
async def test_revoke_closes_connected_relay_socket(self) -> None:
|
||||
caller = self.app["server"].sessions.create_session("Caller", "caller-id")
|
||||
target = self.app["server"].sessions.create_session(
|
||||
"Managed phone",
|
||||
"managed-id",
|
||||
supervised_mode=SupervisedMode(True, "Learning", ("text_chat",)),
|
||||
)
|
||||
socket = AsyncMock()
|
||||
socket.closed = False
|
||||
self.app["server"]._clients[socket] = target.token
|
||||
|
||||
response = await self.client.delete(
|
||||
f"/sessions/{target.token[:8]}",
|
||||
headers={"Authorization": f"Bearer {caller.token}"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIsNone(self.app["server"].sessions.get_session(target.token))
|
||||
socket.close.assert_awaited_once()
|
||||
self.assertEqual(
|
||||
socket.close.await_args.kwargs["message"], b"Relay session revoked"
|
||||
)
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-relay"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -220,6 +220,7 @@ export default defineConfig({
|
||||
{ text: 'Remote access', link: '/guide/remote-access' },
|
||||
{ text: 'Release tracks', link: '/guide/release-tracks' },
|
||||
{ text: 'Chat', link: '/guide/chat' },
|
||||
{ text: 'Supervised Mode', link: '/guide/supervised-mode' },
|
||||
{ text: 'Sessions', link: '/guide/sessions' },
|
||||
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
|
||||
],
|
||||
|
||||
@@ -98,6 +98,12 @@ Google Play builds do not include AccessibilityService-backed screen reading or
|
||||
| Message history | Loads from server on session switch |
|
||||
| Persistence | Last session resumes on app restart |
|
||||
|
||||
## Supervised access
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| Android Supervised Mode | Planned | Parent-controlled, profile-pinned restricted client interface; not a server-enforced child account. See the [Supervised Mode guide](/guide/supervised-mode). |
|
||||
|
||||
## Analytics
|
||||
|
||||
| Feature | Description |
|
||||
|
||||
@@ -53,6 +53,7 @@ voice routes. Sideload builds additionally expose Android Device Control routes.
|
||||
- [Quick Start](/guide/quick-start) — Recommended Android + Relay setup
|
||||
- [Installation & Setup](/guide/getting-started) — Builds, manual setup, and fallbacks
|
||||
- [Chat Guide](/guide/chat) — Using the chat interface
|
||||
- [Supervised Mode](/guide/supervised-mode) — Planned parent-controlled, profile-pinned Android interface
|
||||
- [Sessions](/guide/sessions) — Managing conversations
|
||||
- [Features](/features/) — All features at a glance
|
||||
- [Architecture](/architecture/) — How it works under the hood
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
title: Supervised Mode
|
||||
description: Configure a parent-controlled, profile-pinned Hermes-Relay Android experience
|
||||
---
|
||||
|
||||
# Supervised Mode
|
||||
|
||||
::: warning Physical certification pending
|
||||
Supervised Mode is implemented in the Android client, but it has not completed
|
||||
physical managed-device certification. Treat it as experimental and do not call
|
||||
an installation child-ready until the managed-device checks below pass.
|
||||
:::
|
||||
|
||||
Supervised Mode is a parent-controlled, restricted view of Hermes-Relay for
|
||||
Android. It is intended for a parent or guardian who has already created and
|
||||
reviewed a suitably restricted Hermes profile and wants the phone app to expose
|
||||
only an approved set of chat features.
|
||||
|
||||
It is a client-interface control, not a child account or a server security
|
||||
boundary. The selected Hermes profile still controls the agent's prompt, model,
|
||||
tools, provider credentials, content behavior, and server-side data.
|
||||
|
||||
## Before enabling it
|
||||
|
||||
Prepare the Hermes profile first. At minimum, review its:
|
||||
|
||||
- identity and system instructions;
|
||||
- model and provider safety settings;
|
||||
- enabled skills, tools, and external services;
|
||||
- memory, files, schedules, and existing sessions;
|
||||
- voice and image-generation providers;
|
||||
- retention and parental-review expectations.
|
||||
|
||||
Supervised Mode cannot make an unrestricted profile safe by hiding controls on
|
||||
the phone. Ordinary prose can still cause the configured agent to use whatever
|
||||
server-side capabilities that profile has.
|
||||
|
||||
## Set it up
|
||||
|
||||
From full Android Settings, the parent:
|
||||
|
||||
1. Open **Settings → Advanced → Supervised Mode** for the active Hermes
|
||||
Connection.
|
||||
2. Choose one existing named profile. Android requires a secure device screen
|
||||
lock before the mode can be enabled.
|
||||
3. Select the allowed features and any stricter attachment or history limits.
|
||||
4. Choose a visibility preset or customize what appears in Chat.
|
||||
5. Review the summary, then enable the mode.
|
||||
|
||||
The app returns to the pinned profile's Chat screen. If the Connection or
|
||||
profile is unavailable, the restricted client shows a recovery state
|
||||
without falling back to another profile or exposing full Settings.
|
||||
|
||||
## The everyday experience
|
||||
|
||||
Chat should look like ordinary Hermes-Relay Chat. There is no persistent
|
||||
Supervised Mode banner consuming conversation space. The agent name and avatar
|
||||
remain the primary identity, with a small connection state when permitted.
|
||||
|
||||
The existing Settings button opens **Restricted Settings**, which contains only
|
||||
approved preferences. A clearly labelled **Parent access** row starts device
|
||||
authentication before any parent controls or full application settings appear.
|
||||
|
||||
Restricted Settings may include:
|
||||
|
||||
- a supervised-only theme, text size, language, and haptics;
|
||||
- parent-approved pet display and, when allowed, a phone-local profile icon and
|
||||
chat background;
|
||||
- accessibility preferences;
|
||||
- message presentation and sensitive-media blur;
|
||||
- harmless playback or interaction preferences when voice is allowed;
|
||||
- Help and About;
|
||||
- the locked Parent access row.
|
||||
|
||||
Connections, profiles, Manage, model controls, personalities, reasoning,
|
||||
approvals, tools, plugins, Terminal, TUI, Bridge, Device Control, notification
|
||||
companion, diagnostics, logs, files, credentials, developer options, Relay
|
||||
management, and other sessions are not shown.
|
||||
|
||||
The command palette, slash autocomplete, server command catalog, and command
|
||||
action cards are also absent. Messages whose first non-whitespace character is
|
||||
`/` are rejected by the restricted client. Approved outcomes such as New chat
|
||||
and Cancel remain normal, explicit buttons. If the agent requests approval, a
|
||||
secret, clarification, or elevated access, the restricted client denies or
|
||||
skips that request and shows a short notice. A parent can retry the task later
|
||||
from the full client after authentication.
|
||||
|
||||
## Allowed features
|
||||
|
||||
The parent chooses capabilities independently. The proposed controls are:
|
||||
|
||||
| Capability | Suggested default | Effect when disabled |
|
||||
|---|---:|---|
|
||||
| Text chat | On | Required for the restricted chat experience |
|
||||
| New chat | On | Removes the new-conversation action |
|
||||
| Cancel reply | On | Removes Stop while a reply is running |
|
||||
| Steer reply | On | Queues or disables mid-reply input instead |
|
||||
| Attachments | Parent choice | Removes pickers, camera/share intake, paste-to-file, and restored attachment drafts |
|
||||
| Standard voice | Parent choice | Removes recording, voice intents, and voice preferences |
|
||||
| Generated media | On | Hides generated-image viewing and related actions |
|
||||
| Save/share media | Off | Keeps permitted media view-only inside the app |
|
||||
| Copy replies | On | Removes copy actions |
|
||||
| Retry | On | Removes retry/regenerate actions |
|
||||
| Quote/reply | On | Removes quote/reply actions |
|
||||
| Edit and resend | Off | Prevents rewriting earlier prompts from the client |
|
||||
| Session history | Parent choice | Limits the pinned profile to the current or approved conversations |
|
||||
| Session actions | Off | Individually allows pin, rename, archive, share, and delete for visible history |
|
||||
|
||||
Attachments are a general capability, not a one-image rule. When enabled, the
|
||||
normal supported attachment flow and app limits apply unless the parent chooses
|
||||
a stricter maximum size or permitted-type policy. When disabled, every Android
|
||||
entry point must be removed or rejected consistently, including share intents
|
||||
and a draft restored after process death.
|
||||
|
||||
Standard voice uses the existing host-side voice configuration. Provider
|
||||
credentials stay on the Hermes host and are not exposed in Restricted Settings.
|
||||
|
||||
The supervised theme is stored separately from the parent app theme and applies
|
||||
only while the restricted root is locked. Pet display is parent-controlled.
|
||||
Profile-icon and background changes can be enabled independently for the
|
||||
supervised user; the authenticated parent retains those controls either way.
|
||||
|
||||
Session actions use a separate allowlist. The parent can allow all, allow none,
|
||||
or choose individual actions. Copying technical session identifiers, browsing
|
||||
other profiles, Relay Threads, and drawer customization remain unavailable.
|
||||
Delete continues to require confirmation.
|
||||
|
||||
Generated media is limited by display policy, not by a claim that Android can
|
||||
prove how the server created it. Parents may allow viewing while disabling save
|
||||
and share. Ordinary remote links, files, and unsupported media retain the app's
|
||||
normal safety behavior.
|
||||
|
||||
## What appears in Chat
|
||||
|
||||
Visibility controls affect presentation only. They never suppress an error,
|
||||
safety notice, parent-action state, or connection failure that requires
|
||||
attention.
|
||||
|
||||
### Simple (recommended)
|
||||
|
||||
- Shows the agent name and avatar.
|
||||
- Shows generic **Connected**, **Working**, and **Reconnecting** states.
|
||||
- Hides model, profile, provider, route, context, token usage, reasoning, and
|
||||
tool details.
|
||||
- Keeps the header and composer visually quiet.
|
||||
|
||||
### Transparent
|
||||
|
||||
Adds parent-approved timestamps, bounded usage or context information, and
|
||||
safe activity labels. It still does not reveal tool arguments, tool results,
|
||||
host paths, credentials, or administration surfaces.
|
||||
|
||||
### Custom
|
||||
|
||||
Lets the parent control individual surfaces, including:
|
||||
|
||||
- model name and profile name;
|
||||
- connection state and route identity;
|
||||
- timestamps, context, and token usage;
|
||||
- generic work status, tool names, and tool detail;
|
||||
- reasoning visibility;
|
||||
- message and media actions.
|
||||
|
||||
Model and profile names default off for a new policy. The agent's friendly name
|
||||
and avatar provide the normal identity in the Simple preset.
|
||||
|
||||
## Parent access and relocking
|
||||
|
||||
Enabling, changing, or ending Supervised Mode requires Android device
|
||||
authentication. Parent access should relock when its authenticated task closes,
|
||||
after the configured inactivity period, when the app backgrounds, or after
|
||||
process recreation.
|
||||
|
||||
Android's device-credential prompt authenticates any user enrolled for that
|
||||
device; it does not establish a separate parent identity. Use a device lock the
|
||||
supervised user does not know, or keep the device under direct supervision.
|
||||
|
||||
The restricted root is restored before the first interactive screen. Deep
|
||||
links, notification actions, shortcuts, saved back stacks, and share intents
|
||||
must not provide a route around it. A missing or unreadable policy fails closed
|
||||
to restricted recovery instead of opening full Settings.
|
||||
|
||||
Ending the mode may clear local drafts, pending attachments, and supervised
|
||||
media caches according to the parent's choice. It does not automatically delete
|
||||
Hermes sessions or history stored on the server. Parents review or delete that
|
||||
history through their normal authenticated Hermes interface.
|
||||
|
||||
## Optional Relay visibility
|
||||
|
||||
If the Android client is paired with the optional Relay plugin, it may identify
|
||||
itself with a client-reported **Supervised** tag and a short, non-sensitive
|
||||
capability summary. The Relay UI can then make the device easy to recognize and
|
||||
can revoke its paired Relay session through the normal paired-device controls.
|
||||
|
||||
The tag is informational. Relay does not enforce the Android policy, pin the
|
||||
Hermes profile, filter direct Dashboard/Gateway chat, or certify that the client
|
||||
is unmodified. Revoking the Relay session disables Relay-backed access for that
|
||||
pairing; it does not remotely end an Android-only mode or revoke an independent
|
||||
Dashboard sign-in. Supervised Mode does not require Relay.
|
||||
|
||||
## Limits of protection
|
||||
|
||||
Supervised Mode cannot control:
|
||||
|
||||
- another Hermes client or a modified Android build;
|
||||
- someone with direct access to the Hermes server or parent credentials;
|
||||
- tools, files, services, and provider behavior enabled in the selected profile;
|
||||
- server-side session retention or provider data handling;
|
||||
- the developmental suitability or factual accuracy of model output;
|
||||
- Android behavior outside the Hermes-Relay app.
|
||||
|
||||
Use it alongside a restrictive Hermes profile, parental supervision, Android
|
||||
parental or enterprise controls where appropriate, and regular review of the
|
||||
profile and its conversations.
|
||||
|
||||
## Certification requirement
|
||||
|
||||
The feature should not be described as child-ready until the exact Android
|
||||
build passes automated policy and navigation tests plus physical testing on a
|
||||
managed/restricted Android device. Certification must cover authentication,
|
||||
relocking, restart and offline recovery, process death, deep links,
|
||||
notifications, share intents, attachments, voice, session ownership, Relay
|
||||
tagging/revocation, and attempts to escape the restricted interface.
|
||||
|
||||
See [Profiles](/features/profiles) for the server-owned identity model and
|
||||
[Chat](/guide/chat) for the full, unrestricted interface.
|
||||
Reference in New Issue
Block a user