Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c59f44ad7 | ||
|
|
269a2b5b36 | ||
|
|
a6879aac6f | ||
|
|
a04930c946 | ||
|
|
b71fea701f | ||
|
|
a717278e95 | ||
|
|
19d5237ebb | ||
|
|
6fbb21f437 | ||
|
|
c7f54321ad | ||
|
|
e6b9933d46 | ||
|
|
c27ee439ef | ||
|
|
68fce1b1da | ||
|
|
4547031bba | ||
|
|
41e9acf4c2 | ||
|
|
5372fc1fef | ||
|
|
6c5ecbb028 | ||
|
|
bd5a1c3335 | ||
|
|
2c740c9f04 | ||
|
|
3ec89680ed | ||
|
|
42860d38cd | ||
|
|
ef4b3bdb6c | ||
|
|
44bbb16cd3 | ||
|
|
1f5b7e68fc | ||
|
|
4bb8d6fa7b | ||
|
|
c956232b96 | ||
|
|
9814cdca55 | ||
|
|
273e3f5aff |
@@ -17,6 +17,7 @@ function classifyCiPaths(paths) {
|
||||
android: forceAll || under(['app/', 'gradle/']) || exact([
|
||||
'build.gradle.kts', 'settings.gradle.kts', 'gradle.properties', 'gradlew', 'gradlew.bat',
|
||||
'scripts/check-android-locales.py', 'scripts/android-locale-harness.py',
|
||||
'scripts/check-android-capabilities.py', 'scripts/tests/check_android_capabilities_test.py',
|
||||
'scripts/check-android-collection-apis.py', 'scripts/check-android-native-compat.py',
|
||||
'scripts/check-android-release-notes.py',
|
||||
'scripts/android_release_artifacts.py',
|
||||
|
||||
@@ -128,3 +128,6 @@ assert.match(releaseTrainWorkflow, /name: Hermes-Relay Coordinated Release Appro
|
||||
assert.match(releaseTrainWorkflow, /Coordinated Android approval is stable-only/);
|
||||
|
||||
console.log('CI path classification tests passed.');
|
||||
|
||||
assert.deepEqual(classifyCiPaths(['scripts/check-android-capabilities.py']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['scripts/tests/check_android_capabilities_test.py']), { ...none, android: true });
|
||||
|
||||
@@ -166,6 +166,9 @@ jobs:
|
||||
- name: Build debug APKs
|
||||
run: ./gradlew assembleDebug --console=plain
|
||||
|
||||
- name: Verify Play capability manifest
|
||||
run: python3 scripts/check-android-capabilities.py --variant googlePlayDebug
|
||||
|
||||
- name: Verify packaged native compatibility
|
||||
run: |
|
||||
python3 scripts/check-android-native-compat.py \
|
||||
@@ -203,6 +206,9 @@ jobs:
|
||||
- name: Build release bundles and APKs
|
||||
run: ./gradlew bundleRelease assembleRelease --console=plain
|
||||
|
||||
- name: Verify Play release capability manifest
|
||||
run: python3 scripts/check-android-capabilities.py --variant googlePlayRelease
|
||||
|
||||
- name: Scan release DEX for unsupported collection APIs
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
|
||||
@@ -34,6 +34,8 @@ on:
|
||||
- "scripts/check-android-locales.py"
|
||||
- "scripts/android-locale-harness.py"
|
||||
- "scripts/check-android-collection-apis.py"
|
||||
- "scripts/check-android-capabilities.py"
|
||||
- "scripts/tests/check_android_capabilities_test.py"
|
||||
- "scripts/check-android-native-compat.py"
|
||||
- "scripts/check-android-release-notes.py"
|
||||
- "scripts/tests/check_android_native_compat_test.py"
|
||||
@@ -73,6 +75,11 @@ jobs:
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
- name: Validate Play capability boundary
|
||||
run: |
|
||||
python3 scripts/check-android-capabilities.py
|
||||
python3 -m unittest scripts.tests.check_android_capabilities_test
|
||||
|
||||
- name: Validate translation catalogs
|
||||
run: python3 scripts/check-android-locales.py
|
||||
|
||||
@@ -117,6 +124,9 @@ jobs:
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug --console=plain
|
||||
|
||||
- name: Verify Play capability manifest
|
||||
run: python3 scripts/check-android-capabilities.py --variant googlePlayDebug
|
||||
|
||||
- name: Verify packaged ONNX Runtime compatibility
|
||||
run: |
|
||||
python3 scripts/check-android-native-compat.py \
|
||||
|
||||
@@ -8,6 +8,15 @@ on:
|
||||
- "assets/screenshots/03_voice.png"
|
||||
- "assets/screenshots/06_manage.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- "docs/media/desktop-ui-screenshots.json"
|
||||
- "assets/screenshots/desktop-ui/**"
|
||||
- "desktop/tray/ui/**"
|
||||
- "desktop/tray/scripts/*.mjs"
|
||||
- "desktop/tray/package-lock.json"
|
||||
- "desktop/tray/index.html"
|
||||
- "desktop/tray/icons/icon-256.png"
|
||||
- "desktop/src/endpoint.ts"
|
||||
- "desktop/src/transportSecurity.ts"
|
||||
- ".github/workflows/ci-website.yml"
|
||||
push:
|
||||
branches: [main, dev]
|
||||
@@ -17,6 +26,15 @@ on:
|
||||
- "assets/screenshots/03_voice.png"
|
||||
- "assets/screenshots/06_manage.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- "docs/media/desktop-ui-screenshots.json"
|
||||
- "assets/screenshots/desktop-ui/**"
|
||||
- "desktop/tray/ui/**"
|
||||
- "desktop/tray/scripts/*.mjs"
|
||||
- "desktop/tray/package-lock.json"
|
||||
- "desktop/tray/index.html"
|
||||
- "desktop/tray/icons/icon-256.png"
|
||||
- "desktop/src/endpoint.ts"
|
||||
- "desktop/src/transportSecurity.ts"
|
||||
- ".github/workflows/ci-website.yml"
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -106,6 +106,9 @@ jobs:
|
||||
:app:assembleSideloadRelease \
|
||||
--console=plain
|
||||
|
||||
- name: Verify Play capability manifest
|
||||
run: python3 scripts/check-android-capabilities.py --variant googlePlayRelease
|
||||
|
||||
- name: Scan final release DEX
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
|
||||
@@ -6,16 +6,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Optional voice controls over other apps in Google Play, with contextual permission setup, a persistent Stop voice notification, and session shutdown on screen lock or permission loss. Phone control remains sideload-only.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Android shows Hermes profile display names and groups the resolved server default under its agent identity, while preserving explicit profile selection and saved conversations.
|
||||
|
||||
## [Android 1.16.1] - 2026-09-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Android Dashboard-only connections start the profile-scoped session directory before Gateway readiness, so a cold launch no longer leaves both the directory and passive Gateway socket waiting on each other. (#495, #528)
|
||||
|
||||
## [Android 1.16.0] - 2026-09-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Android no longer crashes when a route probe finishes while a network change invalidates the endpoint cache.
|
||||
- Android opens an authenticated Gateway chat on the first foreground launch instead of waiting for a background-and-resume cycle to leave the waking state. (#495, #528)
|
||||
- Android Dashboard sign-in removes pasted line breaks from username and password fields, matching the browser login while preserving every other credential character. (#541)
|
||||
- **Relay tool availability avoids repeated Windows loopback delays and preserves multi-PC capabilities.** Host-local Android, Desktop, and Phone paths use explicit IPv4 loopback, while Desktop checks share a bounded health snapshot that preserves per-client advertisements and fails closed when Hermes-Relay is unavailable. (#562, #563)
|
||||
- Android keeps saved Dashboard sign-ins bound to their connection when switching gateways, rather than letting a stale resolver route invalidate another connection's session.
|
||||
- Bot Mode no longer crashes when different connections have bots with the same profile name. Both the conversation list and Active Now strip preserve each bot's connection, and opening progress appears only on the selected bot.
|
||||
- Android feedback uses themed banners and action cards instead of platform toasts and default snackbars. Dashboard errors no longer misidentify missing resources as an outdated Relay. Developer settings includes local-only message previews.
|
||||
- Missing chat attachments show their error and retry in the attachment card without repeated global popups. Global action messages occupy the top message area instead of covering the composer.
|
||||
- Chat distinguishes session preparation from response streaming and retains initialization errors that arrive before the session acknowledgement. Long-press the agent header to open a live session-diagnostics drawer.
|
||||
- Delegated-agent activity survives parent replies and leaves compact history entries for later read-only review. The activity strip appears only while work runs; historical process views cannot stop or dismiss live work. (#447)
|
||||
|
||||
## [Plugin 1.11.2] - 2026-09-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Relay tool availability avoids repeated Windows loopback delays and preserves multi-PC capabilities.** Host-local Android, Desktop, and Phone paths use explicit IPv4 loopback, while Desktop checks share a bounded health snapshot that preserves per-client advertisements and fails closed when Hermes-Relay is unavailable. (#562, #563)
|
||||
- **`android_*` tools resolve bridge credentials written after host startup.** Requests retry profile-scoped env and active bridge-session credentials after a stale token is rejected, and vision navigation now shares the same current Relay transport instead of the retired standalone default.
|
||||
- **`android_setup` accepts both its canonical and legacy schema keys.** `bridge_session_token` and `pairing_code` are accepted, while a missing token returns a structured error.
|
||||
- **Android tool setup tests use a temporary Hermes home.** Test runs no longer write bridge settings into a developer environment.
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
# Hermes-Relay Plugin v__VERSION__
|
||||
|
||||
**Release Date:** August 31, 2026
|
||||
**Release Date:** September 10, 2026
|
||||
|
||||
## Summary
|
||||
|
||||
This patch restores native installation compatibility on affected Hermes versions and makes Relay prompt context advertise only capabilities the selected session can actually call. Standard Chat, Manage, standard voice, and ordinary inbound files remain upstream-owned.
|
||||
This patch makes Android and Desktop tool availability fast and reliable when Relay is unavailable, starts late-created Android bridge sessions without restarting Hermes, and restores compatibility with both current and legacy `android_setup` arguments. Standard Chat, Manage, standard voice, and ordinary inbound files remain upstream-owned.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Native installer compatibility.** The plugin keeps its complete current manifest while avoiding the installer/runtime schema mismatch that caused `manifest_version 2` installs to fail after an apparent Hermes update.
|
||||
- **Capability-gated phone context.** Phone-control and cross-platform delivery guidance now follows the selected session/profile tool catalog instead of implying unavailable `android_*` or `send_message` callables.
|
||||
- **Fast, accurate tool availability.** Android and Desktop tool checks use explicit IPv4 loopback and one bounded health snapshot instead of repeated per-tool connection attempts. Multi-PC capability advertisements remain isolated, and unavailable Relay clients continue to fail closed.
|
||||
- **Late Android bridge recovery.** `android_*` calls retry profile-scoped and active bridge-session credentials after a stale token is rejected, so a phone connected after Hermes startup becomes usable without restarting the host.
|
||||
- **Compatible Android setup arguments.** `android_setup` accepts the canonical `bridge_session_token` and `pairing_code` fields as well as their legacy aliases, with structured errors when no usable credential is supplied.
|
||||
- **Isolated setup tests.** Android tool setup tests use a temporary Hermes home instead of writing bridge settings into the operator environment.
|
||||
|
||||
## Install / update
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.15.1
|
||||
# Hermes-Relay Android v1.16.1
|
||||
|
||||
**Release Date:** September 2, 2026
|
||||
**Release Date:** September 12, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.15.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.16.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,29 +12,16 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch improves chat, media, and voice reliability. It reduces memory-heavy work, keeps attachment previews stable through rotation, and makes follow-up message behavior and voice errors easier to understand.
|
||||
|
||||
## Changed
|
||||
|
||||
- Choose Correct now or Queue next from a slim tray behind the composer. Chat settings sets the default; the tray overrides one message. Stop pauses the queue, Resume continues it, and editing or removing an item preserves its successors.
|
||||
- Chat and Voice use readable centered layouts on wider screens, including landscape Voice Focus.
|
||||
This patch fixes a remaining cold-start path that could leave a Dashboard-only connection waiting for Gateway readiness until the app resumed or its network route changed.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Correction and delivery labels remain visible inside user-message bubbles.
|
||||
- Voice errors open in a scrollable dialog with separate Retry and Dismiss actions.
|
||||
- Attachment previews remain open through rotation, and video previews preserve their proportions.
|
||||
- Release optimization preserves the native speech configuration required for wake-word startup.
|
||||
- Standard Hermes attachments download directly to disk with bounded size checks.
|
||||
- Session refresh avoids repeated request loops; history loads, Markdown, image previews, and media exports keep memory use bounded.
|
||||
- Image-generation progress stays visible through gaps between interim replies and returned media.
|
||||
- The first prompt waits for Gateway session readiness. Ownership refusals retain the prompt for retry and show the original server error.
|
||||
- Dashboard-only connections now start the exact profile-scoped session directory before Gateway readiness, so the directory and passive Gateway socket no longer wait on each other during a cold launch.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.15.1** (versionCode **54**).
|
||||
- App version: **1.16.1** (versionCode **56**).
|
||||
- Standard Chat, sessions, profiles, Manage, voice, and ordinary media use current upstream Hermes. Speech-to-text still requires a configured provider on the host.
|
||||
- Hermes-Relay Plugin **1.11.1** remains the current optional plugin release; this Android patch does not require a new plugin version.
|
||||
- Paused text queues can be restored. Attachment bytes are not persisted in preferences; unrestorable attachment queues must be reviewed and sent again.
|
||||
- Hermes-Relay Plugin **1.11.2** remains the matching optional release for Relay tools; this Gateway startup fix does not require it.
|
||||
- Explicit Direct API/API-only connections remain supported and are not used as silent failover for Dashboard-owned chats.
|
||||
- Granular Device Control and the system Voice Focus overlay remain sideload-only.
|
||||
|
||||
@@ -100,9 +100,9 @@ android {
|
||||
}
|
||||
|
||||
// ─── Bridge release tracks ─────────────────────────────────────────────────
|
||||
// Google Play ships Bridge Core only: pairing, chat, voice, terminal/TUI,
|
||||
// Google Play ships Bridge Core and user-started voice-only overlay: pairing, chat, voice, terminal/TUI,
|
||||
// media, notification companion, relay sessions, and status. It does not
|
||||
// declare AccessibilityService, overlay, MediaProjection, wake-lock device
|
||||
// declare AccessibilityService, MediaProjection, wake-lock device
|
||||
// control, SMS/call/contact/location, or unattended-control permissions.
|
||||
//
|
||||
// googlePlay — canonical Play Store install. Bridge Core only.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.data.ApiEndpoint
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.InterruptedIOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EndpointResolverConcurrencyInstrumentedTest {
|
||||
|
||||
@Test
|
||||
fun probeCompletionRacingInvalidation_staysCrashFreeOnAndroidCollections() = runBlocking {
|
||||
repeat(25) { iteration ->
|
||||
val candidateCount = 8
|
||||
val requestsStarted = CountDownLatch(candidateCount)
|
||||
val releaseRequests = CountDownLatch(1)
|
||||
val raceGate = CountDownLatch(1)
|
||||
val requestSequence = AtomicInteger(0)
|
||||
val client = OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
if (requestSequence.incrementAndGet() <= candidateCount) {
|
||||
requestsStarted.countDown()
|
||||
releaseRequests.await(5, TimeUnit.SECONDS)
|
||||
throw InterruptedIOException("instrumented invalidation race")
|
||||
}
|
||||
Response.Builder()
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("{}".toResponseBody())
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
val resolver = EndpointResolver(client)
|
||||
val candidates = (1..candidateCount).map { index ->
|
||||
EndpointCandidate(
|
||||
role = "instrumented-$iteration-$index",
|
||||
priority = 0,
|
||||
api = ApiEndpoint(host = "127.0.0.1", port = 1, tls = false),
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
val staleResolve = async(start = CoroutineStart.UNDISPATCHED) {
|
||||
resolver.resolve(candidates, EndpointSurface.Api)
|
||||
}
|
||||
assertTrue(requestsStarted.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val invalidation = async(Dispatchers.Default) {
|
||||
raceGate.await(5, TimeUnit.SECONDS)
|
||||
resolver.clearCache()
|
||||
}
|
||||
val completions = async(Dispatchers.Default) {
|
||||
raceGate.await(5, TimeUnit.SECONDS)
|
||||
releaseRequests.countDown()
|
||||
}
|
||||
raceGate.countDown()
|
||||
|
||||
withTimeout(2_000L) {
|
||||
invalidation.await()
|
||||
completions.await()
|
||||
staleResolve.await()
|
||||
}
|
||||
assertTrue(resolver.cacheSnapshot().isEmpty())
|
||||
|
||||
resolver.clearCache()
|
||||
val freshWinner = withTimeout(2_000L) {
|
||||
resolver.resolve(listOf(candidates.first()), EndpointSurface.Api)
|
||||
}
|
||||
assertEquals(candidates.first(), freshWinner)
|
||||
assertTrue(
|
||||
resolver.probeOutcomes.value.getValue(
|
||||
EndpointResolver.cacheKey(candidates.first(), EndpointSurface.Api),
|
||||
).reachable,
|
||||
)
|
||||
} finally {
|
||||
raceGate.countDown()
|
||||
releaseRequests.countDown()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,12 @@ import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
@@ -17,25 +18,30 @@ import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnCheckpointStore
|
||||
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
|
||||
import com.hermesandroid.relay.network.upstream.HermesApiClient
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
|
||||
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
|
||||
import com.hermesandroid.relay.ui.screens.shouldOwnVisibleGateway
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
@@ -85,6 +91,8 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
@Volatile
|
||||
private var persistedHistory: List<MessageItem> = emptyList()
|
||||
private val historySignInRequired = MutableStateFlow(false)
|
||||
private val coldStartAdmissionEnabled = MutableStateFlow(false)
|
||||
private val coldStartGatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@@ -119,6 +127,19 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
|
||||
val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
|
||||
val signInRequired by historySignInRequired.collectAsStateWithLifecycle()
|
||||
val admissionEnabled by coldStartAdmissionEnabled.collectAsStateWithLifecycle()
|
||||
val admissionAvailability by coldStartGatewayAvailability.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(admissionEnabled, admissionAvailability) {
|
||||
if (admissionEnabled) {
|
||||
viewModel.setChatVisible(
|
||||
shouldOwnVisibleGateway(
|
||||
appForeground = true,
|
||||
isGatewayTransport = true,
|
||||
gatewayAvailability = admissionAvailability,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
MaterialTheme {
|
||||
Column(Modifier.testTag("contract-transcript")) {
|
||||
GatewayBackgroundProcessStrip(
|
||||
@@ -156,6 +177,122 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
fixture.awaitRpc("session.resume")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authenticatedUnknownColdLaunch_opensObservationSocketWithoutLifecycleBounce() {
|
||||
viewModel.setChatVisible(false)
|
||||
viewModel.updateGatewayClient(null)
|
||||
gatewayClient.shutdown()
|
||||
gatewayScope.cancel()
|
||||
|
||||
val controlMethods = setOf(
|
||||
"session.resume",
|
||||
"session.activate",
|
||||
"prompt.submit",
|
||||
"session.interrupt",
|
||||
)
|
||||
val baseline = controlMethods.associateWith(fixture::rpcCount)
|
||||
val ticketMintsBefore = fixture.requestsTo("/api/auth/ws-ticket")
|
||||
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val okHttp = OkHttpClient()
|
||||
gatewayClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = okHttp,
|
||||
),
|
||||
okHttpClient = okHttp,
|
||||
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
|
||||
scope = gatewayScope,
|
||||
reconnectJitterUnit = { 0.0 },
|
||||
)
|
||||
viewModel.setChatTurnCheckpointStore(null)
|
||||
viewModel.updateGatewayClient(gatewayClient)
|
||||
|
||||
coldStartGatewayAvailability.value = GatewayAvailability.Unknown
|
||||
coldStartAdmissionEnabled.value = true
|
||||
|
||||
compose.waitUntil(5_000) {
|
||||
gatewayClient.connectionState.value == GatewayConnectionState.Ready
|
||||
}
|
||||
serverSocket = fixture.awaitServerSocket()
|
||||
assertEquals(ticketMintsBefore + 1, fixture.requestsTo("/api/auth/ws-ticket"))
|
||||
controlMethods.forEach { method ->
|
||||
assertEquals(
|
||||
"cold observation sent $method",
|
||||
baseline.getValue(method),
|
||||
fixture.rpcCount(method),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardOnlyColdLaunch_waitsForExactDirectoryThenOpensObservationSocket() {
|
||||
viewModel.setChatVisible(false)
|
||||
viewModel.updateGatewayClient(null)
|
||||
gatewayClient.shutdown()
|
||||
gatewayScope.cancel()
|
||||
|
||||
val directoryStarted = CompletableDeferred<Unit>()
|
||||
val directoryResult = CompletableDeferred<Result<List<SessionItem>>>()
|
||||
viewModel.setProfileSessionLister { profile ->
|
||||
assertEquals(PROFILE_NAME, profile)
|
||||
directoryStarted.complete(Unit)
|
||||
directoryResult.await()
|
||||
}
|
||||
handler.setSessionId(null)
|
||||
viewModel.switchProfileContext(
|
||||
AgentDisplay.profileContextKey("fixture-connection", PROFILE_NAME),
|
||||
STORED_SESSION_ID,
|
||||
)
|
||||
|
||||
val controlMethods = setOf(
|
||||
"session.resume",
|
||||
"session.activate",
|
||||
"prompt.submit",
|
||||
"session.interrupt",
|
||||
)
|
||||
val baseline = controlMethods.associateWith(fixture::rpcCount)
|
||||
val ticketMintsBefore = fixture.requestsTo("/api/auth/ws-ticket")
|
||||
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val okHttp = OkHttpClient()
|
||||
gatewayClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = okHttp,
|
||||
),
|
||||
okHttpClient = okHttp,
|
||||
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
|
||||
scope = gatewayScope,
|
||||
reconnectJitterUnit = { 0.0 },
|
||||
)
|
||||
viewModel.setChatTurnCheckpointStore(null)
|
||||
viewModel.updateGatewayClient(gatewayClient)
|
||||
viewModel.setChatVisible(true)
|
||||
|
||||
// This is the production binder's Dashboard/profile hydration edge.
|
||||
// The socket must stay passive and closed until the exact-owner REST
|
||||
// directory publishes, then open without a lifecycle bounce.
|
||||
viewModel.refreshSessions()
|
||||
compose.waitUntil(5_000) { directoryStarted.isCompleted }
|
||||
assertEquals(ticketMintsBefore, fixture.requestsTo("/api/auth/ws-ticket"))
|
||||
|
||||
directoryResult.complete(
|
||||
Result.success(listOf(SessionItem(id = STORED_SESSION_ID, title = "Fixture session"))),
|
||||
)
|
||||
|
||||
compose.waitUntil(5_000) {
|
||||
gatewayClient.connectionState.value == GatewayConnectionState.Ready
|
||||
}
|
||||
serverSocket = fixture.awaitServerSocket()
|
||||
assertEquals(ticketMintsBefore + 1, fixture.requestsTo("/api/auth/ws-ticket"))
|
||||
controlMethods.forEach { method ->
|
||||
assertEquals(
|
||||
"directory-gated cold observation sent $method",
|
||||
baseline.getValue(method),
|
||||
fixture.rpcCount(method),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
viewModel.updateGatewayClient(null)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
Google Play ships Hermes Bridge Core only. It intentionally does not merge
|
||||
any Device Control services or permissions.
|
||||
|
||||
This file is intentionally kept as an empty overlay so future flavor-specific
|
||||
This overlay owns the voice-only special access so future flavor-specific
|
||||
permissions / activities have an obvious home. Mirror structural additions
|
||||
in `app/src/sideload/AndroidManifest.xml` unless the change is intentionally
|
||||
track-specific.
|
||||
@@ -22,6 +22,9 @@
|
||||
android:name="android.permission.WAKE_LOCK"
|
||||
tools:node="remove" />
|
||||
|
||||
<!-- User-started voice controls only; does not enable Device Control. -->
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
|
||||
<application />
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -23,7 +23,7 @@ GOOGLE PLAY AND SIDELOAD
|
||||
|
||||
The Google Play build includes Chat, voice, sessions, Manage, profiles, notifications, media, and Terminal/TUI when the Hermes-Relay plugin is paired.
|
||||
|
||||
Google Play does not include Android Device Control. It cannot read the phone screen, tap, type, swipe, take device screenshots, send SMS, place calls, or access contacts or location.
|
||||
Google Play does not include Android Device Control. It cannot tap, type, swipe, send SMS, place calls, or access contacts or location. Optional Voice Overlay provides user-started voice controls over other apps, with microphone notification and Stop voice. Selecting Hermes as Android Digital Assistant can provide bounded screen text and a screenshot for an explicit unlocked assistant invocation; this context goes to your configured server and AI provider.
|
||||
|
||||
Device Control is available only in the signed Sideload build on this project's GitHub Releases. It requires the Sideload app, a paired Hermes-Relay plugin, explicit Android accessibility permission, and the app's safety controls.
|
||||
|
||||
@@ -32,9 +32,6 @@ FEATURES
|
||||
- Streaming Chat with reasoning, markdown, tool progress, attachments, mid-turn steering, edit-and-resend, and searchable commands.
|
||||
- Manage models and provider keys, edit profiles, and browse, install, or update skills through the Hermes Dashboard.
|
||||
- Hands-free voice through your server's speech providers. Hermes-Relay pairing adds per-profile voices and an experimental realtime engine.
|
||||
- Create, switch, search, rename, pin, archive, and continue sessions.
|
||||
- Connect multiple Hermes servers and switch in one tap; add LAN, Tailscale, or public routes.
|
||||
- Pair the Hermes-Relay plugin for Terminal/TUI, notifications, media, enhanced voice, Relay sessions, and per-feature grants.
|
||||
- Inspect connection readiness, routes, response timing, token usage, and stream health without exposing credentials.
|
||||
|
||||
SECURITY AND PRIVACY
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
v1.15.1 - Steadier chat, media, and voice
|
||||
v1.16.1 - Dashboard-only cold starts recover
|
||||
|
||||
More reliable chats and media: fewer memory-heavy refreshes, smoother large histories, and attachment previews that survive rotation. Choose whether follow-ups correct the current response or wait in a queue. Voice errors are easier to read, image-generation progress stays visible, and wake-word startup and first-message readiness are fixed.
|
||||
Dashboard-only connections now prepare the selected profile before Gateway readiness, fixing a remaining cold-start path that could stay on waking or waiting for Gateway until the app resumed or its network route changed.
|
||||
|
||||
@@ -1,6 +1,100 @@
|
||||
{
|
||||
"schema": 3,
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.16.1",
|
||||
"title": "Dashboard-only cold starts recover",
|
||||
"date": "2026-09-12",
|
||||
"summary": "Dashboard-only connections can now prepare the selected profile and open Gateway chat without waiting for a background, resume, or network-route change.",
|
||||
"changes": [
|
||||
{
|
||||
"id": "gateway-directory-bootstrap",
|
||||
"kind": "fixed",
|
||||
"title": "Open Gateway chat from a Dashboard-only cold start",
|
||||
"summary": "The selected profile's session directory starts before Gateway readiness, so it cannot wait on the same passive socket that depends on its result.",
|
||||
"highlight": true
|
||||
}
|
||||
],
|
||||
"compatibility": [
|
||||
"Standard Dashboard and Gateway chat continue to use current upstream Hermes without requiring the optional Hermes-Relay Plugin.",
|
||||
"Hermes-Relay Plugin 1.11.2 remains the matching optional release for Relay tools."
|
||||
],
|
||||
"playNotes": "Dashboard-only connections now prepare the selected profile before Gateway readiness, fixing a remaining cold-start path that could stay on waking or waiting for Gateway until the app resumed or its network route changed.",
|
||||
"sections": []
|
||||
},
|
||||
{
|
||||
"version": "1.16.0",
|
||||
"title": "Safer startup, connections, and activity",
|
||||
"date": "2026-09-10",
|
||||
"summary": "Gateway chat opens reliably from a cold launch, saved sign-ins stay with the correct connection, and network changes no longer race the route cache. Bot Mode and delegated-work feedback also remain stable across multiple gateways and later review.",
|
||||
"changes": [
|
||||
{
|
||||
"id": "gateway-cold-start",
|
||||
"kind": "fixed",
|
||||
"title": "Open Gateway chat on the first launch",
|
||||
"summary": "An authenticated Gateway wakes and opens from a cold foreground start instead of waiting for the app to background and resume.",
|
||||
"highlight": true
|
||||
},
|
||||
{
|
||||
"id": "connection-owned-signin",
|
||||
"kind": "fixed",
|
||||
"title": "Keep saved sign-ins with their connection",
|
||||
"summary": "Switching gateways cannot reuse an outgoing resolver route to invalidate another connection's saved Dashboard session.",
|
||||
"highlight": true
|
||||
},
|
||||
{
|
||||
"id": "network-change-invalidation",
|
||||
"kind": "fixed",
|
||||
"title": "Recover safely when the network changes",
|
||||
"summary": "Route-probe completion and endpoint-cache invalidation are serialized so Wi-Fi, mobile-data, VPN, or Tailscale changes do not trigger the reported crash.",
|
||||
"highlight": true
|
||||
},
|
||||
{
|
||||
"id": "bot-mode-connection-identity",
|
||||
"kind": "fixed",
|
||||
"title": "Open same-named bots from multiple gateways",
|
||||
"summary": "Bot Mode and Active Now keep connection and profile identity together, avoiding duplicate list keys and opening progress on the selected bot."
|
||||
},
|
||||
{
|
||||
"id": "delegated-activity-receipts",
|
||||
"kind": "improved",
|
||||
"title": "Review delegated work after it finishes",
|
||||
"summary": "Compact, bounded activity receipts survive parent replies and remain available read-only, while the live strip appears only during active work.",
|
||||
"highlight": true
|
||||
},
|
||||
{
|
||||
"id": "chat-feedback-surfaces",
|
||||
"kind": "improved",
|
||||
"title": "Keep feedback with the surface that owns it",
|
||||
"summary": "Themed banners and action cards replace platform popups, global actions stay clear of the composer, and local Developer previews make feedback states inspectable."
|
||||
},
|
||||
{
|
||||
"id": "attachment-error-recovery",
|
||||
"kind": "fixed",
|
||||
"title": "Retry missing attachments in place",
|
||||
"summary": "A missing attachment keeps its error and Retry action in the attachment card without producing repeated global messages."
|
||||
},
|
||||
{
|
||||
"id": "session-preparation-diagnostics",
|
||||
"kind": "improved",
|
||||
"title": "See session preparation and initialization failures",
|
||||
"summary": "Chat distinguishes session preparation from response streaming, retains early initialization errors, and opens session diagnostics from the agent header."
|
||||
},
|
||||
{
|
||||
"id": "pasted-dashboard-credentials",
|
||||
"kind": "fixed",
|
||||
"title": "Paste Dashboard credentials without hidden line breaks",
|
||||
"summary": "Username and password fields remove pasted carriage returns and line feeds while preserving every other credential character."
|
||||
}
|
||||
],
|
||||
"compatibility": [
|
||||
"Standard Chat, sessions, profiles, Manage, voice, Bot Mode, and delegated activity continue to use current upstream Hermes without requiring the optional Hermes-Relay Plugin.",
|
||||
"Hermes-Relay Plugin 1.11.2 is the matching optional release for Relay tools. Existing erased or revoked Dashboard credentials still require a legitimate sign-in.",
|
||||
"Granular Device Control and the system Voice Focus overlay remain sideload-only."
|
||||
],
|
||||
"playNotes": "Gateway chat now opens reliably on a cold launch. Saved Dashboard sign-ins stay bound to the correct connection, pasted credentials ignore accidental line breaks, and network changes no longer race the route cache. Bot Mode supports duplicate profile names across gateways, while delegated work, session setup, attachment errors, and feedback remain visible and easier to review.",
|
||||
"sections": []
|
||||
},
|
||||
{
|
||||
"version": "1.15.1",
|
||||
"title": "Steadier chat, media, and voice",
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
v1.15.1 - Steadier chat, media, and voice
|
||||
v1.16.1 - Dashboard-only cold starts recover
|
||||
|
||||
Summary
|
||||
* Chats use less memory, attachment previews stay in place, and voice failures are easier to recover from. Follow-up controls make it clear whether a message changes the current response or waits for the next turn.
|
||||
* Dashboard-only connections can now prepare the selected profile and open Gateway chat without waiting for a background, resume, or network-route change.
|
||||
|
||||
Highlights
|
||||
* Choose when follow-up messages are sent — A slim tray behind the composer offers Correct now or Queue next. Chat settings sets the default, and a composer choice applies to one message. Stop pauses pending work until Resume; editing or removing an item keeps the remaining queue usable.
|
||||
* Keep attachment previews open through rotation — Image, video, audio, PDF, text, and file previews stay open as the screen rotates. Videos retain their original proportions.
|
||||
* Keep large chats and media manageable — Automatic session refresh no longer loops. Routine history loads, chat rendering, image previews, and media exports use bounded memory instead of allocating entire large responses.
|
||||
|
||||
Improved
|
||||
* Make better use of wider screens — Chat and Voice keep text and controls on readable centered layouts. Landscape Voice Focus separates identity controls from conversation activity.
|
||||
|
||||
Fixed
|
||||
* Read message delivery status clearly — Correction and delivery labels use contrasting text instead of disappearing into the message bubble.
|
||||
* Read and dismiss voice errors — Voice errors open in a contained dialog with scrollable details and separate Retry and Dismiss actions, without overlapping chat controls.
|
||||
* Fix wake-word startup in release builds — Release optimization now preserves the native speech configuration names needed to initialize wake-word detection.
|
||||
* Download attachments with less memory — Standard Hermes attachments stream into the on-disk cache while download size limits remain enforced.
|
||||
* Keep image-generation progress visible — The working indicator stays visible between interim replies and the generated image, including gateways that omit tool activity events.
|
||||
* Wait for new chats to be ready — The first message waits for the Gateway session to initialize. Ownership refusals keep the prompt retryable and show the server's error.
|
||||
* Open Gateway chat from a Dashboard-only cold start — The selected profile's session directory starts before Gateway readiness, so it cannot wait on the same passive socket that depends on its result.
|
||||
|
||||
Compatibility
|
||||
* Standard Chat, sessions, media, and voice continue to use upstream Hermes. Voice transcription still requires a configured speech-to-text provider on the Hermes host.
|
||||
* Paused text queues can be restored. Attachment bytes are not stored in preferences; an attachment queue that cannot be restored must be reviewed and sent again.
|
||||
* Standard Dashboard and Gateway chat continue to use current upstream Hermes without requiring the optional Hermes-Relay Plugin.
|
||||
* Hermes-Relay Plugin 1.11.2 remains the matching optional release for Relay tools.
|
||||
|
||||
@@ -34,9 +34,7 @@ object AgentDisplay {
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile
|
||||
|
||||
// Display can use the root default profile's metadata without making it a
|
||||
// request/session override. Verbose SOUL summaries are filtered by
|
||||
// profileDisplayName below, so this is safe for headers/cards.
|
||||
// Display resolution never changes selection or persistence identity.
|
||||
fun effectiveDisplayProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
@@ -44,36 +42,22 @@ object AgentDisplay {
|
||||
): Profile? {
|
||||
selectedProfile?.let { return it }
|
||||
val resolvedServerDefault = profileRequestName(serverDefaultProfileName)
|
||||
return resolvedServerDefault
|
||||
?.let { activeName ->
|
||||
profiles.firstOrNull { it.name.equals(activeName, ignoreCase = true) }
|
||||
}
|
||||
?: profiles.firstOrNull { it.name.equals("default", ignoreCase = true) }
|
||||
// An absent roster row is not authority to substitute the root profile.
|
||||
// Retain the confirmed name while its display metadata is loading.
|
||||
return resolvedServerDefault?.let { activeName ->
|
||||
profiles.firstOrNull { it.name == activeName }
|
||||
?: Profile(name = activeName, model = "")
|
||||
}
|
||||
}
|
||||
|
||||
// The NAME goes in the name slot. Non-default profiles use their profile
|
||||
// name first. The synthetic default profile uses its description only when
|
||||
// that description looks like a concise human agent name ("Victor"), not a
|
||||
// verbose SOUL summary.
|
||||
// Match upstream Desktop: presentation-only display_name, then exact request name.
|
||||
fun profileDisplayName(profile: Profile?): String? {
|
||||
if (profile == null) return null
|
||||
if (profile.name.equals("default", ignoreCase = true)) {
|
||||
return defaultProfileDisplayName(profile)
|
||||
}
|
||||
return when {
|
||||
profile.name.isNotBlank() -> titleCase(profile.name.trim())
|
||||
profile.description.isNotBlank() -> profile.description.trim()
|
||||
else -> null
|
||||
}
|
||||
return profile.displayName.trim().takeIf(String::isNotEmpty)
|
||||
?: profile.name.trim().takeIf(String::isNotEmpty)
|
||||
}
|
||||
|
||||
fun defaultProfileDisplayName(profile: Profile?): String? =
|
||||
profile
|
||||
?.description
|
||||
?.trim()
|
||||
?.takeIf { it.looksLikeConciseAgentName() }
|
||||
?.let(::titleCase)
|
||||
|
||||
@Suppress("UNUSED_PARAMETER") // connectionLabel retained for source compatibility.
|
||||
fun agentName(
|
||||
profile: Profile?,
|
||||
selectedPersonality: String,
|
||||
@@ -86,7 +70,7 @@ object AgentDisplay {
|
||||
|
||||
// "none"/"neutral" are the upstream "cleared overlay" aliases — treat
|
||||
// them like "default" for identity: fall through to the server default
|
||||
// (or the base connection identity) rather than rendering the literal
|
||||
// identity rather than rendering the literal
|
||||
// word as an agent name.
|
||||
val personalityName = if (
|
||||
isClearedPersonality(selectedPersonality) &&
|
||||
@@ -102,7 +86,6 @@ object AgentDisplay {
|
||||
return when {
|
||||
personalityName.isNotBlank() && personalityName != "default" ->
|
||||
titleCase(personalityName.trim())
|
||||
!connectionLabel.isNullOrBlank() -> connectionLabel.trim()
|
||||
else -> "Hermes"
|
||||
}
|
||||
}
|
||||
@@ -199,16 +182,6 @@ object AgentDisplay {
|
||||
?.replace(Regex("\\s+"), " ")
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun String.looksLikeConciseAgentName(): Boolean {
|
||||
if (isBlank() || length > 40 || contains('\n') || contains('\r')) {
|
||||
return false
|
||||
}
|
||||
if (any { it == '.' || it == ':' || it == ';' }) {
|
||||
return false
|
||||
}
|
||||
return trim().split(Regex("\\s+")).size <= 4
|
||||
}
|
||||
|
||||
private fun titleCase(value: String): String =
|
||||
value.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
@@ -96,7 +96,8 @@ object FeatureFlags {
|
||||
* flavor ships AccessibilityService-backed Device Control. The `googlePlay`
|
||||
* flavor is Bridge Core: relay pairing, chat, voice, terminal, notification
|
||||
* companion, media, and session-grant surfaces without screen reading, taps,
|
||||
* typing, screenshots, overlays, or unattended control.
|
||||
* typing, MediaProjection screenshots, or unattended control. Voice-only overlay
|
||||
* presentation is a separate capability shared by both flavors.
|
||||
*
|
||||
* Device Control tier definitions (see `Phase 3 — Bridge Channel.md`):
|
||||
* 1. baseline — sideload only (app open, tap, navigate within app)
|
||||
@@ -126,6 +127,9 @@ object BuildFlavor {
|
||||
*/
|
||||
val isSideload: Boolean get() = current == SIDELOAD
|
||||
|
||||
/** Voice presentation does not grant Device Control. Unknown distributions fail closed. */
|
||||
val voiceSystemOverlay: Boolean get() = current == GOOGLE_PLAY || current == SIDELOAD
|
||||
|
||||
val bridgeTier1: Boolean get() = current == SIDELOAD // baseline device control
|
||||
val bridgeTier2: Boolean get() = current == SIDELOAD // screen context
|
||||
val bridgeTier3: Boolean get() = current == SIDELOAD // voice-first
|
||||
|
||||
@@ -111,6 +111,8 @@ data class Profile(
|
||||
val hasAvatar: Boolean = false,
|
||||
@SerialName("ui_meta")
|
||||
val uiMeta: JsonObject = JsonObject(emptyMap()),
|
||||
@SerialName("display_name")
|
||||
val displayName: String = "",
|
||||
) {
|
||||
val hasIsolatedApi: Boolean
|
||||
get() = !apiServerUrl.isNullOrBlank()
|
||||
|
||||
@@ -149,7 +149,10 @@ class EndpointResolver(
|
||||
)
|
||||
|
||||
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
|
||||
private val inFlightProbes = ConcurrentHashMap<String, Deferred<Boolean>>()
|
||||
// Every access is owned by [probeStateLock]. This must not be a
|
||||
// concurrently-mutated collection: clearCache() takes a stable snapshot
|
||||
// while completion callbacks remove finished probes.
|
||||
private val inFlightProbes = mutableMapOf<String, Deferred<Boolean>>()
|
||||
private val probeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val probeStateLock = Any()
|
||||
private var probeGeneration = 0L
|
||||
@@ -486,7 +489,13 @@ class EndpointResolver(
|
||||
probe(candidate, surface, generation)
|
||||
}.also { deferred ->
|
||||
inFlightProbes[key] = deferred
|
||||
deferred.invokeOnCompletion { inFlightProbes.remove(key, deferred) }
|
||||
deferred.invokeOnCompletion {
|
||||
synchronized(probeStateLock) {
|
||||
// Identity-aware removal prevents an invalidated
|
||||
// probe from removing its fresh replacement.
|
||||
inFlightProbes.remove(key, deferred)
|
||||
}
|
||||
}
|
||||
deferred.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
@@ -1506,7 +1507,7 @@ class DashboardApiClient(
|
||||
val patched = buildJsonObject {
|
||||
obj.forEach { (k, v) -> put(k, v) }
|
||||
if (obj["name"] == null && !nameOverride.isNullOrBlank()) put("name", nameOverride)
|
||||
if (obj["model"] == null) put("model", "")
|
||||
if (obj["model"] == null || obj["model"] == JsonNull) put("model", "")
|
||||
}
|
||||
json.decodeFromJsonElement(Profile.serializer(), patched)
|
||||
}.getOrNull()
|
||||
|
||||
@@ -1913,6 +1913,7 @@ class GatewayChatClient(
|
||||
model = row.stringField("model").orEmpty(),
|
||||
provider = row.stringField("provider").orEmpty(),
|
||||
description = row.stringField("description").orEmpty(),
|
||||
displayName = row.stringField("display_name").orEmpty(),
|
||||
skillCount = (row["skill_count"] as? JsonPrimitive)?.intOrNull ?: 0,
|
||||
isDefault = (row["is_default"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
hasAvatar = (row["has_avatar"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
@@ -5268,6 +5269,7 @@ private fun parseBotRosterEntry(row: JsonObject): BotRosterEntry? {
|
||||
model = row.stringField("model").orEmpty(),
|
||||
provider = row.stringField("provider").orEmpty(),
|
||||
description = row.stringField("description")?.take(512).orEmpty(),
|
||||
displayName = row.stringField("display_name").orEmpty(),
|
||||
skillCount = (row["skill_count"] as? JsonPrimitive)?.intOrNull ?: 0,
|
||||
isDefault = (row["is_default"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
hasAvatar = (row["has_avatar"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
|
||||
@@ -351,14 +351,18 @@ internal class HermesRuntimeBinder(
|
||||
connection.activeConnectionId,
|
||||
connection.effectiveSessionProfileName,
|
||||
connection.lastSessionId,
|
||||
connection.activeEndpoint,
|
||||
) { ready, connectionId, profileName, sessionId, activeEndpoint ->
|
||||
// The session directory belongs to the standard Dashboard
|
||||
// route. connection.activeEndpoint is the optional Relay
|
||||
// socket's selected candidate and stays null on a valid
|
||||
// Dashboard-only LAN connection.
|
||||
connection.effectiveDashboardUrl,
|
||||
) { ready, connectionId, profileName, sessionId, dashboardUrl ->
|
||||
ProfileContextInputs(
|
||||
ready,
|
||||
connectionId,
|
||||
profileName,
|
||||
sessionId,
|
||||
dashboardRouteResolved = activeEndpoint != null,
|
||||
dashboardUrl = dashboardUrl,
|
||||
)
|
||||
}
|
||||
combine(
|
||||
@@ -374,7 +378,7 @@ internal class HermesRuntimeBinder(
|
||||
)
|
||||
}.collectLatest { inputs ->
|
||||
profileContextReady.value = false
|
||||
if (!shouldRefreshSessionDirectory(inputs.chatReady, inputs.dashboardRouteResolved)) {
|
||||
if (!shouldRefreshSessionDirectory(inputs.chatReady, inputs.dashboardUrl)) {
|
||||
return@collectLatest
|
||||
}
|
||||
if (!inputs.profileSelectionSettled) {
|
||||
@@ -600,7 +604,7 @@ internal class HermesRuntimeBinder(
|
||||
val connectionId: String?,
|
||||
val profileName: String?,
|
||||
val sessionId: String?,
|
||||
val dashboardRouteResolved: Boolean,
|
||||
val dashboardUrl: String,
|
||||
val profileSelectionSettled: Boolean = false,
|
||||
val profileLocked: Boolean = false,
|
||||
val hiddenSources: Set<String> = emptySet(),
|
||||
@@ -632,12 +636,13 @@ internal class HermesRuntimeBinder(
|
||||
/**
|
||||
* Session browsing is Dashboard HTTP state, not Gateway-socket state. API-only
|
||||
* connections still use chat readiness; Dashboard connections can refresh once
|
||||
* the resolver has selected a live route, after the profile-settle fence.
|
||||
* their persisted/resolved Dashboard origin publishes, after the profile-settle
|
||||
* fence. The optional Relay endpoint is deliberately not part of this decision.
|
||||
*/
|
||||
internal fun shouldRefreshSessionDirectory(
|
||||
chatReady: Boolean,
|
||||
dashboardRouteResolved: Boolean,
|
||||
): Boolean = chatReady || dashboardRouteResolved
|
||||
dashboardUrl: String,
|
||||
): Boolean = chatReady || dashboardUrl.isNotBlank()
|
||||
|
||||
internal fun assistantCanTransmitScreenContext(engineMode: VoiceEngineMode): Boolean =
|
||||
engineMode == VoiceEngineMode.HermesVoiceOutput
|
||||
|
||||
@@ -3625,7 +3625,7 @@ internal fun ProfileDisplayManagerDialog(
|
||||
val isServerDefault = key == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
|
||||
val profile = profiles.firstOrNull { it.name == key }
|
||||
val label = if (isServerDefault) {
|
||||
stringResource(R.string.conn_info_server_default)
|
||||
stringResource(R.string.profile_follow_server_default)
|
||||
} else {
|
||||
profile?.let(AgentDisplay::profileDisplayName)
|
||||
?: key.replaceFirstChar { it.uppercase() }
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -41,6 +42,8 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -80,9 +83,10 @@ object ProfileShelfPolicy {
|
||||
profiles: List<Profile>,
|
||||
presentation: ProfilePresentation,
|
||||
selectedProfileName: String?,
|
||||
serverDefaultProfileName: String? = null,
|
||||
): List<ProfileChoice> {
|
||||
val selectedKey = AgentDisplay.profileSessionKey(selectedProfileName)
|
||||
return ProfilePresentationPolicy
|
||||
val choices = ProfilePresentationPolicy
|
||||
.visibleKeys(profiles, presentation, selectedKey)
|
||||
.mapNotNull { key ->
|
||||
if (key == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY) {
|
||||
@@ -91,6 +95,17 @@ object ProfileShelfPolicy {
|
||||
profiles.firstOrNull { it.name == key }?.let { ProfileChoice(key, it) }
|
||||
}
|
||||
}
|
||||
// Group only exact upstream identities, never matching display labels.
|
||||
// Keep the selected choice's request/presentation key unchanged.
|
||||
val hasDefault = choices.any { it.isServerDefault }
|
||||
val hasResolved = choices.any { it.key == serverDefaultProfileName }
|
||||
if (!hasDefault || !hasResolved) return choices
|
||||
val omittedKey = if (selectedProfileName == serverDefaultProfileName) {
|
||||
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
|
||||
} else {
|
||||
serverDefaultProfileName
|
||||
}
|
||||
return choices.filterNot { it.key == omittedKey }
|
||||
}
|
||||
|
||||
fun canSwitch(isStreaming: Boolean, streamingEndpoint: String): Boolean =
|
||||
@@ -124,8 +139,9 @@ fun ProfileShelf(
|
||||
onHide: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val choices = remember(profiles, presentation, selectedProfile?.name) {
|
||||
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name)
|
||||
val serverDefaultProfile by connectionViewModel.serverDefaultDisplayProfile.collectAsState()
|
||||
val choices = remember(profiles, presentation, selectedProfile?.name, serverDefaultProfile) {
|
||||
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name, serverDefaultProfile?.name)
|
||||
}
|
||||
if (choices.size <= 1) return
|
||||
|
||||
@@ -157,7 +173,7 @@ fun ProfileShelf(
|
||||
val label = if (selected) {
|
||||
activeDisplayName
|
||||
} else {
|
||||
profileChoiceLabel(choice, resolvedProfile)
|
||||
profileChoiceLabel(choice, serverDefaultProfile)
|
||||
}
|
||||
if (selected) {
|
||||
val openPassportDescription = stringResource(R.string.profile_shelf_open_passport)
|
||||
@@ -191,7 +207,7 @@ fun ProfileShelf(
|
||||
ProfileChoiceAvatar(
|
||||
connectionViewModel,
|
||||
choice,
|
||||
resolvedProfile,
|
||||
serverDefaultProfile,
|
||||
label,
|
||||
36,
|
||||
)
|
||||
@@ -238,7 +254,7 @@ fun ProfileShelf(
|
||||
ProfileChoiceAvatar(
|
||||
connectionViewModel,
|
||||
choice,
|
||||
resolvedProfile,
|
||||
serverDefaultProfile,
|
||||
label,
|
||||
36,
|
||||
)
|
||||
@@ -275,8 +291,8 @@ fun ProfileShelf(
|
||||
|
||||
actionChoice?.let { choice ->
|
||||
val selected = ProfileShelfPolicy.isSelected(choice, selectedProfile?.name)
|
||||
val target = choice.profile ?: resolvedProfile
|
||||
val label = profileChoiceLabel(choice, resolvedProfile)
|
||||
val target = choice.profile ?: serverDefaultProfile
|
||||
val label = profileChoiceLabel(choice, serverDefaultProfile)
|
||||
ProfileShelfActionsDialog(
|
||||
label = label,
|
||||
canSwitch = switchEnabled && !isProfileLocked,
|
||||
@@ -319,10 +335,14 @@ fun ProfileSwitcherSheet(
|
||||
onManageDisplay: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val choices = remember(profiles, presentation, selectedProfile?.name) {
|
||||
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name)
|
||||
val serverDefaultProfile by connectionViewModel.serverDefaultDisplayProfile.collectAsState()
|
||||
val choices = remember(profiles, presentation, selectedProfile?.name, serverDefaultProfile) {
|
||||
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name, serverDefaultProfile?.name)
|
||||
}
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -346,8 +366,15 @@ fun ProfileSwitcherSheet(
|
||||
}
|
||||
choices.forEach { choice ->
|
||||
val selected = ProfileShelfPolicy.isSelected(choice, selectedProfile?.name)
|
||||
val label = profileChoiceLabel(choice, resolvedProfile)
|
||||
val model = choice.profile?.model?.takeIf { it.isNotBlank() }
|
||||
val label = profileChoiceLabel(choice, serverDefaultProfile)
|
||||
val target = if (choice.isServerDefault) serverDefaultProfile else choice.profile
|
||||
val defaultGroup = serverDefaultProfile != null &&
|
||||
(choice.isServerDefault || choice.key == serverDefaultProfile?.name)
|
||||
val detail = listOfNotNull(
|
||||
stringResource(R.string.conn_info_server_default).takeIf { defaultGroup },
|
||||
target?.name?.takeIf { it != label },
|
||||
target?.model?.takeIf { it.isNotBlank() },
|
||||
).joinToString(" · ")
|
||||
ListItem(
|
||||
modifier = Modifier.selectable(
|
||||
selected = selected,
|
||||
@@ -361,13 +388,13 @@ fun ProfileSwitcherSheet(
|
||||
headlineContent = {
|
||||
Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
supportingContent = if (model != null) {
|
||||
{ Text(model, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
supportingContent = if (detail.isNotBlank()) {
|
||||
{ Text(detail, maxLines = 2, overflow = TextOverflow.Ellipsis) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
leadingContent = {
|
||||
ProfileChoiceAvatar(connectionViewModel, choice, resolvedProfile, label, 42)
|
||||
ProfileChoiceAvatar(connectionViewModel, choice, serverDefaultProfile, label, 42)
|
||||
},
|
||||
trailingContent = if (selected) {
|
||||
{ Icon(Icons.Filled.Check, contentDescription = null) }
|
||||
@@ -375,6 +402,27 @@ fun ProfileSwitcherSheet(
|
||||
null
|
||||
},
|
||||
)
|
||||
if (defaultGroup && selected) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)
|
||||
.toggleable(
|
||||
value = choice.isServerDefault,
|
||||
enabled = switchEnabled && !isProfileLocked,
|
||||
role = Role.Checkbox,
|
||||
onValueChange = {
|
||||
onSelect(if (choice.isServerDefault) serverDefaultProfile else null)
|
||||
onDismiss()
|
||||
},
|
||||
).padding(start = 64.dp, end = 24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(checked = choice.isServerDefault, onCheckedChange = null,
|
||||
enabled = switchEnabled && !isProfileLocked)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.profile_follow_server_default),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
|
||||
TextButton(
|
||||
@@ -518,7 +566,8 @@ private fun ProfileActionRow(
|
||||
@Composable
|
||||
private fun profileChoiceLabel(choice: ProfileChoice, resolvedProfile: Profile?): String =
|
||||
if (choice.isServerDefault) {
|
||||
stringResource(R.string.conn_info_server_default)
|
||||
AgentDisplay.profileDisplayName(resolvedProfile)
|
||||
?: stringResource(R.string.conn_info_server_default)
|
||||
} else {
|
||||
choice.profile?.let(AgentDisplay::profileDisplayName)
|
||||
?: choice.profile?.name?.replaceFirstChar { it.uppercase() }
|
||||
|
||||
@@ -463,6 +463,21 @@ internal fun shouldShowRetainedHistoryDashboardSignIn(
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired &&
|
||||
!apiReachable
|
||||
|
||||
/**
|
||||
* A foreground Gateway-owned Chat must be allowed to open its observation
|
||||
* socket before `gateway.ready` can make chatReady true. Authentication and
|
||||
* protocol failures are terminal; ordinary reachability failures remain
|
||||
* visible so the Gateway client's bounded retry policy can recover them.
|
||||
*/
|
||||
internal fun shouldOwnVisibleGateway(
|
||||
appForeground: Boolean,
|
||||
isGatewayTransport: Boolean,
|
||||
gatewayAvailability: GatewayAvailability,
|
||||
): Boolean = appForeground &&
|
||||
isGatewayTransport &&
|
||||
gatewayAvailability != GatewayAvailability.SignInRequired &&
|
||||
gatewayAvailability != GatewayAvailability.Unsupported
|
||||
|
||||
internal fun shouldPresentChatFailureDuringDashboardSignIn(
|
||||
failure: ChatFailureNotice,
|
||||
dashboardSignInRequired: Boolean,
|
||||
@@ -997,6 +1012,7 @@ fun ChatScreen(
|
||||
// of available profiles itself now lives entirely inside the sheet.
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
|
||||
val serverDefaultDisplayProfile by connectionViewModel.serverDefaultDisplayProfile.collectAsState()
|
||||
val profilePresentation by connectionViewModel.profilePresentation.collectAsState()
|
||||
val isProfileLocked by connectionViewModel.isProfileLocked.collectAsState()
|
||||
val lockedProfileName by connectionViewModel.lockedProfileName.collectAsState()
|
||||
@@ -1212,8 +1228,12 @@ fun ChatScreen(
|
||||
// the foreground. setChatVisible owns that edge; an ordinary Gateway open
|
||||
// warms only the observation socket and never attaches a saved session.
|
||||
val appForeground by com.hermesandroid.relay.util.AppForegroundTracker.isForeground.collectAsState()
|
||||
LaunchedEffect(isGatewayTransport, appForeground, chatReady) {
|
||||
val visibleGatewayOwner = appForeground && chatReady && isGatewayTransport
|
||||
LaunchedEffect(isGatewayTransport, appForeground, chatGatewayAvailability) {
|
||||
val visibleGatewayOwner = shouldOwnVisibleGateway(
|
||||
appForeground = appForeground,
|
||||
isGatewayTransport = isGatewayTransport,
|
||||
gatewayAvailability = chatGatewayAvailability,
|
||||
)
|
||||
chatViewModel.setChatVisible(visibleGatewayOwner)
|
||||
// updateGatewayClient owns the one-time catalog/reasoning bootstrap for
|
||||
// a newly-ready socket. Repeating it here created a duplicate cold-open
|
||||
@@ -1653,65 +1673,53 @@ fun ChatScreen(
|
||||
voiceOutputConfig?.enabled
|
||||
}
|
||||
|
||||
val voiceSystemOverlayAvailable = BuildFlavor.isSideload
|
||||
val voiceSystemOverlayAvailable = BuildFlavor.voiceSystemOverlay
|
||||
val showVoiceSystemOverlay: () -> Unit = {
|
||||
if (!voiceSystemOverlayAvailable) {
|
||||
pendingVoiceOverlayPermission = false
|
||||
} else if (assistantSessionActive) {
|
||||
voiceOverlayHost.hide()
|
||||
} else if (!voiceOverlayHost.hasOverlayPermission()) {
|
||||
if (voiceSystemOverlayAvailable && !assistantSessionActive) {
|
||||
pendingVoiceOverlayPermission = true
|
||||
runCatching {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
context.startActivity(intent)
|
||||
}
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.chat_overlay_perm_enable),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
pendingVoiceOverlayPermission = false
|
||||
val shown = voiceOverlayHost.show(
|
||||
VoiceOverlaySession(
|
||||
uiState = voiceViewModel.uiState,
|
||||
engineMode = voiceStats.voiceEngineMode,
|
||||
provider = activeVoiceProvider,
|
||||
model = activeVoiceModel,
|
||||
voice = activeVoiceName,
|
||||
profileName = AgentDisplay.profileDisplayName(effectiveProfile),
|
||||
configScope = activeVoiceScope,
|
||||
outputEnabled = activeVoiceEnabled,
|
||||
fallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
||||
onStartListening = { voiceViewModel.startListening() },
|
||||
onStopListening = { voiceViewModel.stopListening() },
|
||||
onInterrupt = { voiceViewModel.interruptSpeaking() },
|
||||
onPauseAutoMode = { voiceViewModel.pauseContinuousMode() },
|
||||
onReturnToHermes = {
|
||||
openHermesFromOverlay(context)
|
||||
voiceOverlayHost.hide()
|
||||
},
|
||||
onDismissOverlay = { voiceOverlayHost.hide() },
|
||||
onExit = {
|
||||
voiceOverlayHost.hide()
|
||||
voiceViewModel.exitVoiceMode()
|
||||
},
|
||||
),
|
||||
)
|
||||
if (!shown) {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.chat_overlay_start_failed),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pendingVoiceOverlayPermission && voiceUiState.voiceMode && !assistantSessionActive) {
|
||||
com.hermesandroid.relay.voice.VoiceOverlaySetupDialog(
|
||||
onDismiss = { pendingVoiceOverlayPermission = false },
|
||||
onBeforePermission = { voiceViewModel.pauseContinuousMode() },
|
||||
onStart = {
|
||||
pendingVoiceOverlayPermission = false
|
||||
val shown = voiceOverlayHost.show(
|
||||
VoiceOverlaySession(
|
||||
uiState = voiceViewModel.uiState,
|
||||
engineMode = voiceStats.voiceEngineMode,
|
||||
connectionLabel = activeConnection?.label,
|
||||
provider = activeVoiceProvider,
|
||||
model = activeVoiceModel,
|
||||
voice = activeVoiceName,
|
||||
profileName = AgentDisplay.profileDisplayName(effectiveProfile),
|
||||
configScope = activeVoiceScope,
|
||||
outputEnabled = activeVoiceEnabled,
|
||||
fallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
||||
onStartListening = { voiceViewModel.startListening() },
|
||||
onStopListening = { voiceViewModel.stopListening() },
|
||||
onInterrupt = { voiceViewModel.interruptSpeaking() },
|
||||
onPauseAutoMode = { voiceViewModel.pauseContinuousMode() },
|
||||
onReturnToHermes = {
|
||||
if (!openHermesFromOverlay(context)) voiceOverlayHost.exitVoiceSession()
|
||||
},
|
||||
onDismissOverlay = { voiceOverlayHost.exitVoiceSession() },
|
||||
onExit = { voiceViewModel.exitVoiceMode() },
|
||||
),
|
||||
lifecycleOwner.lifecycle,
|
||||
)
|
||||
if (!shown) {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.chat_overlay_start_failed),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(voiceUiState.voiceMode) {
|
||||
if (!voiceUiState.voiceMode) {
|
||||
@@ -1727,37 +1735,6 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(
|
||||
lifecycleOwner,
|
||||
pendingVoiceOverlayPermission,
|
||||
voiceUiState.voiceMode,
|
||||
voiceOutputConfig,
|
||||
realtimeAgentConfig,
|
||||
voiceStats.voiceEngineMode,
|
||||
) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && pendingVoiceOverlayPermission) {
|
||||
when {
|
||||
voiceOverlayHost.hasOverlayPermission() && voiceUiState.voiceMode -> {
|
||||
showVoiceSystemOverlay()
|
||||
}
|
||||
!voiceOverlayHost.hasOverlayPermission() -> {
|
||||
pendingVoiceOverlayPermission = false
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.chat_overlay_perm_denied),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> pendingVoiceOverlayPermission = false
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
LaunchedEffect(voiceClient, voiceUiState.voiceMode, selectedProfile?.name) {
|
||||
if (!voiceUiState.voiceMode) return@LaunchedEffect
|
||||
val client = voiceClient ?: return@LaunchedEffect
|
||||
@@ -2460,11 +2437,12 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
val selectedProfileKey = AgentDisplay.profileSessionKey(selectedProfile?.name)
|
||||
val profileShelfAvailable = !supervised && ProfilePresentationPolicy.shouldShowShelf(
|
||||
val profileShelfAvailable = !supervised && com.hermesandroid.relay.ui.components.ProfileShelfPolicy.choices(
|
||||
profiles = agentProfiles,
|
||||
presentation = profilePresentation,
|
||||
selectedKey = selectedProfileKey,
|
||||
)
|
||||
selectedProfileName = selectedProfile?.name,
|
||||
serverDefaultProfileName = serverDefaultDisplayProfile?.name,
|
||||
).size > 1
|
||||
val profileSwitchEnabled = com.hermesandroid.relay.ui.components.ProfileShelfPolicy.canSwitch(
|
||||
isStreaming = isStreaming,
|
||||
streamingEndpoint = chatViewModel.streamingEndpoint,
|
||||
|
||||
@@ -4475,7 +4475,8 @@ internal fun summarizeObjectItem(
|
||||
)
|
||||
}
|
||||
|
||||
val title = obj.stringField("name")
|
||||
val title = obj.stringField("display_name")?.takeIf(String::isNotBlank)
|
||||
?: obj.stringField("name")
|
||||
?: obj.stringField("id")
|
||||
?: obj.stringField("title")
|
||||
?: fallbackTitle
|
||||
|
||||
@@ -86,6 +86,10 @@ fun PermissionsStatusScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenBridge: () -> Unit = {},
|
||||
) {
|
||||
var showVoiceOverlaySetup by remember { mutableStateOf(false) }
|
||||
if (showVoiceOverlaySetup) com.hermesandroid.relay.voice.VoiceOverlaySetupDialog(
|
||||
onDismiss = { showVoiceOverlaySetup = false },
|
||||
)
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var status by remember { mutableStateOf(AppPermissionStatusProbe.snapshot(context)) }
|
||||
@@ -209,6 +213,21 @@ fun PermissionsStatusScreen(
|
||||
)
|
||||
}
|
||||
|
||||
PermissionSection(
|
||||
title = stringResource(R.string.voice_overlay_setup_title),
|
||||
subtitle = stringResource(R.string.voice_overlay_settings_hint),
|
||||
) {
|
||||
PermissionStatusRow(
|
||||
icon = Icons.Filled.PictureInPicture,
|
||||
title = stringResource(R.string.perms_display_over_apps),
|
||||
subtitle = stringResource(R.string.voice_overlay_setup_body),
|
||||
badge = stringResource(R.string.perms_badge_optional),
|
||||
statusLabel = optionalStatus(context, status.overlayPermitted),
|
||||
granted = status.overlayPermitted,
|
||||
onClick = { showVoiceOverlaySetup = true },
|
||||
)
|
||||
}
|
||||
|
||||
if (BuildFlavor.isSideload) {
|
||||
SideloadPermissionsSection(
|
||||
status = status,
|
||||
|
||||
@@ -1287,7 +1287,7 @@ private fun ProfileLockDialog(
|
||||
HorizontalDivider()
|
||||
// Server default option.
|
||||
ProfileLockOptionRow(
|
||||
label = stringResource(R.string.settings_server_default),
|
||||
label = stringResource(R.string.profile_follow_server_default),
|
||||
secondary = stringResource(R.string.settings_use_default_profile),
|
||||
selected = lockedIsServerDefault,
|
||||
onSelect = { onLock(null) },
|
||||
|
||||
@@ -582,6 +582,7 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
|
||||
VoiceSettingsSection.Listening -> {
|
||||
com.hermesandroid.relay.voice.VoiceOverlaySettingsCard()
|
||||
GlobalVoiceControlsCard(
|
||||
voiceSettings = voiceSettings,
|
||||
prefsRepo = prefsRepo,
|
||||
|
||||
@@ -2987,9 +2987,10 @@ class ChatViewModel : ViewModel() {
|
||||
_reasoningDisplay.value = null
|
||||
}
|
||||
}
|
||||
if (changed && client != null && streamRecovery != null &&
|
||||
AppForegroundTracker.isForeground.value
|
||||
) {
|
||||
// Visibility can arrive before the runtime binder publishes its client.
|
||||
// Start the same socket-only warmup in either ordering; prewarmGateway
|
||||
// retains the directory barrier and exact-checkpoint ownership rules.
|
||||
if (changed && client != null && chatVisible) {
|
||||
prewarmGateway()
|
||||
}
|
||||
if (changed && client != null) requestSessionActivityRefresh()
|
||||
|
||||
@@ -2357,6 +2357,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
get() = profileController.effectiveSessionProfileName
|
||||
val effectiveDisplayProfile: StateFlow<Profile?>
|
||||
get() = profileController.effectiveDisplayProfile
|
||||
val serverDefaultDisplayProfile: StateFlow<Profile?>
|
||||
get() = profileController.serverDefaultDisplayProfile
|
||||
|
||||
fun refreshDashboardProfiles() = profileController.refreshDashboardProfiles()
|
||||
fun refreshDeferredProfileMetadata() = profileController.refreshDeferredProfileMetadata()
|
||||
|
||||
@@ -193,6 +193,13 @@ class ProfileController(
|
||||
private val _serverDefaultProfileScope = MutableStateFlow<DashboardProfileScope?>(null)
|
||||
val serverDefaultProfileScope: StateFlow<DashboardProfileScope?> =
|
||||
_serverDefaultProfileScope.asStateFlow()
|
||||
/** Default identity independent of the explicit selection; never a routing override. */
|
||||
val serverDefaultDisplayProfile: StateFlow<Profile?> = combine(
|
||||
agentProfiles,
|
||||
serverDefaultProfileScope,
|
||||
) { profiles, serverDefault ->
|
||||
AgentDisplay.effectiveDisplayProfile(null, profiles, serverDefault?.active)
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
private val _serverDefaultProfileSettled = MutableStateFlow(false)
|
||||
|
||||
private fun pendingProfileNameForActiveConnection(): String? {
|
||||
@@ -361,12 +368,8 @@ class ProfileController(
|
||||
activeConnectionId,
|
||||
selectedProfile,
|
||||
serverDefaultProfileScope,
|
||||
_gatewayProfiles,
|
||||
) { connectionId, selected, serverDefault, gatewayProfiles ->
|
||||
connectionId to (
|
||||
selected?.name ?: serverDefault?.active
|
||||
?: gatewayProfiles.firstOrNull(Profile::isDefault)?.name
|
||||
)
|
||||
) { connectionId, selected, serverDefault ->
|
||||
connectionId to (selected?.name ?: serverDefault?.active)
|
||||
}
|
||||
|
||||
/** Cached bytes fetched from Hermes; always preferred over the local fallback. */
|
||||
@@ -398,12 +401,8 @@ class ProfileController(
|
||||
fun profileIconFlow(profileName: String?): Flow<String?> = combine(
|
||||
activeConnectionId,
|
||||
serverDefaultProfileScope,
|
||||
_gatewayProfiles,
|
||||
) { connectionId, serverDefault, gatewayProfiles ->
|
||||
connectionId to (
|
||||
profileName ?: serverDefault?.active
|
||||
?: gatewayProfiles.firstOrNull(Profile::isDefault)?.name
|
||||
)
|
||||
) { connectionId, serverDefault ->
|
||||
connectionId to (profileName ?: serverDefault?.active)
|
||||
}
|
||||
.flatMapLatest { (connectionId, serverProfileName) ->
|
||||
if (connectionId == null) return@flatMapLatest flowOf(null)
|
||||
@@ -1282,7 +1281,6 @@ class ProfileController(
|
||||
|
||||
private fun resolveSharedAssetProfileName(): String? =
|
||||
resolveSessionProfileName()
|
||||
?: _gatewayProfiles.value.firstOrNull(Profile::isDefault)?.name
|
||||
|
||||
private companion object {
|
||||
const val LOCAL_PROFILE_ICON_MAX_BYTES = 8_000_000
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.Manifest
|
||||
import android.app.KeyguardManager
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/** Permissions do not grant permission to start a session: a resumed UI action is also required. */
|
||||
data class VoiceOverlayAccess(
|
||||
val microphone: Boolean,
|
||||
val notifications: Boolean,
|
||||
val overlay: Boolean,
|
||||
val unlocked: Boolean,
|
||||
) {
|
||||
val ready: Boolean get() = microphone && notifications && overlay && unlocked
|
||||
|
||||
companion object {
|
||||
fun read(context: Context): VoiceOverlayAccess {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
val channelEnabled = Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
manager?.getNotificationChannel(VoiceOverlayForegroundService.CHANNEL_ID)?.importance !=
|
||||
NotificationManager.IMPORTANCE_NONE
|
||||
return VoiceOverlayAccess(
|
||||
microphone = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
notifications = NotificationManagerCompat.from(context).areNotificationsEnabled() && channelEnabled,
|
||||
overlay = Settings.canDrawOverlays(context),
|
||||
unlocked = context.getSystemService(KeyguardManager::class.java)?.isKeyguardLocked == false &&
|
||||
context.getSystemService(PowerManager::class.java)?.isInteractive == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,13 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -27,31 +31,84 @@ import com.hermesandroid.relay.R
|
||||
* execution state Android requires once [MainActivity] is backgrounded.
|
||||
*/
|
||||
class VoiceOverlayForegroundService : Service() {
|
||||
private var activeSessionId: Long? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val accessMonitor = object : Runnable {
|
||||
override fun run() {
|
||||
val id = activeSessionId ?: return
|
||||
if (VoiceOverlayHost.peek()?.canContinue(id) != true) {
|
||||
endSession()
|
||||
} else {
|
||||
// Notification channels can be disabled without a runtime-permission event.
|
||||
handler.postDelayed(this, 1_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val screenOffReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == Intent.ACTION_SCREEN_OFF) endSession()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ContextCompat.registerReceiver(this, screenOffReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForegroundNotification()
|
||||
val id = intent?.getLongExtra(EXTRA_SESSION_ID, -1L) ?: -1L
|
||||
val host = VoiceOverlayHost.peek()
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
Log.i(TAG, "Notification stop requested")
|
||||
VoiceOverlayHost.peek()?.exitVoiceSession()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
if (id == activeSessionId) endSession()
|
||||
else if (activeSessionId == null && host?.sessionId == null) stopSelfResult(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// No sticky restart, unowned Intent, or stale callback can start a microphone session.
|
||||
if (intent?.action != ACTION_START || host?.canStart(id) != true) {
|
||||
if (host?.sessionId == id) host.exitVoiceSession(id)
|
||||
if (activeSessionId == null) stopSelfResult(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
activeSessionId = id
|
||||
if (!startForegroundNotification() || !host.onServiceReady(id)) {
|
||||
endSession()
|
||||
} else {
|
||||
handler.removeCallbacks(accessMonitor)
|
||||
handler.post(accessMonitor)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
Log.i(TAG, "App task removed; closing voice overlay")
|
||||
VoiceOverlayHost.peek()?.exitVoiceSession()
|
||||
endSession()
|
||||
}
|
||||
|
||||
private fun endSession() {
|
||||
val id = activeSessionId
|
||||
activeSessionId = null
|
||||
handler.removeCallbacks(accessMonitor)
|
||||
if (id != null) VoiceOverlayHost.peek()?.exitVoiceSession(id)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
val id = activeSessionId
|
||||
activeSessionId = null
|
||||
handler.removeCallbacks(accessMonitor)
|
||||
unregisterReceiver(screenOffReceiver)
|
||||
if (id != null) VoiceOverlayHost.peek()?.exitVoiceSession(id)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForegroundNotification() {
|
||||
ensureChannel()
|
||||
private fun startForegroundNotification(): Boolean {
|
||||
try {
|
||||
ensureChannel()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
@@ -63,9 +120,9 @@ class VoiceOverlayForegroundService : Service() {
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "Could not foreground voice overlay microphone service", t)
|
||||
VoiceOverlayHost.peek()?.hide()
|
||||
stopSelf()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
@@ -76,8 +133,10 @@ class VoiceOverlayForegroundService : Service() {
|
||||
val openPending = PendingIntent.getActivity(this, 0, openIntent, pendingFlags)
|
||||
val stopPending = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
Intent(this, VoiceOverlayForegroundService::class.java).setAction(ACTION_STOP),
|
||||
activeSessionId?.toInt() ?: 0,
|
||||
Intent(this, VoiceOverlayForegroundService::class.java).setAction(ACTION_STOP)
|
||||
.setData(android.net.Uri.parse("hermes-voice-overlay:stop/$activeSessionId"))
|
||||
.putExtra(EXTRA_SESSION_ID, activeSessionId ?: -1L),
|
||||
pendingFlags,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
@@ -93,6 +152,7 @@ class VoiceOverlayForegroundService : Service() {
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.addAction(
|
||||
0,
|
||||
getString(R.string.voice_overlay_notification_stop),
|
||||
@@ -123,14 +183,15 @@ class VoiceOverlayForegroundService : Service() {
|
||||
const val NOTIFICATION_ID = 4715
|
||||
const val ACTION_START = "com.hermesandroid.relay.voice.OVERLAY_MIC_START"
|
||||
const val ACTION_STOP = "com.hermesandroid.relay.voice.OVERLAY_MIC_STOP"
|
||||
const val EXTRA_SESSION_ID = "voice_overlay_session_id"
|
||||
|
||||
fun start(context: Context): Boolean {
|
||||
fun start(context: Context, sessionId: Long): Boolean {
|
||||
val appContext = context.applicationContext
|
||||
return runCatching {
|
||||
ContextCompat.startForegroundService(
|
||||
appContext,
|
||||
Intent(appContext, VoiceOverlayForegroundService::class.java)
|
||||
.setAction(ACTION_START),
|
||||
.setAction(ACTION_START).putExtra(EXTRA_SESSION_ID, sessionId),
|
||||
)
|
||||
true
|
||||
}.getOrElse { error ->
|
||||
|
||||
@@ -22,6 +22,8 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -31,6 +33,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -81,6 +84,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
@@ -153,21 +157,78 @@ class VoiceOverlayHost(context: Context) {
|
||||
private var overlayView: View? = null
|
||||
private var overlayOwner: VoiceOverlayLifecycleOwner? = null
|
||||
private var overlayParams: WindowManager.LayoutParams? = null
|
||||
private var generation = android.os.SystemClock.elapsedRealtimeNanos()
|
||||
internal var sessionId: Long? = null
|
||||
private set
|
||||
private var callerLifecycle: Lifecycle? = null
|
||||
private val callerObserver = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
// Keep microphone protection until the real Activity is foreground again.
|
||||
Lifecycle.Event.ON_RESUME -> if (overlayView != null) handoffToApp()
|
||||
Lifecycle.Event.ON_DESTROY -> exitVoiceSession()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun hasOverlayPermission(): Boolean = Settings.canDrawOverlays(appContext)
|
||||
|
||||
fun show(session: VoiceOverlaySession, lifecycle: Lifecycle): Boolean {
|
||||
if (!com.hermesandroid.relay.data.BuildFlavor.voiceSystemOverlay ||
|
||||
!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) ||
|
||||
!VoiceOverlayAccess.read(appContext).ready || !session.uiState.value.voiceMode
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (sessionId != null) return true
|
||||
val id = ++generation
|
||||
sessionId = id
|
||||
fun guarded(action: () -> Unit): () -> Unit = {
|
||||
if (canContinue(id)) action() else exitVoiceSession(id)
|
||||
}
|
||||
sessionState.value = session.copy(
|
||||
onStartListening = guarded(session.onStartListening),
|
||||
onStopListening = guarded(session.onStopListening),
|
||||
onInterrupt = guarded(session.onInterrupt),
|
||||
onPauseAutoMode = guarded(session.onPauseAutoMode),
|
||||
onReturnToHermes = guarded {
|
||||
session.onReturnToHermes()
|
||||
// Already foreground: no lifecycle transition is needed for a safe handoff.
|
||||
if (sessionId == id &&
|
||||
callerLifecycle?.currentState?.isAtLeast(Lifecycle.State.RESUMED) == true
|
||||
) handoffToApp()
|
||||
},
|
||||
onExit = { exitVoiceSession(id) },
|
||||
onDismissOverlay = { exitVoiceSession(id) },
|
||||
onResetPosition = guarded { moveTo(24, 96) },
|
||||
)
|
||||
exitCallback = session.onExit
|
||||
callerLifecycle = lifecycle
|
||||
lifecycle.addObserver(callerObserver)
|
||||
if (!VoiceOverlayForegroundService.start(appContext, id)) {
|
||||
exitVoiceSession(id)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private var exitCallback: (() -> Unit)? = null
|
||||
|
||||
private fun handoffToApp() {
|
||||
val id = sessionId ?: return
|
||||
// A fast return from Android Settings must not bypass revocation teardown.
|
||||
if (canContinue(id)) hide() else exitVoiceSession(id)
|
||||
}
|
||||
|
||||
internal fun canStart(id: Long): Boolean = sessionId == id &&
|
||||
callerLifecycle?.currentState?.isAtLeast(Lifecycle.State.RESUMED) == true && canContinue(id)
|
||||
|
||||
internal fun canContinue(id: Long): Boolean = sessionId == id &&
|
||||
sessionState.value?.uiState?.value?.voiceMode == true && VoiceOverlayAccess.read(appContext).ready
|
||||
|
||||
/** Called only after startForeground succeeds; a queued service start is not readiness. */
|
||||
@SuppressLint("InflateParams")
|
||||
fun show(session: VoiceOverlaySession): Boolean {
|
||||
sessionState.value = session
|
||||
if (!hasOverlayPermission()) {
|
||||
Log.w(TAG, "show: SYSTEM_ALERT_WINDOW not granted")
|
||||
return false
|
||||
}
|
||||
if (!VoiceOverlayForegroundService.start(appContext)) {
|
||||
Log.w(TAG, "show: microphone foreground service could not start")
|
||||
sessionState.value = null
|
||||
return false
|
||||
}
|
||||
internal fun onServiceReady(id: Long): Boolean {
|
||||
if (!canStart(id)) return false
|
||||
if (overlayView != null) return true
|
||||
|
||||
val compose = ComposeView(appContext).apply {
|
||||
@@ -191,9 +252,7 @@ class VoiceOverlayHost(context: Context) {
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
overlayType(),
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
@@ -205,8 +264,6 @@ class VoiceOverlayHost(context: Context) {
|
||||
.onFailure { Log.w(TAG, "addView(voice overlay) failed", it) }
|
||||
.isSuccess
|
||||
if (!added) {
|
||||
sessionState.value = null
|
||||
VoiceOverlayForegroundService.stop(appContext)
|
||||
overlayOwner?.stop()
|
||||
overlayOwner = null
|
||||
return false
|
||||
@@ -214,10 +271,17 @@ class VoiceOverlayHost(context: Context) {
|
||||
|
||||
overlayView = compose
|
||||
overlayParams = params
|
||||
compose.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
moveTo(params.x, params.y)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
sessionId = null
|
||||
exitCallback = null
|
||||
callerLifecycle?.removeObserver(callerObserver)
|
||||
callerLifecycle = null
|
||||
val view = overlayView
|
||||
overlayView = null
|
||||
overlayParams = null
|
||||
@@ -231,17 +295,29 @@ class VoiceOverlayHost(context: Context) {
|
||||
VoiceOverlayForegroundService.stop(appContext)
|
||||
}
|
||||
|
||||
fun exitVoiceSession() {
|
||||
val onExit = sessionState.value?.onExit
|
||||
fun exitVoiceSession(expectedId: Long? = sessionId) {
|
||||
if (expectedId == null || sessionId != expectedId) return
|
||||
val onExit = exitCallback
|
||||
hide()
|
||||
onExit?.invoke()
|
||||
}
|
||||
|
||||
private fun moveBy(dx: Float, dy: Float) {
|
||||
val params = overlayParams ?: return
|
||||
moveTo(params.x + dx.roundToInt(), params.y + dy.roundToInt())
|
||||
}
|
||||
|
||||
private fun moveTo(x: Int, y: Int) {
|
||||
val view = overlayView ?: return
|
||||
val params = overlayParams ?: return
|
||||
params.x = (params.x + dx.roundToInt()).coerceAtLeast(0)
|
||||
params.y = (params.y + dy.roundToInt()).coerceAtLeast(0)
|
||||
val size = android.graphics.Point()
|
||||
@Suppress("DEPRECATION")
|
||||
wm.defaultDisplay.getSize(size)
|
||||
val nextX = x.coerceIn(0, (size.x - view.width).coerceAtLeast(0))
|
||||
val nextY = y.coerceIn(0, (size.y - view.height).coerceAtLeast(0))
|
||||
if (params.x == nextX && params.y == nextY) return
|
||||
params.x = nextX
|
||||
params.y = nextY
|
||||
runCatching { wm.updateViewLayout(view, params) }
|
||||
.onFailure { Log.w(TAG, "updateViewLayout(voice overlay) failed", it) }
|
||||
}
|
||||
@@ -280,10 +356,12 @@ data class VoiceOverlaySession(
|
||||
val onReturnToHermes: () -> Unit,
|
||||
val onDismissOverlay: () -> Unit,
|
||||
val onExit: () -> Unit,
|
||||
val onResetPosition: () -> Unit = {},
|
||||
val connectionLabel: String? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun VoiceFloatingOverlayPill(
|
||||
internal fun VoiceFloatingOverlayPill(
|
||||
session: VoiceOverlaySession,
|
||||
onDragBy: (Float, Float) -> Unit,
|
||||
) {
|
||||
@@ -301,20 +379,31 @@ private fun VoiceFloatingOverlayPill(
|
||||
?: stringResource(R.string.voice_overlay_label_default_profile)
|
||||
val stateText = voiceOverlayStateLabel(uiState.state)
|
||||
val overlayWidth = (LocalConfiguration.current.screenWidthDp - 24)
|
||||
.coerceIn(280, 368)
|
||||
.coerceIn(200, 368)
|
||||
.dp
|
||||
|
||||
if (minimized) {
|
||||
VoiceFloatingOverlayBubble(
|
||||
uiState = uiState,
|
||||
stateText = stateText,
|
||||
onExpand = { minimized = false },
|
||||
onStartListening = session.onStartListening,
|
||||
onStopListening = session.onStopListening,
|
||||
onInterrupt = session.onInterrupt,
|
||||
onPauseAutoMode = session.onPauseAutoMode,
|
||||
onDragBy = onDragBy,
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
VoiceFloatingOverlayBubble(
|
||||
uiState = uiState,
|
||||
stateText = stateText,
|
||||
onExpand = { minimized = false },
|
||||
onStartListening = session.onStartListening,
|
||||
onStopListening = session.onStopListening,
|
||||
onInterrupt = session.onInterrupt,
|
||||
onPauseAutoMode = session.onPauseAutoMode,
|
||||
onDragBy = onDragBy,
|
||||
)
|
||||
TextButton(onClick = session.onExit) {
|
||||
Text(stringResource(R.string.voice_overlay_notification_stop))
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -329,6 +418,7 @@ private fun VoiceFloatingOverlayPill(
|
||||
},
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.98f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 10.dp,
|
||||
) {
|
||||
@@ -382,7 +472,9 @@ private fun VoiceOverlayHeader(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(50.dp), contentAlignment = Alignment.Center) {
|
||||
if (LocalConfiguration.current.screenWidthDp >= 360) Box(
|
||||
modifier = Modifier.size(50.dp), contentAlignment = Alignment.Center,
|
||||
) {
|
||||
OverlayCircularWaveformRing(
|
||||
amplitude = uiState.amplitude,
|
||||
state = uiState.state,
|
||||
@@ -396,21 +488,28 @@ private fun VoiceOverlayHeader(
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = listOfNotNull(session.connectionLabel, session.profileName).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stateText,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = overlayPrimaryText(uiState, stateText),
|
||||
val primaryText = overlayPrimaryText(uiState, stateText)
|
||||
if (primaryText != stateText) Text(
|
||||
text = primaryText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onToggleExpanded, modifier = Modifier.size(38.dp)) {
|
||||
IconButton(onClick = onToggleExpanded, modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = stringResource(
|
||||
@@ -426,6 +525,9 @@ private fun VoiceOverlayHeader(
|
||||
onPauseAutoMode = session.onPauseAutoMode,
|
||||
size = 50.dp,
|
||||
)
|
||||
IconButton(onClick = session.onExit, modifier = Modifier.size(48.dp)) {
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.voice_overlay_notification_stop))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,7 +541,10 @@ private fun ExpandedVoiceOverlayBody(
|
||||
session: VoiceOverlaySession,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
modifier = Modifier
|
||||
.heightIn(max = (LocalConfiguration.current.screenHeightDp - 120).coerceAtLeast(100).dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 8.dp),
|
||||
) {
|
||||
OverlayLinearWaveform(
|
||||
amplitude = uiState.amplitude,
|
||||
@@ -502,31 +607,36 @@ private fun ExpandedVoiceOverlayBody(
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.62f))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val actions: @Composable (Modifier) -> Unit = { actionModifier ->
|
||||
VoiceOverlayAction(
|
||||
icon = Icons.Filled.ExpandMore,
|
||||
label = stringResource(R.string.voice_overlay_minimize),
|
||||
onClick = onMinimize,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = actionModifier,
|
||||
)
|
||||
VoiceOverlayAction(
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
label = stringResource(R.string.voice_overlay_hide),
|
||||
onClick = session.onDismissOverlay,
|
||||
modifier = Modifier.weight(1f),
|
||||
icon = Icons.Filled.GraphicEq,
|
||||
label = stringResource(R.string.voice_overlay_reset_position),
|
||||
onClick = session.onResetPosition,
|
||||
modifier = actionModifier,
|
||||
)
|
||||
VoiceOverlayAction(
|
||||
icon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
label = stringResource(R.string.voice_overlay_open_hermes),
|
||||
onClick = session.onReturnToHermes,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = actionModifier,
|
||||
)
|
||||
}
|
||||
val actionContainer = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
val stackActions = androidx.compose.ui.platform.LocalDensity.current.fontScale > 1.2f &&
|
||||
LocalConfiguration.current.screenWidthDp < 360
|
||||
if (stackActions) {
|
||||
Column(modifier = actionContainer) { actions(Modifier.fillMaxWidth()) }
|
||||
} else {
|
||||
Row(modifier = actionContainer, verticalAlignment = Alignment.CenterVertically) {
|
||||
actions(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +680,7 @@ private fun VoiceOverlayAction(
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(54.dp),
|
||||
modifier = modifier.heightIn(min = 72.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -584,7 +694,7 @@ private fun VoiceOverlayAction(
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
@@ -1049,7 +1159,7 @@ private fun voiceEngineLabel(engineMode: String?): String = when (engineMode) {
|
||||
.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
}
|
||||
|
||||
fun openHermesFromOverlay(context: Context) {
|
||||
fun openHermesFromOverlay(context: Context): Boolean {
|
||||
val appContext = context.applicationContext
|
||||
val launchIntent = appContext.packageManager.getLaunchIntentForPackage(appContext.packageName)
|
||||
?: Intent().setPackage(appContext.packageName)
|
||||
@@ -1057,8 +1167,9 @@ fun openHermesFromOverlay(context: Context) {
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
runCatching { appContext.startActivity(launchIntent) }
|
||||
return runCatching { appContext.startActivity(launchIntent) }
|
||||
.onFailure { Log.w("VoiceOverlayHost", "return to Hermes failed", it) }
|
||||
.isSuccess
|
||||
}
|
||||
|
||||
private class VoiceOverlayLifecycleOwner :
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/** Settings and Voice Focus share the explanation; only Voice Focus supplies a start action. */
|
||||
@Composable
|
||||
fun VoiceOverlaySetupDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onStart: (() -> Unit)? = null,
|
||||
onBeforePermission: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
var access by remember { mutableStateOf(VoiceOverlayAccess.read(context)) }
|
||||
var requestedMicrophone by remember { mutableStateOf(false) }
|
||||
var requestedNotifications by remember { mutableStateOf(false) }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
access = VoiceOverlayAccess.read(context)
|
||||
}
|
||||
DisposableEffect(lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) access = VoiceOverlayAccess.read(context)
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
fun open(intent: Intent) {
|
||||
onBeforePermission()
|
||||
failed = runCatching { context.startActivity(intent) }.isFailure
|
||||
}
|
||||
VoiceOverlaySetupContent(
|
||||
access = access,
|
||||
failed = failed,
|
||||
onDismiss = onDismiss,
|
||||
onStart = onStart?.let { start -> {
|
||||
access = VoiceOverlayAccess.read(context)
|
||||
if (access.ready && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) start()
|
||||
} },
|
||||
onMicrophone = {
|
||||
if (access.microphone || requestedMicrophone) {
|
||||
open(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}")))
|
||||
} else {
|
||||
onBeforePermission()
|
||||
requestedMicrophone = true
|
||||
permission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
onNotifications = {
|
||||
if (Build.VERSION.SDK_INT >= 33 && !requestedNotifications &&
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
onBeforePermission()
|
||||
requestedNotifications = true
|
||||
permission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
open(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName))
|
||||
}
|
||||
},
|
||||
onOverlay = {
|
||||
open(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${context.packageName}")))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun VoiceOverlaySetupContent(
|
||||
access: VoiceOverlayAccess,
|
||||
failed: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onStart: (() -> Unit)?,
|
||||
onMicrophone: () -> Unit,
|
||||
onNotifications: () -> Unit,
|
||||
onOverlay: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.voice_overlay_setup_title)) },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.voice_overlay_setup_body))
|
||||
AccessButton(R.string.perms_microphone, access.microphone, onMicrophone)
|
||||
AccessButton(R.string.perms_app_notifications, access.notifications, onNotifications)
|
||||
AccessButton(R.string.perms_display_over_apps, access.overlay, onOverlay)
|
||||
if (onStart == null) Text(stringResource(R.string.voice_overlay_settings_hint))
|
||||
if (failed) Text(stringResource(R.string.chat_overlay_start_failed), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (onStart != null) TextButton(onClick = onStart, enabled = access.ready) {
|
||||
Text(stringResource(R.string.voice_overlay_setup_start))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cw_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccessButton(@androidx.annotation.StringRes label: Int, granted: Boolean, onClick: () -> Unit) {
|
||||
TextButton(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(label) + " · " + stringResource(
|
||||
if (granted) R.string.ncs_access_granted else R.string.chat_open_settings,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VoiceOverlaySettingsCard() {
|
||||
var show by remember { mutableStateOf(false) }
|
||||
Card {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.voice_overlay_setup_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.voice_overlay_settings_hint))
|
||||
TextButton(onClick = { show = true }) { Text(stringResource(R.string.onboarding_review_optional_permissions)) }
|
||||
}
|
||||
}
|
||||
if (show) VoiceOverlaySetupDialog(onDismiss = { show = false })
|
||||
}
|
||||
@@ -4439,4 +4439,12 @@
|
||||
<string name="tool_progress_status_dispatched">Enviado</string>
|
||||
<string name="chat_activity_history_notice">Atividade registrada. As atualizações de progresso não são mantidas; o histórico disponível dos subagentes pode ser aberto somente para leitura.</string>
|
||||
<string name="chat_activity_output_unavailable">A saída não está mais disponível. Este registro preserva o estado registrado do processo.</string>
|
||||
<string name="profile_follow_server_default">Seguir o perfil padrão do servidor</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Voz sobre outros apps</string>
|
||||
<string name="voice_overlay_setup_body">Mantenha os controles de voz visíveis sobre outro app. O áudio vai para o servidor Hermes configurado. Isso não permite ler a tela nem controlar o telefone. Permita a exibição sobre outros apps nas configurações do Android e volte aqui. É necessária uma notificação do microfone com Parar voz. Fechar a sobreposição ou bloquear a tela encerra a sessão de voz.</string>
|
||||
<string name="voice_overlay_setup_start">Iniciar sobreposição de voz</string>
|
||||
<string name="voice_overlay_settings_hint">Opcional nas duas versões. Abra o foco de voz no Chat e escolha Sobreposição. As permissões sozinhas nunca iniciam a escuta.</string>
|
||||
<string name="voice_overlay_reset_position">Redefinir posição</string>
|
||||
</resources>
|
||||
|
||||
@@ -4520,4 +4520,12 @@
|
||||
<string name="tool_progress_status_dispatched">已分派</string>
|
||||
<string name="chat_activity_history_notice">已记录的活动。进度更新不会保留;可用的子代理历史记录可以以只读方式打开。</string>
|
||||
<string name="chat_activity_output_unavailable">输出已不可用。此条目保留了记录的进程状态。</string>
|
||||
<string name="profile_follow_server_default">跟随服务器默认配置</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">在其他应用上使用语音</string>
|
||||
<string name="voice_overlay_setup_body">在其他应用上保持语音控件可见。音频会发送到你配置的 Hermes 服务器。这不会授权读取屏幕或控制手机。请在 Android 设置中允许显示在其他应用上层,然后返回此处。必须启用带有停止操作的麦克风通知。关闭悬浮窗或锁定屏幕会结束语音会话。</string>
|
||||
<string name="voice_overlay_setup_start">启动语音悬浮窗</string>
|
||||
<string name="voice_overlay_settings_hint">两个版本均可选择使用。在聊天的语音专注模式中选择悬浮窗。仅授予权限不会开始监听。</string>
|
||||
<string name="voice_overlay_reset_position">重置位置</string>
|
||||
</resources>
|
||||
|
||||
@@ -4596,4 +4596,12 @@
|
||||
<string name="tool_progress_status_dispatched">Übergeben</string>
|
||||
<string name="chat_activity_history_notice">Gespeicherte Aktivität. Fortschrittsmeldungen werden nicht gespeichert; der verfügbare Verlauf der Unteragenten kann schreibgeschützt geöffnet werden.</string>
|
||||
<string name="chat_activity_output_unavailable">Die Ausgabe ist nicht mehr verfügbar. Dieser Eintrag enthält den gespeicherten Prozessstatus.</string>
|
||||
<string name="profile_follow_server_default">Serverstandard folgen</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Sprache über anderen Apps</string>
|
||||
<string name="voice_overlay_setup_body">Sprachsteuerung bleibt über anderen Apps sichtbar. Audio geht an deinen konfigurierten Hermes-Server. Dies erlaubt weder Bildschirmlesen noch Telefonsteuerung. Erlaube die Anzeige über anderen Apps in den Android-Einstellungen und kehre zurück. Eine Mikrofonbenachrichtigung mit Stopp-Aktion ist erforderlich. Schließen des Overlays oder Sperren des Bildschirms beendet die Sprachsitzung.</string>
|
||||
<string name="voice_overlay_setup_start">Sprach-Overlay starten</string>
|
||||
<string name="voice_overlay_settings_hint">In beiden Versionen optional. Öffne den Sprachfokus im Chat und wähle Overlay. Berechtigungen allein starten kein Zuhören.</string>
|
||||
<string name="voice_overlay_reset_position">Position zurücksetzen</string>
|
||||
</resources>
|
||||
|
||||
@@ -4287,4 +4287,12 @@
|
||||
<string name="tool_progress_status_dispatched">Enviado</string>
|
||||
<string name="chat_activity_history_notice">Actividad registrada. No se conservan las actualizaciones de progreso; el historial disponible de los subagentes se puede abrir en modo de solo lectura.</string>
|
||||
<string name="chat_activity_output_unavailable">La salida ya no está disponible. Esta entrada conserva el estado registrado del proceso.</string>
|
||||
<string name="profile_follow_server_default">Seguir el perfil predeterminado del servidor</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Voz sobre otras apps</string>
|
||||
<string name="voice_overlay_setup_body">Mantén los controles de voz visibles sobre otra app. El audio se envía a tu servidor Hermes configurado. Esto no permite leer la pantalla ni controlar el teléfono. Permite mostrar sobre otras apps en los ajustes de Android y vuelve aquí. Se requiere una notificación del micrófono con Detener voz. Cerrar la ventana o bloquear la pantalla termina la sesión de voz.</string>
|
||||
<string name="voice_overlay_setup_start">Iniciar ventana de voz</string>
|
||||
<string name="voice_overlay_settings_hint">Opcional en ambas versiones. Abre el enfoque de voz en Chat y elige Superposición. Los permisos por sí solos nunca inician la escucha.</string>
|
||||
<string name="voice_overlay_reset_position">Restablecer posición</string>
|
||||
</resources>
|
||||
|
||||
@@ -4591,4 +4591,12 @@
|
||||
<string name="tool_progress_status_dispatched">ディスパッチ済み</string>
|
||||
<string name="chat_activity_history_notice">記録されたアクティビティです。進捗の更新は保存されません。利用可能なサブエージェントの履歴は読み取り専用で開けます。</string>
|
||||
<string name="chat_activity_output_unavailable">出力は利用できなくなりました。この項目には記録されたプロセスの状態が残っています。</string>
|
||||
<string name="profile_follow_server_default">サーバーのデフォルトに従う</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">他のアプリ上で音声を使用</string>
|
||||
<string name="voice_overlay_setup_body">他のアプリ上に音声操作を表示します。音声は設定したHermesサーバーに送信されます。画面の読み取りや端末操作は許可されません。Androidの設定で他のアプリ上への表示を許可し、ここに戻ってください。停止操作付きのマイク通知が必要です。表示を閉じるか画面をロックすると音声セッションは終了します。</string>
|
||||
<string name="voice_overlay_setup_start">音声オーバーレイを開始</string>
|
||||
<string name="voice_overlay_settings_hint">両方のビルドで任意に利用できます。チャットの音声フォーカスでオーバーレイを選択します。権限の付与だけで録音は始まりません。</string>
|
||||
<string name="voice_overlay_reset_position">位置をリセット</string>
|
||||
</resources>
|
||||
|
||||
@@ -4335,4 +4335,12 @@
|
||||
<string name="tool_progress_status_dispatched">Отправлено</string>
|
||||
<string name="chat_activity_history_notice">Сохранённая активность. Обновления хода работы не сохраняются; доступную историю субагентов можно открыть только для чтения.</string>
|
||||
<string name="chat_activity_output_unavailable">Вывод больше недоступен. Эта запись сохраняет зафиксированное состояние процесса.</string>
|
||||
<string name="profile_follow_server_default">Следовать профилю сервера по умолчанию</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Голос поверх других приложений</string>
|
||||
<string name="voice_overlay_setup_body">Голосовые элементы управления видны поверх другого приложения. Аудио отправляется на настроенный сервер Hermes. Это не разрешает чтение экрана или управление телефоном. Разрешите показ поверх приложений в настройках Android и вернитесь сюда. Требуется уведомление микрофона с кнопкой остановки. Закрытие окна или блокировка экрана завершает голосовой сеанс.</string>
|
||||
<string name="voice_overlay_setup_start">Начать голосовой оверлей</string>
|
||||
<string name="voice_overlay_settings_hint">Необязательно в обеих сборках. Откройте голосовой фокус в чате и выберите оверлей. Одни разрешения никогда не включают прослушивание.</string>
|
||||
<string name="voice_overlay_reset_position">Сбросить положение</string>
|
||||
</resources>
|
||||
|
||||
@@ -4654,4 +4654,12 @@
|
||||
<string name="dev_message_retry_result" translatable="false">Preview retry selected — no request sent.</string>
|
||||
<string name="dev_message_clear" translatable="false">Clear previews</string>
|
||||
<string name="dev_message_clear_desc" translatable="false">Dismiss only these samples; real app messages are preserved.</string>
|
||||
<string name="profile_follow_server_default">Follow server default</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Voice over other apps</string>
|
||||
<string name="voice_overlay_setup_body">Keep voice controls visible while using another app. Audio goes to your configured Hermes server. This does not grant screen reading or phone control. Allow display over other apps in Android Settings, then return here. A microphone notification with Stop voice is required. Closing the overlay or locking the screen ends this voice session.</string>
|
||||
<string name="voice_overlay_setup_start">Start voice overlay</string>
|
||||
<string name="voice_overlay_settings_hint">Optional in both builds. Open Voice Focus in Chat and choose Overlay to start. Permissions alone never start listening.</string>
|
||||
<string name="voice_overlay_reset_position">Reset position</string>
|
||||
</resources>
|
||||
|
||||
@@ -17,11 +17,10 @@
|
||||
<!--
|
||||
Tier C (C1-C4) sideload-only permissions.
|
||||
|
||||
These are deliberately NOT declared in the main manifest: Google Play
|
||||
policy forbids CALL_PHONE / SEND_SMS / READ_CONTACTS without a default-
|
||||
dialer / default-SMS-app justification, and ACCESS_FINE_LOCATION is
|
||||
flagged alongside auto-dial on the `googlePlay` track. The `sideload`
|
||||
flavor is the only track that ships them.
|
||||
These are deliberately kept outside Play's voice-only capability surface.
|
||||
SMS permissions require an eligible default-handler/approved use on Play;
|
||||
contacts, foreground location and CALL_PHONE have distinct requirements.
|
||||
Their omission here is a product boundary, not a blanket permission ban.
|
||||
|
||||
Each of the four `android_*` tools (location, search_contacts, call,
|
||||
send_sms) runtime-checks the corresponding permission in
|
||||
|
||||
@@ -43,13 +43,13 @@ class AgentDisplayTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun effectiveDisplayProfile_usesDefaultProfileForDisplayOnly() {
|
||||
fun effectiveDisplayProfile_doesNotAssumeRootWhenScopeIsUnknown() {
|
||||
val effective = AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = null,
|
||||
profiles = listOf(mizu, defaultProfile),
|
||||
)
|
||||
|
||||
assertEquals(defaultProfile, effective)
|
||||
assertNull(effective)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -86,7 +86,7 @@ class AgentDisplayTest {
|
||||
fun agentName_usesProfileNameNotVerboseDescription() {
|
||||
// The name slot shows the NAME, even when a (verbose) description exists.
|
||||
assertEquals(
|
||||
"Mizu",
|
||||
"mizu",
|
||||
AgentDisplay.agentName(
|
||||
profile = mizu.copy(description = "Builds and maintains the codebase"),
|
||||
selectedPersonality = "friendly",
|
||||
@@ -96,7 +96,7 @@ class AgentDisplayTest {
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Coder",
|
||||
"coder",
|
||||
AgentDisplay.agentName(
|
||||
profile = mizu.copy(name = "coder", description = ""),
|
||||
selectedPersonality = "friendly",
|
||||
@@ -107,11 +107,11 @@ class AgentDisplayTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun agentName_usesConciseDefaultDescriptionNotVerboseSummary() {
|
||||
fun agentName_usesDisplayNameAndNeverInfersIdentityFromDescription() {
|
||||
assertEquals(
|
||||
"Victor",
|
||||
AgentDisplay.agentName(
|
||||
profile = defaultProfile.copy(description = "victor"),
|
||||
profile = defaultProfile.copy(displayName = "Victor", description = "Summary"),
|
||||
selectedPersonality = "default",
|
||||
defaultPersonality = "",
|
||||
connectionLabel = "Lab",
|
||||
@@ -119,7 +119,7 @@ class AgentDisplayTest {
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Lab",
|
||||
"default",
|
||||
AgentDisplay.agentName(
|
||||
profile = defaultProfile.copy(description = "Builds and maintains the codebase."),
|
||||
selectedPersonality = "default",
|
||||
@@ -175,7 +175,7 @@ class AgentDisplayTest {
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"Lab",
|
||||
"Hermes",
|
||||
AgentDisplay.agentName(
|
||||
profile = null,
|
||||
selectedPersonality = "default",
|
||||
@@ -218,7 +218,7 @@ class AgentDisplayTest {
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"Lab",
|
||||
"Hermes",
|
||||
AgentDisplay.agentName(
|
||||
profile = null,
|
||||
selectedPersonality = "none",
|
||||
@@ -255,6 +255,18 @@ class AgentDisplayTest {
|
||||
assertNull(AgentDisplay.effectiveSessionProfileName(null, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmedDefaultKeepsItsNameWhileRosterLoadsAndDoesNotBorrowRootMetadata() {
|
||||
val effective = AgentDisplay.effectiveDisplayProfile(null, listOf(defaultProfile), "victor")
|
||||
assertEquals("victor", effective?.name)
|
||||
assertEquals("victor", AgentDisplay.profileDisplayName(effective))
|
||||
assertEquals("default", AgentDisplay.profileDisplayName(defaultProfile))
|
||||
val victor = Profile("victor", "", displayName = "Victor")
|
||||
assertEquals("Victor", AgentDisplay.profileDisplayName(
|
||||
AgentDisplay.effectiveDisplayProfile(null, listOf(defaultProfile, victor), "victor")))
|
||||
assertEquals(defaultProfile, AgentDisplay.effectiveDisplayProfile(defaultProfile, listOf(victor), "victor"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayModelName_hidesGenericApiAlias() {
|
||||
assertNull(AgentDisplay.displayModelName("hermes-agent"))
|
||||
|
||||
@@ -21,6 +21,22 @@ import kotlinx.coroutines.sync.withLock
|
||||
* raw DataStore shape that its Context constructor resolves in production.
|
||||
*/
|
||||
class ProfileSelectionStoreTest {
|
||||
@Test
|
||||
fun restartedStoreRetainsIndependentDefaultAndNamedChoicesAcrossConnections() = runBlocking {
|
||||
val data = InMemoryPreferencesDataStore()
|
||||
val first = ProfileSelectionStore(data)
|
||||
first.setSelectedProfile("a", AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
first.setSelectedProfile("b", "default")
|
||||
first.setSelectedProfile("c", "victor")
|
||||
val restarted = ProfileSelectionStore(data)
|
||||
assertEquals(AgentDisplay.SERVER_DEFAULT_PROFILE_KEY, restarted.selectedProfileFlow("a").first())
|
||||
assertEquals("default", restarted.selectedProfileFlow("b").first())
|
||||
assertEquals("victor", restarted.selectedProfileFlow("c").first())
|
||||
restarted.setSelectedProfile("a", "victor")
|
||||
assertEquals("default", restarted.selectedProfileFlow("b").first())
|
||||
assertEquals("victor", restarted.selectedProfileFlow("c").first())
|
||||
}
|
||||
|
||||
|
||||
private val store = ProfileSelectionStore(InMemoryPreferencesDataStore())
|
||||
|
||||
|
||||
@@ -477,6 +477,151 @@ class EndpointResolverTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearCache_handlesConcurrentProbeCompletions_withoutThrowingOrPublishingStaleState() = runTest {
|
||||
val candidateCount = 24
|
||||
val staleRequestsStarted = CountDownLatch(candidateCount)
|
||||
val releaseStaleRequests = CountDownLatch(1)
|
||||
val staleRequestsFinished = CountDownLatch(candidateCount)
|
||||
val raceGate = CountDownLatch(1)
|
||||
val requestSequence = AtomicInteger(0)
|
||||
val blockingClient = fastClient.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
if (requestSequence.incrementAndGet() <= candidateCount) {
|
||||
staleRequestsStarted.countDown()
|
||||
try {
|
||||
releaseStaleRequests.await(5, TimeUnit.SECONDS)
|
||||
} finally {
|
||||
staleRequestsFinished.countDown()
|
||||
}
|
||||
throw InterruptedIOException("concurrent invalidation test probe")
|
||||
}
|
||||
chain.proceed(chain.request())
|
||||
}
|
||||
.build()
|
||||
val resolver = EndpointResolver(blockingClient, clock = { clockMillis.get() })
|
||||
val candidates = (1..candidateCount).map { index ->
|
||||
candidate("concurrent-clear-$index", priority = 0, server = reachableServer)
|
||||
}
|
||||
|
||||
try {
|
||||
val staleResolve = async(start = CoroutineStart.UNDISPATCHED) {
|
||||
resolver.resolve(candidates, EndpointSurface.Api)
|
||||
}
|
||||
assertTrue(
|
||||
"every physical probe must be active before the completion/invalidation race",
|
||||
staleRequestsStarted.await(5, TimeUnit.SECONDS),
|
||||
)
|
||||
|
||||
val invalidation = async(Dispatchers.Default) {
|
||||
raceGate.await(5, TimeUnit.SECONDS)
|
||||
resolver.clearCache()
|
||||
}
|
||||
val completions = async(Dispatchers.Default) {
|
||||
raceGate.await(5, TimeUnit.SECONDS)
|
||||
releaseStaleRequests.countDown()
|
||||
}
|
||||
raceGate.countDown()
|
||||
|
||||
withContext(Dispatchers.Default.limitedParallelism(1)) {
|
||||
withTimeout(2_000L) {
|
||||
invalidation.await()
|
||||
completions.await()
|
||||
staleResolve.await()
|
||||
}
|
||||
}
|
||||
assertTrue(staleRequestsFinished.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(resolver.cacheSnapshot().isEmpty())
|
||||
|
||||
resolver.clearCache()
|
||||
val freshWinner = withContext(Dispatchers.Default.limitedParallelism(1)) {
|
||||
withTimeout(2_000L) {
|
||||
resolver.resolve(listOf(candidates.first()), EndpointSurface.Api)
|
||||
}
|
||||
}
|
||||
assertEquals(candidates.first(), freshWinner)
|
||||
assertTrue(
|
||||
"a completion racing invalidation must not overwrite the fresh generation",
|
||||
resolver.probeOutcomes.value.getValue(
|
||||
EndpointResolver.cacheKey(candidates.first(), EndpointSurface.Api),
|
||||
).reachable,
|
||||
)
|
||||
} finally {
|
||||
raceGate.countDown()
|
||||
releaseStaleRequests.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidatedProbeCompletion_cannotRemoveFreshReplacement() = runTest {
|
||||
val staleRequestStarted = CountDownLatch(1)
|
||||
val releaseStaleRequest = CountDownLatch(1)
|
||||
val staleRequestFinished = CountDownLatch(1)
|
||||
val freshRequestStarted = CountDownLatch(1)
|
||||
val releaseFreshRequest = CountDownLatch(1)
|
||||
val requestSequence = AtomicInteger(0)
|
||||
val blockingClient = fastClient.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
when (requestSequence.incrementAndGet()) {
|
||||
1 -> {
|
||||
staleRequestStarted.countDown()
|
||||
try {
|
||||
releaseStaleRequest.await(5, TimeUnit.SECONDS)
|
||||
} finally {
|
||||
staleRequestFinished.countDown()
|
||||
}
|
||||
throw InterruptedIOException("invalidated identity test probe")
|
||||
}
|
||||
2 -> {
|
||||
freshRequestStarted.countDown()
|
||||
releaseFreshRequest.await(5, TimeUnit.SECONDS)
|
||||
chain.proceed(chain.request())
|
||||
}
|
||||
else -> chain.proceed(chain.request())
|
||||
}
|
||||
}
|
||||
.build()
|
||||
val resolver = EndpointResolver(blockingClient, clock = { clockMillis.get() })
|
||||
val candidate = candidate("replacement-identity-test", priority = 0, server = reachableServer)
|
||||
|
||||
try {
|
||||
val staleResolve = async(start = CoroutineStart.UNDISPATCHED) {
|
||||
resolver.resolve(listOf(candidate), EndpointSurface.Api)
|
||||
}
|
||||
assertTrue(staleRequestStarted.await(5, TimeUnit.SECONDS))
|
||||
resolver.clearCache()
|
||||
|
||||
val freshResolve = async(start = CoroutineStart.UNDISPATCHED) {
|
||||
resolver.resolve(listOf(candidate), EndpointSurface.Api)
|
||||
}
|
||||
assertTrue(freshRequestStarted.await(5, TimeUnit.SECONDS))
|
||||
|
||||
releaseStaleRequest.countDown()
|
||||
assertTrue(staleRequestFinished.await(5, TimeUnit.SECONDS))
|
||||
withContext(Dispatchers.Default.limitedParallelism(1)) {
|
||||
withTimeout(1_000L) { staleResolve.await() }
|
||||
}
|
||||
|
||||
val joiningResolve = async(start = CoroutineStart.UNDISPATCHED) {
|
||||
resolver.resolve(listOf(candidate), EndpointSurface.Api)
|
||||
}
|
||||
releaseFreshRequest.countDown()
|
||||
|
||||
withContext(Dispatchers.Default.limitedParallelism(1)) {
|
||||
assertEquals(candidate, withTimeout(2_000L) { freshResolve.await() })
|
||||
assertEquals(candidate, withTimeout(2_000L) { joiningResolve.await() })
|
||||
}
|
||||
assertEquals(
|
||||
"the late stale completion must leave the fresh shared probe registered",
|
||||
2,
|
||||
requestSequence.get(),
|
||||
)
|
||||
} finally {
|
||||
releaseStaleRequest.countDown()
|
||||
releaseFreshRequest.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 6 — cached-reachable result is re-probed after TTL
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -1070,6 +1070,18 @@ class DashboardApiClientTest {
|
||||
assertEquals("claude-opus-4-8", profiles[1].model)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listProfiles_keepsDisplayIdentitySeparateAndAcceptsUnconfiguredRoot() = runTest {
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(
|
||||
"""{"profiles":[{"name":"default","model":null,"is_default":true},
|
||||
{"name":"guide","model":"model","display_name":"Guide","description":"Summary"}]}"""))
|
||||
val profiles = DashboardApiClient(baseUrl = server.url("/").toString()).listProfiles().getOrThrow()
|
||||
assertEquals(listOf("default", "guide"), profiles.map { it.name })
|
||||
assertEquals("", profiles[0].model)
|
||||
assertEquals("Guide", profiles[1].displayName)
|
||||
assertEquals("Summary", profiles[1].description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listProfiles_parsesObjectMapShapeWithInjectedName() = runTest {
|
||||
server.enqueue(
|
||||
|
||||
@@ -197,6 +197,7 @@ class GatewayClientHarness(
|
||||
put("model", "gpt-5.6")
|
||||
put("provider", "openai")
|
||||
put("description", "Android operator")
|
||||
put("display_name", "Guide")
|
||||
put("skill_count", 3)
|
||||
put("has_avatar", true)
|
||||
put("ui_meta", buildJsonObject { put("accent", "#ff5500") })
|
||||
@@ -1009,6 +1010,7 @@ class GatewayChatClientTest {
|
||||
|
||||
assertEquals(1, profiles.size)
|
||||
assertEquals("operator", profiles.single().name)
|
||||
assertEquals("Guide", profiles.single().displayName)
|
||||
assertEquals("openai", profiles.single().provider)
|
||||
assertTrue(profiles.single().hasAvatar)
|
||||
assertEquals("#ff5500", (profiles.single().uiMeta["accent"] as JsonPrimitive).content)
|
||||
|
||||
@@ -6,23 +6,29 @@ import org.junit.Test
|
||||
|
||||
class HermesRuntimeBinderSessionDirectoryPolicyTest {
|
||||
@Test
|
||||
fun `session directory can refresh from either standard route owner`() {
|
||||
fun `session directory starts from dashboard publication before Gateway readiness`() {
|
||||
assertTrue(
|
||||
shouldRefreshSessionDirectory(
|
||||
chatReady = true,
|
||||
dashboardRouteResolved = false,
|
||||
dashboardUrl = "",
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
shouldRefreshSessionDirectory(
|
||||
chatReady = false,
|
||||
dashboardRouteResolved = true,
|
||||
dashboardUrl = "https://dashboard.example.test",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
shouldRefreshSessionDirectory(
|
||||
chatReady = false,
|
||||
dashboardRouteResolved = false,
|
||||
dashboardUrl = "",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
shouldRefreshSessionDirectory(
|
||||
chatReady = false,
|
||||
dashboardUrl = " ",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.hermesandroid.relay.screenshots
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.assertHeightIsAtLeast
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.ProfilePresentation
|
||||
import com.hermesandroid.relay.ui.components.ProfileSwitcherSheet
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w360dp-h720dp-xhdpi")
|
||||
class ProfileIdentityScreenshotTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test fun profileSwitcher() = render("profile-switcher")
|
||||
|
||||
@Test fun enlargedText() = render("profile-switcher-large-text", fontScale = 1.5f)
|
||||
|
||||
@Test fun longName() = render("profile-switcher-long-name", fontScale = 1.5f,
|
||||
displayName = "Research and planning assistant for the whole team")
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "w720dp-h360dp-xhdpi")
|
||||
fun landscape() = render("profile-switcher-landscape")
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "w840dp-h720dp-xhdpi")
|
||||
fun expanded() = render("profile-switcher-expanded")
|
||||
|
||||
private fun render(file: String, fontScale: Float = 1f, displayName: String = "Guide") {
|
||||
RuntimeEnvironment.setFontScale(fontScale)
|
||||
val agent = Profile(name = "guide", model = "example-model", displayName = displayName)
|
||||
val vm = mockk<ConnectionViewModel>(relaxed = true)
|
||||
every { vm.profileIconFlow(any()) } returns MutableStateFlow(null)
|
||||
every { vm.serverDefaultDisplayProfile } returns MutableStateFlow(agent)
|
||||
val selected = mutableStateOf<Profile?>(null)
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, fontScale)) {
|
||||
ProfileSwitcherSheet(vm, listOf(Profile("default", "root-model"), agent), selected.value,
|
||||
selected.value ?: agent, ProfilePresentation(), false, true, { selected.value = it }, {}, {})
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onAllNodesWithText(displayName, substring = false).assertCountEquals(1)
|
||||
compose.onNodeWithText("Follow server default").assertIsDisplayed().assertIsOn()
|
||||
.assertHeightIsAtLeast(48.dp)
|
||||
val output = File("build/ui-evidence/$file.png")
|
||||
output.parentFile?.mkdirs()
|
||||
compose.onRoot().captureRoboImage(output.absolutePath)
|
||||
compose.onNodeWithText("Follow server default").performClick()
|
||||
compose.runOnIdle { assertEquals("guide", selected.value?.name) }
|
||||
compose.onNodeWithText("Follow server default").assertIsOff()
|
||||
compose.onNodeWithText("Follow server default").performClick()
|
||||
compose.runOnIdle { assertEquals(null, selected.value) }
|
||||
// Scrollable even in landscape or at enlarged text sizes.
|
||||
compose.onNodeWithText("default", substring = false).performScrollTo().performClick()
|
||||
compose.runOnIdle { assertEquals("default", selected.value?.name) }
|
||||
compose.onNodeWithText("default", substring = false).assertIsSelected()
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@ import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.RelayEndpoint
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.ui.screens.shouldOwnVisibleGateway
|
||||
import com.hermesandroid.relay.viewmodel.ChatConnectState
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportPath
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatConnectState
|
||||
@@ -196,6 +197,50 @@ class RelayAppStatusTest {
|
||||
assertEquals(ChatRuntimeStatus.Connecting, status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `foreground Gateway owns cold observation before gateway ready`() {
|
||||
assertTrue(
|
||||
shouldOwnVisibleGateway(
|
||||
appForeground = true,
|
||||
isGatewayTransport = true,
|
||||
gatewayAvailability = GatewayAvailability.Unknown,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
shouldOwnVisibleGateway(
|
||||
appForeground = true,
|
||||
isGatewayTransport = true,
|
||||
gatewayAvailability = GatewayAvailability.Unreachable,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
shouldOwnVisibleGateway(
|
||||
appForeground = false,
|
||||
isGatewayTransport = true,
|
||||
gatewayAvailability = GatewayAvailability.Unknown,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
shouldOwnVisibleGateway(
|
||||
appForeground = true,
|
||||
isGatewayTransport = false,
|
||||
gatewayAvailability = GatewayAvailability.Unknown,
|
||||
),
|
||||
)
|
||||
listOf(
|
||||
GatewayAvailability.SignInRequired,
|
||||
GatewayAvailability.Unsupported,
|
||||
).forEach { terminal ->
|
||||
assertFalse(
|
||||
shouldOwnVisibleGateway(
|
||||
appForeground = true,
|
||||
isGatewayTransport = true,
|
||||
gatewayAvailability = terminal,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dashboard sign-out is not masked by a reachable sibling API`() {
|
||||
val status = resolveAppChatRuntimeStatus(
|
||||
|
||||
@@ -9,6 +9,30 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProfileShelfPolicyTest {
|
||||
@Test
|
||||
fun resolvedDefaultGroupsOnlyExactIdentityAndRetainsSelectedRequestKey() {
|
||||
val victor = Profile("victor", "", displayName = "Victor")
|
||||
val duplicate = Profile("other", "", displayName = "Victor")
|
||||
val roster = listOf(Profile("default", ""), victor, duplicate)
|
||||
for (selected in listOf(null, "victor", "default", "other")) {
|
||||
val choices = ProfileShelfPolicy.choices(roster, ProfilePresentation(), selected, "victor")
|
||||
assertEquals(3, choices.size)
|
||||
assertTrue(choices.any { ProfileShelfPolicy.isSelected(it, selected) })
|
||||
assertTrue(choices.any { it.key == "default" })
|
||||
assertTrue(choices.any { it.key == "other" })
|
||||
val grouped = choices.first { it.isServerDefault || it.key == "victor" }
|
||||
assertEquals(if (selected == "victor") "victor" else null, grouped.profile?.name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownDefaultNeverCollapsesRootOrNamesWithMatchingLabels() {
|
||||
val roster = listOf(Profile("default", ""), Profile("one", "", displayName = "Same"),
|
||||
Profile("two", "", displayName = "Same"))
|
||||
assertEquals(4, ProfileShelfPolicy.choices(roster, ProfilePresentation(), null).size)
|
||||
assertEquals(4, ProfileShelfPolicy.choices(roster, ProfilePresentation(), null, "missing").size)
|
||||
}
|
||||
|
||||
private val profiles = listOf(
|
||||
Profile(name = "default", model = "root"),
|
||||
Profile(name = "alpha", model = "a"),
|
||||
|
||||
@@ -182,6 +182,55 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coldGatewayClientBeforeVisibilityOpensObservationWithoutControlRpc() {
|
||||
viewModel.setChatVisible(false)
|
||||
replaceGatewayClient(ticketTimeoutMs = 5_000L)
|
||||
val controlMethods = setOf(
|
||||
"session.resume",
|
||||
"session.activate",
|
||||
"prompt.submit",
|
||||
"session.interrupt",
|
||||
)
|
||||
val baseline = controlMethods.associateWith { method ->
|
||||
gatewayHarness.rpcLog.count { it.first == method }
|
||||
}
|
||||
val ticketMintsBefore = gatewayHarness.ticketMints.get()
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
|
||||
awaitCondition { gatewayClient.connectionState.value == GatewayConnectionState.Ready }
|
||||
assertEquals(ticketMintsBefore + 1, gatewayHarness.ticketMints.get())
|
||||
controlMethods.forEach { method ->
|
||||
assertEquals(baseline.getValue(method), gatewayHarness.rpcLog.count { it.first == method })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coldGatewayVisibilityBeforeClientBindingOpensObservationWithoutControlRpc() {
|
||||
viewModel.setChatVisible(false)
|
||||
replaceGatewayClient(ticketTimeoutMs = 5_000L, bind = false)
|
||||
val controlMethods = setOf(
|
||||
"session.resume",
|
||||
"session.activate",
|
||||
"prompt.submit",
|
||||
"session.interrupt",
|
||||
)
|
||||
val baseline = controlMethods.associateWith { method ->
|
||||
gatewayHarness.rpcLog.count { it.first == method }
|
||||
}
|
||||
val ticketMintsBefore = gatewayHarness.ticketMints.get()
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
viewModel.updateGatewayClient(gatewayClient)
|
||||
|
||||
awaitCondition { gatewayClient.connectionState.value == GatewayConnectionState.Ready }
|
||||
assertEquals(ticketMintsBefore + 1, gatewayHarness.ticketMints.get())
|
||||
controlMethods.forEach { method ->
|
||||
assertEquals(baseline.getValue(method), gatewayHarness.rpcLog.count { it.first == method })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineGatewaySendPublishesRetryableFailureAndKeepsPrompt() {
|
||||
DiagnosticsLog.clear()
|
||||
@@ -4322,7 +4371,10 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
),
|
||||
)
|
||||
|
||||
private fun replaceGatewayClient(ticketTimeoutMs: Long): GatewayChatClient {
|
||||
private fun replaceGatewayClient(
|
||||
ticketTimeoutMs: Long,
|
||||
bind: Boolean = true,
|
||||
): GatewayChatClient {
|
||||
viewModel.updateGatewayClient(null)
|
||||
gatewayClient.shutdown()
|
||||
gatewayScope.cancel()
|
||||
@@ -4340,7 +4392,7 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
scope = gatewayScope,
|
||||
reconnectJitterUnit = { Math.nextDown(1.0) },
|
||||
)
|
||||
viewModel.updateGatewayClient(gatewayClient)
|
||||
if (bind) viewModel.updateGatewayClient(gatewayClient)
|
||||
return gatewayClient
|
||||
}
|
||||
|
||||
|
||||
@@ -250,6 +250,48 @@ class ProfileControllerLockTest {
|
||||
Thread.sleep(100)
|
||||
|
||||
assertEquals("newer", controller.effectiveSessionProfileName.value)
|
||||
assertEquals("newer", awaitFlow(controller.serverDefaultDisplayProfile) { it?.name == "newer" }?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionSwitchRejectsOldDefaultIdentity() {
|
||||
dashboardUrl = "https://dashboard.example"
|
||||
val started = CompletableDeferred<Unit>()
|
||||
val old = CompletableDeferred<DashboardProfileScope>()
|
||||
coEvery { dashboardClient.getActiveProfileScope() } coAnswers {
|
||||
started.complete(Unit)
|
||||
Result.success(old.await())
|
||||
}
|
||||
controller.refreshDashboardProfileScope()
|
||||
runBlocking { withTimeout(5_000) { started.await() } }
|
||||
controller.resetForConnectionSwitch()
|
||||
activeConnectionId.value = "other-connection"
|
||||
coEvery { dashboardClient.getActiveProfileScope() } returns Result.success(
|
||||
DashboardProfileScope(active = "other", current = "default"))
|
||||
controller.refreshDashboardProfileScope()
|
||||
assertEquals("other", awaitFlow(controller.serverDefaultDisplayProfile) { it?.name == "other" }?.name)
|
||||
old.complete(DashboardProfileScope(active = "victor", current = "default"))
|
||||
assertEquals("other", awaitFlow(controller.effectiveSessionProfileName) { it == "other" })
|
||||
assertNull(controller.selectedProfile.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unresolvedDefaultDoesNotBorrowRootAvatarFromGatewayRoster() = runBlocking {
|
||||
gatewayClient = mockk(relaxed = true)
|
||||
coEvery { gatewayClient!!.petInfo(any(), any()) } returns Result.failure(
|
||||
IllegalStateException("Pet metadata temporarily unavailable"))
|
||||
coEvery { gatewayClient!!.listProfiles() } returns Result.success(
|
||||
listOf(literalDefault.copy(isDefault = true, hasAvatar = true)))
|
||||
coEvery { gatewayClient!!.getProfileAvatar("default") } returns Result.failure(
|
||||
IllegalStateException("Avatar temporarily unavailable"))
|
||||
controller.refreshGatewayProfiles()
|
||||
awaitFlow(controller.agentProfiles) { it.any { profile -> profile.name == "default" } }
|
||||
controller.profileIconStore.setServerAvatar(connectionId, "default", "root-avatar.png")
|
||||
assertNull(controller.profileIconFlow(null).first())
|
||||
assertEquals("root-avatar.png", controller.profileIconFlow("default").first())
|
||||
assertNull(controller.serverDefaultDisplayProfile.value)
|
||||
controller.clearSharedProfileAvatar()
|
||||
coVerify(exactly = 0) { gatewayClient!!.clearProfileAvatar(any()) }
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -352,6 +394,7 @@ class ProfileControllerLockTest {
|
||||
|
||||
assertEquals("pinned", awaitFlow(controller.effectiveSessionProfileName) { it == "pinned" })
|
||||
assertEquals(pinned, awaitFlow(controller.effectiveDisplayProfile) { it?.name == "pinned" })
|
||||
assertEquals(pinned, awaitFlow(controller.serverDefaultDisplayProfile) { it?.name == "pinned" })
|
||||
assertEquals("default", controller.serverDefaultProfileScope.value?.current)
|
||||
assertTrue(awaitFlow(controller.selectionSettled) { it })
|
||||
runBlocking { controller.listProfileScopedSessions()?.getOrThrow() }
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.spyk
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class, sdk = [35])
|
||||
class VoiceOverlayForegroundServiceTest {
|
||||
private val host = mockk<VoiceOverlayHost>(relaxed = true)
|
||||
private lateinit var service: VoiceOverlayForegroundService
|
||||
@Before fun setup() {
|
||||
mockkObject(VoiceOverlayHost.Companion)
|
||||
every { VoiceOverlayHost.peek() } returns host
|
||||
every { host.sessionId } returns 7L
|
||||
every { host.canStart(7L) } returns true
|
||||
every { host.canContinue(7L) } returns true
|
||||
every { host.onServiceReady(7L) } returns true
|
||||
service = Robolectric.buildService(VoiceOverlayForegroundService::class.java).create().get()
|
||||
}
|
||||
@After fun cleanup() { service.onDestroy(); unmockkAll() }
|
||||
private fun command(action: String, id: Long = 7L) = Intent().setAction(action)
|
||||
.putExtra(VoiceOverlayForegroundService.EXTRA_SESSION_ID, id)
|
||||
|
||||
@Test fun notificationIsPostedBeforeWindowReadiness() {
|
||||
every { host.onServiceReady(7L) } answers {
|
||||
assertNotNull(shadowOf(service).lastForegroundNotification)
|
||||
true
|
||||
}
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
verify(exactly = 1) { host.onServiceReady(7L) }
|
||||
}
|
||||
@Test fun unownedOrRestartIntentNeverPromotesService() {
|
||||
service.onStartCommand(null, 0, 1)
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START, 8), 0, 2)
|
||||
assertNull(shadowOf(service).lastForegroundNotification)
|
||||
verify(exactly = 0) { host.onServiceReady(any()) }
|
||||
}
|
||||
@Test fun oldNotificationCannotStopCurrentSession() {
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_STOP, 6), 0, 2)
|
||||
verify(exactly = 0) { host.exitVoiceSession(any()) }
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_STOP), 0, 3)
|
||||
verify(exactly = 1) { host.exitVoiceSession(7L) }
|
||||
}
|
||||
@Test fun windowFailureEndsVoiceInsteadOfLeavingUnprotectedCapture() {
|
||||
every { host.onServiceReady(7L) } returns false
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
verify(exactly = 1) { host.exitVoiceSession(7L) }
|
||||
}
|
||||
@Test fun taskRemovalEndsVoice() {
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
service.onTaskRemoved(null)
|
||||
verify(exactly = 1) { host.exitVoiceSession(7L) }
|
||||
}
|
||||
|
||||
@Test fun permissionLossEndsVoiceAtTheNextAccessCheck() {
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
every { host.canContinue(7L) } returns false
|
||||
shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
verify(exactly = 1) { host.exitVoiceSession(7L) }
|
||||
}
|
||||
|
||||
@Test fun screenOffEndsVoiceWithoutWaitingForPolling() {
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
service.sendBroadcast(Intent(Intent.ACTION_SCREEN_OFF))
|
||||
shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
verify(exactly = 1) { host.exitVoiceSession(7L) }
|
||||
}
|
||||
|
||||
@Test fun failedForegroundPromotionNeverAttachesWindow() {
|
||||
service = spyk(service)
|
||||
every { service.startForeground(any(), any(), any()) } throws SecurityException("denied")
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
verify(exactly = 0) { host.onServiceReady(any()) }
|
||||
verify(exactly = 1) { host.exitVoiceSession(7L) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class, sdk = [35])
|
||||
class VoiceOverlayLifecycleTest {
|
||||
private lateinit var host: VoiceOverlayHost
|
||||
private val owner = object : LifecycleOwner {
|
||||
override val lifecycle = LifecycleRegistry(this)
|
||||
}
|
||||
private var exits = 0
|
||||
private var access = VoiceOverlayAccess(true, true, true, true)
|
||||
private val state = MutableStateFlow(VoiceUiState(voiceMode = true))
|
||||
|
||||
@Before fun setup() {
|
||||
mockkObject(VoiceOverlayAccess.Companion)
|
||||
every { VoiceOverlayAccess.read(any()) } answers { access }
|
||||
host = VoiceOverlayHost(RuntimeEnvironment.getApplication())
|
||||
owner.lifecycle.currentState = Lifecycle.State.RESUMED
|
||||
}
|
||||
@After fun cleanup() { host.hide(); unmockkAll() }
|
||||
|
||||
private fun session() = VoiceOverlaySession(state, provider = null, model = null, voice = null,
|
||||
profileName = "Test", configScope = null, outputEnabled = true, fallbackEnabled = false,
|
||||
onStartListening = {}, onStopListening = {}, onInterrupt = {}, onPauseAutoMode = {},
|
||||
onReturnToHermes = {}, onDismissOverlay = {}, onExit = { exits++ })
|
||||
|
||||
@Test fun backgroundCallerCannotCreateSession() {
|
||||
owner.lifecycle.currentState = Lifecycle.State.STARTED
|
||||
assertFalse(host.show(session(), owner.lifecycle))
|
||||
assertNull(host.sessionId)
|
||||
}
|
||||
|
||||
@Test fun missingAccessCannotCreateSession() {
|
||||
listOf(access.copy(microphone = false), access.copy(notifications = false),
|
||||
access.copy(overlay = false), access.copy(unlocked = false)).forEach {
|
||||
access = it
|
||||
assertFalse(host.show(session(), owner.lifecycle))
|
||||
assertNull(host.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun stopBeforeServiceReadyRejectsLateStartAndExitsOnce() {
|
||||
assertTrue(host.show(session(), owner.lifecycle))
|
||||
val id = host.sessionId!!
|
||||
host.exitVoiceSession(id)
|
||||
host.exitVoiceSession(id)
|
||||
assertFalse(host.onServiceReady(id))
|
||||
assertEquals(1, exits)
|
||||
}
|
||||
|
||||
@Test fun oldStopAndReadyCannotAffectNewSession() {
|
||||
host.show(session(), owner.lifecycle)
|
||||
val old = host.sessionId!!
|
||||
host.exitVoiceSession(old)
|
||||
host.show(session(), owner.lifecycle)
|
||||
val current = host.sessionId!!
|
||||
host.exitVoiceSession(old)
|
||||
assertFalse(host.onServiceReady(old))
|
||||
assertEquals(current, host.sessionId)
|
||||
assertTrue(host.canStart(current))
|
||||
assertEquals(1, exits)
|
||||
}
|
||||
|
||||
@Test fun lossOfForegroundBeforeServicePromotionFailsClosed() {
|
||||
host.show(session(), owner.lifecycle)
|
||||
owner.lifecycle.currentState = Lifecycle.State.STARTED
|
||||
assertFalse(host.canStart(host.sessionId!!))
|
||||
}
|
||||
|
||||
@Test fun revocationAndVoiceExitInvalidateActiveEligibility() {
|
||||
host.show(session(), owner.lifecycle)
|
||||
val id = host.sessionId!!
|
||||
access = access.copy(overlay = false)
|
||||
assertFalse(host.canContinue(id))
|
||||
access = access.copy(overlay = true)
|
||||
state.value = VoiceUiState(voiceMode = false)
|
||||
assertFalse(host.canContinue(id))
|
||||
}
|
||||
|
||||
@Test fun destroyedCallerEndsPendingSession() {
|
||||
host.show(session(), owner.lifecycle)
|
||||
owner.lifecycle.currentState = Lifecycle.State.DESTROYED
|
||||
assertNull(host.sessionId)
|
||||
assertEquals(1, exits)
|
||||
}
|
||||
|
||||
@Test fun returningToResumedAppReleasesOverlayWithoutEndingVoice() {
|
||||
host.show(session(), owner.lifecycle)
|
||||
val id = host.sessionId!!
|
||||
assertTrue(host.onServiceReady(id))
|
||||
owner.lifecycle.currentState = Lifecycle.State.STARTED
|
||||
assertTrue(host.canContinue(id))
|
||||
owner.lifecycle.currentState = Lifecycle.State.RESUMED
|
||||
assertNull(host.sessionId)
|
||||
assertEquals(0, exits)
|
||||
assertTrue(state.value.voiceMode)
|
||||
}
|
||||
|
||||
@Test fun revocationBeforeResumeEndsVoiceInsteadOfHandingItBack() {
|
||||
host.show(session(), owner.lifecycle)
|
||||
assertTrue(host.onServiceReady(host.sessionId!!))
|
||||
owner.lifecycle.currentState = Lifecycle.State.STARTED
|
||||
access = access.copy(overlay = false)
|
||||
owner.lifecycle.currentState = Lifecycle.State.RESUMED
|
||||
assertNull(host.sessionId)
|
||||
assertEquals(1, exits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class, qualifiers = "w360dp-h720dp-xhdpi", sdk = [35])
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class VoiceOverlayPresentationTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test fun missingPermissionKeepsStartDisabled() = setupDialog(false, 1f)
|
||||
@Test fun grantedPermissionsStillRequireStart() = setupDialog(true, 1f)
|
||||
@Test fun largeTextPermissionSetup() = setupDialog(true, 1.5f)
|
||||
|
||||
private fun setupDialog(granted: Boolean, scale: Float) {
|
||||
org.robolectric.RuntimeEnvironment.setFontScale(scale)
|
||||
compose.waitForIdle()
|
||||
var starts = 0
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, scale)) {
|
||||
VoiceOverlaySetupContent(VoiceOverlayAccess(true, granted, granted, true), false,
|
||||
{}, { starts++ }, {}, {}, {})
|
||||
}
|
||||
}
|
||||
}
|
||||
val start = compose.onNodeWithText("Start voice overlay")
|
||||
start.assertIsDisplayed()
|
||||
assertEquals(0, starts)
|
||||
if (granted) start.assertIsEnabled() else start.assertIsNotEnabled()
|
||||
capture("setup-$granted-$scale")
|
||||
compose.onNodeWithText("Display over other apps", substring = true).performScrollTo().assertIsDisplayed()
|
||||
if (scale > 1f) capture("setup-large-text-scrolled")
|
||||
if (granted) {
|
||||
start.performClick()
|
||||
compose.runOnIdle { assertEquals(1, starts) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun stopRemainsReachableWhenMinimized() = overlay(1f)
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "w320dp-h480dp-xhdpi")
|
||||
fun narrowOverlayWithLargeText() = overlay(1.5f)
|
||||
|
||||
private fun overlay(scale: Float) {
|
||||
org.robolectric.RuntimeEnvironment.setFontScale(scale)
|
||||
compose.waitForIdle()
|
||||
var exits = 0
|
||||
val session = VoiceOverlaySession(MutableStateFlow(VoiceUiState(voiceMode = true)),
|
||||
provider = null, model = null, voice = null, profileName = "Research", configScope = null,
|
||||
outputEnabled = true, fallbackEnabled = false, onStartListening = {}, onStopListening = {},
|
||||
onInterrupt = {}, onPauseAutoMode = {}, onReturnToHermes = {}, onDismissOverlay = {},
|
||||
onExit = { exits++ }, connectionLabel = "Home server")
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
VoiceFloatingOverlayPill(session, { _, _ -> })
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithContentDescription("Stop voice").assertIsDisplayed().assertHeightIsAtLeast(48.dp)
|
||||
capture("overlay-compact-$scale")
|
||||
compose.onNodeWithContentDescription("Expand voice controls")
|
||||
.performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() }
|
||||
compose.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
compose.mainClock.advanceTimeByFrame()
|
||||
compose.mainClock.advanceTimeBy(500)
|
||||
capture("overlay-expanded-$scale")
|
||||
compose.onNodeWithText("Minimize").performScrollTo()
|
||||
.performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() }
|
||||
compose.mainClock.advanceTimeBy(500)
|
||||
compose.onNodeWithText("Stop voice").assertIsDisplayed()
|
||||
capture("overlay-minimized-$scale")
|
||||
compose.onNodeWithText("Stop voice")
|
||||
.performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() }
|
||||
compose.runOnIdle { assertEquals(1, exits) }
|
||||
}
|
||||
|
||||
private fun capture(name: String) {
|
||||
val file = File("build/ui-evidence/play-voice-$name.png")
|
||||
file.parentFile?.mkdirs()
|
||||
compose.onRoot().captureRoboImage(file.absolutePath)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 81 KiB |
@@ -22,13 +22,24 @@ export const desktopUiScreenshotSourceFiles = Object.freeze([
|
||||
|
||||
const binaryExtensions = new Set(['.png'])
|
||||
|
||||
function normalizedSource(relativePath, bytes) {
|
||||
if (binaryExtensions.has(extname(relativePath))) return bytes
|
||||
const text = bytes.toString('utf8').replace(/\r\n?/g, '\n')
|
||||
if (relativePath !== 'desktop/tray/package-lock.json') return Buffer.from(text, 'utf8')
|
||||
|
||||
// The screenshot fixture has fixed display versions. A release-only bump of
|
||||
// the root package does not change it; dependency versions still affect rendering.
|
||||
const lockfile = JSON.parse(text)
|
||||
delete lockfile.version
|
||||
if (lockfile.packages?.['']) delete lockfile.packages[''].version
|
||||
return Buffer.from(JSON.stringify(lockfile), 'utf8')
|
||||
}
|
||||
|
||||
export async function computeDesktopUiSourceFingerprint(repositoryRoot = defaultRepositoryRoot) {
|
||||
const hash = createHash('sha256')
|
||||
for (const relativePath of desktopUiScreenshotSourceFiles) {
|
||||
const bytes = await readFile(resolve(repositoryRoot, relativePath))
|
||||
const normalized = binaryExtensions.has(extname(relativePath))
|
||||
? bytes
|
||||
: Buffer.from(bytes.toString('utf8').replace(/\r\n?/g, '\n'), 'utf8')
|
||||
const normalized = normalizedSource(relativePath, bytes)
|
||||
hash.update(relativePath)
|
||||
hash.update('\0')
|
||||
hash.update(normalized)
|
||||
@@ -36,7 +47,7 @@ export async function computeDesktopUiSourceFingerprint(repositoryRoot = default
|
||||
}
|
||||
return {
|
||||
algorithm: 'sha256',
|
||||
normalization: 'text-lf-v1',
|
||||
normalization: 'text-lf-lockfile-root-version-v2',
|
||||
digest: hash.digest('hex'),
|
||||
files: [...desktopUiScreenshotSourceFiles]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { computeDesktopUiSourceFingerprint, desktopUiScreenshotSourceFiles } from './desktop-ui-source-fingerprint.mjs'
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'hermes-desktop-fingerprint-'))
|
||||
t.after(async () => {
|
||||
assert.equal(dirname(resolve(root)), resolve(tmpdir()))
|
||||
assert.ok(basename(root).startsWith('hermes-desktop-fingerprint-'))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
for (const file of desktopUiScreenshotSourceFiles) {
|
||||
const destination = join(root, file)
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
await writeFile(destination, 'fixture\n')
|
||||
}
|
||||
const lockfile = {
|
||||
name: '@hermes-relay/tray-ui', version: '0.4.0-beta.6', lockfileVersion: 3,
|
||||
packages: {
|
||||
'': { name: '@hermes-relay/tray-ui', version: '0.4.0-beta.6', dependencies: { react: '^19.0.0' } },
|
||||
'node_modules/react': { version: '19.0.0', integrity: 'example-integrity' },
|
||||
},
|
||||
}
|
||||
const saveLock = () => writeFile(join(root, 'desktop/tray/package-lock.json'), JSON.stringify(lockfile, null, 2))
|
||||
await saveLock()
|
||||
return { root, lockfile, saveLock, fingerprint: () => computeDesktopUiSourceFingerprint(root) }
|
||||
}
|
||||
|
||||
test('root release versions do not invalidate fixed-version screenshots', async t => {
|
||||
const f = await fixture(t)
|
||||
const before = await f.fingerprint()
|
||||
f.lockfile.version = '0.4.0-beta.7'
|
||||
f.lockfile.packages[''].version = '0.4.0-beta.7'
|
||||
await f.saveLock()
|
||||
assert.deepEqual(await f.fingerprint(), before)
|
||||
})
|
||||
|
||||
test('dependency versions and integrity remain fingerprinted', async t => {
|
||||
const f = await fixture(t)
|
||||
const before = (await f.fingerprint()).digest
|
||||
f.lockfile.packages['node_modules/react'].version = '19.1.0'
|
||||
await f.saveLock()
|
||||
const changedVersion = (await f.fingerprint()).digest
|
||||
assert.notEqual(changedVersion, before)
|
||||
f.lockfile.packages['node_modules/react'].integrity = 'changed-integrity'
|
||||
await f.saveLock()
|
||||
assert.notEqual((await f.fingerprint()).digest, changedVersion)
|
||||
})
|
||||
|
||||
test('production UI changes still invalidate screenshots', async t => {
|
||||
const f = await fixture(t)
|
||||
const before = (await f.fingerprint()).digest
|
||||
await writeFile(join(f.root, 'desktop/tray/ui/App.tsx'), 'changed UI\n')
|
||||
assert.notEqual((await f.fingerprint()).digest, before)
|
||||
})
|
||||
|
||||
test('text line endings do not cause platform-only drift', async t => {
|
||||
const f = await fixture(t)
|
||||
const before = await f.fingerprint()
|
||||
for (const file of desktopUiScreenshotSourceFiles.filter(file => !file.endsWith('.png'))) {
|
||||
const path = join(f.root, file)
|
||||
await writeFile(path, (await readFile(path, 'utf8')).replace(/\n/g, '\r\n'))
|
||||
}
|
||||
assert.deepEqual(await f.fingerprint(), before)
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
# Play voice overlay verification
|
||||
|
||||
The Google Play build adds user-started voice controls over other apps. This
|
||||
does not enable Device Control, AccessibilityService, MediaProjection or phone
|
||||
utility permissions. Standard voice keeps the upstream Dashboard/Gateway path.
|
||||
|
||||
## Session contract
|
||||
|
||||
- Permission setup explains the purpose before launching Android Settings.
|
||||
Permission grants do not start the overlay; Start requires a resumed, unlocked
|
||||
app with microphone, overlay and notification access.
|
||||
- The microphone foreground service must successfully promote before the window
|
||||
attaches. Session identifiers reject stale starts, readiness and Stop actions.
|
||||
- The existing voice runtime owns capture and microphone-release barriers. The
|
||||
service creates no recorder. Stop/close, screen lock, task removal, permission
|
||||
loss and failed startup terminate the voice session. Notification-channel
|
||||
revocation is checked at most one second later while the service is running.
|
||||
- Returning to Hermes preserves foreground protection until the app resumes.
|
||||
No overlay session is restored after process death or started by boot, Relay
|
||||
commands or wake detection. Independent opt-in wake settings remain unchanged.
|
||||
|
||||
## Host verification
|
||||
|
||||
Run focused tests through the Windows Android lane:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle :app:testGooglePlayDebugUnitTest `
|
||||
--tests '*VoiceOverlay*Test' --tests '*VoiceModeOverlayInteractionTest' `
|
||||
--tests '*VoiceViewModelBargeInTest' --tests '*BargeInListenerShutdownRaceTest' `
|
||||
--console=plain
|
||||
```
|
||||
|
||||
The new session/service tests exercise foreground eligibility, missing/revoked
|
||||
access, stopped-before-ready sessions, old notification actions, failed foreground
|
||||
promotion/window attachment, screen-off and task removal. Existing voice tests
|
||||
cover the microphone handoff and shutdown race. The same new tests are included
|
||||
in the both-flavor on-demand focused preset.
|
||||
|
||||
`scripts/check-android-capabilities.py` checks source overlays and the merged
|
||||
Play debug/release manifests. Its mutation tests reject transitive sensitive
|
||||
permissions, renamed accessibility services and exported overlay services.
|
||||
CI debug/release builds and Play preflight run this check.
|
||||
|
||||
## Rendered controls
|
||||
|
||||
These are production Compose components rendered by Robolectric/Roborazzi on
|
||||
API 35, with synthetic voice state. They are not physical microphone evidence.
|
||||
Normal layout is 360 × 720 dp; narrow layout is 320 × 480 dp with 150% font size.
|
||||
The permission dialog is also checked at 150%, including scrolling to the last
|
||||
permission row while Start/Cancel remain reachable.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Release validation
|
||||
|
||||
Physical-device tests and Play Console changes are separate release work. Before
|
||||
production, validate real repeated microphone turns after backgrounding on Android
|
||||
14–16, denial/revocation, task/process termination, screen lock, audio interruption,
|
||||
network loss and OEM window behavior. Use the Standard Phone API 36 emulator lane
|
||||
for the smallest relevant instrumentation run; device claims still require a real
|
||||
device. Do not install an APK or submit a test-track build without authorization.
|
||||
|
||||
Update the microphone FGS declaration and demo for the actual Google Play package.
|
||||
`scripts/android-fgs-demo.py` defaults to that package and requires permissions to
|
||||
be granted manually before recording. Review Data Safety against real recipients,
|
||||
retention and any applicable exceptions. Publish the corresponding canonical and
|
||||
legacy privacy pages before stable preflight; the strengthened live checker
|
||||
deliberately rejects the old blanket no-screen-access policy. See the
|
||||
[submission requirements](../play-store-listing.md#voice-overlay-review-before-production).
|
||||
@@ -0,0 +1,96 @@
|
||||
# Android profile identity audit
|
||||
|
||||
## Upstream contract
|
||||
|
||||
Source inspection used clean upstream commit
|
||||
`b0c383cdf7d8e9e540087610324ec3bb89f3b250` from `NousResearch/hermes-agent`.
|
||||
|
||||
- [`hermes_cli/profiles.py`](https://github.com/NousResearch/hermes-agent/blob/b0c383cdf7d8e9e540087610324ec3bb89f3b250/hermes_cli/profiles.py):
|
||||
`default` is the reserved request name for the root Hermes home. Named profiles
|
||||
resolve beneath the profiles directory. `is_default` marks that root row; it
|
||||
does not identify the sticky selection. The active-profile marker is written
|
||||
atomically, and selecting root removes the marker. Profile `display_name` is
|
||||
presentation metadata in `profile.yaml`; renaming root changes that display
|
||||
metadata rather than moving its home.
|
||||
- [`hermes_cli/web_routers/profiles.py`](https://github.com/NousResearch/hermes-agent/blob/b0c383cdf7d8e9e540087610324ec3bb89f3b250/hermes_cli/web_routers/profiles.py):
|
||||
`/api/profiles` returns `name`, `display_name`, `description`, and `is_default`.
|
||||
`/api/profiles/active` returns two different authorities: `active` is the sticky
|
||||
choice for new invocations, while `current` describes the running Dashboard.
|
||||
- [`tui_gateway/methods_profiles.py`](https://github.com/NousResearch/hermes-agent/blob/b0c383cdf7d8e9e540087610324ec3bb89f3b250/tui_gateway/methods_profiles.py):
|
||||
`profiles.list` returns the same profile identity plus avatar/UI metadata.
|
||||
Standard Android requests `include_sessions:false`; Bot Mode retains the
|
||||
richer roster contract and its own optional Bot title.
|
||||
- [`tui_gateway/methods_session.py`](https://github.com/NousResearch/hermes-agent/blob/b0c383cdf7d8e9e540087610324ec3bb89f3b250/tui_gateway/methods_session.py):
|
||||
explicit `profile` selects the profile home for session creation and resume.
|
||||
Omission uses launch context; it is not inherently a request for the sticky
|
||||
active profile. Android already resolves the sticky setting explicitly for
|
||||
standard profile-scoped sessions and retains that behavior.
|
||||
- [`apps/desktop/src/store/profile.ts`](https://github.com/NousResearch/hermes-agent/blob/b0c383cdf7d8e9e540087610324ec3bb89f3b250/apps/desktop/src/store/profile.ts):
|
||||
`profileLabel` uses trimmed `display_name`, then the request name. TUI's
|
||||
[`appLayout.tsx`](https://github.com/NousResearch/hermes-agent/blob/b0c383cdf7d8e9e540087610324ec3bb89f3b250/ui-tui/src/components/appLayout.tsx)
|
||||
supplies session `profile_name` to its composer prompt rather than a server
|
||||
connection label.
|
||||
|
||||
The `active_status_profile_scope` scenario's current-upstream conformance check
|
||||
passed against this SHA. This is source-only evidence, with no provider calls.
|
||||
|
||||
## Product decision
|
||||
|
||||
The agent name is Hermes `display_name`, otherwise the exact profile request
|
||||
name. Descriptions are not identity. A connection name remains infrastructure
|
||||
identity. Existing local aliases and personality fallback remain supported; an
|
||||
otherwise unknown agent is **Hermes**.
|
||||
|
||||
When the server default resolves to a catalog profile, the shelf and canonical
|
||||
switcher show one identity with a home badge. The switcher shows **Server default**
|
||||
as secondary status and **Follow server default** as a separate selection control.
|
||||
Unchecking it explicitly selects the resolved profile; checking it restores the
|
||||
null selection. Both use the existing profile-switch lifecycle. No server setting
|
||||
is written and no session, draft, history, lock, or asset key is migrated.
|
||||
|
||||
The explicit root `default` choice remains available. Equal display names never
|
||||
cause grouping. Grouping requires exact upstream request identity within the
|
||||
current connection. Missing scope stays unresolved; a known scope without roster
|
||||
metadata retains its request name rather than borrowing root metadata.
|
||||
|
||||
## Surface review
|
||||
|
||||
| Surface | Result |
|
||||
| --- | --- |
|
||||
| Chat header and Passport | Shared identity helper consumes display metadata; connection label and description no longer masquerade as an agent. |
|
||||
| Profile shelf and canonical switcher | Exact-identity grouping, selected-key preservation, role text, follow control, unchanged switch callbacks and local/shared avatar lookup. |
|
||||
| Connection selection | Connection identity and credentials remain separately scoped; no endpoint or persistence changes. |
|
||||
| Settings locks and display management | Follow-default preference is labeled as an action; independent lock/order/hidden keys remain visible for management. |
|
||||
| Manage profile catalog | Uses upstream display name while preserving the raw name for actions. |
|
||||
| Bot Mode | Already prioritizes Bot title, then display name, then request name; parsing now also retains display name in its Profile metadata. Route keys and connection-qualified handles stay unchanged. |
|
||||
| Restoration and diagnostics | Raw profile/session/connection keys remain authoritative. No display-label parsing, normalization, session deletion, or routing changes. |
|
||||
| Supervised mode, avatars, pets | Lock gates and exact asset keys remain unchanged. An unresolved default cannot borrow or modify the root avatar from `is_default`; shared-avatar actions require a resolved or explicit profile. The follow control is disabled under a lock. |
|
||||
| Legacy private agent sheet | Not the canonical switcher; retained without a separate routing/model rewrite. |
|
||||
|
||||
## Rendered evidence
|
||||
|
||||
Sanitized fixtures render the production Compose profile switcher with mocked
|
||||
metadata and avatar flows. They make no network requests and contain no profile
|
||||
contents or private infrastructure. These are JVM/Robolectric renders, not phone
|
||||
screenshots or live-server certification.
|
||||
|
||||
| Before | After |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
The rendered checks cover 360 x 720 dp, 720 x 360 dp landscape, and
|
||||
840 x 720 dp expanded layouts, plus 1.5x system font scale and a long display
|
||||
name. Text truncates with ellipsis, the request name remains available as
|
||||
supporting text, and the follow control preserves checkbox state and a 48 dp
|
||||
minimum target. Landscape opens fully expanded with scrollable content.
|
||||
|
||||
- [Large text](assets/profile-identity/large-text.png)
|
||||
- [Landscape](assets/profile-identity/landscape.png)
|
||||
- [Expanded layout](assets/profile-identity/expanded.png)
|
||||
- [Long name](assets/profile-identity/long-name.png)
|
||||
|
||||
Focused sideload and Google Play suites each passed 497 tests with no failures
|
||||
or skips. Lint and build results are recorded with the PR. Live-server,
|
||||
physical-device, screen-reader, and fold-posture transition behavior remain
|
||||
separate verification gaps. No APK was installed and no server profile was
|
||||
modified.
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -2896,7 +2896,10 @@ the selected-hidden exception. Local icons remain connection/profile scoped.
|
||||
Compose renders state and invokes ViewModel actions; it never writes a store.
|
||||
|
||||
Server default is represented only by `SERVER_DEFAULT_PROFILE_KEY`/a null
|
||||
selection and uses a home glyph. A profile literally named `default` retains its
|
||||
selection. Its resolved agent is grouped once with a home badge and a follow-default
|
||||
control; the selected request key remains unchanged. Upstream `display_name` is
|
||||
presentation-only. Descriptions and connection names are not profile names, and
|
||||
an unresolved default never assumes root identity. A profile named `default` retains its
|
||||
own request, lock, icon, presentation, and session keys. Selecting either does
|
||||
not call the upstream sticky-default mutation.
|
||||
|
||||
@@ -4408,3 +4411,36 @@ approval bypass.
|
||||
- `.github/workflows/approve-release-train.yml`
|
||||
- `scripts/android_release_artifacts.py`
|
||||
- `scripts/android-prepush.py`
|
||||
|
||||
|
||||
## ADR 74 — Play voice overlay is independent of phone control
|
||||
|
||||
**Status:** Accepted (2026-09-12).
|
||||
|
||||
Voice Focus and a user-started voice-only system overlay are shared presentation
|
||||
surfaces. `SYSTEM_ALERT_WINDOW` is special access, not permission to read or drive
|
||||
other apps. Play retains the no-op voice bridge handler, unsupported Device Control
|
||||
status, absent accessibility/projection services and closed bridge-command gate.
|
||||
|
||||
The overlay requires a resumed, unlocked Activity action and microphone,
|
||||
notification and overlay access. Permission grants never start listening. Its
|
||||
session identifier fences queued starts and old notification actions. The window
|
||||
is attached only after microphone foreground promotion succeeds. The existing
|
||||
voice runtime remains the sole microphone owner; no second recorder is created.
|
||||
|
||||
Stop/close, screen lock, task removal, permission loss and service/window failure
|
||||
end the voice session. Returning to the Activity releases overlay protection only
|
||||
after it resumes. The session is not persisted or restarted by background callers.
|
||||
Existing independent opt-in wake/Assistant settings are not changed by overlay use.
|
||||
|
||||
Play's autonomous Accessibility Device Control boundary remains sideload-only.
|
||||
User-mediated MediaProjection and phone compose/picker actions are separate future
|
||||
features, not implicitly enabled by this decision. Standard voice remains upstream
|
||||
Dashboard/Gateway-owned and never requires Relay. Google Play declaration, live
|
||||
privacy/listing publication and physical-device certification are release gates,
|
||||
not consequences of merging this change.
|
||||
|
||||
Source and merged-manifest validation enforce this boundary. Foreground-service
|
||||
lifecycle tests and rendered permission/Stop controls supplement, but do not
|
||||
replace, device tests or a reviewed Play test-track submission. See
|
||||
[Play declarations](play-store-listing.md#voice-overlay-review-before-production).
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
|
||||
"main": "f174b8e5ff26511e81d31fc53249b03886a2067fd1aabac4bb2d5b027d5513ca",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
|
||||
"main": "f174b8e5ff26511e81d31fc53249b03886a2067fd1aabac4bb2d5b027d5513ca",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
|
||||
"main": "f174b8e5ff26511e81d31fc53249b03886a2067fd1aabac4bb2d5b027d5513ca",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
|
||||
"main": "f174b8e5ff26511e81d31fc53249b03886a2067fd1aabac4bb2d5b027d5513ca",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
|
||||
"main": "f174b8e5ff26511e81d31fc53249b03886a2067fd1aabac4bb2d5b027d5513ca",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
|
||||
"main": "f174b8e5ff26511e81d31fc53249b03886a2067fd1aabac4bb2d5b027d5513ca",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
],
|
||||
"sourceFingerprint": {
|
||||
"algorithm": "sha256",
|
||||
"normalization": "text-lf-v1",
|
||||
"digest": "38253cb9fb1124a629625ea0bc5be09a4f61af91e3fca0103cbb12a5279aae54",
|
||||
"normalization": "text-lf-lockfile-root-version-v2",
|
||||
"digest": "ed01f2a0d65cd152c271bc37f9c6b0e555ca0e1306ec09c7638cdcbe283ed5eb",
|
||||
"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.15.1 - Steadier chat, media, and voice
|
||||
v1.16.1 - Dashboard-only cold starts recover
|
||||
|
||||
More reliable chats and media: fewer memory-heavy refreshes, smoother large histories, and attachment previews that survive rotation. Choose whether follow-ups correct the current response or wait in a queue. Voice errors are easier to read, image-generation progress stays visible, and wake-word startup and first-message readiness are fixed.
|
||||
Dashboard-only connections now prepare the selected profile before Gateway readiness, fixing a remaining cold-start path that could stay on waking or waiting for Gateway until the app resumed or its network route changed.
|
||||
```
|
||||
## Category
|
||||
|
||||
@@ -197,6 +197,8 @@ entry points in its description and demonstration:
|
||||
and Stop action, then opens the voice overlay, backgrounds Hermes, and ends
|
||||
the session from the overlay or notification.
|
||||
|
||||
The Play build declares `SYSTEM_ALERT_WINDOW` only for explicitly user-started Voice Overlay. It never enables Device Control. Overlay permission and notification refusal retain in-app voice.
|
||||
|
||||
The Play build does **not** declare `FOREGROUND_SERVICE_MEDIA_PROJECTION` or the Device Control accessibility/bridge services — those are sideload-only.
|
||||
|
||||
### Data safety
|
||||
@@ -213,3 +215,23 @@ questions against this flow before the next Play submission.
|
||||
- `POST_NOTIFICATIONS` — chat input, turn-complete, and keep-alive notifications, requested on API 33+.
|
||||
- `CAMERA` — QR pairing / attachments, requested at use.
|
||||
- Notification listener (companion) — user-enabled in system settings.
|
||||
|
||||
### Voice Overlay review before production
|
||||
|
||||
Update the microphone FGS declaration and demonstrate the actual Google Play
|
||||
package on an explicitly approved test track. Sideload recordings do not certify
|
||||
Play. Show permission refusal, notification Stop, screen-lock termination and
|
||||
repeated turns after switching apps. Do not use the Production-draft stable
|
||||
preflight as an experiment. Upload success is not policy approval.
|
||||
|
||||
Reconcile Data Safety for audio, messages, attachments, notification content and
|
||||
Assistant text/screenshots, including recipients and retention. No hosted backend
|
||||
or analytics does not itself establish "no data collected." Record any applicable
|
||||
collection/sharing exceptions against Google's definitions. Confirm the listing,
|
||||
canonical privacy page and legacy page describe the reviewed behavior. Console
|
||||
updates and test-track submission require release authorization.
|
||||
|
||||
Official sources checked September 12, 2026: [special access](https://developer.android.com/training/permissions/requesting-special),
|
||||
[FGS declarations](https://support.google.com/googleplay/android-developer/answer/13392821),
|
||||
[Data Safety](https://support.google.com/googleplay/android-developer/answer/10787469),
|
||||
and [Accessibility automation](https://support.google.com/googleplay/android-developer/answer/10964491).
|
||||
|
||||
@@ -17,7 +17,7 @@ Hermes-Relay has two Android tracks:
|
||||
|
||||
| Track | Bridge scope | Sensitive Android APIs |
|
||||
|-------|--------------|------------------------|
|
||||
| Google Play | **Bridge Core**: chat, voice, terminal/TUI relay, notification companion, media handoff, relay sessions, status | No AccessibilityService, overlay permission, MediaProjection, wake-lock device-control service, or contacts/location/SMS/call permissions. Optional Android Assistant screen context is described below. |
|
||||
| Google Play | **Bridge Core**: chat, voice, terminal/TUI relay, notification companion, media handoff, relay sessions, status | Optional voice-only overlay. No AccessibilityService, MediaProjection, wake-lock device-control service, or contacts/location/SMS/call permissions. Optional Android Assistant screen context is described below. |
|
||||
| Sideload | **Device Control**: the full agent-driven phone-control bridge | AccessibilityService, foreground service, overlay chip, optional screenshots, and phone-utility permissions when enabled |
|
||||
|
||||
The Google Play build cannot use Accessibility or MediaProjection to inspect or
|
||||
@@ -118,3 +118,7 @@ Stats for Nerds tracks performance metrics such as time to first token, completi
|
||||
## Open Source
|
||||
|
||||
Hermes-Relay is MIT licensed. All source code is publicly available and auditable at [GitHub](https://github.com/Codename-11/hermes-relay).
|
||||
|
||||
## Voice Overlay
|
||||
|
||||
Voice Overlay is optional in both builds. Start it explicitly from Voice Focus while Hermes-Relay is visible and unlocked. It requires microphone access, display-over-other-apps access and an enabled microphone notification with Stop voice. Audio goes to the configured Hermes server; the overlay does not read or control other apps. Stop voice, closing the overlay, screen lock, task removal or loss of required access ends the overlay voice session. Returning to the app keeps foreground protection until the app is resumed. Granting permissions never starts a session.
|
||||
|
||||
@@ -601,7 +601,7 @@ Bottom navigation bar with 4 tabs:
|
||||
1. **Connection chip** — tap to open `ConnectionSwitcherSheet` (all paired servers + health indicator). Auto-hidden when you only have one Connection. See `docs/decisions.md` §19.
|
||||
2. **Agent avatar/name region** — tap to expand or collapse the Profile Shelf immediately below the app bar. With only one visible effective identity, the shelf stays hidden and the same tap opens Agent Passport.
|
||||
3. Remaining top-bar actions (session drawer hamburger, ambient toggle, etc.).
|
||||
- **Profile Shelf** — the active avatar/name/chevron capsule opens Agent Passport; inactive profiles are avatar-only 48 dp switch targets; a fixed overflow opens the canonical full switcher also used by Passport. The shelf scrolls horizontally, honors `ProfilePresentationStore` ordering/hidden preferences, keeps a hidden selected profile disclosed, and disappears when only one visible identity remains. Hermes-owned avatars win by default, followed by device-local icons and display initials; an explicit per-connection/profile **This phone only** override lets the local icon win without mutating Hermes. Server default uses a home glyph and remains distinct from a profile literally named `default`.
|
||||
- **Profile Shelf** — the active avatar/name/chevron capsule opens Agent Passport; inactive profiles are avatar-only 48 dp switch targets; a fixed overflow opens the canonical full switcher also used by Passport. The shelf scrolls horizontally, honors `ProfilePresentationStore` ordering/hidden preferences, keeps a hidden selected profile disclosed, and disappears when only one visible identity remains. Hermes-owned avatars win by default, followed by device-local icons and display initials; an explicit per-connection/profile **This phone only** override lets the local icon win without mutating Hermes. The resolved server default is grouped with its exact named profile and carries a home badge; a follow-default control preserves implicit and explicit selection keys. Display identity uses upstream `display_name`, then the exact profile name, never a connection label or description. Unknown default identity stays unresolved rather than borrowing root metadata.
|
||||
- **Hermes-owned profile identity** — on a current Gateway, Android calls `profiles.list {include_sessions:false}` and consumes bounded `ui_meta` plus `has_avatar`. A true avatar flag triggers `profiles.get_asset`; validated server bytes are cached per connection/profile and win over the older device-local `ProfileIconStore`. A false flag or successful clear removes only the server cache. Refresh generations and exact connection identity prevent a late fetch from repainting another connection or resurrecting a cleared avatar.
|
||||
- **Separated shared and phone avatar controls** — **Shared across Hermes** directly selects or removes the upstream `profiles.set_asset` avatar without changing local presentation. **This phone only** is a persisted per-connection/profile override populated from a phone image. The Relay-host `GET /api/profiles/{name}/avatar` conventional-file importer is a legacy enhancement only; it does not own or replace upstream profile assets. Selecting a phone image enables the override, while disabling it immediately returns to the shared avatar. Phone-local PNG/JPEG/WebP/GIF bytes are magic-checked and capped at 8 MB; Coil renders animated GIF/WebP consistently anywhere the profile icon appears. The shared picker accepts any image Android can decode, applies its display orientation, and downscales/re-encodes when necessary while retaining the exact upstream PNG/JPEG/WebP and 2,000,000-byte storage contract. Pet sheets and Sphere skins never enter `ui_meta` or profile assets.
|
||||
- **Upstream animated pets** — the agent sheet consumes the profile-scoped Gateway `pet.info`, `pet.gallery`, `pet.select`, and `pet.disable` contracts. Android caches the bounded PNG/WebP sprite sheet by connection, effective profile, and `spritesheetRevision`; it sends `knownRevision` on refresh and reuses the existing bounded pet renderer for the returned geometry, row taxonomy, and activity states. The active upstream pet becomes the phone companion unless the user explicitly selected a phone-local floating pet. Selection and disable write Hermes `display.pet.*` state and therefore follow the profile across current Hermes surfaces; a method-not-found response leaves older hosts on the established local pet flow.
|
||||
@@ -1016,7 +1016,7 @@ The v0.4 wave includes three reliability patterns applied to existing code and o
|
||||
|
||||
**WakeLockManager — wake-scope wrapping for gesture dispatch.** New `object WakeLockManager` at `app/src/main/kotlin/com/hermesandroid/relay/power/WakeLockManager.kt` exposes `suspend fun <T> wakeForAction(block: suspend () -> T): T`. Uses `PowerManager.PARTIAL_WAKE_LOCK`, ref-counted so nested calls don't release each other prematurely, with a hard 10-second timeout as a battery safety rail. `ActionExecutor` wraps every gesture-dispatching function (`tap`, `tapText`, `typeText`, `swipe`, `scroll`, `longPress`, `drag`) in `wakeForAction { ... }`. Read-only accessibility calls (`readScreen`, `findNodes`, `describeNode`, `screenHash`, `diffScreen`, `currentApp`, `clipboardRead/Write`, `mediaControl`) are not wrapped — they don't need the screen on. Closes the "gesture fires into the void when the screen is off" failure mode that silently broke `android_tap` / `android_swipe` whenever Bailey's phone hit idle between commands. Requires `android.permission.WAKE_LOCK` in the main manifest.
|
||||
|
||||
**Multi-window ScreenReader (P1).** `ScreenReader.readCurrentScreen` now iterates `service.windows.mapNotNull { it.root }` instead of the single `rootInActiveWindow`. Returns a merged tree where each `AccessibilityNodeInfo` is walked per-window and recycled in the per-iteration `try/finally`. Catches system overlays, popup menus, notification shade, and split-screen secondary windows — the previous single-root path silently ignored them. **Node-ID scheme update:** stable IDs are now prefixed `w<windowIndex>:<sequentialIndex>` (e.g. `w0:42`, `w1:7`) so IDs are disambiguated across windows. A single-window fallback kicks in when `service.windows` is empty, which happens on the googlePlay flavor without `flagRetrieveInteractiveWindows` (the conservative a11y config that survives Play Store policy review). Node IDs are end-to-end resolvable after A4 wired parsing into `/tap` and `/scroll` — `android_find_nodes` and `android_describe_node` emit them, and `android_tap` / `android_scroll` accept them as input, so an agent can search → describe → act without re-reading the tree.
|
||||
**Multi-window ScreenReader (P1).** `ScreenReader.readCurrentScreen` now iterates `service.windows.mapNotNull { it.root }` instead of the single `rootInActiveWindow`. Returns a merged tree where each `AccessibilityNodeInfo` is walked per-window and recycled in the per-iteration `try/finally`. Catches system overlays, popup menus, notification shade, and split-screen secondary windows — the previous single-root path silently ignored them. **Node-ID scheme update:** stable IDs are now prefixed `w<windowIndex>:<sequentialIndex>` (e.g. `w0:42`, `w1:7`) so IDs are disambiguated across windows. A single-window fallback kicks in when `service.windows` is empty, when Android cannot provide interactive windows; the Google Play flavor does not bind this AccessibilityService. Node IDs are end-to-end resolvable after A4 wired parsing into `/tap` and `/scroll` — `android_find_nodes` and `android_describe_node` emit them, and `android_tap` / `android_scroll` accept them as input, so an agent can search → describe → act without re-reading the tree.
|
||||
|
||||
**A9 three-tier `tapText` cascade.** `ActionExecutor.tapText` replaces the single-shot `findNodeBoundsByText → performAction(ACTION_CLICK)` path with a 3-tier fallback:
|
||||
1. Find node by text across all windows. If `node.isClickable` → `performAction(ACTION_CLICK)`.
|
||||
@@ -1369,3 +1369,13 @@ Preserved verbatim from the original scoping session. This is a historical snaps
|
||||
|
||||
All six deliverables shipped in v0.1.0. Four of the five "non-goals for tonight" have since shipped in v0.2.0 / v0.3.0; biometrics is the one remaining open item.
|
||||
- **ClawPort** — Web dashboard (parallel effort, different interface surface)
|
||||
|
||||
|
||||
### Voice Overlay capability boundary
|
||||
|
||||
Both Android flavors offer an optional voice-only overlay from Voice Focus.
|
||||
Microphone, notification and display-over-other-apps permission checks are followed
|
||||
by a fresh Start action in the resumed app. The microphone FGS acknowledges
|
||||
readiness before the overlay attaches. Stop, screen lock, task removal and lost
|
||||
access terminate the session; an Activity handoff retains protection until resume.
|
||||
Device Control, Accessibility and MediaProjection remain sideload-only. See ADR 74.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.15.1"
|
||||
appVersionCode = "54"
|
||||
appVersionName = "1.16.1"
|
||||
appVersionCode = "56"
|
||||
agp = "9.4.0"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.08.00"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"label": "Hermes-Relay",
|
||||
"description": "Paired devices, Bridge activity, media tokens, and remote access for Hermes-Relay",
|
||||
"icon": "Activity",
|
||||
"version": "1.11.1",
|
||||
"version": "1.11.2",
|
||||
"tab": {
|
||||
"path": "/relay",
|
||||
"position": "after:skills"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.11.1",
|
||||
"version": "1.11.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.11.1",
|
||||
"version": "1.11.2",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.12",
|
||||
"qrcode": "^1.5.4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.11.1",
|
||||
"version": "1.11.2",
|
||||
"private": true,
|
||||
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,7 +2,7 @@ name: hermes-relay
|
||||
# Temporary v1 shim for Hermes installers that reject manifests the runtime supports; see docs/project/TODO.md.
|
||||
manifest_version: 1
|
||||
api_version: 1
|
||||
version: 1.11.1
|
||||
version: 1.11.2
|
||||
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
|
||||
license: MIT
|
||||
|
||||
@@ -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.11.1"
|
||||
__version__ = "1.11.2"
|
||||
|
||||
from .server import create_app, main # noqa: E402 — must come after __version__
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-relay"
|
||||
version = "1.11.1"
|
||||
version = "1.11.2"
|
||||
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -19,7 +19,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_PACKAGE = "com.axiomlabs.hermesrelay.sideload"
|
||||
DEFAULT_PACKAGE = "com.axiomlabs.hermesrelay"
|
||||
REMOTE_UI = "/sdcard/hermes-fgs-demo.xml"
|
||||
REMOTE_VIDEO = "/sdcard/hermes-fgs-demo.mp4"
|
||||
|
||||
@@ -420,6 +420,8 @@ def main() -> int:
|
||||
demo.tap_selector(description="Start voice conversation", settle=1)
|
||||
demo.tap_selector(description="Expand voice controls", settle=0.7)
|
||||
demo.tap_selector(text="Overlay", settle=1.5)
|
||||
# Grant required access manually before recording; never automate consent.
|
||||
demo.tap_selector(text="Start voice overlay", settle=1.5)
|
||||
demo.home()
|
||||
demo.pause(3)
|
||||
nodes, _ = demo.wait_for_notification("Hermes voice overlay active")
|
||||
|
||||
@@ -21,6 +21,9 @@ import sys
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
FOCUSED_TESTS = (
|
||||
"com.hermesandroid.relay.voice.VoiceOverlayLifecycleTest",
|
||||
"com.hermesandroid.relay.voice.VoiceOverlayForegroundServiceTest",
|
||||
"com.hermesandroid.relay.voice.VoiceOverlayPresentationTest",
|
||||
"com.hermesandroid.relay.network.ArchitectureBoundaryTest",
|
||||
"com.hermesandroid.relay.network.relay.RelayUrlDeriverTest",
|
||||
"com.hermesandroid.relay.viewmodel.ConnectionSwitchTest",
|
||||
@@ -45,6 +48,7 @@ RELEASE_PREP_TESTS = (
|
||||
"com.hermesandroid.relay.screenshots.WhatsNewToastScreenshotTest",
|
||||
)
|
||||
REPOSITORY_CHECKS = (
|
||||
"check-android-capabilities.py",
|
||||
"check-android-locales.py",
|
||||
"check-user-docs-locales.py",
|
||||
"check-android-collection-apis.py",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify Play voice capabilities without allowing Device Control through a manifest merge."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ANDROID = "{http://schemas.android.com/apk/res/android}"
|
||||
TOOLS = "{http://schemas.android.com/tools}"
|
||||
FORBIDDEN = {
|
||||
"BIND_ACCESSIBILITY_SERVICE", "WAKE_LOCK", "FOREGROUND_SERVICE_MEDIA_PROJECTION",
|
||||
"READ_CONTACTS", "WRITE_CONTACTS", "SEND_SMS", "READ_SMS", "RECEIVE_SMS",
|
||||
"READ_CALL_LOG", "WRITE_CALL_LOG", "CALL_PHONE", "ACCESS_FINE_LOCATION",
|
||||
"ACCESS_COARSE_LOCATION", "ACCESS_BACKGROUND_LOCATION", "DISABLE_KEYGUARD",
|
||||
"REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", "QUERY_ALL_PACKAGES", "MANAGE_EXTERNAL_STORAGE",
|
||||
}
|
||||
REQUIRED = {"RECORD_AUDIO", "POST_NOTIFICATIONS", "FOREGROUND_SERVICE",
|
||||
"FOREGROUND_SERVICE_MICROPHONE", "SYSTEM_ALERT_WINDOW"}
|
||||
|
||||
|
||||
def validate(roots: list[ET.Element]) -> None:
|
||||
permissions: set[str] = set()
|
||||
services: dict[str, ET.Element] = {}
|
||||
for root in roots:
|
||||
for node in root:
|
||||
if node.tag.startswith("uses-permission"):
|
||||
name = node.get(ANDROID + "name", "").removeprefix("android.permission.")
|
||||
if node.get(TOOLS + "node") == "remove":
|
||||
permissions.discard(name)
|
||||
else:
|
||||
permissions.add(name)
|
||||
for node in root.findall("application/service"):
|
||||
name = node.get(ANDROID + "name", "")
|
||||
services[name.rsplit(".", 1)[-1]] = node
|
||||
if node.get(ANDROID + "permission") == "android.permission.BIND_ACCESSIBILITY_SERVICE":
|
||||
raise ValueError("Play must not bind an AccessibilityService")
|
||||
if "mediaProjection" in node.get(ANDROID + "foregroundServiceType", "").split("|"):
|
||||
raise ValueError("Play must not declare a MediaProjection service")
|
||||
if permissions & FORBIDDEN:
|
||||
raise ValueError(f"Play contains Device Control permissions: {sorted(permissions & FORBIDDEN)}")
|
||||
if REQUIRED - permissions:
|
||||
raise ValueError(f"Play voice permissions missing: {sorted(REQUIRED - permissions)}")
|
||||
if {"BridgeForegroundService", "HermesAccessibilityService"} & services.keys():
|
||||
raise ValueError("Play contains a Device Control service")
|
||||
voice = services.get("VoiceOverlayForegroundService")
|
||||
if voice is None or voice.get(ANDROID + "exported") != "false" or \
|
||||
voice.get(ANDROID + "foregroundServiceType") != "microphone":
|
||||
raise ValueError("Play voice overlay must use a non-exported microphone FGS")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--variant", choices=("googlePlayDebug", "googlePlayRelease"))
|
||||
parser.add_argument("--build-dir", type=Path, default=ROOT / "app/build")
|
||||
args = parser.parse_args()
|
||||
validate([ET.parse(ROOT / f"app/src/{flavor}/AndroidManifest.xml").getroot()
|
||||
for flavor in ("main", "googlePlay")])
|
||||
if args.variant:
|
||||
files = list((args.build_dir / "intermediates/merged_manifests" / args.variant)
|
||||
.rglob("AndroidManifest.xml"))
|
||||
if len(files) != 1:
|
||||
raise SystemExit(f"Expected one merged {args.variant} manifest, found {len(files)}")
|
||||
validate([ET.parse(files[0]).getroot()])
|
||||
print(f"Play capability manifest validated ({args.variant or 'source overlays'})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -333,8 +333,18 @@ def _check_active_list(server: SourceFile, methods: SourceFile) -> CheckResult:
|
||||
missing_fields = sorted({"id", "session_key", "status"} - item_strings)
|
||||
if missing_fields:
|
||||
raise ValueError("active-list row missing field(s): " + ", ".join(missing_fields))
|
||||
required_markers = ("_sessions_lock", "_sessions.items()", "_session_live_item(")
|
||||
missing_markers = [marker for marker in required_markers if marker not in handler_text]
|
||||
snapshot_node = handler
|
||||
snapshot_text = handler_text
|
||||
if "_snapshot_sessions(" in handler_text:
|
||||
snapshot_node = methods.function("_snapshot_sessions")
|
||||
snapshot_text = methods.segment(snapshot_node)
|
||||
required_snapshot_markers = ("_sessions_lock", "_sessions.items()")
|
||||
missing_snapshot_markers = [
|
||||
marker for marker in required_snapshot_markers if marker not in snapshot_text
|
||||
]
|
||||
missing_markers = list(missing_snapshot_markers)
|
||||
if "_session_live_item(" not in handler_text:
|
||||
missing_markers.append("_session_live_item(")
|
||||
if missing_markers or "sessions" not in _string_constants(handler):
|
||||
raise ValueError(
|
||||
"session.active_list no longer snapshots the live registry: "
|
||||
@@ -352,7 +362,7 @@ def _check_active_list(server: SourceFile, methods: SourceFile) -> CheckResult:
|
||||
server.evidence(status, "starting, working, waiting, and idle derivation"),
|
||||
server.evidence(item, "live row carries runtime and durable identities"),
|
||||
methods.evidence(
|
||||
handler, "active list snapshots the process-wide in-memory registry"
|
||||
snapshot_node, "active list snapshots the process-wide in-memory registry"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -21,6 +21,9 @@ REQUIRED_MARKERS = (
|
||||
"Data export and deletion",
|
||||
"Children's privacy",
|
||||
"Hermes-Relay issue tracker",
|
||||
"Voice Overlay",
|
||||
"Android Digital Assistant",
|
||||
"Stop voice",
|
||||
)
|
||||
|
||||
|
||||
@@ -28,6 +31,9 @@ def validate_content(label: str, content: str) -> None:
|
||||
missing = [marker for marker in REQUIRED_MARKERS if marker not in content]
|
||||
if missing:
|
||||
raise ValueError(f"{label} is missing required markers: {', '.join(missing)}")
|
||||
for obsolete in ("cannot read your screen", "does not collect, transmit, or share personal data"):
|
||||
if obsolete in content:
|
||||
raise ValueError(f"{label} has an obsolete blanket privacy claim: {obsolete}")
|
||||
|
||||
|
||||
def validate_repository() -> None:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location("capabilities", ROOT / "scripts/check-android-capabilities.py")
|
||||
checker = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(checker)
|
||||
|
||||
|
||||
class PlayManifestTest(unittest.TestCase):
|
||||
def roots(self):
|
||||
return [ET.parse(ROOT / f"app/src/{flavor}/AndroidManifest.xml").getroot()
|
||||
for flavor in ("main", "googlePlay")]
|
||||
|
||||
def test_current_voice_only_split(self):
|
||||
checker.validate(self.roots())
|
||||
|
||||
def test_transitive_sensitive_permission_rejected(self):
|
||||
for permission in checker.FORBIDDEN:
|
||||
with self.subTest(permission=permission):
|
||||
roots = self.roots()
|
||||
ET.SubElement(roots[-1], "uses-permission", {checker.ANDROID + "name": f"android.permission.{permission}"})
|
||||
with self.assertRaises(ValueError):
|
||||
checker.validate(roots)
|
||||
|
||||
def test_renamed_accessibility_service_rejected(self):
|
||||
roots = self.roots()
|
||||
ET.SubElement(roots[-1].find("application"), "service", {
|
||||
checker.ANDROID + "name": "third.party.UnexpectedService",
|
||||
checker.ANDROID + "permission": "android.permission.BIND_ACCESSIBILITY_SERVICE",
|
||||
})
|
||||
with self.assertRaises(ValueError):
|
||||
checker.validate(roots)
|
||||
|
||||
def test_exported_microphone_service_rejected(self):
|
||||
roots = self.roots()
|
||||
for node in roots[0].findall("application/service"):
|
||||
if node.get(checker.ANDROID + "name").endswith("VoiceOverlayForegroundService"):
|
||||
node.set(checker.ANDROID + "exported", "true")
|
||||
with self.assertRaises(ValueError):
|
||||
checker.validate(roots)
|
||||
@@ -110,11 +110,16 @@ def _(rid, params):
|
||||
session, error = _sess_nowait(params, rid)
|
||||
return _live_session_payload(params["session_id"], session)
|
||||
|
||||
def _snapshot_sessions(rid):
|
||||
with _sessions_lock:
|
||||
return list(_sessions.items()), None
|
||||
|
||||
@method("session.active_list")
|
||||
def _(rid, params):
|
||||
snapshot, error = _snapshot_sessions(rid)
|
||||
if error:
|
||||
return error
|
||||
current = str(params.get("current_session_id") or "")
|
||||
with _sessions_lock:
|
||||
snapshot = list(_sessions.items())
|
||||
rows = [_session_live_item(sid, session, current) for sid, session in snapshot]
|
||||
return _ok(rid, {"sessions": rows})
|
||||
'''
|
||||
|
||||