The frontend screen
The Android screen that shows the Home Assistant frontend in a web view, its states, the JavaScript bridge, and the external bus.
The frontend screen shows the Home Assistant frontend inside a WebView, wrapped with native features: authentication, gestures, downloads, NFC, Matter and Thread, and media playback. It is the most complex screen in the app, and the reference implementation of the UI architecture. Its inputs come from everywhere at once: the user, the web view, the external bus, system callbacks, and timeouts. That is exactly the situation the pattern is made for. The code lives in the frontend/ package. The older webview/ package is the legacy implementation that is being removed.
| Role in the pattern | Frontend implementation |
|---|---|
| Navigation and events | frontendScreen(), FrontendEventHandler |
| View | FrontendScreen, FrontendScreenContent |
| ViewModel | FrontendViewModel |
| State | FrontendViewState |
| Events | FrontendEvent |
| Actions | WebViewAction |
| Prompts | SingleSlotQueue with FrontendDialog, PermissionRequest, FileChooserRequest |
| Handlers | FrontendMessageHandler, FrontendMatterThreadHandler, FrontendHttpAuthHandler, FrontendFileChooserHandler, and more |
| Managers | PermissionManager, FrontendDialogManager, FrontendUrlManager, FrontendDownloadManager, FrontendGestureManager, FrontendExoPlayerManager, and more |
| Repositories | FrontendExternalBusRepository, ServerSessionManager |
Their dependencies follow the layers of the pattern, and only point down:
flowchart TB
Nav["frontendScreen()"]
EventHandler["FrontendEventHandler"]
Screen["FrontendScreen<br/>holds the WebView"]
VM["FrontendViewModel"]
Handlers["<b>Handlers</b><br/>FrontendMessageHandler<br/>FrontendHttpAuthHandler<br/>FrontendMatterThreadHandler, ..."]
Managers["<b>Managers</b><br/>PermissionManager<br/>FrontendDialogManager<br/>FrontendUrlManager, ..."]
UseCases["<b>Use cases</b><br/>CheckLocalNetworkPermissionUseCase"]
Repos["<b>Repositories</b><br/>FrontendExternalBusRepository<br/>ServerSessionManager<br/>PrefsRepository, ..."]
Nav --> Screen & EventHandler
EventHandler -->|"collects events"| VM
Screen --> VM
VM --> Handlers & Managers & Repos
Handlers --> Managers & Repos
Managers --> UseCases & ReposAt runtime, results flow back up through the flows each layer exposes. Managers push pending state that the screen renders, handlers push results that the ViewModel reduces, and repositories emit typed messages that the handlers consume.
FrontendScreen always renders the web view at the bottom, and draws one overlay on top depending on the state. The web view stays mounted underneath, so it keeps its page when the overlay changes.
| State | Overlay |
|---|---|
LoadServer, Loading |
Loading indicator |
Content |
None, the frontend shows through |
SecurityLevelRequired |
The connection security level setup |
Insecure |
The insecure connection screen |
Error |
The connection error screen |
LoadServer is the entry point. It also blanks the web view, by loading about:blank, while the ViewModel resolves the URL to load. From there it moves to Loading, Insecure, SecurityLevelRequired, or Error. Loading moves to Content once the frontend connects over the external bus. Servers from 2026.8 also report when the frontend has finished rendering, and the loading indicator stays up until then. A timeout or load error moves to Error, and a retry goes back to LoadServer. Switching servers also re-enters LoadServer. One kind of error can't be retried: a FrontendConnectionError.Unrecoverable, like the system WebView failing to start. The ViewModel ignores any transition out of that Error state.
The main transitions between the states:
stateDiagram-v2
[*] --> LoadServer
LoadServer --> Loading: URL resolved
LoadServer --> Insecure: insecure URL<br/>blocked
LoadServer --> SecurityLevelRequired: security level<br/>not set
LoadServer --> Error: no server,<br/>session, or URL
Loading --> Content: frontend connected
Loading --> Error: timeout,<br/>load error
Content --> Loading: URL changed
Content --> Error: load error
Error --> Content: connected<br/>after a timeout
Error --> Loading: wait
Error --> LoadServer: retry
Insecure --> LoadServer: retry
Insecure --> SecurityLevelRequired: change<br/>security level
SecurityLevelRequired --> LoadServer: doneThe connection error screen is shared with onboarding, so it can't depend on FrontendViewModel. It depends on a small interface with only what it needs, FrontendConnectionErrorStateProvider:
interface FrontendConnectionErrorStateProvider {
val urlFlow: StateFlow<String?>
val errorFlow: StateFlow<FrontendConnectionError?>
val connectivityCheckState: StateFlow<ConnectivityCheckState>
fun runConnectivityChecks()
}FrontendConnectionErrorScreen is written against this interface. FrontendViewModel implements it, so the error overlay gets the ViewModel itself as its provider. Previews and tests use FrontendConnectionErrorStateProvider.noOp.
That is the general trick for reusing UI or logic across screens: define an interface for exactly what the reusable part needs, and let each ViewModel implement it. The dependency then points at the interface, not at one specific ViewModel.
FrontendScreen creates the web view through HAWebView, a reusable composable. It creates the platform WebView inside an AndroidView, applies the base settings, sends the back button to the web view history before the navigation host, and shows a placeholder in previews and screenshot tests. When creating the web view fails, it reports that, and the ViewModel turns it into the unrecoverable Error state.
On top of that, FrontendScreen adds what only the frontend needs: it attaches the two clients below, enables cookies (including third-party cookies, which some integrations rely on), and wires up downloads and the gesture listener.
The ViewModel creates the two clients of the web view:
HAWebViewClientcomes fromHAWebViewClientFactory. It handles TLS client certificates, maps load and TLS errors, reports finished page loads, surfaces HTTP basic auth challenges, and recovers when the renderer crashes.HAWebChromeClientcomes fromviewModel.createWebChromeClient(). It handles camera and microphone permission requests, JavaScriptconfirm()dialogs, the file chooser, and full screen video.
A ViewModel normally holds no platform UI types. These clients are the one accepted exception, because they are wired to ViewModel logic: error mapping, page load callbacks, HTTP auth, and permissions. It doesn't cost testability: the web view client comes from a factory that a test can fake, and the chrome client is only built when the screen asks for it. The reduction logic of the ViewModel still runs as a plain JVM unit test.
The frontend and the app talk over a JavaScript bridge. It carries two things:
- External authentication: the frontend asks the app for an access token instead of showing a login page.
- The external bus: messages in both directions, for example to open the native settings, scan a QR code, or start Assist.
The frontend side is documented in external authentication and the external bus.
FrontendJsBridge is the JavaScript interface registered in the web view. It parses calls from the frontend into typed messages and hands them to FrontendMessageHandler. FrontendExternalBusRepository owns the bus itself: it deserializes incoming JSON, tolerating message types it doesn't know, and turns outgoing messages into scripts.
Nothing below FrontendScreen touches the web view. Everything else talks through flows:
flowchart TB
JS["Home Assistant frontend<br/>in the WebView"]
Screen["FrontendScreen"]
VM["FrontendViewModel"]
Bridge["FrontendJsBridge"]
Handler["FrontendMessageHandler"]
Bus["FrontendExternalBusRepository"]
Session["ServerSessionManager"]
Screen -->|"loadUrl(),<br/>runs WebViewAction"| JS
Screen -->|"attachToWebView()"| Bridge
VM -->|"webViewActions"| Screen
VM -->|"creates"| Bridge
JS -->|"getExternalAuth,<br/>revokeExternalAuth,<br/>externalBus"| Bridge
Bridge -->|"FrontendJsHandler"| Handler
Handler -->|"messageResults(),<br/>webViewActions()"| VM
Handler -->|"getExternalAuth(),<br/>revokeExternalAuth()"| Session
Handler -->|"onMessageReceived(),<br/>send(), evaluateScript()"| Bus
Bus -->|"incomingMessages(),<br/>webViewActions()"| HandlerIn words:
- Frontend to app: the frontend calls the bridge, the handler passes the message to the repository, and the result flows back to the ViewModel as a typed event.
- App to frontend: a component sends a typed message to the repository, which wraps it in a
WebViewAction.EvaluateScript.FrontendScreenruns it in the web view. - Authentication: the frontend calls
getExternalAuthorrevokeExternalAuth, the handler asksServerSessionManager, and the callback script runs in the web view. The bridge checks the callback name before it passes the call on.
The action and event flows are SharedFlows with a small extra buffer, so a sender doesn't have to wait while the web view is still busy with an earlier command. They don't replay, though: a command sent while nothing collects the flow is dropped.
The bridge exists in two versions:
- V1 (
window.externalApp) usesaddJavascriptInterface. The frontend calls named methods directly. - V2 (
window.externalAppV2) usesWebViewCompat.addWebMessageListener, for Home Assistant 2026.4.2 and later. All messages go throughpostMessageas JSON with atype, with filtering on origin and frames for security.
The app uses V2 when the server is new enough and the device's WebView supports the WEB_MESSAGE_LISTENER feature. Otherwise, it falls back to V1.
The blocks of one screen session are @ViewModelScoped. That includes FrontendExternalBusRepository: the bus only exists for one web view, so buffered messages from one visit can't leak into the next. FrontendHandlerModule binds FrontendMessageHandler as both FrontendJsHandler and FrontendBusObserver in the ViewModelComponent, so both roles share one instance.
| What | Test type |
|---|---|
FrontendViewModel |
Unit tests with JUnit and Turbine |
| Managers and handlers | Unit tests, one concern at a time |
FrontendScreenContent |
Compose UI tests, per state |
FrontendEventHandler |
Compose UI tests, one per event |
FrontendScreen |
Screenshot tests, for looks only, no logic |