diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendJsBridge.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendJsBridge.kt deleted file mode 100644 index 4e04773282c..00000000000 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendJsBridge.kt +++ /dev/null @@ -1,185 +0,0 @@ -package io.homeassistant.companion.android.frontend - -import android.webkit.JavascriptInterface -import android.webkit.WebView -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import timber.log.Timber - -/** - * Handler interface for processing messages from the Home Assistant frontend. - * - * The Home Assistant frontend communicates with the Android app through a JavaScript bridge. - * This interface defines the suspend functions that process those messages asynchronously. - * - * The frontend calls methods on the `externalApp` JavaScript interface, which are received - * by [FrontendJsBridge] and forwarded to this handler. - * - * @see FrontendJsBridge - * @see FrontendJsCallback - */ -interface FrontendJsHandler { - /** - * Called when the frontend requests an authentication token. - * - * The frontend calls this when it needs to authenticate API requests. - * The handler should retrieve or refresh the auth token and invoke the JavaScript - * callback specified in the payload. - * - * @param payload JSON string containing `callback` (JS function name) and `force` (refresh flag) - * @param serverId The server ID to authenticate against - */ - suspend fun getExternalAuth(payload: String, serverId: Int) - - /** - * Called when the frontend requests to revoke the authentication session. - * - * This is triggered when the user logs out from the frontend. - * The handler should clear the stored session and invoke the JavaScript callback. - * - * @param payload JSON string containing the `callback` function name to invoke - * @param serverId The server ID whose session should be revoked - */ - suspend fun revokeExternalAuth(payload: String, serverId: Int) - - /** - * Called when the frontend sends a message through the external bus. - * - * The external bus is the primary communication channel between the frontend and native app. - * Messages include commands like haptic feedback, theme changes, sensor registration, etc. - * - * @param message JSON string containing the bus message with `type` and optional `payload` - */ - suspend fun externalBus(message: String) - - /** - * Called when the frontend theme changes. - * - * @deprecated Kept for backwards compatibility with older frontend versions. - * Theme changes are now communicated through [externalBus]. - */ - suspend fun onHomeAssistantSetTheme() {} -} - -/** - * Synchronous callback interface for JavaScript calls from the Home Assistant frontend. - * - * This interface defines the contract that JavaScript can call via `@JavascriptInterface`. - * Methods must be synchronous (non-suspend) because JavaScript bridge calls cannot be suspended. - * - * Implemented by [FrontendJsBridge] which launches coroutines to bridge to the - * suspend [FrontendJsHandler]. - * - * @see FrontendJsBridge - * @see FrontendJsHandler - */ -interface FrontendJsCallback { - /** - * Called when the frontend requests an authentication token. - */ - fun getExternalAuth(payload: String) - - /** - * Called when the frontend requests to revoke the authentication token. - */ - fun revokeExternalAuth(payload: String) - - /** - * Called when the frontend sends an external bus message. - */ - fun externalBus(message: String) - - /** - * Called when the frontend theme changes (deprecated, for backwards compatibility). - */ - fun onHomeAssistantSetTheme() {} - - /** - * Attaches this callback interface to a WebView. - * - * This registers the JavaScript interface so the frontend can call native methods. - * Any previously attached interface is removed first to prevent duplicates. - */ - fun attachToWebView(webView: WebView) -} - -/** - * JavaScript bridge that connects the Home Assistant frontend WebView with native Android code. - * - * This class is registered with the WebView under the name `externalApp`, allowing the - * Home Assistant frontend to call methods like: - * ```javascript - * window.externalApp.getExternalAuth('{"callback":"authCallback","force":false}') - * window.externalApp.externalBus('{"type":"config/get"}') - * ``` - * - * Each method is annotated with [JavascriptInterface] and launches a coroutine to call - * the corresponding suspend method on [FrontendJsHandler]. - * - * @param handler Handler that processes the JavaScript callbacks asynchronously - * @param serverIdProvider Provides the current server ID for authentication operations - * @param scope Coroutine scope for launching async operations from synchronous JS calls - */ -class FrontendJsBridge( - private val handler: FrontendJsHandler, - private val serverIdProvider: () -> Int, - private val scope: CoroutineScope, -) : FrontendJsCallback { - - @JavascriptInterface - override fun getExternalAuth(payload: String) { - scope.launch { handler.getExternalAuth(payload, serverIdProvider()) } - } - - @JavascriptInterface - override fun revokeExternalAuth(payload: String) { - scope.launch { handler.revokeExternalAuth(payload, serverIdProvider()) } - } - - @JavascriptInterface - override fun externalBus(message: String) { - scope.launch { handler.externalBus(message) } - } - - @JavascriptInterface - override fun onHomeAssistantSetTheme() { - scope.launch { handler.onHomeAssistantSetTheme() } - } - - override fun attachToWebView(webView: WebView) { - with(webView) { - removeJavascriptInterface(INTERFACE_NAME) - addJavascriptInterface(this@FrontendJsBridge, INTERFACE_NAME) - Timber.d("JavaScript interface attached") - } - } - - companion object { - /** - * The name used to attach this interface to the WebView's JavaScript context. - * - * This name is known by the frontend. No changes can be made without proper discussion - * and planning for backward compatibility. - */ - private const val INTERFACE_NAME = "externalApp" - - /** - * A no-op implementation for use in tests and previews. - * - * All methods are empty stubs that do nothing when called. - */ - val noOp = object : FrontendJsCallback { - override fun getExternalAuth(payload: String) { - } - - override fun revokeExternalAuth(payload: String) { - } - - override fun externalBus(message: String) { - } - - override fun attachToWebView(webView: WebView) { - } - } - } -} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt index f4928a39a33..ca218bd92ae 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt @@ -48,6 +48,8 @@ import io.homeassistant.companion.android.frontend.error.FrontendConnectionError import io.homeassistant.companion.android.frontend.externalbus.WebViewScript import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType import io.homeassistant.companion.android.frontend.haptic.HapticFeedbackPerformer +import io.homeassistant.companion.android.frontend.js.FrontendJsBridge +import io.homeassistant.companion.android.frontend.js.FrontendJsCallback import io.homeassistant.companion.android.frontend.permissions.NotificationPermissionPrompt import io.homeassistant.companion.android.frontend.permissions.PendingWebViewPermissionRequest import io.homeassistant.companion.android.frontend.permissions.WebViewPermissionEffect @@ -167,6 +169,7 @@ internal fun FrontendScreenContent( WebViewEffects( webView = webView, url = viewState.url, + frontendJsCallback = frontendJsCallback, scriptsToEvaluate = scriptsToEvaluate, hapticEvents = hapticEvents, ) @@ -183,7 +186,6 @@ internal fun FrontendScreenContent( onWebViewCreated = { webView = it }, webViewClient = webViewClient, webChromeClient = webChromeClient, - frontendJsCallback = frontendJsCallback, contentState = viewState as? FrontendViewState.Content, onWebViewCreationFailed = onWebViewCreationFailed, ) @@ -371,7 +373,6 @@ private fun SafeHAWebView( onBackClick: () -> Unit, onWebViewCreated: (WebView) -> Unit, webViewClient: WebViewClient, - frontendJsCallback: FrontendJsCallback, contentState: FrontendViewState.Content?, onWebViewCreationFailed: (Throwable) -> Unit, webChromeClient: WebChromeClient? = null, @@ -411,9 +412,6 @@ private fun SafeHAWebView( .background(Color.Transparent), configure = { onWebViewCreated(this) - // Injecting the javascript interface should happen as early as possible in the process - // even before loading the server URL to not miss any events from the frontend. - frontendJsCallback.attachToWebView(this) this.webViewClient = webViewClient webChromeClient?.let { this.webChromeClient = it } }, @@ -455,11 +453,13 @@ private fun Color.Overlay(modifier: Modifier = Modifier) { private fun WebViewEffects( webView: WebView?, url: String, + frontendJsCallback: FrontendJsCallback, scriptsToEvaluate: Flow, hapticEvents: Flow, ) { if (webView != null) { LaunchedEffect(webView, url) { + frontendJsCallback.attachToWebView(webView) Timber.v("Load url ${sensitive(url)}") webView.loadUrl(url) } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt index b51df39e16d..5f7170643ed 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt @@ -13,8 +13,11 @@ import io.homeassistant.companion.android.frontend.error.FrontendConnectionError import io.homeassistant.companion.android.frontend.error.FrontendConnectionErrorStateProvider import io.homeassistant.companion.android.frontend.externalbus.WebViewScript import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType +import io.homeassistant.companion.android.frontend.handler.FrontendBusObserver import io.homeassistant.companion.android.frontend.handler.FrontendHandlerEvent -import io.homeassistant.companion.android.frontend.handler.FrontendMessageHandler +import io.homeassistant.companion.android.frontend.js.BridgeState +import io.homeassistant.companion.android.frontend.js.FrontendJsBridgeFactory +import io.homeassistant.companion.android.frontend.js.FrontendJsCallback import io.homeassistant.companion.android.frontend.navigation.FrontendNavigationEvent import io.homeassistant.companion.android.frontend.navigation.FrontendRoute import io.homeassistant.companion.android.frontend.permissions.PermissionManager @@ -61,10 +64,11 @@ internal class FrontendViewModel @VisibleForTesting constructor( initialServerId: Int, initialPath: String?, webViewClientFactory: HAWebViewClientFactory, - private val frontendMessageHandler: FrontendMessageHandler, + private val frontendBusObserver: FrontendBusObserver, private val urlManager: FrontendUrlManager, private val connectivityCheckRepository: ConnectivityCheckRepository, private val permissionManager: PermissionManager, + private val frontendJsBridgeFactory: FrontendJsBridgeFactory, ) : ViewModel(), FrontendConnectionErrorStateProvider { @@ -72,18 +76,20 @@ internal class FrontendViewModel @VisibleForTesting constructor( constructor( savedStateHandle: SavedStateHandle, webViewClientFactory: HAWebViewClientFactory, - frontendMessageHandler: FrontendMessageHandler, + frontendBusObserver: FrontendBusObserver, urlManager: FrontendUrlManager, connectivityCheckRepository: ConnectivityCheckRepository, permissionManager: PermissionManager, + frontendJsBridgeFactory: FrontendJsBridgeFactory, ) : this( initialServerId = savedStateHandle.toRoute().serverId, initialPath = savedStateHandle.toRoute().path, webViewClientFactory = webViewClientFactory, - frontendMessageHandler = frontendMessageHandler, + frontendBusObserver = frontendBusObserver, urlManager = urlManager, connectivityCheckRepository = connectivityCheckRepository, permissionManager = permissionManager, + frontendJsBridgeFactory = frontendJsBridgeFactory, ) /** @@ -147,23 +153,21 @@ internal class FrontendViewModel @VisibleForTesting constructor( .stateIn(viewModelScope, SharingStarted.Eagerly, (_viewState.value as? FrontendViewState.Error)?.error) /** Flow of scripts to be evaluated in the WebView. */ - val scriptsToEvaluate: Flow = frontendMessageHandler.scriptsToEvaluate() + val scriptsToEvaluate: Flow = frontendBusObserver.scriptsToEvaluate() /** * JavaScript bridge for communication between the WebView and native code. * - * Must be attached to the WebView via [FrontendJsBridge.attachToWebView]. + * Must be attached to the WebView via [io.homeassistant.companion.android.frontend.js.FrontendJsBridge.attachToWebView]. */ - val frontendJsCallback: FrontendJsCallback = FrontendJsBridge( - handler = frontendMessageHandler, - serverIdProvider = { viewState.value.serverId }, + val frontendJsCallback: FrontendJsCallback = frontendJsBridgeFactory.create( scope = viewModelScope, + stateProvider = { BridgeState(serverId = viewState.value.serverId, url = viewState.value.url) }, ) val webViewClient: HAWebViewClient = webViewClientFactory.create( currentUrlFlow = urlFlow, onFrontendError = ::onError, - frontendJsCallback = frontendJsCallback, onCrash = ::onRetry, ) @@ -200,7 +204,7 @@ internal class FrontendViewModel @VisibleForTesting constructor( } viewModelScope.launch { - frontendMessageHandler.messageResults().collect { result -> + frontendBusObserver.messageResults().collect { result -> handleMessageResult(result) } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/FrontendExternalBusRepository.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/FrontendExternalBusRepository.kt index 3dce4408917..5b0bdecbac4 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/FrontendExternalBusRepository.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/FrontendExternalBusRepository.kt @@ -4,6 +4,7 @@ import io.homeassistant.companion.android.frontend.externalbus.incoming.Incoming import io.homeassistant.companion.android.frontend.externalbus.outgoing.OutgoingExternalBusMessage import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.json.JsonElement /** * Represents a JavaScript script to be evaluated in the WebView. @@ -59,7 +60,7 @@ interface FrontendExternalBusRepository { * * The message is deserialized and emitted to subscribers of [incomingMessages]. * - * @param messageJson The raw JSON string from the frontend + * @param messageJson The JSON message from the frontend */ - suspend fun onMessageReceived(messageJson: String) + suspend fun onMessageReceived(messageJson: JsonElement) } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/FrontendExternalBusRepositoryImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/FrontendExternalBusRepositoryImpl.kt index a56520a6b5b..2dfa1e4a8ef 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/FrontendExternalBusRepositoryImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/FrontendExternalBusRepositoryImpl.kt @@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.modules.plus import timber.log.Timber @@ -65,22 +67,20 @@ class FrontendExternalBusRepositoryImpl @Inject constructor() : FrontendExternal override fun incomingMessages(): Flow = incomingFlow.asSharedFlow() - override suspend fun onMessageReceived(messageJson: String) { + override suspend fun onMessageReceived(messageJson: JsonElement) { val message = deserializeMessage(messageJson) if (message != null) { incomingFlow.emit(message) } } - private fun deserializeMessage(json: String): IncomingExternalBusMessage? { - if (json.isBlank()) return null - + private fun deserializeMessage(json: JsonElement): IncomingExternalBusMessage? { return runCatching { - frontendExternalBusJson.decodeFromString(json) + frontendExternalBusJson.decodeFromJsonElement(json) }.onFailure { error -> Timber.w( error, - "Failed to deserialize external bus message: ${sensitive(json)}", + "Failed to deserialize external bus message: ${sensitive { json.toString() }}", ) }.getOrNull() } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessage.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessage.kt index d3913f1e047..44790e1f22b 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessage.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessage.kt @@ -35,10 +35,10 @@ sealed interface IncomingExternalBusMessage { * Unknown types are deserialized as [UnknownIncomingMessage] instead of throwing an exception. */ internal val serializersModule = SerializersModule { - polymorphicDefaultDeserializer(IncomingExternalBusMessage::class) { + polymorphicDefaultDeserializer(IncomingExternalBusMessage::class) { className -> object : UnknownJsonContentDeserializer() { override val builder = UnknownJsonContentBuilder { content -> - UnknownIncomingMessage(content) + UnknownIncomingMessage(className, content) } } } @@ -55,7 +55,7 @@ sealed interface IncomingExternalBusMessage { * * @property content The raw JSON content of the unknown message */ -data class UnknownIncomingMessage(override val content: JsonElement) : +data class UnknownIncomingMessage(override val discriminator: String?, override val content: JsonElement) : IncomingExternalBusMessage, UnknownJsonContent { override val id: Int? = null diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendBusObserver.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendBusObserver.kt new file mode 100644 index 00000000000..4a9795588c1 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendBusObserver.kt @@ -0,0 +1,26 @@ +package io.homeassistant.companion.android.frontend.handler + +import io.homeassistant.companion.android.frontend.externalbus.WebViewScript +import kotlinx.coroutines.flow.Flow + +/** + * Observes the frontend external bus for incoming events and scripts to evaluate. + * + * This interface exposes the ViewModel-facing API of [FrontendMessageHandler], + * separating it from the bridge-facing [io.homeassistant.companion.android.frontend.js.FrontendJsHandler]. + */ +interface FrontendBusObserver { + + /** + * Flow of events from incoming external bus messages and authentication results. + */ + fun messageResults(): Flow + + /** + * Returns a flow of scripts to evaluate in the WebView. + * + * The WebView should collect this flow and call `evaluateJavascript` for each script, + * then complete the deferred result with the evaluation output. + */ + fun scriptsToEvaluate(): Flow +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendHandlerModule.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendHandlerModule.kt new file mode 100644 index 00000000000..66c44ba30a6 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendHandlerModule.kt @@ -0,0 +1,18 @@ +package io.homeassistant.companion.android.frontend.handler + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import io.homeassistant.companion.android.frontend.js.FrontendJsHandler + +@Module +@InstallIn(ViewModelComponent::class) +abstract class FrontendHandlerModule { + + @Binds + abstract fun bindFrontendJsHandler(impl: FrontendMessageHandler): FrontendJsHandler + + @Binds + abstract fun bindFrontendBusObserver(impl: FrontendMessageHandler): FrontendBusObserver +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandler.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandler.kt index d9f6dceb31a..72e2e402302 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandler.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandler.kt @@ -3,10 +3,7 @@ package io.homeassistant.companion.android.frontend.handler import android.content.pm.PackageManager import dagger.hilt.android.scopes.ViewModelScoped import io.homeassistant.companion.android.common.util.AppVersionProvider -import io.homeassistant.companion.android.common.util.FailFast -import io.homeassistant.companion.android.common.util.kotlinJsonMapper import io.homeassistant.companion.android.di.qualifiers.IsAutomotive -import io.homeassistant.companion.android.frontend.FrontendJsHandler import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalBusRepository import io.homeassistant.companion.android.frontend.externalbus.WebViewScript import io.homeassistant.companion.android.frontend.externalbus.incoming.ConfigGetMessage @@ -20,17 +17,20 @@ import io.homeassistant.companion.android.frontend.externalbus.incoming.ThemeUpd import io.homeassistant.companion.android.frontend.externalbus.incoming.UnknownIncomingMessage import io.homeassistant.companion.android.frontend.externalbus.outgoing.ConfigResult import io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage +import io.homeassistant.companion.android.frontend.js.FrontendJsHandler import io.homeassistant.companion.android.frontend.session.AuthPayload import io.homeassistant.companion.android.frontend.session.ExternalAuthResult import io.homeassistant.companion.android.frontend.session.RevokeAuthResult import io.homeassistant.companion.android.frontend.session.ServerSessionManager import io.homeassistant.companion.android.matter.MatterManager import io.homeassistant.companion.android.thread.ThreadManager +import io.homeassistant.companion.android.util.sensitive import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge +import kotlinx.serialization.json.JsonElement import timber.log.Timber /** @@ -56,17 +56,18 @@ class FrontendMessageHandler @Inject constructor( private val appVersionProvider: AppVersionProvider, private val sessionManager: ServerSessionManager, @param:IsAutomotive private val isAutomotive: Boolean, -) : FrontendJsHandler { +) : FrontendJsHandler, + FrontendBusObserver { private val authResultsFlow = MutableSharedFlow(extraBufferCapacity = 1) /** - * Called by the JavaScript interface when the frontend requests authentication. + * Called when the frontend requests authentication. + * + * The bridge has already parsed and validated the callback name before calling this. */ - override suspend fun getExternalAuth(payload: String, serverId: Int) { + override suspend fun getExternalAuth(authPayload: AuthPayload, serverId: Int) { Timber.d("getExternalAuth called") - val authPayload = parseAuthPayload(payload) ?: return - when (val result = sessionManager.getExternalAuth(serverId, authPayload)) { is ExternalAuthResult.Success -> { evaluateScript(result.callbackScript) @@ -79,20 +80,8 @@ class FrontendMessageHandler @Inject constructor( } } - private fun parseAuthPayload(json: String): AuthPayload? { - return FailFast.failOnCatch( - message = { "Failed to parse auth payload" }, - fallback = null, - ) { kotlinJsonMapper.decodeFromString(json) } - } - - /** - * Called by the JavaScript interface when the frontend revokes authentication. - */ - override suspend fun revokeExternalAuth(payload: String, serverId: Int) { + override suspend fun revokeExternalAuth(authPayload: AuthPayload, serverId: Int) { Timber.d("revokeExternalAuth called") - val authPayload = parseAuthPayload(payload) ?: return - when (val result = sessionManager.revokeExternalAuth(serverId, authPayload)) { is RevokeAuthResult.Success -> { evaluateScript(result.callbackScript) @@ -104,45 +93,28 @@ class FrontendMessageHandler @Inject constructor( } } - /** - * Called by the JavaScript interface when an external bus message is received. - */ - override suspend fun externalBus(message: String) { - Timber.v("externalBus: $message") + override suspend fun externalBus(message: JsonElement) { + Timber.v("External bus message received: ${sensitive { message.toString() }}") externalBusRepository.onMessageReceived(message) } /** - * Flow of events from incoming external bus messages and authentication results. - * * Merges deserialized external bus messages with auth-related events into a single * stream of [FrontendHandlerEvent]. */ - fun messageResults(): Flow { + override fun messageResults(): Flow { val incomingResults = externalBusRepository.incomingMessages().map { message -> handleMessage(message) } return merge(incomingResults, authResultsFlow) } - /** - * Evaluate script in WebView. - * - * @param script The JavaScript code to evaluate - * @return The evaluation result from the WebView, or null if the script returns no value - */ - suspend fun evaluateScript(script: String): String? { + override fun scriptsToEvaluate(): Flow = externalBusRepository.scriptsToEvaluate() + + private suspend fun evaluateScript(script: String): String? { return externalBusRepository.evaluateScript(script) } - /** - * Expose scripts flow for WebView. - * - * The WebView should collect this flow and call `evaluateJavascript` for each script, - * then complete the deferred result with the evaluation output. - */ - fun scriptsToEvaluate(): Flow = externalBusRepository.scriptsToEvaluate() - private suspend fun handleMessage(message: IncomingExternalBusMessage): FrontendHandlerEvent { return when (message) { is ConnectionStatusMessage -> { diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/js/FrontendJsBridge.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/js/FrontendJsBridge.kt new file mode 100644 index 00000000000..5bab624dc4e --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/js/FrontendJsBridge.kt @@ -0,0 +1,341 @@ +package io.homeassistant.companion.android.frontend.js + +import android.annotation.SuppressLint +import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.annotation.VisibleForTesting +import androidx.core.net.toUri +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.common.util.FailFast +import io.homeassistant.companion.android.common.util.UnknownJsonContent +import io.homeassistant.companion.android.common.util.UnknownJsonContentBuilder +import io.homeassistant.companion.android.common.util.UnknownJsonContentDeserializer +import io.homeassistant.companion.android.database.server.Server +import io.homeassistant.companion.android.frontend.externalbus.frontendExternalBusJson +import io.homeassistant.companion.android.frontend.session.AuthPayload +import io.homeassistant.companion.android.util.hasSameOrigin +import io.homeassistant.companion.android.util.sensitive +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.plus +import timber.log.Timber + +/** + * Current state of the frontend. + * + * @property serverId The current server ID for authentication and version checks + * @property url The current URL for V2 origin validation, or `null` if not yet resolved + */ +data class BridgeState(val serverId: Int, val url: String?) + +/** + * Parsed message received from the Home Assistant frontend via the JavaScript bridge. + * + * Both V1 and V2 protocols convert their raw input into a [BridgeMessage] variant + * before dispatching to the handler via [FrontendJsBridge.dispatchToHandler]. + * + * V2 messages are deserialized directly from the JSON envelope using [bridgeMessageJson]. + * V1 messages are constructed from the individual `@JavascriptInterface` method calls. + */ +@Serializable +@VisibleForTesting +internal sealed interface BridgeMessage { + + /** Frontend requests an authentication token. */ + @Serializable + @SerialName("getExternalAuth") + data class GetExternalAuth(val payload: AuthPayload) : BridgeMessage + + /** Frontend requests to revoke the authentication session. */ + @Serializable + @SerialName("revokeExternalAuth") + data class RevokeExternalAuth(val payload: AuthPayload) : BridgeMessage + + /** Frontend sends a message through the external bus. */ + @Serializable + @SerialName("externalBus") + data class ExternalBus(val payload: JsonElement) : BridgeMessage + + /** Unknown message type */ + @Serializable + data class Unknown(override val discriminator: String?, override val content: JsonElement) : + BridgeMessage, + UnknownJsonContent + + companion object { + val serializersModule = SerializersModule { + polymorphicDefaultDeserializer(BridgeMessage::class) { className -> + object : UnknownJsonContentDeserializer() { + override val builder = UnknownJsonContentBuilder { Unknown(className, it) } + } + } + } + } +} + +/** + * Private Json instance for deserializing V2 bridge envelopes into [BridgeMessage] variants. + * + * Inherits settings from [frontendExternalBusJson] (camelCase, ignoreUnknownKeys) and adds + * the [BridgeMessage.serializersModule] for polymorphic dispatch on the `type` discriminator. + */ +private val bridgeMessageJson = Json(frontendExternalBusJson) { + serializersModule += BridgeMessage.serializersModule +} + +/** + * JavaScript bridge that connects the Home Assistant frontend WebView with native Android code. + * + * This class supports two bridge protocols: + * - **V1** (`externalApp`): Uses [WebView.addJavascriptInterface]. The frontend + * detects `window.externalApp` and calls named methods directly. + * - **V2** (`externalAppV2`): Uses [WebViewCompat.addWebMessageListener]. The frontend detects + * `window.externalAppV2.postMessage` and sends all messages through it with a `type` + * discriminator. V2 provides iframe and origin filtering for improved security. + * + * Both protocols parse messages into [BridgeMessage] variants and route them through + * [dispatchToHandler]. + * + * @param handler Handler that processes the JavaScript callbacks asynchronously + * @param serverManager Used to check server version for V2 protocol support + * @param scope Coroutine scope for launching async operations from synchronous JS calls + * @param stateProvider Provides the current server ID and URL from the ViewModel state + */ +class FrontendJsBridge @AssistedInject constructor( + private val handler: FrontendJsHandler, + private val serverManager: ServerManager, + @Assisted private val scope: CoroutineScope, + @Assisted private val stateProvider: () -> BridgeState, +) : FrontendJsCallback { + + /** + * Registers the appropriate native bridge for the current server. + * + * Queries the server version via [serverManager] to determine whether to use the + * V2 bridge or the legacy V1 bridge. V2 also requires + * [WebViewFeature.WEB_MESSAGE_LISTENER] support; falls back to V1 if unavailable. + * + * Safe to call multiple times: each path removes the previously registered interface + * before adding the new one. + */ + @SuppressLint("RequiresFeature") + override suspend fun attachToWebView(webView: WebView) { + val useV2 = serverManager.getServer(stateProvider().serverId).isServerSupportingExternalAppV2() + val webMessageListenerSupported = WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) + if (useV2 && webMessageListenerSupported) { + webView.removeJavascriptInterface(EXTERNAL_APP_V1) + registerV2(webView) + } else { + if (webMessageListenerSupported) { + WebViewCompat.removeWebMessageListener(webView, EXTERNAL_APP_V2_LISTENER) + } + registerV1(webView) + } + } + + /** + * Dispatches a parsed [BridgeMessage] to the [handler]. + * + * Validates auth callback names at this layer before forwarding to the handler. + * This is the shared routing logic used by both V1 and V2 protocols. + */ + private fun dispatchToHandler(message: BridgeMessage) { + scope.launch { + when (message) { + is BridgeMessage.GetExternalAuth -> { + if (FailFast.failWhen(message.payload.callback != EXPECTED_GET_AUTH_CALLBACK) { + "Aborting getExternalAuth: callback '${message.payload.callback}' does not match expected '$EXPECTED_GET_AUTH_CALLBACK'" + } + ) { + return@launch + } + handler.getExternalAuth(message.payload, stateProvider().serverId) + } + + is BridgeMessage.RevokeExternalAuth -> { + if (FailFast.failWhen(message.payload.callback != EXPECTED_REVOKE_AUTH_CALLBACK) { + "Aborting revokeExternalAuth: callback '${message.payload.callback}' does not match expected '$EXPECTED_REVOKE_AUTH_CALLBACK'" + } + ) { + return@launch + } + handler.revokeExternalAuth(message.payload, stateProvider().serverId) + } + + is BridgeMessage.ExternalBus -> handler.externalBus(message.payload) + + is BridgeMessage.Unknown -> FailFast.fail { + "Unknown bridge message type '${message.discriminator}' content ${ + sensitive { + message.content.toString() + } + }" + } + } + } + } + + /** + * Registers the legacy V1 bridge using [WebView.addJavascriptInterface]. + * + * The HA frontend detects `window.externalApp` and calls named methods directly. + * Each method parses its raw JSON string into the corresponding [BridgeMessage] variant. + */ + private fun registerV1(webView: WebView) { + webView.removeJavascriptInterface(EXTERNAL_APP_V1) + webView.addJavascriptInterface( + object : Any() { + @JavascriptInterface + fun getExternalAuth(payload: String) { + val authPayload = parseAuthPayload(payload) ?: return + dispatchToHandler(BridgeMessage.GetExternalAuth(payload = authPayload)) + } + + @JavascriptInterface + fun revokeExternalAuth(payload: String) { + val authPayload = parseAuthPayload(payload) ?: return + dispatchToHandler(BridgeMessage.RevokeExternalAuth(payload = authPayload)) + } + + @JavascriptInterface + fun externalBus(message: String) { + val json = parseJsonElement(message) ?: return + dispatchToHandler(BridgeMessage.ExternalBus(payload = json)) + } + }, + EXTERNAL_APP_V1, + ) + Timber.d("JS $EXTERNAL_APP_V1 interface added") + } + + /** + * Registers the V2 bridge using [WebViewCompat.addWebMessageListener]. + * + * The HA frontend detects `window.externalAppV2.postMessage` and sends all messages + * through it with a `type` discriminator. Messages are rejected if they come from + * an iframe or from an origin that doesn't match the currently loaded server URL. + * + * The raw JSON envelope is deserialized directly into a [BridgeMessage] variant. + */ + @SuppressLint("RequiresFeature") + private fun registerV2(webView: WebView) { + WebViewCompat.removeWebMessageListener(webView, EXTERNAL_APP_V2_LISTENER) + WebViewCompat.addWebMessageListener( + webView, + EXTERNAL_APP_V2_LISTENER, + setOf("*"), + ) { _, message, sourceOrigin, isMainFrame, _ -> + if (!isMainFrame) { + Timber.w("Ignored message from iframe") + return@addWebMessageListener + } + val currentUri = stateProvider().url?.toUri() + if (!sourceOrigin.hasSameOrigin(currentUri)) { + Timber.w( + "Ignored message from unexpected origin: ${sensitive(sourceOrigin.toString())} current ${ + sensitive { + currentUri.toString() + } + }", + ) + return@addWebMessageListener + } + + val data = message.data ?: return@addWebMessageListener + val bridgeMessage = parseBridgeMessage(data) ?: return@addWebMessageListener + dispatchToHandler(bridgeMessage) + } + Timber.d("JS $EXTERNAL_APP_V2_LISTENER listener added") + } + + private fun parseAuthPayload(json: String): AuthPayload? { + return FailFast.failOnCatch( + message = { "Failed to parse auth payload" }, + fallback = null, + ) { frontendExternalBusJson.decodeFromString(json) } + } + + private fun parseJsonElement(json: String): JsonElement? { + return FailFast.failOnCatch( + message = { "Failed to parse JSON element" }, + fallback = null, + ) { frontendExternalBusJson.parseToJsonElement(json) } + } + + private fun parseBridgeMessage(json: String): BridgeMessage? { + return FailFast.failOnCatch( + message = { "Failed to parse V2 bridge message" }, + fallback = null, + ) { bridgeMessageJson.decodeFromString(json) } + } + + companion object { + /** + * The only callback name the app accepts for `getExternalAuth` requests. + * + * Requests with a different callback name are rejected. + */ + const val EXPECTED_GET_AUTH_CALLBACK = "externalAuthSetToken" + + /** + * The only callback name the app accepts for `revokeExternalAuth` requests. + * + * Requests with a different callback name are rejected. + */ + const val EXPECTED_REVOKE_AUTH_CALLBACK = "externalAuthRevokeToken" + + /** + * The JavaScript interface name for the V1 bridge. + * + * The frontend detects `window.externalApp` and calls methods on it directly. + * This name is part of the frontend contract do not change without coordinating + * with the Home Assistant frontend team. + */ + const val EXTERNAL_APP_V1 = "externalApp" + + /** + * The listener name for the V2 bridge. + * + * The frontend detects `window.externalAppV2.postMessage` and sends + * JSON-encoded messages through it. This name is part of the frontend contract + * do not change without coordinating with the Home Assistant frontend team. + */ + const val EXTERNAL_APP_V2_LISTENER = "externalAppV2" + + /** + * Whether this server supports the V2 bridge protocol. + * + * V2 was introduced in Home Assistant 2026.4.2. + */ + fun Server?.isServerSupportingExternalAppV2(): Boolean = this?.version?.isAtLeast(2026, 4, 2) == true + + /** A no-op implementation for use in tests and previews. */ + val noOp = object : FrontendJsCallback { + override suspend fun attachToWebView(webView: WebView) {} + } + } +} + +/** + * Factory for creating [FrontendJsBridge] instances via assisted injection. + */ +@AssistedFactory +interface FrontendJsBridgeFactory { + /** + * Creates a new [FrontendJsBridge]. + * + * @param scope Coroutine scope for launching async operations from synchronous JS calls + * @param stateProvider Provides the current server ID and URL from the ViewModel state + */ + fun create(scope: CoroutineScope, stateProvider: () -> BridgeState): FrontendJsBridge +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/js/FrontendJsCallback.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/js/FrontendJsCallback.kt new file mode 100644 index 00000000000..793dfdf7052 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/js/FrontendJsCallback.kt @@ -0,0 +1,23 @@ +package io.homeassistant.companion.android.frontend.js + +import android.webkit.WebView + +/** + * Interface for registering the frontend JavaScript callbacks into a WebView. + * + * Implementations are responsible for setting up the communication channel between + * the Home Assistant frontend and native Android code so that the frontend can invoke + * native callbacks (authentication, external bus, etc.). + * + * @see FrontendJsBridge + */ +interface FrontendJsCallback { + /** + * Registers the JavaScript callbacks into the given [webView]. + * + * Must be called before loading a URL so the frontend can discover the bridge. + * + * @param webView The WebView to register callbacks into + */ + suspend fun attachToWebView(webView: WebView) +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/js/FrontendJsHandler.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/js/FrontendJsHandler.kt new file mode 100644 index 00000000000..3088cd3614f --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/js/FrontendJsHandler.kt @@ -0,0 +1,36 @@ +package io.homeassistant.companion.android.frontend.js + +import io.homeassistant.companion.android.frontend.session.AuthPayload +import kotlinx.serialization.json.JsonElement + +/** + * Handler interface for processing messages from the Home Assistant frontend. + */ +interface FrontendJsHandler { + /** + * Called when the frontend requests an authentication token. + * + * The callback name has already been validated by the bridge before this is called. + * + * @param authPayload Parsed authentication payload with callback name and force flag + * @param serverId The server ID to authenticate against + */ + suspend fun getExternalAuth(authPayload: AuthPayload, serverId: Int) + + /** + * Called when the frontend requests to revoke the authentication session. + * + * The callback name has already been validated by the bridge before this is called. + * + * @param authPayload Parsed authentication payload with callback name + * @param serverId The server ID whose session should be revoked + */ + suspend fun revokeExternalAuth(authPayload: AuthPayload, serverId: Int) + + /** + * Called when the frontend sends a message through the external bus. + * + * @param message Already-parsed JSON element containing the bus message + */ + suspend fun externalBus(message: JsonElement) +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/HAWebViewClient.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/HAWebViewClient.kt index bd25f2bd2a9..9f0012c9501 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/HAWebViewClient.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/HAWebViewClient.kt @@ -13,7 +13,6 @@ import androidx.core.net.toUri import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository import io.homeassistant.companion.android.common.data.keychain.NamedKeyChain -import io.homeassistant.companion.android.frontend.FrontendJsCallback import io.homeassistant.companion.android.frontend.error.FrontendConnectionError import javax.inject.Inject import kotlinx.coroutines.flow.StateFlow @@ -32,10 +31,7 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC * @param currentUrlFlow StateFlow providing the current URL being loaded. * Used to filter errors - only errors for this URL trigger [onFrontendError]. * @param onFrontendError Callback when a WebView error is mapped to a [FrontendConnectionError]. - * @param frontendJsCallback Optional JS interface to attach to the WebView. - * If will be re-attached after WebView crash recovery. * @param onCrash Optional callback invoked after WebView crash recovery. - * Called after the JS bridge is re-attached (if present). * @param onUrlIntercepted Optional callback to intercept URL navigation. * Receives the URI and whether TLS client auth was required. * Return `true` to prevent WebView from loading the URL. @@ -44,7 +40,6 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC fun create( currentUrlFlow: StateFlow, onFrontendError: (FrontendConnectionError) -> Unit, - frontendJsCallback: FrontendJsCallback? = null, onCrash: (() -> Unit)? = null, onUrlIntercepted: ((uri: Uri, isTLSClientAuthNeeded: Boolean) -> Boolean)? = null, onPageFinished: (() -> Unit)? = null, @@ -53,7 +48,6 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC keyChainRepository = keyChainRepository, currentUrlFlow = currentUrlFlow, onFrontendError = onFrontendError, - frontendJsCallback = frontendJsCallback, onCrash = onCrash, onUrlIntercepted = onUrlIntercepted, onPageFinished = onPageFinished, @@ -71,7 +65,6 @@ class HAWebViewClient internal constructor( keyChainRepository: KeyChainRepository, private val currentUrlFlow: StateFlow, private val onFrontendError: (FrontendConnectionError) -> Unit, - private val frontendJsCallback: FrontendJsCallback?, private val onCrash: (() -> Unit)?, private val onUrlIntercepted: ((uri: Uri, isTLSClientAuthNeeded: Boolean) -> Boolean)?, private val onPageFinished: (() -> Unit)?, @@ -210,10 +203,7 @@ class HAWebViewClient internal constructor( override fun onRenderProcessGone(view: WebView?, detail: RenderProcessGoneDetail?): Boolean { Timber.e("onRenderProcessGone: webView crashed") - view?.let { webView -> - frontendJsCallback?.attachToWebView(webView) - onCrash?.invoke() - } + onCrash?.invoke() return true } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt index 0ab5f02cde8..1690f480708 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -121,6 +121,11 @@ import io.homeassistant.companion.android.database.server.ServerConnectionInfo import io.homeassistant.companion.android.databinding.DialogAuthenticationBinding import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType import io.homeassistant.companion.android.frontend.haptic.HapticFeedbackPerformer +import io.homeassistant.companion.android.frontend.js.FrontendJsBridge.Companion.EXPECTED_GET_AUTH_CALLBACK +import io.homeassistant.companion.android.frontend.js.FrontendJsBridge.Companion.EXPECTED_REVOKE_AUTH_CALLBACK +import io.homeassistant.companion.android.frontend.js.FrontendJsBridge.Companion.EXTERNAL_APP_V1 +import io.homeassistant.companion.android.frontend.js.FrontendJsBridge.Companion.EXTERNAL_APP_V2_LISTENER +import io.homeassistant.companion.android.frontend.js.FrontendJsBridge.Companion.isServerSupportingExternalAppV2 import io.homeassistant.companion.android.improv.ui.ImprovPermissionDialog import io.homeassistant.companion.android.improv.ui.ImprovSetupDialog import io.homeassistant.companion.android.launch.LaunchActivity @@ -175,18 +180,6 @@ class WebViewActivity : const val EXTRA_SERVER = "server" const val EXTRA_SHOW_WHEN_LOCKED = "show_when_locked" - private const val EXTERNAL_APP_V1 = "externalApp" - private const val EXTERNAL_APP_V2_LISTENER = "externalAppV2" - - /** - * Expected callback names for external authentication. - * - * The frontend sends a dynamic callback name in its requests, but the app - * only allows these hardcoded values. - */ - private const val GET_AUTH_CALLBACK = "externalAuthSetToken" - private const val REVOKE_AUTH_CALLBACK = "externalAuthRevokeToken" - private const val APP_PREFIX = "app://" private const val INTENT_PREFIX = "intent:" private const val MARKET_PREFIX = "https://play.google.com/store/apps/details?id=" @@ -869,7 +862,9 @@ class WebViewActivity : */ @SuppressLint("RequiresFeature") private suspend fun webViewAddJavascriptInterface() { - if (isServerSupportingExternalAppV2() && + val isServerSupportingExternalAppV2 = + serverManager.getServer(presenter.getActiveServer()).isServerSupportingExternalAppV2() + if (isServerSupportingExternalAppV2 && WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) ) { webView.removeJavascriptInterface(EXTERNAL_APP_V1) @@ -965,34 +960,38 @@ class WebViewActivity : /** * Validates and handles a `getExternalAuth` request from either V1 or V2 bridge. * - * Rejects requests whose callback name does not match the expected [GET_AUTH_CALLBACK]. + * Rejects requests whose callback name does not match the expected [EXPECTED_GET_AUTH_CALLBACK]. */ private fun handleGetExternalAuth(payload: JsonObject?) { val callback = payload?.getStringOrNull("callback") ?: "" - if (callback == GET_AUTH_CALLBACK) { - presenter.onGetExternalAuth( - this, - callback, - force = payload?.getBooleanOrNull("force") ?: false, - ) - } else { - Timber.w("Aborting getExternalAuth callback is not the one expected ($GET_AUTH_CALLBACK)") + if (FailFast.failWhen(callback != EXPECTED_GET_AUTH_CALLBACK) { + "Aborting getExternalAuth: callback '$callback' does not match expected '$EXPECTED_GET_AUTH_CALLBACK'" + } + ) { + return } + presenter.onGetExternalAuth( + this, + callback, + force = payload?.getBooleanOrNull("force") ?: false, + ) } /** * Validates and handles a `revokeExternalAuth` request from either V1 or V2 bridge. * - * Rejects requests whose callback name does not match the expected [REVOKE_AUTH_CALLBACK]. + * Rejects requests whose callback name does not match the expected [EXPECTED_REVOKE_AUTH_CALLBACK]. */ private fun handleRevokeExternalAuth(payload: JsonObject?) { val callback = payload?.getStringOrNull("callback") ?: "" - if (callback == REVOKE_AUTH_CALLBACK) { - presenter.onRevokeExternalAuth(callback) - isRelaunching = true - } else { - Timber.w("Aborting revokeExternalAuth callback is not the one expected ($REVOKE_AUTH_CALLBACK)") + if (FailFast.failWhen(callback != EXPECTED_REVOKE_AUTH_CALLBACK) { + "Aborting revokeExternalAuth: callback '$callback' does not match expected '$EXPECTED_REVOKE_AUTH_CALLBACK'" + } + ) { + return } + presenter.onRevokeExternalAuth(callback) + isRelaunching = true } /** @@ -1672,12 +1671,6 @@ class WebViewActivity : } } - /** - * Whether the active server supports the `externalAppV2` bridge (>= 2026.4.2). - */ - private suspend fun isServerSupportingExternalAppV2(): Boolean = - serverManager.getServer(presenter.getActiveServer())?.version?.isAtLeast(2026, 4, 2) == true - override fun showConnectionSecurityLevel(serverId: Int) { // Skip if already showing ConnectionSecurityLevelFragment to avoid blinking if (supportFragmentManager.fragments.any { it is ConnectionSecurityLevelFragment }) { diff --git a/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenScreenshotTest.kt b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenScreenshotTest.kt index 8e231eb81b5..1127acdde34 100644 --- a/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenScreenshotTest.kt +++ b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenScreenshotTest.kt @@ -7,6 +7,7 @@ import com.android.tools.screenshot.PreviewTest import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview import io.homeassistant.companion.android.frontend.error.FrontendConnectionError +import io.homeassistant.companion.android.frontend.js.FrontendJsBridge import io.homeassistant.companion.android.util.compose.HAPreviews import kotlinx.coroutines.flow.emptyFlow diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendJsBridgeTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendJsBridgeTest.kt deleted file mode 100644 index 4505af70c63..00000000000 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendJsBridgeTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package io.homeassistant.companion.android.frontend - -import android.webkit.WebView -import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension -import io.mockk.coVerify -import io.mockk.mockk -import io.mockk.verifyOrder -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith - -@ExtendWith(ConsoleLogExtension::class) -@OptIn(ExperimentalCoroutinesApi::class) -class FrontendJsBridgeTest { - - private val handler: FrontendJsHandler = mockk(relaxed = true) - private val serverId = 42 - - @Test - fun `Given payload when getExternalAuth called then handler is called with payload and serverId`() = runTest { - val payload = """{"callback":"authCallback","force":true}""" - val bridge = FrontendJsBridge( - handler = handler, - serverIdProvider = { serverId }, - scope = this, - ) - - bridge.getExternalAuth(payload) - advanceUntilIdle() - - coVerify(exactly = 1) { handler.getExternalAuth(payload, serverId) } - } - - @Test - fun `Given payload when revokeExternalAuth called then handler is called with payload and serverId`() = runTest { - val payload = """{"callback":"revokeCallback"}""" - val bridge = FrontendJsBridge( - handler = handler, - serverIdProvider = { serverId }, - scope = this, - ) - - bridge.revokeExternalAuth(payload) - advanceUntilIdle() - - coVerify(exactly = 1) { handler.revokeExternalAuth(payload, serverId) } - } - - @Test - fun `Given message when externalBus called then handler is called with message`() = runTest { - val message = """{"type":"config/get","id":1}""" - val bridge = FrontendJsBridge( - handler = handler, - serverIdProvider = { serverId }, - scope = this, - ) - - bridge.externalBus(message) - advanceUntilIdle() - - coVerify(exactly = 1) { handler.externalBus(message) } - } - - @Test - fun `Given onHomeAssistantSetTheme called then handler is called`() = runTest { - val bridge = FrontendJsBridge( - handler = handler, - serverIdProvider = { serverId }, - scope = this, - ) - - bridge.onHomeAssistantSetTheme() - advanceUntilIdle() - - coVerify(exactly = 1) { handler.onHomeAssistantSetTheme() } - } - - @Test - fun `Given webView when attachToWebView called then removes old interface before adding new one`() = runTest { - val webView: WebView = mockk(relaxed = true) - val bridge = FrontendJsBridge( - handler = handler, - serverIdProvider = { serverId }, - scope = this, - ) - - bridge.attachToWebView(webView) - - verifyOrder { - webView.removeJavascriptInterface("externalApp") - webView.addJavascriptInterface(bridge, "externalApp") - } - } - - @Test - fun `Given different serverId provider when getExternalAuth called then uses current serverId`() = runTest { - var currentServerId = 1 - val dynamicBridge = FrontendJsBridge( - handler = handler, - serverIdProvider = { currentServerId }, - scope = this, - ) - - dynamicBridge.getExternalAuth("payload1") - advanceUntilIdle() - - currentServerId = 2 - dynamicBridge.getExternalAuth("payload2") - advanceUntilIdle() - - coVerify(exactly = 1) { handler.getExternalAuth("payload1", 1) } - coVerify(exactly = 1) { handler.getExternalAuth("payload2", 2) } - } -} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenTest.kt index 981bc97cf4d..245d80c1205 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenTest.kt @@ -28,6 +28,7 @@ import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.database.settings.SettingsDao import io.homeassistant.companion.android.frontend.error.FrontendConnectionError import io.homeassistant.companion.android.frontend.error.FrontendConnectionErrorStateProvider +import io.homeassistant.companion.android.frontend.js.FrontendJsBridge import io.homeassistant.companion.android.frontend.permissions.PendingWebViewPermissionRequest import io.homeassistant.companion.android.frontend.permissions.PermissionManager import io.homeassistant.companion.android.testing.unit.ConsoleLogRule diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt index 26457edafeb..c3b22c5b84a 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt @@ -5,8 +5,9 @@ import io.homeassistant.companion.android.common.data.connectivity.ConnectivityC import io.homeassistant.companion.android.common.data.connectivity.ConnectivityCheckState import io.homeassistant.companion.android.frontend.error.FrontendConnectionError import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType +import io.homeassistant.companion.android.frontend.handler.FrontendBusObserver import io.homeassistant.companion.android.frontend.handler.FrontendHandlerEvent -import io.homeassistant.companion.android.frontend.handler.FrontendMessageHandler +import io.homeassistant.companion.android.frontend.js.FrontendJsBridgeFactory import io.homeassistant.companion.android.frontend.navigation.FrontendNavigationEvent import io.homeassistant.companion.android.frontend.permissions.PermissionManager import io.homeassistant.companion.android.frontend.url.FrontendUrlManager @@ -50,18 +51,19 @@ class FrontendViewModelTest { val mainDispatcherExtension = MainDispatcherJUnit5Extension(UnconfinedTestDispatcher()) private val webViewClientFactory: HAWebViewClientFactory = mockk(relaxed = true) - private val externalBusHandler: FrontendMessageHandler = mockk(relaxed = true) + private val frontendBusObserver: FrontendBusObserver = mockk(relaxed = true) private val urlManager: FrontendUrlManager = mockk(relaxed = true) private val connectivityCheckRepository: ConnectivityCheckRepository = mockk(relaxed = true) private val permissionManager: PermissionManager = mockk(relaxed = true) + private val frontendJsBridgeFactory: FrontendJsBridgeFactory = mockk(relaxed = true) private val serverId = 1 private val testUrlWithAuth = "https://example.com?external_auth=1" @BeforeEach fun setUp() { - every { externalBusHandler.messageResults() } returns emptyFlow() - every { externalBusHandler.scriptsToEvaluate() } returns emptyFlow() + every { frontendBusObserver.messageResults() } returns emptyFlow() + every { frontendBusObserver.scriptsToEvaluate() } returns emptyFlow() every { connectivityCheckRepository.runChecks(any()) } returns flowOf(ConnectivityCheckState()) } @@ -73,10 +75,11 @@ class FrontendViewModelTest { initialServerId = serverId, initialPath = path, webViewClientFactory = webViewClientFactory, - frontendMessageHandler = externalBusHandler, + frontendBusObserver = frontendBusObserver, urlManager = urlManager, connectivityCheckRepository = connectivityCheckRepository, permissionManager = permissionManager, + frontendJsBridgeFactory = frontendJsBridgeFactory, ) } @@ -412,7 +415,7 @@ class FrontendViewModelTest { @Test fun `Given connected message result when collected then state transitions to Content`() = runTest { val messageFlow = MutableSharedFlow() - every { externalBusHandler.messageResults() } returns messageFlow + every { frontendBusObserver.messageResults() } returns messageFlow every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), ) @@ -435,7 +438,7 @@ class FrontendViewModelTest { @Test fun `Given auth error message result when collected then state transitions to Error`() = runTest { val messageFlow = MutableSharedFlow() - every { externalBusHandler.messageResults() } returns messageFlow + every { frontendBusObserver.messageResults() } returns messageFlow every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), ) @@ -463,7 +466,7 @@ class FrontendViewModelTest { @Test fun `Given show assist message result when collected then NavigateToAssist event is emitted`() = runTest { val messageFlow = MutableSharedFlow() - every { externalBusHandler.messageResults() } returns messageFlow + every { frontendBusObserver.messageResults() } returns messageFlow every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), ) @@ -491,7 +494,7 @@ class FrontendViewModelTest { @Test fun `Given open settings message result when collected then navigation event is emitted`() = runTest { val messageFlow = MutableSharedFlow() - every { externalBusHandler.messageResults() } returns messageFlow + every { frontendBusObserver.messageResults() } returns messageFlow every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), ) @@ -515,7 +518,7 @@ class FrontendViewModelTest { @Test fun `Given haptic message when collected then hapticEvents emits the type`() = runTest { val messageFlow = MutableSharedFlow() - every { externalBusHandler.messageResults() } returns messageFlow + every { frontendBusObserver.messageResults() } returns messageFlow every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), ) @@ -542,7 +545,7 @@ class FrontendViewModelTest { @Test fun `Given open assist settings message result when collected then navigation event is emitted`() = runTest { val messageFlow = MutableSharedFlow() - every { externalBusHandler.messageResults() } returns messageFlow + every { frontendBusObserver.messageResults() } returns messageFlow every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), ) @@ -720,7 +723,7 @@ class FrontendViewModelTest { shouldAsk: Boolean, ) = runTest { val messageFlow = MutableSharedFlow() - every { externalBusHandler.messageResults() } returns messageFlow + every { frontendBusObserver.messageResults() } returns messageFlow every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), ) @@ -740,7 +743,7 @@ class FrontendViewModelTest { @Test fun `Given notification permission result when called then showNotificationPermission becomes false`() = runTest { val messageFlow = MutableSharedFlow() - every { externalBusHandler.messageResults() } returns messageFlow + every { frontendBusObserver.messageResults() } returns messageFlow every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), ) @@ -822,7 +825,7 @@ class FrontendViewModelTest { @Test fun `Given loading state when connected before timeout then no timeout error`() = runTest { val messageFlow = MutableSharedFlow() - every { externalBusHandler.messageResults() } returns messageFlow + every { frontendBusObserver.messageResults() } returns messageFlow every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), ) diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/FrontendExternalBusRepositoryImplTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/FrontendExternalBusRepositoryImplTest.kt index 14253ac7970..1a8868e85af 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/FrontendExternalBusRepositoryImplTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/FrontendExternalBusRepositoryImplTest.kt @@ -9,6 +9,10 @@ import io.homeassistant.companion.android.frontend.externalbus.outgoing.Outgoing import io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertInstanceOf @@ -27,7 +31,7 @@ class FrontendExternalBusRepositoryImplTest { @Test fun `Given connection-status message when received then emit ConnectionStatusMessage`() = runTest { - val json = """{"type":"connection-status","id":1,"payload":{"event":"connected"}}""" + val json = Json.parseToJsonElement("""{"type":"connection-status","id":1,"payload":{"event":"connected"}}""") repository.incomingMessages().test { repository.onMessageReceived(json) @@ -42,7 +46,7 @@ class FrontendExternalBusRepositoryImplTest { @Test fun `Given config-get message when received then emit ConfigGetMessage`() = runTest { - val json = """{"type":"config/get","id":42}""" + val json = Json.parseToJsonElement("""{"type":"config/get","id":42}""") repository.incomingMessages().test { repository.onMessageReceived(json) @@ -55,7 +59,7 @@ class FrontendExternalBusRepositoryImplTest { @Test fun `Given unknown type when received then emit UnknownIncomingMessage`() = runTest { - val json = """{"type":"future-feature","id":99,"payload":{"data":"something"}}""" + val json = Json.parseToJsonElement("""{"type":"future-feature","id":99,"payload":{"data":"something"}}""") repository.incomingMessages().test { repository.onMessageReceived(json) @@ -68,20 +72,29 @@ class FrontendExternalBusRepositoryImplTest { } @Test - fun `Given invalid input when received then do not emit`() = runTest { + fun `Given non-object JsonElement when received then do not emit`() = runTest { repository.incomingMessages().test { - repository.onMessageReceived("") - repository.onMessageReceived(" ") - repository.onMessageReceived("not valid json") - repository.onMessageReceived("{invalid}") + repository.onMessageReceived(JsonNull) expectNoEvents() } } + @Test + fun `Given object without type when received then emit UnknownIncomingMessage`() = runTest { + val json = buildJsonObject { put("data", "value") } + + repository.incomingMessages().test { + repository.onMessageReceived(json) + + val message = awaitItem() + assertInstanceOf(UnknownIncomingMessage::class.java, message) + } + } + @Test fun `Given message without id when received then id is null`() = runTest { - val json = """{"type":"config/get"}""" + val json = Json.parseToJsonElement("""{"type":"config/get"}""") repository.incomingMessages().test { repository.onMessageReceived(json) diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandlerTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandlerTest.kt index d82846073f6..43e2e1140c2 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandlerTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandlerTest.kt @@ -38,9 +38,11 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach @@ -260,7 +262,7 @@ class FrontendMessageHandlerTest { @Test fun `Given unknown message when messageResults then emits UnknownMessage`() = runTest { - val message = UnknownIncomingMessage(content = JsonPrimitive("unknown-type")) + val message = UnknownIncomingMessage(discriminator = "unknown-type", content = JsonPrimitive("unknown-type")) every { externalBusRepository.incomingMessages() } returns flowOf(message) handler.messageResults().test { @@ -270,18 +272,6 @@ class FrontendMessageHandlerTest { } } - @Test - fun `Given script when evaluateScript then calls repository evaluateScript`() = runTest { - val script = "console.log('test')" - val expectedResult = "undefined" - coEvery { externalBusRepository.evaluateScript(script) } returns expectedResult - - val result = handler.evaluateScript(script) - - assertEquals(expectedResult, result) - coVerify { externalBusRepository.evaluateScript(script) } - } - @Test fun `Given scripts flow when scriptsToEvaluate then returns repository flow`() = runTest { val script = WebViewScript(script = "test()") @@ -355,37 +345,34 @@ class FrontendMessageHandlerTest { } @Test - fun `Given valid payload and successful auth when getExternalAuth then evaluates success callback`() = runTest { - val payload = """{"callback":"authCallback","force":false}""" - val authPayload = AuthPayload(callback = "authCallback", force = false) - val authResult = ExternalAuthResult.Success(callbackScript = "authCallback(true, {token})") + fun `Given successful auth when getExternalAuth then evaluates success callback`() = runTest { + val authPayload = AuthPayload(callback = "externalAuthSetToken", force = false) + val authResult = ExternalAuthResult.Success(callbackScript = "externalAuthSetToken(true, {token})") coEvery { sessionManager.getExternalAuth(1, authPayload) } returns authResult - coEvery { externalBusRepository.evaluateScript("authCallback(true, {token})") } returns null + coEvery { externalBusRepository.evaluateScript("externalAuthSetToken(true, {token})") } returns null - handler.getExternalAuth(payload, serverId = 1) + handler.getExternalAuth(authPayload, serverId = 1) - coVerify { externalBusRepository.evaluateScript("authCallback(true, {token})") } + coVerify { externalBusRepository.evaluateScript("externalAuthSetToken(true, {token})") } } @Test - fun `Given valid payload and failed auth with error when getExternalAuth then evaluates callback and emits AuthError`() = runTest { - val payload = """{"callback":"authCallback","force":false}""" - val authPayload = AuthPayload(callback = "authCallback", force = false) + fun `Given failed auth with error when getExternalAuth then evaluates callback and emits AuthError`() = runTest { + val authPayload = AuthPayload(callback = "externalAuthSetToken", force = false) val error = FrontendConnectionError.AuthenticationError( message = commonR.string.error_connection_failed, errorDetails = "Auth failed", rawErrorType = "ExternalAuthFailed", ) - val authResult = ExternalAuthResult.Failed(callbackScript = "authCallback(false)", error = error) + val authResult = ExternalAuthResult.Failed(callbackScript = "externalAuthSetToken(false)", error = error) coEvery { sessionManager.getExternalAuth(1, authPayload) } returns authResult - coEvery { externalBusRepository.evaluateScript("authCallback(false)") } returns null + coEvery { externalBusRepository.evaluateScript("externalAuthSetToken(false)") } returns null every { externalBusRepository.incomingMessages() } returns emptyFlow() - // Start collecting BEFORE calling getExternalAuth to catch the emitted event handler.messageResults().test { - handler.getExternalAuth(payload, serverId = 1) + handler.getExternalAuth(authPayload, serverId = 1) val event = awaitItem() assertTrue(event is FrontendHandlerEvent.AuthError) @@ -393,24 +380,22 @@ class FrontendMessageHandlerTest { expectNoEvents() } - coVerify { externalBusRepository.evaluateScript("authCallback(false)") } + coVerify { externalBusRepository.evaluateScript("externalAuthSetToken(false)") } } @Test - fun `Given valid payload and failed auth without error when getExternalAuth then evaluates callback only`() = runTest { - val payload = """{"callback":"authCallback","force":false}""" - val authPayload = AuthPayload(callback = "authCallback", force = false) - val authResult = ExternalAuthResult.Failed(callbackScript = "authCallback(false)", error = null) + fun `Given failed auth without error when getExternalAuth then evaluates callback only`() = runTest { + val authPayload = AuthPayload(callback = "externalAuthSetToken", force = false) + val authResult = ExternalAuthResult.Failed(callbackScript = "externalAuthSetToken(false)", error = null) coEvery { sessionManager.getExternalAuth(1, authPayload) } returns authResult - coEvery { externalBusRepository.evaluateScript("authCallback(false)") } returns null + coEvery { externalBusRepository.evaluateScript("externalAuthSetToken(false)") } returns null every { externalBusRepository.incomingMessages() } returns emptyFlow() - handler.getExternalAuth(payload, serverId = 1) + handler.getExternalAuth(authPayload, serverId = 1) - coVerify { externalBusRepository.evaluateScript("authCallback(false)") } + coVerify { externalBusRepository.evaluateScript("externalAuthSetToken(false)") } - // No AuthError should be emitted - flow should have no items from auth handler.messageResults().test { expectNoEvents() expectNoEvents() @@ -419,44 +404,41 @@ class FrontendMessageHandlerTest { @Test fun `Given force true when getExternalAuth then passes force to sessionManager`() = runTest { - val payload = """{"callback":"authCallback","force":true}""" - val authPayload = AuthPayload(callback = "authCallback", force = true) - val authResult = ExternalAuthResult.Success(callbackScript = "authCallback(true, {token})") + val authPayload = AuthPayload(callback = "externalAuthSetToken", force = true) + val authResult = ExternalAuthResult.Success(callbackScript = "externalAuthSetToken(true, {token})") coEvery { sessionManager.getExternalAuth(1, authPayload) } returns authResult coEvery { externalBusRepository.evaluateScript(any()) } returns null - handler.getExternalAuth(payload, serverId = 1) + handler.getExternalAuth(authPayload, serverId = 1) coVerify { sessionManager.getExternalAuth(1, authPayload) } } @Test - fun `Given valid payload and successful revoke when revokeExternalAuth then evaluates success callback`() = runTest { - val payload = """{"callback":"revokeCallback","force":false}""" - val authPayload = AuthPayload(callback = "revokeCallback", force = false) - val revokeResult = RevokeAuthResult.Success(callbackScript = "revokeCallback(true)") + fun `Given successful revoke when revokeExternalAuth then evaluates success callback`() = runTest { + val authPayload = AuthPayload(callback = "externalAuthRevokeToken", force = false) + val revokeResult = RevokeAuthResult.Success(callbackScript = "externalAuthRevokeToken(true)") coEvery { sessionManager.revokeExternalAuth(1, authPayload) } returns revokeResult - coEvery { externalBusRepository.evaluateScript("revokeCallback(true)") } returns null + coEvery { externalBusRepository.evaluateScript("externalAuthRevokeToken(true)") } returns null - handler.revokeExternalAuth(payload, serverId = 1) + handler.revokeExternalAuth(authPayload, serverId = 1) - coVerify { externalBusRepository.evaluateScript("revokeCallback(true)") } + coVerify { externalBusRepository.evaluateScript("externalAuthRevokeToken(true)") } } @Test - fun `Given valid payload and failed revoke when revokeExternalAuth then evaluates failure callback`() = runTest { - val payload = """{"callback":"revokeCallback","force":false}""" - val authPayload = AuthPayload(callback = "revokeCallback", force = false) - val revokeResult = RevokeAuthResult.Failed(callbackScript = "revokeCallback(false)") + fun `Given failed revoke when revokeExternalAuth then evaluates failure callback`() = runTest { + val authPayload = AuthPayload(callback = "externalAuthRevokeToken", force = false) + val revokeResult = RevokeAuthResult.Failed(callbackScript = "externalAuthRevokeToken(false)") coEvery { sessionManager.revokeExternalAuth(1, authPayload) } returns revokeResult - coEvery { externalBusRepository.evaluateScript("revokeCallback(false)") } returns null + coEvery { externalBusRepository.evaluateScript("externalAuthRevokeToken(false)") } returns null - handler.revokeExternalAuth(payload, serverId = 1) + handler.revokeExternalAuth(authPayload, serverId = 1) - coVerify { externalBusRepository.evaluateScript("revokeCallback(false)") } + coVerify { externalBusRepository.evaluateScript("externalAuthRevokeToken(false)") } } @Test @@ -478,7 +460,10 @@ class FrontendMessageHandlerTest { @Test fun `Given message when externalBus then forwards to repository`() = runTest { - val message = """{"type":"test","id":1}""" + val message = buildJsonObject { + put("type", "test") + put("id", 1) + } handler.externalBus(message) diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/js/FrontendJsBridgeTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/js/FrontendJsBridgeTest.kt new file mode 100644 index 00000000000..43e3e4c5f70 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/js/FrontendJsBridgeTest.kt @@ -0,0 +1,342 @@ +package io.homeassistant.companion.android.frontend.js + +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.common.util.FailFast +import io.homeassistant.companion.android.database.server.Server +import io.homeassistant.companion.android.database.server.ServerConnectionInfo +import io.homeassistant.companion.android.database.server.ServerSessionInfo +import io.homeassistant.companion.android.database.server.ServerUserInfo +import io.homeassistant.companion.android.frontend.externalbus.frontendExternalBusJson +import io.homeassistant.companion.android.frontend.session.AuthPayload +import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension +import io.homeassistant.companion.android.util.FailFastExtension +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.modules.plus +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource + +@ExtendWith(ConsoleLogExtension::class, FailFastExtension::class) +@OptIn(ExperimentalCoroutinesApi::class) +class FrontendJsBridgeTest { + + private val handler: FrontendJsHandler = mockk(relaxed = true) + private val serverManager: ServerManager = mockk(relaxed = true) + private val serverId = 42 + private val serverUrl = "https://example.com" + + /** Identifies which bridge protocol to test. */ + enum class BridgeVersion { V1, V2 } + + @AfterEach + fun tearDown() { + unmockkAll() + } + + private fun createBridge( + stateProvider: () -> BridgeState = { BridgeState(serverId = serverId, url = serverUrl) }, + scope: TestScope, + ) = FrontendJsBridge( + handler = handler, + serverManager = serverManager, + scope = scope, + stateProvider = stateProvider, + ) + + private fun mockGetServer(version: String?) { + val server = Server( + _name = "test", + _version = version, + connection = ServerConnectionInfo(externalUrl = serverUrl), + session = ServerSessionInfo(), + user = ServerUserInfo(), + ) + coEvery { serverManager.getServer(any()) } returns server + } + + private fun mockWebViewFeatureSupported(supported: Boolean) { + mockkStatic(WebViewFeature::class) + every { WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) } returns supported + } + + private fun mockWebViewCompat() { + mockkStatic(WebViewCompat::class) + every { WebViewCompat.addWebMessageListener(any(), any(), any(), any()) } returns Unit + every { WebViewCompat.removeWebMessageListener(any(), any()) } returns Unit + } + + /** + * Attaches the bridge using the given [bridgeVersion] and returns a function that sends messages. + * + * The returned lambda takes (methodName/type, payloadJson) and sends the message through + * the appropriate protocol path. + */ + private suspend fun attach(bridge: FrontendJsBridge, bridgeVersion: BridgeVersion): (String, String) -> Unit = when (bridgeVersion) { + BridgeVersion.V1 -> attachV1(bridge) + BridgeVersion.V2 -> attachV2(bridge) + } + + private suspend fun attachV1(bridge: FrontendJsBridge): (String, String) -> Unit { + mockGetServer(version = "2025.12.0") + mockWebViewFeatureSupported(supported = false) + val jsInterfaceSlot = slot() + val webView: WebView = mockk(relaxed = true) + every { webView.addJavascriptInterface(capture(jsInterfaceSlot), any()) } returns Unit + + bridge.attachToWebView(webView) + + val captured = jsInterfaceSlot.captured + return { methodName, payload -> + captured.javaClass.getMethod(methodName, String::class.java).invoke(captured, payload) + } + } + + private suspend fun attachV2(bridge: FrontendJsBridge): (String, String) -> Unit { + mockGetServer(version = "2026.4.2") + mockWebViewFeatureSupported(supported = true) + mockkStatic(WebViewCompat::class) + every { WebViewCompat.removeWebMessageListener(any(), any()) } returns Unit + val listenerSlot = slot() + every { WebViewCompat.addWebMessageListener(any(), any(), any(), capture(listenerSlot)) } returns Unit + mockkStatic(Uri::class) + val mockCurrentUri: Uri = mockk { + every { scheme } returns "https" + every { host } returns "example.com" + every { port } returns -1 + } + every { Uri.parse(serverUrl) } returns mockCurrentUri + val webView: WebView = mockk(relaxed = true) + + bridge.attachToWebView(webView) + + val listener = listenerSlot.captured + val origin: Uri = mockk { + every { scheme } returns "https" + every { host } returns "example.com" + every { port } returns -1 + } + return { type, payloadJson -> + val envelope = """{"type":"$type","payload":$payloadJson}""" + val message: WebMessageCompat = mockk { every { data } returns envelope } + val replyProxy: JavaScriptReplyProxy = mockk(relaxed = true) + listener.onPostMessage(webView, message, origin, true, replyProxy) + } + } + + @Nested + inner class AttachToWebView { + + @Test + fun `Given old server with WebMessageListener supported then removes V2 and registers V1`() = runTest { + mockGetServer(version = "2025.12.0") + mockWebViewFeatureSupported(supported = true) + mockWebViewCompat() + val webView: WebView = mockk(relaxed = true) + val bridge = createBridge(scope = this) + + bridge.attachToWebView(webView) + + verify { WebViewCompat.removeWebMessageListener(webView, FrontendJsBridge.EXTERNAL_APP_V2_LISTENER) } + verify { webView.addJavascriptInterface(any(), FrontendJsBridge.EXTERNAL_APP_V1) } + } + + @Test + fun `Given V2 server and WebMessageListener supported then registers V2 listener`() = runTest { + mockGetServer(version = "2026.4.2") + mockWebViewFeatureSupported(supported = true) + mockWebViewCompat() + val webView: WebView = mockk(relaxed = true) + val bridge = createBridge(scope = this) + + bridge.attachToWebView(webView) + + verify { WebViewCompat.addWebMessageListener(webView, FrontendJsBridge.EXTERNAL_APP_V2_LISTENER, any(), any()) } + verify { webView.removeJavascriptInterface(FrontendJsBridge.EXTERNAL_APP_V1) } + } + + @Test + fun `Given V2 server but WebMessageListener not supported then falls back to V1`() = runTest { + mockGetServer(version = "2026.4.2") + mockWebViewFeatureSupported(supported = false) + val webView: WebView = mockk(relaxed = true) + val bridge = createBridge(scope = this) + + bridge.attachToWebView(webView) + + verify { webView.addJavascriptInterface(any(), FrontendJsBridge.EXTERNAL_APP_V1) } + } + + @Test + fun `Given null server then registers V1 interface`() = runTest { + coEvery { serverManager.getServer(serverId) } returns null + mockWebViewFeatureSupported(supported = true) + mockWebViewCompat() + val webView: WebView = mockk(relaxed = true) + val bridge = createBridge(scope = this) + + bridge.attachToWebView(webView) + + verify { webView.addJavascriptInterface(any(), FrontendJsBridge.EXTERNAL_APP_V1) } + } + } + + @Nested + inner class Dispatching { + + @ParameterizedTest + @EnumSource(BridgeVersion::class) + fun `Given getExternalAuth then handler receives parsed AuthPayload`(bridgeVersion: BridgeVersion) = runTest { + val bridge = createBridge(scope = this) + val invoke = attach(bridge, bridgeVersion) + + invoke("getExternalAuth", """{"callback":"externalAuthSetToken","force":true}""") + advanceUntilIdle() + + coVerify(exactly = 1) { + handler.getExternalAuth(AuthPayload(callback = "externalAuthSetToken", force = true), serverId) + } + } + + @ParameterizedTest + @EnumSource(BridgeVersion::class) + fun `Given revokeExternalAuth then handler receives parsed AuthPayload`(bridgeVersion: BridgeVersion) = runTest { + val bridge = createBridge(scope = this) + val invoke = attach(bridge, bridgeVersion) + + invoke("revokeExternalAuth", """{"callback":"externalAuthRevokeToken"}""") + advanceUntilIdle() + + coVerify(exactly = 1) { + handler.revokeExternalAuth(AuthPayload(callback = "externalAuthRevokeToken"), serverId) + } + } + + @ParameterizedTest + @EnumSource(BridgeVersion::class) + fun `Given externalBus then handler receives parsed JsonElement`(bridgeVersion: BridgeVersion) = runTest { + val bridge = createBridge(scope = this) + val invoke = attach(bridge, bridgeVersion) + + invoke("externalBus", """{"type":"config/get","id":1}""") + advanceUntilIdle() + + val expected = buildJsonObject { + put("type", "config/get") + put("id", 1) + } + coVerify(exactly = 1) { handler.externalBus(expected) } + } + + @ParameterizedTest + @EnumSource(BridgeVersion::class) + fun `Given changing serverId then uses current serverId each time`(bridgeVersion: BridgeVersion) = runTest { + var currentServerId = 1 + val bridge = createBridge( + stateProvider = { BridgeState(serverId = currentServerId, url = serverUrl) }, + scope = this, + ) + val invoke = attach(bridge, bridgeVersion) + val payload = """{"callback":"externalAuthSetToken","force":false}""" + val expectedPayload = AuthPayload(callback = "externalAuthSetToken", force = false) + + invoke("getExternalAuth", payload) + advanceUntilIdle() + + currentServerId = 2 + invoke("getExternalAuth", payload) + advanceUntilIdle() + + coVerify(exactly = 1) { handler.getExternalAuth(expectedPayload, 1) } + coVerify(exactly = 1) { handler.getExternalAuth(expectedPayload, 2) } + } + } + + @Nested + inner class CallbackValidation { + + @ParameterizedTest + @EnumSource(BridgeVersion::class) + fun `Given unexpected callback in getExternalAuth then handler is not called`(bridgeVersion: BridgeVersion) = runTest { + var failFastTriggered = false + FailFast.setHandler { _, _ -> failFastTriggered = true } + val bridge = createBridge(scope = this) + val invoke = attach(bridge, bridgeVersion) + + invoke("getExternalAuth", """{"callback":"unknown","force":false}""") + advanceUntilIdle() + + coVerify(exactly = 0) { handler.getExternalAuth(any(), any()) } + assertTrue(failFastTriggered) + } + + @ParameterizedTest + @EnumSource(BridgeVersion::class) + fun `Given unexpected callback in revokeExternalAuth then handler is not called`(bridgeVersion: BridgeVersion) = runTest { + var failFastTriggered = false + FailFast.setHandler { _, _ -> failFastTriggered = true } + val bridge = createBridge(scope = this) + val invoke = attach(bridge, bridgeVersion) + + invoke("revokeExternalAuth", """{"callback":"unknown"}""") + advanceUntilIdle() + + coVerify(exactly = 0) { handler.revokeExternalAuth(any(), any()) } + assertTrue(failFastTriggered) + } + } + + @Nested + inner class Deserialization { + + @Test + fun `Given unknown type in JSON when deserialized then produces Unknown variant`() { + val json = Json(frontendExternalBusJson) { + serializersModule += BridgeMessage.serializersModule + } + val input = """{"type":"futureFeature","payload":{"key":"value"}}""" + + val result = json.decodeFromString(input) + + assertTrue(result is BridgeMessage.Unknown) + } + + @Test + fun `Given known type in JSON when deserialized then produces correct variant`() { + val json = Json(frontendExternalBusJson) { + serializersModule += BridgeMessage.serializersModule + } + val input = """{"type":"getExternalAuth","payload":{"callback":"externalAuthSetToken","force":true}}""" + + val result = json.decodeFromString(input) + + assertTrue(result is BridgeMessage.GetExternalAuth) + val auth = result as BridgeMessage.GetExternalAuth + assertEquals(AuthPayload(callback = "externalAuthSetToken", force = true), auth.payload) + } + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModelTest.kt index f73a4f64924..d1770a67311 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModelTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModelTest.kt @@ -58,7 +58,6 @@ class ConnectionViewModelTest { create( currentUrlFlow = any>(), onFrontendError = any(), - frontendJsCallback = any(), onCrash = any(), onUrlIntercepted = any(), onPageFinished = any(), @@ -68,10 +67,9 @@ class ConnectionViewModelTest { keyChainRepository = keyChainRepository, currentUrlFlow = firstArg(), onFrontendError = secondArg(), - frontendJsCallback = thirdArg(), - onCrash = arg(3), - onUrlIntercepted = arg(4), - onPageFinished = arg(5), + onCrash = thirdArg(), + onUrlIntercepted = arg(3), + onPageFinished = arg(4), ) } } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/util/HAWebViewClientTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/util/HAWebViewClientTest.kt index 6dbda3b093c..633ee9cbfc4 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/util/HAWebViewClientTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/util/HAWebViewClientTest.kt @@ -45,7 +45,6 @@ class HAWebViewClientTest { keyChainRepository = keyChainRepository, currentUrlFlow = currentUrlFlow, onFrontendError = { capturedError = it }, - frontendJsCallback = null, onCrash = null, onUrlIntercepted = null, onPageFinished = null, diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/impl/entities/SocketResponse.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/impl/entities/SocketResponse.kt index 407760535c8..16328c9df0c 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/impl/entities/SocketResponse.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/impl/entities/SocketResponse.kt @@ -17,11 +17,11 @@ internal sealed interface SocketResponse { * This module needs to be given to the Json builder to be able to deserialize unknown types. * Without this module, a runtime exception will be thrown for received messages with an unknown type. */ - internal val socketResponseSerializerModuler = SerializersModule { - polymorphicDefaultDeserializer(SocketResponse::class) { + internal val socketResponseSerializerModule = SerializersModule { + polymorphicDefaultDeserializer(SocketResponse::class) { className -> object : UnknownJsonContentDeserializer() { override val builder = UnknownJsonContentBuilder { content -> - UnknownTypeSocketResponse(content) + UnknownTypeSocketResponse(className, content) } } } @@ -33,7 +33,7 @@ internal sealed interface SocketResponse { * This class is used as fallback when the type received is not known within the codebase. * The type can be found directly within the [content]. */ -internal data class UnknownTypeSocketResponse(override val content: JsonElement) : +internal data class UnknownTypeSocketResponse(override val discriminator: String?, override val content: JsonElement) : SocketResponse, UnknownJsonContent diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/FailFast.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/FailFast.kt index 75d014d123e..ba754128252 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/FailFast.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/FailFast.kt @@ -82,14 +82,21 @@ object FailFast { * If the `condition` evaluates to `true`, a [FailFastException] is created with the provided * `message` and passed to the [FailFastHandler]. * + * Returns the [condition] value so it can be used as an inline guard: + * ```kotlin + * if (FailFast.failWhen(invalid) { "Unexpected state" }) return + * ``` + * * @param condition The boolean condition to check. If true, the handler is triggered. * @param message A lambda function that returns the message for the [FailFastException]. * This is evaluated only if the condition is true. + * @return The [condition] value, allowing callers to branch on the same check */ - fun failWhen(condition: Boolean, message: () -> String) { + fun failWhen(condition: Boolean, message: () -> String): Boolean { if (condition) { fail(message) } + return condition } /** diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/JsonUtil.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/JsonUtil.kt index ead5ae01217..cef49ed54ce 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/JsonUtil.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/JsonUtil.kt @@ -65,7 +65,7 @@ val kotlinJsonMapper = Json { encodeDefaults = true prettyPrint = false // explicitNulls = true // default is to print null values in the JSON send if you don't want this behavior you need a custom serializer - serializersModule += SocketResponse.socketResponseSerializerModuler + serializersModule += SocketResponse.socketResponseSerializerModule } /** @@ -99,9 +99,13 @@ class LocalDateTimeSerializer : KSerializer { * where some types are well-known and explicitly handled, while others are unknown * and need to be captured as raw JSON content. * + * @property discriminator The unrecognized type discriminator value, + * or `null` if the discriminator was missing from the JSON input. + * Useful for logging which unknown type was received. * @property content The raw JSON content of the object. */ interface UnknownJsonContent { + val discriminator: String? val content: JsonElement } @@ -139,14 +143,17 @@ fun interface UnknownJsonContentBuilder { * data class KnownType(val data: String) : MyResponse * * @Serializable - * data class UnknownType(override val content: JsonElement) : MyResponse, UnknownJsonContent + * data class UnknownType( + * override val discriminator: String?, + * override val content: JsonElement, + * ) : MyResponse, UnknownJsonContent * } * * val module = SerializersModule { - * polymorphicDefaultDeserializer(MyResponse::class) { + * polymorphicDefaultDeserializer(MyResponse::class) { className -> * object : UnknownJsonContentDeserializer() { * override val builder = UnknownJsonContentBuilder { content -> - * MyResponse.UnknownType(content) + * MyResponse.UnknownType(discriminator = className, content = content) * } * } * } diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/util/FailFastTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/util/FailFastTest.kt index ef6a8a5b654..cb619ddaf98 100644 --- a/common/src/test/kotlin/io/homeassistant/companion/android/common/util/FailFastTest.kt +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/util/FailFastTest.kt @@ -2,6 +2,8 @@ package io.homeassistant.companion.android.common.util import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertNull @@ -51,13 +53,13 @@ class FailFastTest { @Test fun `Given condition not met when invoking failWhen then returns value`() { - FailFast.failWhen(false) { "fail" } + assertFalse(FailFast.failWhen(false) { "fail" }) assertNull(exceptionCaught) } @Test fun `Given condition met when invoking failWhen then properly propagate the error`() { - FailFast.failWhen(true) { "expected error" } + assertTrue(FailFast.failWhen(true) { "expected error" }) assertEquals("expected error", exceptionCaught?.message) } }