Hermes-Android Architecture

August 27, 2026 · View on GitHub

Goals

  • Keep Hermes WebUI as primary UX surface for parity and maintainability.
  • Treat the official Hermes Dashboard link as a WebUI-owned secondary surface.
  • Add native Android affordances around security, integration, and reliability.
  • Preserve clean boundaries so native enhancements can scale incrementally.

Layered design

  • core/: security policy primitives (UrlPolicy, UrlOrigins, and the context-specific WebTrustPolicy)
  • data/: local encrypted settings, persistence, and Hermes API client
  • domain/: intent parsing and validation rules
  • notification/: scoped WebUI notification bridge plus Android notification presentation/tap routing
  • background/: foreground services plus activity-facing lifecycle/service coordination
  • server/: startup preflight and multi-server validation/switch orchestration
  • update/: app-update checking, alerting, Play flow launch, and GitHub APK download handling
  • webview/: shared WebView configuration helpers
  • ui/: ViewModel state and Compose screens
  • MainActivity: Android platform boundary (WebView host, intents, activity contract, Custom Tabs dashboard handoff)

Coordinator map

Recent cleanup keeps MainActivity as the Android boundary while moving clustered responsibilities into small helpers:

  • HermesNotificationBridgeCoordinator: receives document-start WebUI notification bridge messages, tracks Android notification permission replies, and rejects untrusted origins.
  • HermesNotificationPresenter: renders Android-backed WebUI notifications and parses trusted notification-tap intents back into WebView navigation.
  • HermesAppUpdateCoordinator: owns automatic/manual app-update checks, channel-specific update notifications, Play update launch, and GitHub APK download intents.
  • HermesServerProfileCoordinator: owns startup preflight plus add/edit/delete/switch validation and confirmation flows for Hermes server profiles.
  • HermesForegroundServiceCoordinator: owns reconnect/debug foreground-service lifecycle sync so onResume, onStop, and settings toggles do not each duplicate service promotion/teardown rules.
  • HermesWebViewConfigurator: centralizes shared main/popup WebView hardening and settings setup.

Runtime flow

  1. App starts and loads encrypted WebUI settings (SettingsRepository). The bundled dashboard origin default is blank so WebUI owns dashboard auto-detect and persistence.
  2. WebView boots with hardened configuration, default HTTP cache behavior, DOM storage, and service-worker cache settings for WebUI-managed assets.
  3. The Compose root fills the full window background, then applies WindowInsets.safeDrawing around the WebView shell and native snackbar so Android 15 edge-to-edge enforcement does not put content under status or navigation bars.
  4. Android WebView compatibility shims stay scoped to Hermes WebUI. Android keeps native long-click enabled without a consuming listener so message text remains selectable while Hermes WebUI's own touch timer drives its action menus. A document-start hybrid viewport polyfill fixes the Android WebView bug where CSS viewport units (vh, dvh, svh, lvh) evaluate to 0px instead of actual dimensions before WebUI boot code measures the page: the polyfill injects CSS custom properties (--vh, --dvh) with stable layout-viewport values plus separate visual-viewport height/top values for keyboard-constrained prompts, applies baseline CSS for root/layout containers, and uses generic collapse detection to find and repair elements that appear collapsed due to the viewport-unit bug. Generic repair changes only height constraints, preserving the element's original overflow contract so it neither creates a new clipping container nor loses an existing inline overflow declaration; visible repairs retain their measured constraints until hidden because repaired geometry cannot prove the underlying viewport-unit rule recovered. Approval and Clarify surfaces are excluded from generic repair and instead shift above the visual-viewport bottom before fitting the measured space below the titlebar/visual-viewport top, dropping below WebUI's preferred 180px floor when necessary and scrolling internally. Runtime application remains as a fallback for already-loaded content. Android also injects a document-start microphone fallback so WebUI voice input uses its MediaRecorder path instead of Web Speech API. Clarify keyboard compatibility keys its one-shot suppression to WebUI's current Clarify ID/signature (with a DOM fallback), so replacing a visible card starts a new focus contract: only that request's first automatic #clarifyInput focus is suppressed, while direct Android touches, hardware Tab navigation, the Other action, and later validation/error refocus remain available. Unrelated editable dialogs are never inspected or mutated. Attached-WebView instrumentation executes these focus, real-touch, geometry, and overflow contracts in required PR and release gates. The official dashboard is not rendered in an app WebView.
  5. On the Hermes WebUI route, Android does not write /api/dashboard/config or overwrite WebUI's Official Hermes Dashboard setting. WebUI owns dashboard auto-detect, persistence, rendering, and behavior for the dashboard link in its rail/sidebar.
  6. Official Hermes Dashboard links are treated as secondary browser surfaces. When Android has an explicitly configured local dashboard origin, it handles matching WebView new-window requests and dashboard-origin navigations by launching a Chrome Custom Tab with title/share UI minimized, instead of replacing the primary Hermes WebUI WebView. OAuth/OIDC callbacks are handled before this dashboard matching so a configured dashboard origin cannot steal /auth/callback from the primary Hermes WebView.
  7. Hermes WebUI OAuth/OIDC sign-in stays inside Android once a trusted authorization code flow starts. Android parses the authorization request redirect_uri, keeps popup or top-level HTTP/HTTPS provider redirects in-app only when the declared callback returns to the configured Hermes WebUI origin, and loads the verified callback endpoint back into the primary WebView when it returns with a code or error. Scheme compatibility is asymmetric: an HTTP origin may upgrade to HTTPS for public-IP/proxy deployments, but an HTTPS origin and declared callback can never downgrade to HTTP. A separate bounded return state keeps the callback and all same-origin redirects in the primary WebView until a finished page proves it is Hermes WebUI through its bundle or shell DOM marker, covering popup callbacks, onPageStarted callback ordering, 302 chains, JavaScript redirects, and same-origin interstitials without allowing a dashboard Custom Tab match to steal the return. Callback URLs are never persisted as startup state. During the provider flow window, Android temporarily enables third-party cookies and restores the stricter default once the provider flow ends or times out.
  8. Dashboard-origin pages are not saved as the app startup URL. For Hermes WebUI routes, Android persists both full page loads and client-side history updates (WebView visited-history callbacks). On the configured WebUI origin, Android also installs a narrow workspace-button recovery shim: when the workspace toggle is tapped from a blank root route and the panel still remains hidden, Android redirects to the last known trusted in-app session route so WebUI can rehydrate workspace state. If a dashboard URL is ever the last observed WebView URL, the next launch falls back to the configured Hermes WebUI URL.
  9. UrlPolicy enforces supported web schemes (http/https) + domain allowlist for every navigation.
  10. WebView microphone permission requests are accepted only for audio capture on trusted Hermes pages. Android prefers explicit allowlisted HTTP/HTTPS origins, normalizes edge-case origin formatting, and allows null/opaque-origin requests only when the active main-frame URL is the configured Hermes WebUI route. Android runtime microphone permission is requested on demand before granting the WebView request.
  11. WebUI browser notifications are backed by Android notifications. HermesNotificationBridgeCoordinator injects and services the scoped Notification/ServiceWorkerRegistration.showNotification compatibility facade for the configured Hermes WebUI origin, requests POST_NOTIFICATIONS on Android 13+, and HermesNotificationPresenter publishes through the native notification channel while accepting tap routes only when they target an allowlisted Hermes WebUI URL.
  12. MainViewModel drives loading/error/offline/share UI state. After the first successful page render, reloads and navigations keep the existing WebView content visible so cached pages do not flicker behind a full-screen native loading veil.
  13. Share intents are parsed in domain, staged in ViewModel, then pushed into WebView flow.
  14. Settings updates rewrite encrypted preferences and reload trusted hosts. Before Android saves or switches to a Hermes WebUI URL, it preflights {serverUrl}/api/status and rejects unreachable targets, obvious scheme mismatches (for example https:// pointed at an HTTP-only server), setup-mode responses, and non-Hermes pages so the app does not persist a server that cannot boot the WebView successfully. If /api/status returns 401 or 403, Android probes the root page and accepts the server only when the root response fingerprints as Hermes. On startup for an already configured server, Android also allows reachable 401/403 status responses to continue into the WebView so the page's normal sign-in/session recovery can run instead of trapping the user in settings. Saved server rows run this health check before switching: reachable or auth-challenged servers ask for confirmation, while setup/offline/non-Hermes results leave the current server active and offer browser recovery. The Android setup sheet only asks for the Hermes WebUI URL; the dashboard origin is visible and editable in WebUI Settings > System. When the optional VPN guard is enabled, startup/save/switch loads for Tailscale-addressed servers require an active Android VPN transport (detected from available network capabilities). Startup checks the VPN guard before the server readiness preflight so an intentionally disconnected Tailscale server launches the VPN app instead of failing validation first. The app first sends Tailscale's exported com.tailscale.ipn.CONNECT_VPN broadcast as a best-effort auto-connect hint, then tries launching Tailscale, then the default/custom VPN launch package selected from the searchable installed-app picker, then Android VPN settings before proceeding. While blocked, Hermes keeps a short-timeout /api/status check running every second across the Tailscale app handoff and automatically resumes the pending load only when both VPN transport and the server are ready. Android package visibility declares Tailscale plus launcher apps so the picker can discover installed VPN clients on Android 11+.
  15. When Tailscale recovery begins, Hermes first sends Tailscale's connect broadcast and keeps its own activity visible. It probes every second, dismisses the native Settings recovery surface, and loads the pending trusted route as soon as both VPN and server are ready. Only if no VPN transport is available after ten seconds does it open Tailscale (or the configured VPN app/settings) as a fallback; recovery remains automatic. If that fallback app is foregrounded when recovery completes, Android shows a notification that returns to the ready Hermes activity because modern Android blocks unreliable background activity launches. Pull-to-refresh, Retry, reconnect signals, and taps on the current server use the same VPN-aware loader; a current-server tap does not clear its WebView session.
  16. On WebView load failure, MainViewModel probes {serverUrl}/api/status to distinguish "server is down" from a transient content/navigation error. HTTP 401 or 403 still proves the configured server is reachable for reconnect purposes, because WebView authentication may be handled by cookies or the page sign-in flow. This refines the isOffline state and the copy shown to the user in WebShell.
  17. After a quick app switch, Android treats the first resumed load error more leniently when the WebView had already rendered trusted content: MainViewModel briefly keeps the existing page visible, starts the bounded reconnect probe, and delays showing the native full-screen error state for a short grace window. When the native SSE transport toggle is enabled, Android first attempts the lightweight Hermes WebUI reconnect stream at /api/sessions/events; if that handshake fails, it falls back to the user-configurable fixed polling interval from native settings (1-10 s). While the app is backgrounded and the background activity toggle is enabled, HermesReconnectService can also subscribe to the current session's authenticated /api/session/stream feed when it can derive a trusted session route plus WebView cookies, and uses activity_summary, approval_required, server_turn_started, completion, and failure events to refresh the ongoing notification text. When an approval_required event includes an approval_id, Android adds tray actions for the preferred allow scope plus deny, validates the current queue head via /api/approval/pending, and only then submits /api/approval/respond; stale, mismatched, duplicate, or no-longer-offered choices fail closed. Lock-screen privacy stays user-controlled: Android publishes the live body with a generic public version by default, and only exposes full text on the lock screen when the user explicitly enables that preview toggle. Android still probes /api/sessions/gateway/stream?probe=1 to detect whether the fuller gateway/session SSE contract is enabled, but reconnect no longer depends on that richer feed being present. All SSE capability and reconnect-liveness probes attach the WebView session cookie for the configured origin (explicit parameter, with a guarded CookieManager fallback) so password/OIDC-protected servers are probed with the same credentials as /api/session/stream, and HTTP 401/403 probe responses classify as authentication-required/capability-unverified (SseCapability.AUTH_REQUIRED, transport preserved) instead of NONE disabling the SSE transport. If Hermes reconnects quickly, the app reloads in place with less visible flash; if recovery fails, the normal error UI appears as soon as that grace window expires. If the activity backgrounds while that bounded reconnect is already running, HermesForegroundServiceCoordinator promotes it into HermesReconnectService so the retry loop is not canceled immediately on onStop; the same coordinator also manages debug-log foreground-service sync and keeps those service lifecycle rules out of MainActivity.
  18. Native settings include an opt-in troubleshooting debug logging mode. Android also maintains a small app-private diagnostic breadcrumb file for startup validation, server health checks, server switching decisions, and main-frame WebView failures. Breadcrumbs store safe metadata only: timestamps, app version, sanitized origin/path, status categories, HTTP status codes, and decisions; they never store cookies, auth headers, query strings, response bodies, or full token-bearing URLs. When debug logging is enabled, HermesDebugLoggingService starts as a foreground service with a persistent notification and one-tap Stop action, captures logcat output into app-private files under files/debug-logs/, and includes recent diagnostic breadcrumbs in the exported log header.
  19. Native app update alerts share the existing Hermes updates notification channel. The feature has separate settings for update alerts and automatic checks: automatic checks run on each app open while enabled and can be disabled without removing the manual Settings check. Build-time fields select the provider: Google Play release builds query Play Core in-app update availability and notification taps launch the Play immediate update flow, GitHub APK builds query the latest GitHub Release, parse the *-github.apk asset plus release notes, and trigger direct APK download through Android DownloadManager. On GitHub update downloads, Hermes preflights unknown-sources install permission at download start so users can grant it while the APK downloads. Settings now exposes a stateful primary action (Check -> Download -> Install) and keeps install-ready state after download completion instead of auto-launching the installer while foregrounded; background completion still posts an install-ready notification that returns to Hermes for install handoff (with unknown-sources permission handoff when required). After installer handoff, Android removes the staged GitHub APK on the next app launch to keep app-private downloads clean. Debug builds expose no update provider.
  20. Enabling background foreground-service features from native settings (background activity notifications or debug logging) requests Android POST_NOTIFICATIONS on Android 13+ when needed so users can see the required persistent status notifications.
  21. hermes://session/{id} deep links are handled in MainActivity.onNewIntent, navigating the WebView to {serverUrl}/{id} — the Hermes WebUI session route contract (see apps/desktop/src/app/routes.ts: sessionRoute() in hermes-agent).
  22. An "Application Settings" entry point is injected into the Hermes WebUI sidebar immediately after the WebUI Settings item via a document-start WebView shim, with Help used only as a fallback anchor if Settings cannot be found. This shim clones the target menu item structure, strips routing attributes, injects a custom phone-outline SVG icon, and binds the click handler to the hermes://app/settings deep link, which triggers MainViewModel.openSettings() to display the native full-page settings screen overlay (SettingsScreen). Android exports the same hermes://app/settings deep link as a recovery route so native settings can be opened from ADB or an external link if WebUI is stuck on a bad route.

Build and release flow

  • Local signed release builds load upload-key credentials from an untracked repo-root keystore.properties file.
  • CI signed release builds load the same values from ANDROID_KEYSTORE_* environment variables, with the keystore file decoded from the ANDROID_KEYSTORE_BASE64 GitHub Actions secret.
  • The signed release workflow uses Node 24-compatible GitHub Actions majors to avoid deprecated Node 20 runner execution.
  • :app:assembleRelease, :app:stageGithubReleaseApk, and :app:stageReleaseArtifacts fail fast when signing credentials are missing or the keystore file path is invalid, preventing unsigned distribution artifacts from being staged as release-ready output.
  • :app:stageGithubReleaseApk builds the signed github build type and copies it into the ignored root build/release/ output folder as hermes-webui-v<version>-github.apk so generated binaries do not live beside source files.
  • The release workflow (.github/workflows/1-orchestration-release.yml) builds both signed artifacts in one run: the github APK and the official Play AAB. The GitHub build type uses applicationIdSuffix = ".github" and versionNameSuffix = "-github", so it installs beside the Play build as com.hermeswebui.android.github and reports a channel-specific version such as 1.1.0-github.
  • Release-channel update checks are selected by generated BuildConfig fields. release uses the Play Core provider, github uses the GitHub Releases API plus release page URL, and debug uses none so local development builds do not prompt for production updates.
  • Release builds package native debug symbol tables (debugSymbolLevel = "SYMBOL_TABLE") so Google Play can symbolicate native crashes and ANRs from bundled native libraries without uploading fuller source-level native debug data.
  • Manual and tag-triggered orchestration runs build the reviewed appVersionName already checked into Git; the workflow never edits or pushes source. Gradle derives versionCode directly from semantic version (major*10000 + minor*100 + patch). Manual production runs are restricted to main, reject already-published versions, and tag-triggered runs require the tag to match versionName exactly.
  • After artifacts are uploaded, .github/workflows/1-orchestration-release.yml publishes both targets in the same run: GitHub (hermes-webui-v<version>-github.apk) and Play production (hermes-webui-v<version>.aab) with the configured Play service account.
  • The beta workflow .github/workflows/play-store-beta-manual.yml remains available for manual/open-testing runs and is no longer part of default orchestration.
  • The build job generates release metadata once from .github/release.yml categories and bundles it with both artifacts. GitHub notes preserve clickable PR links; Play's brief whatsnew-en-US keeps compact PR/issue URLs, caps output below the Play limit, and appends the in-app bug-report reminder. Retry publishers validate version, tag, commit, build-run SHA, exact artifact name, and bundled metadata before publishing.
  • CI's tools/check_markdown.py rejects rendering breaks, missing in-repository targets, and links whose resolved files escape the repository, including through symlinks. tools/detect_docs_only.py owns the docs-only Gradle-gate short-circuit and returns false for empty, unreadable, invalid, renamed, or mixed diffs.
  • Release workflows use concurrency groups so duplicate runs for the same ref or target version do not publish over each other.
  • The build and publish workflows validate that exactly one matching APK or AAB exists before upload or publication.
  • The publish workflows also support manual dispatch with the build run ID and artifact metadata so a failed GitHub or Play publish can be retried without rebuilding both release artifacts.
  • CI runs release-tool tests, JVM unit tests, Android Lint, and a debug build for every pull request and direct main push. Android source/build changes run the complete instrumentation suite on API 35 and 36 in those gates; release builds run the unfiltered API 36 suite again and verify APK/AAB signatures before artifacts are uploaded.

Security model

  • Trust boundary is the configured Hermes WebUI and dashboard URL host set.
  • WebTrustPolicy centralizes normalized WebUI/dashboard route checks plus the distinct notification-source, notification-target, and WebView-permission rules so those contexts cannot drift independently inside MainActivity.
  • In-app navigation remains inside trust boundary only.
  • Everything else is blocked or externalized.
  • The official dashboard is browser-rendered through Custom Tabs so Chrome handles dashboard compatibility, cookies, transport behavior, and same-origin navigation. OAuth/OIDC callback URLs for the configured Hermes WebUI origin bypass Custom Tabs and return directly to the primary WebView.
  • Microphone access requires Android RECORD_AUDIO plus MODIFY_AUDIO_SETTINGS and a trusted WebView context (allowlisted origin, with a null/opaque-origin fallback only while the active main frame is the configured Hermes WebUI route). Android grants only RESOURCE_AUDIO_CAPTURE; camera/video capture remains denied. Android also seeds WebUI's mic_force_mediarecorder localStorage flag at document start for the configured WebUI origin only, avoiding Web Speech API false-denied errors in Android WebView.
  • Local network access on Android 16/17 is requested only when the configured Hermes server URL points at an obvious LAN host such as a private IP, localhost, or a .local name. The app declares ACCESS_LOCAL_NETWORK, makes a best-effort pre-load request for those LAN URLs, and still retries after a real ERR_LOCAL_NETWORK_PERMISSION_MISSING WebView failure so OEM builds that expose no grant toggle do not hard-block startup before the first network attempt.
  • Hermes WebUI drives its own conversation long-press menus from a touch timer (e.g. static/sessions.js, ~400ms). Android keeps native long-click enabled without a consuming listener so text-selection handles remain available, and it does not synthesize contextmenu or interfere with touch events. Because Android WebView can evaluate CSS viewport units (vh, dvh, etc.) as 0px, menus capped with max-height: calc(100vh - 16px) collapse to ~2px and approval panels using min(60dvh, 420px) lose their actionable body. The hybrid viewport polyfill fixes this by injecting CSS custom properties (--vh, --dvh) with measured pixel values, applying baseline CSS for root/layout containers, and using generic collapse detection to find and repair ANY element that appears collapsed due to the viewport-unit bug—without needing explicit selectors. This eliminates the previous whack-a-mole approach of adding new CSS selectors each time a viewport-unit-based component was discovered broken. The shim keeps only horizontal overflow locked; vertical page scrolling remains available for expandable WebUI panels.
  • Browser notification access requires Android app notification permission and a trusted WebView context. The bridge uses AndroidX WebMessageListener rather than a secret-bearing JavaScript interface, rejects subframes and non-WebUI origins, limits payload handling to notification title/body/tag/target URL, and validates notification tap URLs against the same allowlist before loading them.
  • Client certificates support PKCS#12 (.pfx/.p12) files selected through Android's document picker. Requests are limited to allowlisted hosts; malformed, password-mismatched, keyless, or otherwise unreadable stores fail closed and cancel the WebView request.
  • WebAuthn support is enabled for app-hosted WebView authentication flows (WEB_AUTHENTICATION_SUPPORT_FOR_APP) when the underlying WebView feature is available, allowing passkey-capable identity providers to complete embedded sign-in.
  • Android cleartext traffic is permitted so configured HTTP deployments can load. App-level URL policy still limits trusted in-app navigation and downloads to the configured host allowlist.
  • WebView uses browser-managed HTTP and service-worker cache semantics. Android does not maintain a separate stale copy of Hermes WebUI HTML or authenticated API responses.
  • Sensitive app-side config is encrypted with Android Keystore-backed keys.

Extensibility points

  • Add more deep-link hosts/paths in the hermes:// scheme (see AndroidManifest.xml + handleDeepLink in MainActivity).
  • FCM push notification handlers can map server-originated events to allowlisted Hermes URLs/session IDs.
  • Add biometric gate before WebShell composable rendering.
  • Add advanced native settings in ui/settings + data/SettingsRepository.
  • Multi-server profile support (Issue #20, in progress): Native "Application Settings" entry point is injected into the WebUI sidebar after the WebUI Settings item and opens the native settings screen. Android also exports hermes://app/settings as a recovery route for stuck WebView states. Current implementation includes encrypted profile storage, profile CRUD, and active profile switching from native settings. Remaining hardening should continue to validate server switches against UrlPolicy, clear prior session/cookie state safely, and expand regression coverage for switching flows. This keeps multi-server support entirely native (app-owned) and does not modify WebUI UI/behavior.
  • Prefer WebUI-owned navigation/settings for dashboard links; do not write WebUI dashboard config from Android. Open explicitly configured dashboard origins through Custom Tabs rather than an app-owned dashboard WebView unless there is a tested reason to revisit the WebView path.
  • Extend HermesApiClient with authenticated calls (e.g. /api/sessions) once an API key storage strategy is decided.