chore: merge current dev into batch Clarify fix

This commit is contained in:
Bailey Dixon
2026-09-12 20:11:40 -04:00
71 changed files with 1364 additions and 195 deletions
+1
View File
@@ -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 });
+6
View File
@@ -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 \
+10
View File
@@ -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 \
+18
View File
@@ -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 \
+4
View File
@@ -6,6 +6,10 @@ 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 answers upstream Clarify batches one question at a time, with independent choices, custom answers, and confirmed progress preserved across reconnects. (#474)
+2 -2
View File
@@ -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.
+4 -1
View File
@@ -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
@@ -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
@@ -1673,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) {
@@ -1747,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
@@ -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,
@@ -582,6 +582,7 @@ fun VoiceSettingsScreen(
}
VoiceSettingsSection.Listening -> {
com.hermesandroid.relay.voice.VoiceOverlaySettingsCard()
GlobalVoiceControlsCard(
voiceSettings = voiceSettings,
prefsRepo = prefsRepo,
@@ -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 })
}
@@ -4445,4 +4445,11 @@
<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>
@@ -4526,4 +4526,11 @@
<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>
+7
View File
@@ -4602,4 +4602,11 @@
<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>
+7
View File
@@ -4293,4 +4293,11 @@
<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>
+7
View File
@@ -4597,4 +4597,11 @@
<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>
+7
View File
@@ -4341,4 +4341,11 @@
<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>
+7
View File
@@ -4660,4 +4660,11 @@
<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>
+4 -5
View File
@@ -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
@@ -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)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

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.
![Permission setup](assets/play-voice-overlay/setup.png)
![Large text after scrolling](assets/play-voice-overlay/setup-large-text.png)
![Compact overlay](assets/play-voice-overlay/compact.png)
![Narrow overlay with large text](assets/play-voice-overlay/narrow.png)
![Minimized overlay with Stop](assets/play-voice-overlay/minimized.png)
## 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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+33
View File
@@ -4411,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).
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8f0fb1a30592b0100876570cbf88ad2ec5cee67818f9221035184d63838ab26f",
"main": "1059097e479afcdc8414c3f2df2ccffa9447c2a601dc408ba87cbce661e1e5a6",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8f0fb1a30592b0100876570cbf88ad2ec5cee67818f9221035184d63838ab26f",
"main": "1059097e479afcdc8414c3f2df2ccffa9447c2a601dc408ba87cbce661e1e5a6",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8f0fb1a30592b0100876570cbf88ad2ec5cee67818f9221035184d63838ab26f",
"main": "1059097e479afcdc8414c3f2df2ccffa9447c2a601dc408ba87cbce661e1e5a6",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8f0fb1a30592b0100876570cbf88ad2ec5cee67818f9221035184d63838ab26f",
"main": "1059097e479afcdc8414c3f2df2ccffa9447c2a601dc408ba87cbce661e1e5a6",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8f0fb1a30592b0100876570cbf88ad2ec5cee67818f9221035184d63838ab26f",
"main": "1059097e479afcdc8414c3f2df2ccffa9447c2a601dc408ba87cbce661e1e5a6",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8f0fb1a30592b0100876570cbf88ad2ec5cee67818f9221035184d63838ab26f",
"main": "1059097e479afcdc8414c3f2df2ccffa9447c2a601dc408ba87cbce661e1e5a6",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+2 -2
View File
@@ -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",
+22
View File
@@ -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).
+5 -1
View File
@@ -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.
+11 -1
View File
@@ -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.
+3 -1
View File
@@ -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")
+4
View File
@@ -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",
+70
View File
@@ -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()
+6
View File
@@ -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)
+28 -4
View File
@@ -6,7 +6,7 @@ Hermes-Relay ships as two product flavors from the same codebase: **googlePlay**
| Flavor | Install ID | Bridge scope |
|--------|------------|--------------|
| `googlePlay` | `com.axiomlabs.hermesrelay` | **Bridge Core** only. No AccessibilityService, screen reading, gestures, screenshots, SMS/call/contact/location access, overlays, wake-lock Device Control, or unattended control. |
| `googlePlay` | `com.axiomlabs.hermesrelay` | **Bridge Core** only. No AccessibilityService or MediaProjection Device Control, gestures, SMS/call/contact/location access, wake-lock Device Control, or unattended control. |
| `sideload` | `com.axiomlabs.hermesrelay.sideload` | **Device Control**. Adds AccessibilityService-backed screen reads, taps, typing, screenshots, app launch, clipboard/media control, phone utilities, safety rails, and unattended controls. |
## Build System
@@ -65,14 +65,14 @@ The shared manifest declares the app permissions and services needed by both tra
```
INTERNET, ACCESS_NETWORK_STATE, CAMERA, RECORD_AUDIO, MODIFY_AUDIO_SETTINGS,
POST_NOTIFICATIONS, FOREGROUND_SERVICE, FOREGROUND_SERVICE_SPECIAL_USE
POST_NOTIFICATIONS, FOREGROUND_SERVICE, FOREGROUND_SERVICE_SPECIAL_USE, FOREGROUND_SERVICE_MICROPHONE
```
It also declares the optional notification companion service and `GatewayKeepAliveService`, a `specialUse` foreground service that protects user-started active turns automatically and optionally extends to idle time through "Persistent connection." It ships on **both** flavors. It does not declare the accessibility service, the Device Control bridge foreground service, overlay permission, wake lock, MediaProjection foreground-service type, contacts, location, SMS, or call permissions.
It also declares the optional notification companion service and `GatewayKeepAliveService`, a `specialUse` foreground service that protects user-started active turns automatically and optionally extends to idle time through "Persistent connection." It ships on **both** flavors. It does not declare the accessibility service, the Device Control bridge foreground service, wake lock, MediaProjection foreground-service type, contacts, location, SMS, or call permissions. Both tracks declare the voice-only `SYSTEM_ALERT_WINDOW` special access and microphone foreground-service permission.
### Google Play manifest
The Play manifest does not add Device Control permissions or services. The merged Play manifest should contain no `AccessibilityService`, `BIND_ACCESSIBILITY_SERVICE`, `SYSTEM_ALERT_WINDOW`, `WAKE_LOCK`, `FOREGROUND_SERVICE_MEDIA_PROJECTION`, or `BridgeForegroundService` declaration.
The Play manifest adds `SYSTEM_ALERT_WINDOW` for user-started Voice Overlay, removes the transitive `WAKE_LOCK`, and adds no Device Control services. The merged Play manifest should contain no `AccessibilityService`, `BIND_ACCESSIBILITY_SERVICE`, `WAKE_LOCK`, `FOREGROUND_SERVICE_MEDIA_PROJECTION`, or `BridgeForegroundService` declaration.
> **Exception — `FOREGROUND_SERVICE_SPECIAL_USE` + `GatewayKeepAliveService` _are_ in the merged Play manifest.** They protect user-started active turns and back the opt-in idle "Persistent connection" feature, which ships on both flavors via the main manifest. This means the Play build carries one `specialUse` foreground service and therefore needs a Play Console foreground-service declaration at submission (see `docs/play-store-listing.md`). The Device Control bridge's `specialUse|mediaProjection` service stays sideload-only.
@@ -149,3 +149,27 @@ val bridgeTier6: Boolean get() = current == SIDELOAD
The server-side `android_*` Device Control tools are registered with a runtime requirement check. Tools other than `android_setup` are visible only when `/bridge/status` reports both `phone_connected=true` and `bridge.device_control_supported=true`.
That means a connected Google Play Bridge Core phone can still show up in relay status and use non-device-control features, but the agent will not see phone-control tools for it.
## Voice presentation is independent of Device Control
Both flavors support in-app Voice Focus and optional voice-only system Overlay.
`BuildFlavor.voiceSystemOverlay` gates presentation independently of the sideload
bridge tiers. The overlay cannot dispatch phone-control commands on Play.
Voice Focus explains microphone, notification and display-over-other-apps access
before opening Android Settings. Returning from Settings never starts a session;
the user taps Start voice overlay while the app is visible and unlocked. A
non-exported microphone FGS must be ready before the window appears. Stop voice,
screen lock, task removal, failed startup and permission loss end the session.
Returning to Hermes retains protection until the Activity resumes. No overlay
session survives process death or starts from boot, a Relay request or wake word.
The selected Android Digital Assistant can separately receive bounded text and an
available screenshot for an explicit compatible unlocked invocation. This uses
platform Assistant APIs, not AccessibilityService or MediaProjection. User-shared
files and images likewise remain available in both builds.
`scripts/check-android-capabilities.py` validates the source split and, with
`--variant googlePlayDebug` or `googlePlayRelease`, the merged manifest. CI builds
and Play preflight check the merged Play artifact's manifest for forbidden Device
Control permissions and services while requiring the voice overlay capabilities.
+7 -1
View File
@@ -4,7 +4,7 @@ Hermes-Relay connects only to your own machines — no cloud accounts, no hosted
## Track split
- **Google Play:** Bridge Core only — chat, voice, standard inbound files, terminal/TUI relay, notification companion, Relay media enhancements, relay sessions, and status. No AccessibilityService, screen reading, taps, typing, screenshots, SMS, calls, contacts, location, overlay, or unattended phone control.
- **Google Play:** Bridge Core only — chat, voice, standard inbound files, terminal/TUI relay, notification companion, Relay media enhancements, relay sessions, and status. No AccessibilityService or MediaProjection Device Control, taps, typing, SMS, calls, contacts, location, or unattended phone control. Voice-only overlays and bounded user-invoked Android Assistant context are separate optional capabilities.
- **Sideload:** Device Control — the separate sideload track can include AccessibilityService-backed phone control and the extra Android permissions needed for that surface.
## What stays on your phone
@@ -48,3 +48,9 @@ From **Settings**, you can export a full connection backup, import a backup, or
## Open source
All code is [MIT licensed](https://github.com/Codename-11/hermes-relay) and publicly auditable. See the full [privacy policy](https://hermes-relay.dev/privacy.html).
## 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.
If Hermes-Relay is selected as Android’s Digital Assistant, a compatible explicit unlocked assistant-button invocation may include bounded visible text and an available screenshot in one Standard voice turn sent to the configured Hermes server and AI provider. Ordinary wake and keyguard invocations do not request screen context.
+1 -1
View File
@@ -95,7 +95,7 @@ On sideload, `/ping`, `/current_app`, and `/return_to_hermes` — liveness, intr
### Sideload-only permissions
The `sideload` build ships a fourth tier of phone-utility tools (`/location`, `/search_contacts`, `/call`, `/send_sms`) that require runtime permissions Google Play's policy forbids without a default-dialer / default-SMS-app justification. These are compiled out of the `googlePlay` build entirely; picking which flavor to install is itself a trust decision.
The `sideload` build ships a fourth tier of phone-utility tools (`/location`, `/search_contacts`, `/call`, `/send_sms`) that remain outside the Play product boundary. SMS has default-handler/approved-use restrictions; contacts, location and direct calling have separate permission requirements. These are compiled out of the `googlePlay` build entirely; picking which flavor to install is itself a trust decision.
### Activity log
+6
View File
@@ -547,3 +547,9 @@ provider (ElevenLabs, OpenAI, Groq, Mistral), your audio goes to them. If you're
using local providers (faster-whisper, NeuTTS, Edge TTS), nothing leaves your network.
The mp3 files returned from `/voice/synthesize` are cached briefly in the app's cache directory and cleared automatically as new ones arrive (capped at 6 at a time). On the server side, the relay writes each `/voice/synthesize` render to a private temp file and deletes it after streaming, so relay synthesis no longer accumulates files in `~/voice-memos/` (other Hermes agent voice features may still use that directory).
## Voice over other apps
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.
Open **Chat → Voice Focus → Overlay**. Review the explanation, grant only the missing permissions in Android, return, and tap **Start voice overlay**. Permission refusal leaves in-app voice available. Review access later in **Settings → Voice → Listening** or **Settings → Permissions**. The expanded panel can reset its position. Every panel size exposes Stop voice.
+5 -2
View File
@@ -6,7 +6,7 @@ description: Privacy policy for the Hermes-Relay Android app
# Privacy Policy
**Hermes-Relay** · Effective date: July 18, 2026
**Hermes-Relay** · Effective date: September 12, 2026
Hermes-Relay is a native Android app that connects to your own [Hermes Agent](https://github.com/NousResearch/hermes-agent) host. This policy describes how the app handles your data.
@@ -64,6 +64,7 @@ Google Play build:
| Network State | Detect connectivity for reconnect behavior | Yes |
| Camera | QR code scanning for server pairing | No |
| Microphone | Voice mode speech-to-text and opt-in local “Hey Hermes” detection | No |
| Display over other apps | Optional user-started Voice Overlay, with microphone notification and Stop voice | No |
| Notification Access | Optional notification companion metadata forwarding to your paired relay | No |
| Android Digital Assistant role | Optional assistant session; compatible unlocked assistant-button invocations may include one-turn screen context | No |
@@ -75,7 +76,9 @@ microphone while the mode is enabled. Pre-activation wake audio is not sent to
Hermes, Relay, or a hosted service. The Digital Assistant listener and the
notification-based experimental listener cannot be active together.
Sideload Device Control builds may request additional permissions for overlay, foreground service, wake lock, screenshots, contacts, location, SMS, and calls. Those permissions are not present in the Google Play build.
Both builds declare foreground-service permissions for user-started voice and connection tasks. Both offer an optional voice-only overlay. Sideload Device Control adds accessibility, wake lock, MediaProjection screenshots, contacts, location, SMS and calls; these Device Control permissions are absent from Play.
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.
## Third-Party Services
+16
View File
@@ -57,6 +57,22 @@ The same scripts generate and verify smaller WebP derivatives used by the
responsive `srcset` declarations. The full-resolution PNG files remain the
canonical fallback; do not hand-edit the WebP variants.
Desktop UI screenshots are captured from the production React surface with fixed
public fixtures. From the repository root, refresh them with:
```bash
node desktop/tray/scripts/capture-desktop-ui.mjs
node website/scripts/desktop-ui-assets.mjs sync
```
The capture command updates `docs/media/desktop-ui-screenshots.json` only after
the scenes succeed. Its fingerprint includes UI sources, fixtures and dependency
versions/integrity. The tray lockfile's two root-package `version` fields are
excluded because the fixture pins its display versions; a release-only version
bump does not invalidate the images. Changes to fixture versions still invalidate
the fingerprint. `npm run assets:check` tests this distinction before checking
the canonical images and website derivatives.
## Coolify
Deploy the website and VitePress guide with their repository-owned Dockerfile.
+1 -1
View File
@@ -8,7 +8,7 @@
},
"scripts": {
"assets:sync": "node scripts/product-assets.mjs sync && node scripts/responsive-assets.mjs sync && node scripts/desktop-ui-assets.mjs sync",
"assets:check": "node scripts/product-assets.mjs check && node scripts/responsive-assets.mjs check && node scripts/desktop-ui-assets.mjs check",
"assets:check": "node --test ../desktop/tray/scripts/desktop-ui-source-fingerprint.test.mjs && node scripts/product-assets.mjs check && node scripts/responsive-assets.mjs check && node scripts/desktop-ui-assets.mjs check",
"locales:validate": "python ../scripts/check-website-locales.py",
"dev": "npm run assets:check && npm run locales:validate && astro dev",
"build": "npm run assets:check && npm run locales:validate && astro check && astro build && node scripts/check-built-site.mjs",
+10 -4
View File
@@ -31,15 +31,15 @@
<body>
<main>
<h1>Privacy Policy</h1>
<p><strong>Hermes-Relay</strong> · Effective date: July 18, 2026</p>
<p><strong>Hermes-Relay</strong> · Effective date: September 12, 2026</p>
<section class="summary">
<h2>Summary</h2>
<p>Hermes-Relay does not collect, transmit, or share personal data with third parties. The app connects only to servers that you configure. There are no accounts, no hosted Hermes-Relay cloud service, and no externally transmitted analytics.</p>
<p>Hermes-Relay has no hosted cloud service and sends no analytics. Chat, voice, attachments, notifications and optional Android Assistant screen context are sent to servers you configure. Those servers may send content to your configured AI providers.</p>
</section>
<h2>Google Play build</h2>
<p>The Google Play build ships Hermes Bridge Core: chat, voice, standard inbound files, terminal and TUI relay, notification companion, Relay media enhancements, relay sessions, and status. It does not include AccessibilityService-based Device Control and cannot read your screen, tap, type, swipe, capture screenshots, send SMS, place calls, access contacts or location, or perform unattended phone control.</p>
<p>The Google Play build ships Hermes Bridge Core: chat, voice, standard inbound files, terminal and TUI relay, notification companion, Relay media enhancements, relay sessions, and status. It does not include AccessibilityService or MediaProjection Device Control and cannot tap, type, swipe, send SMS, place calls, access contacts or location, or perform unattended phone control. If Hermes-Relay is selected as Android’s Digital Assistant, a compatible explicit unlocked assistant-button invocation may include bounded visible text and an available screenshot in one Standard voice turn sent to the configured Hermes server and AI provider. Ordinary wake and keyguard invocations do not request screen context.</p>
<p>The sideload build is a separate distribution track for users who intentionally install Device Control outside Google Play.</p>
<h2>Data storage</h2>
@@ -51,6 +51,7 @@
<tr><td>API keys and relay session tokens</td><td>AES-256-GCM encryption via Android Keystore</td></tr>
<tr><td>Performance counters</td><td>Android DataStore (local only)</td></tr>
<tr><td>Notification trigger rules and activity log</td><td>Android DataStore (local only)</td></tr>
<tr><td>Pending Android Assistant context</td><td>App-private cache until one turn is accepted, cancellation/session exit, or one-hour stale cleanup; failed preflight retains it for retry</td></tr>
</tbody>
</table>
<p>Chat messages are not cached on your device. They are loaded from your Hermes server on demand and exist only in memory while the app is running.</p>
@@ -59,6 +60,9 @@
<p>The app connects only to endpoints you configure: your Hermes Dashboard/Gateway for standard chat, management, voice, and inbound files; your optional API server for chat fallback; your relay server for terminal and TUI relay, Bridge Core status, explicit Relay media enhancements, notification companion, and session management; and your relay voice routes when you use enhanced Voice modes.</p>
<p>No connections are made to Google, Anthropic, or any other third-party service by the app. There is no telemetry, advertising, external crash reporting, DNS prefetching, or background network activity to hosted Hermes-Relay services. Your Hermes server may separately connect to AI providers according to its configuration; that server-side activity is outside the scope of this app.</p>
<h2>Voice Overlay</h2>
<p>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.</p>
<h2>Permissions</h2>
<table>
<thead><tr><th>Permission or access</th><th>Purpose</th><th>Required</th></tr></thead>
@@ -66,7 +70,9 @@
<tr><td>Internet</td><td>Connect to your Hermes servers</td><td>Yes</td></tr>
<tr><td>Network state</td><td>Detect connectivity for reconnect behavior</td><td>Yes</td></tr>
<tr><td>Camera</td><td>QR code scanning for server pairing</td><td>No</td></tr>
<tr><td>Microphone</td><td>Voice mode speech-to-text</td><td>No</td></tr>
<tr><td>Microphone</td><td>Voice, Voice Overlay and opt-in local wake detection</td><td>No</td></tr>
<tr><td>Display over other apps</td><td>User-started voice controls only</td><td>No</td></tr>
<tr><td>Android Digital Assistant role</td><td>Optional assistant invocation and bounded one-turn screen context</td><td>No</td></tr>
<tr><td>Notification access</td><td>Optional notification companion metadata forwarding to your paired relay</td><td>No</td></tr>
</tbody>
</table>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 81 KiB