From 88756f8f9cdc17050d4472770fbfa7f0f46fa00f Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:40:21 +0200 Subject: [PATCH 1/4] Add Add to message in/out and improve Outgoing Result class usage --- .../android/frontend/FrontendViewModel.kt | 3 +- .../incoming/IncomingExternalBusMessage.kt | 21 ++- .../outgoing/OutgoingExternalBusMessage.kt | 142 +++++++++++------- .../handler/FrontendMessageHandler.kt | 17 +-- .../android/frontend/FrontendViewModelTest.kt | 6 +- .../FrontendExternalBusRepositoryImplTest.kt | 18 +-- .../IncomingExternalBusMessageTest.kt | 25 +++ .../OutgoingExternalBusMessageTest.kt | 34 ++++- 8 files changed, 179 insertions(+), 87 deletions(-) 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 d70bfa2903c..5b51f91c3db 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 @@ -22,6 +22,7 @@ import io.homeassistant.companion.android.frontend.error.FrontendConnectionError import io.homeassistant.companion.android.frontend.exoplayer.FrontendExoPlayerManager import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalBusRepository import io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage +import io.homeassistant.companion.android.frontend.externalbus.outgoing.SuccessResultMessage import io.homeassistant.companion.android.frontend.filechooser.FileChooserManager import io.homeassistant.companion.android.frontend.filechooser.FileChooserRequest import io.homeassistant.companion.android.frontend.gesture.FrontendGestureHandler @@ -431,7 +432,7 @@ internal class FrontendViewModel @VisibleForTesting constructor( */ fun onNfcWriteCompleted(messageId: Int) { viewModelScope.launch { - externalBusRepository.send(ResultMessage.success(messageId)) + externalBusRepository.send(SuccessResultMessage(messageId)) } } 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 f457f6e9c11..0ce4a90c264 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 @@ -147,7 +147,7 @@ data class HapticMessage(override val id: Int? = null, val payload: HapticType) * Message requesting the app to open the NFC tag-write flow. * * The optional [TagWritePayload.tag] is a pre-filled tag identifier. When null or missing, the - * user is prompted to enter/scan a tag manually. Once handled, a [io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage.success] + * user is prompted to enter/scan a tag manually. Once handled, a [io.homeassistant.companion.android.frontend.externalbus.outgoing.SuccessResultMessage] * should be sent back to the frontend with the [id]. */ @Serializable @@ -216,3 +216,22 @@ data class ExoPlayerResizePayload( val right: Double = 0.0, val bottom: Double = 0.0, ) + +@Serializable +@SerialName("entity/add_to/get_actions") +data class EntityAddToGetActionsMessage(override val id: Int? = null, val payload: EntityAddToGetActionsPayload) : + IncomingExternalBusMessage + +@Serializable +data class EntityAddToGetActionsPayload(@SerialName("entity_id") val entityId: String) + +@Serializable +@SerialName("entity/add_to") +data class EntityAddToMessage(override val id: Int? = null, val payload: EntityAddToPayload) : + IncomingExternalBusMessage + +@Serializable +data class EntityAddToPayload( + @SerialName("entity_id") val entityId: String, + @SerialName("app_payload") val appPayload: String, +) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt index 0c7119d0903..2443422ec0c 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt @@ -1,8 +1,9 @@ package io.homeassistant.companion.android.frontend.externalbus.outgoing +import androidx.annotation.VisibleForTesting import io.homeassistant.companion.android.common.util.AppVersion import io.homeassistant.companion.android.frontend.externalbus.frontendExternalBusJson -import io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage.Companion.config +import io.homeassistant.companion.android.webview.externalbus.ExternalEntityAddToAction import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement @@ -31,70 +32,105 @@ sealed interface OutgoingExternalBusMessage { */ @Serializable @SerialName("result") -data class ResultMessage( +@VisibleForTesting +internal data class ResultMessage( override val id: Int?, val success: Boolean = true, val result: JsonElement = JsonNull, val error: JsonElement = JsonNull, -) : OutgoingExternalBusMessage { +) : OutgoingExternalBusMessage - companion object { +object SuccessResultMessage { + operator fun invoke(id: Int?): OutgoingExternalBusMessage { + return ResultMessage( + id = id, + result = JsonObject(emptyMap()), + ) + } +} - fun success(id: Int?): ResultMessage { - return ResultMessage( - id = id, - result = JsonObject(emptyMap()), - ) - } +object ConfigResultMessage { + /** + * Creates a config response with app capabilities. + * + * @param id The message ID from the config/get request + */ + operator fun invoke( + id: Int?, + hasNfc: Boolean, + canCommissionMatter: Boolean, + canExportThread: Boolean, + hasBarCodeScanner: Int, + appVersion: AppVersion, + ): OutgoingExternalBusMessage { + return ResultMessage( + id = id, + result = frontendExternalBusJson.encodeToJsonElement( + ConfigResult.create( + hasNfc, + canCommissionMatter, + canExportThread, + hasBarCodeScanner, + appVersion, + ), + ), + ) + } - /** - * Creates a config response with app capabilities. - * - * @param id The message ID from the config/get request - * @param config The app capabilities configuration - */ - fun config(id: Int?, config: ConfigResult): ResultMessage { - return ResultMessage( - id = id, - result = frontendExternalBusJson.encodeToJsonElement(config), + /** + * Configuration result payload for config/get requests. + * + * Contains the app's capabilities that the frontend needs to know about. + */ + @Serializable + private data class ConfigResult( + val hasSettingsScreen: Boolean = true, + val canWriteTag: Boolean, + val hasExoPlayer: Boolean = true, + val canCommissionMatter: Boolean, + val canImportThreadCredentials: Boolean, + val hasAssist: Boolean = true, + val hasBarCodeScanner: Int, + val canSetupImprov: Boolean = true, + val downloadFileSupported: Boolean = true, + val appVersion: String, + val hasEntityAddTo: Boolean = true, + val hasAssistSettings: Boolean = true, + ) { + companion object { + fun create( + hasNfc: Boolean, + canCommissionMatter: Boolean, + canExportThread: Boolean, + hasBarCodeScanner: Int, + appVersion: AppVersion, + ) = ConfigResult( + canWriteTag = hasNfc, + canCommissionMatter = canCommissionMatter, + canImportThreadCredentials = canExportThread, + hasBarCodeScanner = hasBarCodeScanner, + appVersion = appVersion.value, ) } } } -/** - * Configuration result payload for config/get requests. - * - * Contains the app's capabilities that the frontend needs to know about. - */ -@Serializable -data class ConfigResult( - val hasSettingsScreen: Boolean = true, - val canWriteTag: Boolean, - val hasExoPlayer: Boolean = true, - val canCommissionMatter: Boolean, - val canImportThreadCredentials: Boolean, - val hasAssist: Boolean = true, - val hasBarCodeScanner: Int, - val canSetupImprov: Boolean = true, - val downloadFileSupported: Boolean = true, - val appVersion: String, - val hasEntityAddTo: Boolean = true, - val hasAssistSettings: Boolean = true, -) { - companion object { - fun create( - hasNfc: Boolean, - canCommissionMatter: Boolean, - canExportThread: Boolean, - hasBarCodeScanner: Int, - appVersion: AppVersion, - ) = ConfigResult( - canWriteTag = hasNfc, - canCommissionMatter = canCommissionMatter, - canImportThreadCredentials = canExportThread, - hasBarCodeScanner = hasBarCodeScanner, - appVersion = appVersion.value, +object EntityAddToActionsResultMessage { + /** + * Creates a response with available EntityAddTo actions. + * + * @param id The message ID from the entity/add_to/get_actions request + * @param actions The available actions for the entity + */ + operator fun invoke(id: Int?, actions: List): OutgoingExternalBusMessage { + return ResultMessage( + id = id, + result = frontendExternalBusJson.encodeToJsonElement( + EntityAddToActionsResult(actions = actions), + ), ) } + + @Serializable + private data class EntityAddToActionsResult(val actions: List) } 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 2b0dd7953f7..ab9ea55b7d6 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 @@ -23,8 +23,7 @@ import io.homeassistant.companion.android.frontend.externalbus.incoming.OpenSett import io.homeassistant.companion.android.frontend.externalbus.incoming.TagWriteMessage import io.homeassistant.companion.android.frontend.externalbus.incoming.ThemeUpdateMessage 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.externalbus.outgoing.ConfigResultMessage import io.homeassistant.companion.android.frontend.js.FrontendJsHandler import io.homeassistant.companion.android.frontend.session.AuthPayload import io.homeassistant.companion.android.frontend.session.ExternalAuthResult @@ -243,15 +242,13 @@ class FrontendMessageHandler @Inject constructor( 0 } - val response = ResultMessage.config( + val response = ConfigResultMessage( id = messageId, - config = ConfigResult.create( - hasNfc = hasNfc, - canCommissionMatter = canCommissionMatter, - canExportThread = canExportThread, - hasBarCodeScanner = hasBarCodeScanner, - appVersion = appVersionProvider(), - ), + hasNfc = hasNfc, + canCommissionMatter = canCommissionMatter, + canExportThread = canExportThread, + hasBarCodeScanner = hasBarCodeScanner, + appVersion = appVersionProvider(), ) externalBusRepository.send(response) } 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 01abe514276..c6eff29ed6b 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 @@ -29,6 +29,7 @@ import io.homeassistant.companion.android.frontend.exoplayer.FrontendExoPlayerMa import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalBusRepository import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType import io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage +import io.homeassistant.companion.android.frontend.externalbus.outgoing.SuccessResultMessage import io.homeassistant.companion.android.frontend.filechooser.FileChooserManager import io.homeassistant.companion.android.frontend.gesture.FrontendGestureHandler import io.homeassistant.companion.android.frontend.gesture.GestureResult @@ -62,7 +63,6 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.JsonObject import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Assertions.assertNotNull @@ -716,9 +716,7 @@ class FrontendViewModelTest { advanceUntilIdle() coVerify { - externalBusRepository.send( - ResultMessage(id = 42, success = true, result = JsonObject(emptyMap())), - ) + externalBusRepository.send(SuccessResultMessage(id = 42)) } } } 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 c0aa09c6b37..fc5c5e8faeb 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 @@ -1,14 +1,14 @@ package io.homeassistant.companion.android.frontend.externalbus import app.cash.turbine.test +import io.homeassistant.companion.android.common.util.AppVersion import io.homeassistant.companion.android.frontend.EvaluateJavascriptUsage import io.homeassistant.companion.android.frontend.WebViewAction import io.homeassistant.companion.android.frontend.externalbus.incoming.ConfigGetMessage import io.homeassistant.companion.android.frontend.externalbus.incoming.ConnectionStatusMessage 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.ConfigResultMessage import io.homeassistant.companion.android.frontend.externalbus.outgoing.OutgoingExternalBusMessage -import io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json @@ -109,15 +109,13 @@ class FrontendExternalBusRepositoryImplTest { @Test fun `Given outgoing message when sent then emit as EvaluateScript on webViewActions flow`() = runTest { - val configResponse = ResultMessage.config( + val configResponse = ConfigResultMessage( id = 1, - config = ConfigResult( - canWriteTag = true, - canCommissionMatter = false, - canImportThreadCredentials = true, - hasBarCodeScanner = 2, - appVersion = "1.0.0", - ), + hasNfc = true, + canCommissionMatter = false, + canExportThread = true, + hasBarCodeScanner = 2, + appVersion = AppVersion.from("1.0.0"), ) repository.webViewActions().test { diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessageTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessageTest.kt index 1409730d4e6..0b18ff42843 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessageTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessageTest.kt @@ -216,4 +216,29 @@ class IncomingExternalBusMessageTest { assertEquals(0.0, resize.payload.right) assertEquals(0.0, resize.payload.bottom) } + + @Test + fun `Given entity add_to get_actions JSON then parses to EntityAddToGetActionsMessage`() { + val json = """{"type":"entity/add_to/get_actions","id":20,"payload":{"entity_id":"light.living_room"}}""" + + val message = frontendExternalBusJson.decodeFromString(json) + + assertInstanceOf(EntityAddToGetActionsMessage::class.java, message) + val addToMessage = message as EntityAddToGetActionsMessage + assertEquals(20, addToMessage.id) + assertEquals("light.living_room", addToMessage.payload.entityId) + } + + @Test + fun `Given entity add_to JSON then parses to EntityAddToMessage`() { + val json = """{"type":"entity/add_to","id":21,"payload":{"entity_id":"light.living_room","app_payload":"dGVzdA=="}}""" + + val message = frontendExternalBusJson.decodeFromString(json) + + assertInstanceOf(EntityAddToMessage::class.java, message) + val addToMessage = message as EntityAddToMessage + assertEquals(21, addToMessage.id) + assertEquals("light.living_room", addToMessage.payload.entityId) + assertEquals("dGVzdA==", addToMessage.payload.appPayload) + } } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessageTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessageTest.kt index af77011d742..9d6c1844091 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessageTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessageTest.kt @@ -3,6 +3,7 @@ package io.homeassistant.companion.android.frontend.externalbus.outgoing import io.homeassistant.companion.android.common.util.AppVersion import io.homeassistant.companion.android.frontend.externalbus.frontendExternalBusJson import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension +import io.homeassistant.companion.android.webview.externalbus.ExternalEntityAddToAction import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -14,15 +15,13 @@ class OutgoingExternalBusMessageTest { @Test fun `Given a result config message when serializing then it generates valid JSON`() { val json = frontendExternalBusJson.encodeToString( - ResultMessage.config( + ConfigResultMessage( id = 1, - config = ConfigResult.create( - hasNfc = true, - canCommissionMatter = true, - canExportThread = true, - hasBarCodeScanner = 0, - appVersion = AppVersion.from("1.0.0 (1)"), - ), + hasNfc = true, + canCommissionMatter = true, + canExportThread = true, + hasBarCodeScanner = 0, + appVersion = AppVersion.from("1.0.0 (1)"), ), ) assertEquals( @@ -49,4 +48,23 @@ class OutgoingExternalBusMessageTest { assertTrue(config.hasEntityAddTo) assertTrue(config.hasAssistSettings) } + + @Test + fun `Given EntityAddToActions response when serializing then JSON uses snake_case fields`() { + val actions = listOf( + ExternalEntityAddToAction( + appPayload = "dGVzdA==", + enabled = true, + name = "Entity Widget", + details = null, + mdiIcon = "mdi:shape", + ), + ) + val json = frontendExternalBusJson.encodeToString(EntityAddToActionsResultMessage(id = 20, actions = actions)) + + assertEquals( + """{"type":"result","id":20,"success":true,"result":{"actions":[{"app_payload":"dGVzdA==","enabled":true,"name":"Entity Widget","details":null,"mdi_icon":"mdi:shape"}]},"error":null}""", + json, + ) + } } From 17b3388a6ed81b756f0ed36a3fe11fea08203a75 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:13:03 +0200 Subject: [PATCH 2/4] Replace EntityAddToHandler per FrontendEntityAddToHandler --- .../addto/FrontendEntityAddToHandler.kt | 153 ++++++++ .../outgoing/OutgoingExternalBusMessage.kt | 3 +- .../frontend/navigation/FrontendEvent.kt | 8 + .../frontend/navigation/FrontendNavigation.kt | 9 + .../android/frontend/navigation/WidgetType.kt | 42 +++ .../android/webview/WebViewActivity.kt | 29 +- .../webview/addto/EntityAddToHandler.kt | 182 ---------- .../OutgoingExternalBusMessageTest.kt | 2 +- .../webview/addto/EntityAddToHandlerTest.kt | 335 ------------------ 9 files changed, 232 insertions(+), 531 deletions(-) create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/frontend/addto/FrontendEntityAddToHandler.kt create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/WidgetType.kt delete mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/webview/addto/EntityAddToHandler.kt delete mode 100644 app/src/test/kotlin/io/homeassistant/companion/android/webview/addto/EntityAddToHandlerTest.kt diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/addto/FrontendEntityAddToHandler.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/addto/FrontendEntityAddToHandler.kt new file mode 100644 index 00000000000..2175d673f33 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/addto/FrontendEntityAddToHandler.kt @@ -0,0 +1,153 @@ +package io.homeassistant.companion.android.frontend.addto + +import android.content.Context +import androidx.annotation.VisibleForTesting +import dagger.hilt.android.qualifiers.ApplicationContext +import io.homeassistant.companion.android.BuildConfig +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.CAMERA_DOMAIN +import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.IMAGE_DOMAIN +import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.MEDIA_PLAYER_DOMAIN +import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.TODO_DOMAIN +import io.homeassistant.companion.android.common.data.prefs.AutoFavorite +import io.homeassistant.companion.android.common.data.prefs.PrefsRepository +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.common.util.FailFast +import io.homeassistant.companion.android.di.qualifiers.IsAutomotive +import io.homeassistant.companion.android.frontend.navigation.FrontendEvent +import io.homeassistant.companion.android.frontend.navigation.WidgetType +import io.homeassistant.companion.android.util.QuestUtil +import io.homeassistant.companion.android.util.vehicle.isVehicleDomain +import io.homeassistant.companion.android.webview.addto.EntityAddToAction +import io.homeassistant.companion.android.webview.externalbus.ExternalEntityAddToAction +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber + +/** + * Handles the "Add To" functionality for Home Assistant entities, allowing users to add entities + * to various Android platform features and connected devices. + * + * This class provides two main capabilities: + * 1. Determining which actions are available for a given entity based on its type and domain + * 2. Executing the selected action to add the entity to the chosen platform feature + * + * The available actions depend on the entity's domain and current system state (for example, watch connectivity, + * shortcut limits). + */ +class FrontendEntityAddToHandler @VisibleForTesting constructor( + private val context: Context, + private val serverManager: ServerManager, + private val prefsRepository: PrefsRepository, + private val isAutomotive: Boolean, + private val isQuest: Boolean, + private val isFullFlavor: Boolean, +) { + + @Inject + constructor( + @ApplicationContext context: Context, + serverManager: ServerManager, + prefsRepository: PrefsRepository, + @IsAutomotive isAutomotive: Boolean, + ) : this( + context = context, + serverManager = serverManager, + prefsRepository = prefsRepository, + isAutomotive = isAutomotive, + isQuest = QuestUtil.isQuest, + isFullFlavor = BuildConfig.FLAVOR == "full", + ) + + /** + * Returns the list of available actions for the specified entity. + * + * The available actions depend on the entity's domain. For example, media player entities + * will include both the general entity widget and a specialized media player widget option. + * Vehicle-related entities will include Android Auto favorites. Camera and image entities + * will include camera widget options. + * + * @param entityId The entity ID to get available actions for (for example, "light.living_room") + * @return List of actions that can be performed for this entity. Returns an empty list if the + * entity is not found or if the server is unavailable. + */ + suspend fun getActionsForEntity(entityId: String): List = + withContext(Dispatchers.Default) { + val server = serverManager.getServer() ?: return@withContext emptyList() + val entity = try { + serverManager.integrationRepository(server.id).getEntity(entityId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to get entity for id $entityId") + null + } ?: return@withContext emptyList() + + val actions = mutableListOf() + + if (!isAutomotive && !isQuest) { + actions.add(EntityAddToAction.EntityWidget) + + if (entity.domain == MEDIA_PLAYER_DOMAIN) { + actions.add(EntityAddToAction.MediaPlayerWidget) + } + if (entity.domain == TODO_DOMAIN) { + actions.add(EntityAddToAction.TodoWidget) + } + if (entity.domain == CAMERA_DOMAIN || entity.domain == IMAGE_DOMAIN) { + actions.add(EntityAddToAction.CameraWidget) + } + } + + if (isVehicleDomain(entity) && (isFullFlavor || isAutomotive)) { + actions.add(EntityAddToAction.AndroidAutoFavorite) + } + + actions.map { action -> ExternalEntityAddToAction.fromAction(context, action) } + } + + /** + * Executes an EntityAddTo action and returns the corresponding [FrontendEvent]. + * + * For [EntityAddToAction.AndroidAutoFavorite], persists the favorite and returns a snackbar event. + * For widget actions, returns a [FrontendEvent.LaunchWidgetConfig]. + * For unimplemented actions (Shortcut, Tile, Watch), returns null. + */ + suspend fun execute(entityId: String, action: EntityAddToAction): FrontendEvent? { + return when (action) { + is EntityAddToAction.AndroidAutoFavorite -> { + addToAndroidAutoFavorite(entityId) + FrontendEvent.ShowSnackbar(commonR.string.add_to_android_auto_success) + } + is EntityAddToAction.EntityWidget -> + FrontendEvent.LaunchWidgetConfig(entityId, WidgetType.Entity) + is EntityAddToAction.MediaPlayerWidget -> + FrontendEvent.LaunchWidgetConfig(entityId, WidgetType.MediaPlayer) + is EntityAddToAction.CameraWidget -> + FrontendEvent.LaunchWidgetConfig(entityId, WidgetType.Camera) + is EntityAddToAction.TodoWidget -> + FrontendEvent.LaunchWidgetConfig(entityId, WidgetType.Todo) + is EntityAddToAction.Shortcut, + is EntityAddToAction.Tile, + is EntityAddToAction.Watch, + -> null + } + } + + /** + * Persists the entity as an Android Auto favorite for the active server. + * + * Called from both the frontend [execute] path and the legacy webview handler so + * the favorite-storage logic stays in a single place. + */ + private suspend fun addToAndroidAutoFavorite(entityId: String) { + val serverId = serverManager.getServer()?.id + if (serverId != null) { + prefsRepository.addAutoFavorite(AutoFavorite(serverId, entityId)) + } else { + FailFast.fail { "Server is null when adding auto favorite" } + } + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt index 2443422ec0c..df0f10bea89 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt @@ -83,7 +83,8 @@ object ConfigResultMessage { * Contains the app's capabilities that the frontend needs to know about. */ @Serializable - private data class ConfigResult( + @VisibleForTesting + data class ConfigResult( val hasSettingsScreen: Boolean = true, val canWriteTag: Boolean, val hasExoPlayer: Boolean = true, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendEvent.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendEvent.kt index acb24856d33..da376fe07d3 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendEvent.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendEvent.kt @@ -64,4 +64,12 @@ sealed interface FrontendEvent { * the preference already enables fullscreen, a `false` request won't leave fullscreen. */ data class RequestFullscreen(val fullscreen: Boolean) : FrontendEvent + + /** + * Launch a widget configuration screen for the given entity. + * + * @param entityId The entity to pre-fill in the widget configuration + * @param widgetType The type of widget to configure + */ + data class LaunchWidgetConfig(val entityId: String, val widgetType: WidgetType) : FrontendEvent } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt index efed3e71bfb..e888a00ed81 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt @@ -101,6 +101,10 @@ internal fun NavGraphBuilder.frontendScreen( nfcWriteLauncher.launch(WriteNfcTag.Input(tagId = tagId, messageId = messageId)) }, onRequestFullscreen = onRequestFullscreen, + onLaunchWidgetConfig = { entityId, widgetType -> + val context = navController.context + context.startActivity(widgetType.toConfigureIntent(context, entityId)) + }, ) FrontendScreen( @@ -144,6 +148,7 @@ internal fun FrontendEventHandler( onShowServerSwitcher: () -> Unit, onNavigateToNfcWrite: (messageId: Int, tagId: String?) -> Unit, onRequestFullscreen: (Boolean) -> Unit, + onLaunchWidgetConfig: (entityId: String, widgetType: WidgetType) -> Unit, ) { val resources = LocalResources.current LaunchedEffect(Unit) { @@ -184,6 +189,10 @@ internal fun FrontendEventHandler( is FrontendEvent.RequestFullscreen -> { onRequestFullscreen(event.fullscreen) } + + is FrontendEvent.LaunchWidgetConfig -> { + onLaunchWidgetConfig(event.entityId, event.widgetType) + } } } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/WidgetType.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/WidgetType.kt new file mode 100644 index 00000000000..812a7de1568 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/WidgetType.kt @@ -0,0 +1,42 @@ +package io.homeassistant.companion.android.frontend.navigation + +import android.content.Context +import android.content.Intent +import io.homeassistant.companion.android.widgets.camera.CameraWidgetConfigureActivity +import io.homeassistant.companion.android.widgets.entity.EntityWidgetConfigureActivity +import io.homeassistant.companion.android.widgets.mediaplayer.MediaPlayerControlsWidgetConfigureActivity +import io.homeassistant.companion.android.widgets.todo.TodoWidgetConfigureActivity + +/** + * Widget types that can be configured via the EntityAddTo flow. + * + * Each variant knows how to build the configuration [Intent] for its underlying widget activity, + * so callers can launch the right configure screen. + */ +sealed interface WidgetType { + + /** + * Builds the configuration [Intent] for this widget type, pre-filled with [entityId]. + */ + fun toConfigureIntent(context: Context, entityId: String): Intent + + data object Entity : WidgetType { + override fun toConfigureIntent(context: Context, entityId: String): Intent = + EntityWidgetConfigureActivity.newInstance(context, entityId) + } + + data object MediaPlayer : WidgetType { + override fun toConfigureIntent(context: Context, entityId: String): Intent = + MediaPlayerControlsWidgetConfigureActivity.newInstance(context, entityId) + } + + data object Camera : WidgetType { + override fun toConfigureIntent(context: Context, entityId: String): Intent = + CameraWidgetConfigureActivity.newInstance(context, entityId) + } + + data object Todo : WidgetType { + override fun toConfigureIntent(context: Context, entityId: String): Intent = + TodoWidgetConfigureActivity.newInstance(context, entityId) + } +} 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 6a1ede4c700..d9d920913d5 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 @@ -119,6 +119,7 @@ import io.homeassistant.companion.android.database.authentication.Authentication import io.homeassistant.companion.android.database.server.ServerConnectionInfo import io.homeassistant.companion.android.databinding.DialogAuthenticationBinding import io.homeassistant.companion.android.frontend.EvaluateJavascriptUsage +import io.homeassistant.companion.android.frontend.addto.FrontendEntityAddToHandler 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 @@ -127,6 +128,7 @@ import io.homeassistant.companion.android.frontend.js.FrontendJsBridge.Companion import io.homeassistant.companion.android.frontend.js.FrontendJsBridge.Companion.EXTERNAL_APP_V2_LISTENER import io.homeassistant.companion.android.frontend.js.FrontendJsBridge.Companion.externalBusCallback import io.homeassistant.companion.android.frontend.js.FrontendJsBridge.Companion.isServerSupportingExternalAppV2 +import io.homeassistant.companion.android.frontend.navigation.FrontendEvent import io.homeassistant.companion.android.improv.ui.ImprovPermissionDialog import io.homeassistant.companion.android.improv.ui.ImprovSetupDialog import io.homeassistant.companion.android.launch.LaunchActivity @@ -151,7 +153,6 @@ import io.homeassistant.companion.android.util.isStarted import io.homeassistant.companion.android.util.sensitive import io.homeassistant.companion.android.websocket.WebsocketManager import io.homeassistant.companion.android.webview.WebView.ErrorType -import io.homeassistant.companion.android.webview.addto.EntityAddToHandler import io.homeassistant.companion.android.webview.externalbus.EntityAddToActionsResponse import io.homeassistant.companion.android.webview.externalbus.ExternalBusMessage import io.homeassistant.companion.android.webview.externalbus.ExternalConfigResponse @@ -259,7 +260,7 @@ class WebViewActivity : lateinit var appVersionProvider: AppVersionProvider @Inject - lateinit var entityAddToHandler: EntityAddToHandler + lateinit var entityAddToHandler: FrontendEntityAddToHandler @Inject lateinit var dataUriDownloadManager: DataUriDownloadManager @@ -1182,12 +1183,18 @@ class WebViewActivity : if (entityId != null && appPayload != null) { val action = ExternalEntityAddToAction.appPayloadToAction(appPayload) lifecycleScope.launch { - entityAddToHandler.execute(this@WebViewActivity, action, entityId) { message, action -> - snackbarHostState.showSnackbar( - message, - action, - duration = SnackbarDuration.Short, - ) == SnackbarResult.ActionPerformed + when (val event = entityAddToHandler.execute(entityId, action)) { + is FrontendEvent.LaunchWidgetConfig -> { + startActivity(event.widgetType.toConfigureIntent(this@WebViewActivity, event.entityId)) + } + is FrontendEvent.ShowSnackbar -> { + snackbarHostState.showSnackbar( + getString(event.messageResId), + duration = SnackbarDuration.Short, + ) + } + null -> Unit + else -> FailFast.fail { "Unexpected event $event from EntityAddTo execute" } } } } else { @@ -1200,13 +1207,11 @@ class WebViewActivity : val entityId = payload?.getStringOrNull("entity_id") entityId?.let { lifecycleScope.launch { - val actions = entityAddToHandler.actionsForEntity(this@WebViewActivity, entityId) + val actions = entityAddToHandler.getActionsForEntity(entityId) sendExternalBusMessage( EntityAddToActionsResponse( id = json["id"], - actions = actions.map { action -> - ExternalEntityAddToAction.fromAction(this@WebViewActivity, action) - }, + actions = actions, ), ) } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/addto/EntityAddToHandler.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/addto/EntityAddToHandler.kt deleted file mode 100644 index ddb62065e9d..00000000000 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/addto/EntityAddToHandler.kt +++ /dev/null @@ -1,182 +0,0 @@ -package io.homeassistant.companion.android.webview.addto - -import android.content.Context -import androidx.annotation.VisibleForTesting -import io.homeassistant.companion.android.BuildConfig -import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.CAMERA_DOMAIN -import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.IMAGE_DOMAIN -import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.MEDIA_PLAYER_DOMAIN -import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.TODO_DOMAIN -import io.homeassistant.companion.android.common.data.prefs.AutoFavorite -import io.homeassistant.companion.android.common.data.prefs.PrefsRepository -import io.homeassistant.companion.android.common.data.servers.ServerManager -import io.homeassistant.companion.android.common.util.FailFast -import io.homeassistant.companion.android.common.util.isAutomotive -import io.homeassistant.companion.android.util.QuestUtil -import io.homeassistant.companion.android.util.vehicle.isVehicleDomain -import io.homeassistant.companion.android.widgets.camera.CameraWidgetConfigureActivity -import io.homeassistant.companion.android.widgets.entity.EntityWidgetConfigureActivity -import io.homeassistant.companion.android.widgets.mediaplayer.MediaPlayerControlsWidgetConfigureActivity -import io.homeassistant.companion.android.widgets.todo.TodoWidgetConfigureActivity -import javax.inject.Inject -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber - -/** - * Handles the "Add To" functionality for Home Assistant entities, allowing users to add entities - * to various Android platform features and connected devices. - * - * This class provides two main capabilities: - * 1. Determining which actions are available for a given entity based on its type and domain - * 2. Executing the selected action to add the entity to the chosen platform feature - * - * The available actions depend on the entity's domain and current system state (for example, watch connectivity, - * shortcut limits). - */ -class EntityAddToHandler @Inject constructor( - private val serverManager: ServerManager, - private val prefsRepository: PrefsRepository, -) { - - /** - * Returns the list of available actions for the specified entity. - * - * The available actions depend on the entity's domain. For example, media player entities - * will include both the general entity widget and a specialized media player widget option. - * Vehicle-related entities will include Android Auto favorites. Camera and image entities - * will include camera widget options. - * - * @param context An android context - * @param entityId The entity ID to get available actions for (for example, "light.living_room") - * @return List of actions that can be performed for this entity. Returns an empty list if the - * entity is not found or if the server is unavailable. - */ - suspend fun actionsForEntity(context: Context, entityId: String): List { - return actionsForEntity( - isFullFlavor = BuildConfig.FLAVOR == "full", - isAutomotive = context.isAutomotive(), - isQuest = QuestUtil.isQuest, - entityId, - ) - } - - @VisibleForTesting - suspend fun actionsForEntity( - isFullFlavor: Boolean, - isAutomotive: Boolean, - isQuest: Boolean, - entityId: String, - ): List { - return withContext(Dispatchers.Default) { - val actions = mutableListOf() - serverManager.getServer()?.let { server -> - try { - serverManager.integrationRepository(server.id).getEntity(entityId) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.e(e, "Failed to get entity for id $entityId") - null - }?.let { entity -> - if (!isAutomotive && !isQuest) { - actions.add(EntityAddToAction.EntityWidget) - - if (entity.domain == MEDIA_PLAYER_DOMAIN) { - actions.add(EntityAddToAction.MediaPlayerWidget) - } - - if (entity.domain == TODO_DOMAIN) { - actions.add(EntityAddToAction.TodoWidget) - } - - if (entity.domain == CAMERA_DOMAIN || entity.domain == IMAGE_DOMAIN) { - actions.add(EntityAddToAction.CameraWidget) - } - } - - if (isVehicleDomain(entity) && (isFullFlavor || isAutomotive)) { - // We could check if it already exist but the action won't do anything so we can keep it - actions.add(EntityAddToAction.AndroidAutoFavorite) - } - } - } - actions - } - } - - /** - * Executes the specified action to add the entity to the chosen platform feature. - * - * This function performs the appropriate operation based on the action type. - * - * @param context Android context used for starting activities and accessing system services - * @param action The action to execute (determines what platform feature to add the entity to) - * @param entityId The entity ID to add (for example, "light.living_room") - * @param onShowSnackbar A lambda to show a snackbar that takes a message and an action as parameters - * and returns true when the action is performed - */ - suspend fun execute( - context: Context, - action: EntityAddToAction, - entityId: String, - onShowSnackbar: suspend (message: String, action: String?) -> Boolean, - ) { - when (action) { - is EntityAddToAction.AndroidAutoFavorite -> { - addToAndroidAutoFavorite(entityId) - onShowSnackbar(context.getString(commonR.string.add_to_android_auto_success), null) - } - - is EntityAddToAction.Tile -> { - // TODO go to a new tile https://github.com/home-assistant/android/issues/5623 - } - is EntityAddToAction.EntityWidget -> { - context.startActivity( - EntityWidgetConfigureActivity.newInstance( - context = context, - entityId = entityId, - ), - ) - } - is EntityAddToAction.MediaPlayerWidget -> { - context.startActivity( - MediaPlayerControlsWidgetConfigureActivity.newInstance( - context = context, - entityId = entityId, - ), - ) - } - is EntityAddToAction.CameraWidget -> { - context.startActivity( - CameraWidgetConfigureActivity.newInstance( - context = context, - entityId = entityId, - ), - ) - } - is EntityAddToAction.TodoWidget -> { - context.startActivity( - TodoWidgetConfigureActivity.newInstance( - context = context, - entityId = entityId, - ), - ) - } - is EntityAddToAction.Shortcut -> { - // TODO support shortcut https://github.com/home-assistant/android/issues/5625 - } - is EntityAddToAction.Watch -> { - // TODO support watch favorite https://github.com/home-assistant/android/issues/5624 - } - } - } - - private suspend fun addToAndroidAutoFavorite(entityId: String) { - serverManager.getServer()?.id?.let { serverId -> - prefsRepository.addAutoFavorite(AutoFavorite(serverId, entityId)) - } ?: FailFast.fail { "Server is null" } - } -} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessageTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessageTest.kt index 9d6c1844091..31f4090069e 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessageTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessageTest.kt @@ -32,7 +32,7 @@ class OutgoingExternalBusMessageTest { @Test fun `Given ConfigResult then default values are correct`() { - val config = ConfigResult.create( + val config = ConfigResultMessage.ConfigResult.create( hasNfc = false, canCommissionMatter = false, canExportThread = false, diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/webview/addto/EntityAddToHandlerTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/webview/addto/EntityAddToHandlerTest.kt deleted file mode 100644 index 8afde6d7aac..00000000000 --- a/app/src/test/kotlin/io/homeassistant/companion/android/webview/addto/EntityAddToHandlerTest.kt +++ /dev/null @@ -1,335 +0,0 @@ -package io.homeassistant.companion.android.webview.addto - -import android.content.Context -import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.integration.Entity -import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.CAMERA_DOMAIN -import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.IMAGE_DOMAIN -import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.MEDIA_PLAYER_DOMAIN -import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.TODO_DOMAIN -import io.homeassistant.companion.android.common.data.integration.IntegrationRepository -import io.homeassistant.companion.android.common.data.prefs.AutoFavorite -import io.homeassistant.companion.android.common.data.prefs.PrefsRepository -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.testing.unit.ConsoleLogExtension -import io.homeassistant.companion.android.util.FailFastExtension -import io.homeassistant.companion.android.widgets.camera.CameraWidgetConfigureActivity -import io.homeassistant.companion.android.widgets.entity.EntityWidgetConfigureActivity -import io.homeassistant.companion.android.widgets.mediaplayer.MediaPlayerControlsWidgetConfigureActivity -import io.homeassistant.companion.android.widgets.todo.TodoWidgetConfigureActivity -import io.mockk.coEvery -import io.mockk.coJustRun -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.verify -import java.time.LocalDateTime -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertNotNull -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.api.fail -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource -import org.junit.jupiter.params.provider.ValueSource - -@ExperimentalCoroutinesApi -@ExtendWith(ConsoleLogExtension::class, FailFastExtension::class) -class EntityAddToHandlerTest { - - private lateinit var serverManager: ServerManager - private lateinit var prefsRepository: PrefsRepository - private lateinit var integrationRepository: IntegrationRepository - private lateinit var context: Context - private lateinit var handler: EntityAddToHandler - - private val serverId = 42 - - @BeforeEach - fun setUp() { - serverManager = mockk() - prefsRepository = mockk() - integrationRepository = mockk() - context = mockk(relaxed = true) - val server = mockk() - - every { server.id } returns serverId - coEvery { serverManager.integrationRepository(serverId) } returns integrationRepository - coEvery { serverManager.getServer() } returns server - handler = EntityAddToHandler(serverManager, prefsRepository) - - // Always override the handler so individual test can decide to override it or not without impacting the other tests - FailFast.setHandler { exception, _ -> - fail("Unhandled exception caught", exception) - } - } - - @Test - fun `Given AndroidAutoFavorite action when executing then it adds to favorite`() = runTest { - val entityId = "vehicle.test" - val action = EntityAddToAction.AndroidAutoFavorite - val onShowSnackbar: suspend (String, String?) -> Boolean = mockk(relaxed = true) - coJustRun { prefsRepository.addAutoFavorite(any()) } - every { context.getString(commonR.string.add_to_android_auto_success) } returns "hello" - - handler.execute(context, action, entityId, onShowSnackbar) - - coVerify { - prefsRepository.addAutoFavorite(AutoFavorite(serverId, entityId)) - } - coVerify { onShowSnackbar("hello", null) } - } - - @Test - fun `Given null server when adding AndroidAutoFavorite then call FailFast and show snackbar in release`() = runTest { - val entityId = "vehicle.test" - val action = EntityAddToAction.AndroidAutoFavorite - val onShowSnackbar: suspend (String, String?) -> Boolean = mockk(relaxed = true) - var throwableCaptured: Throwable? = null - - every { context.getString(commonR.string.add_to_android_auto_success) } returns "hello" - - FailFast.setHandler { throwable, _ -> - throwableCaptured = throwable - } - - coEvery { serverManager.getServer() } returns null - - handler.execute(context, action, entityId, onShowSnackbar) - - assertNotNull(throwableCaptured) - coVerify(exactly = 0) { prefsRepository.addAutoFavorite(any()) } - // In debug this won't be display since FailFast would throw - coVerify { onShowSnackbar("hello", null) } - } - - @Test - fun `Given null server when getting actionsForEntity then return empty list`() = runTest { - val entityId = "light.test" - coEvery { serverManager.getServer() } returns null - - val actions = handler.actionsForEntity(isFullFlavor = true, isAutomotive = false, isQuest = false, entityId = entityId) - - assertEquals(emptyList(), actions) - } - - @Test - fun `Given entity not found when getting actionsForEntity then return empty list`() = runTest { - val entityId = "light.nonexistent" - coEvery { integrationRepository.getEntity(entityId) } returns null - - val actions = handler.actionsForEntity(isFullFlavor = true, isAutomotive = false, isQuest = false, entityId) - - assertEquals(emptyList(), actions) - } - - @Test - fun `Given standard entityId when getting actionsForEntity then returns EntityWidget`() = runTest { - val entityId = "standard.test" - - mockGetEntity(entityId) - - val actions = handler.actionsForEntity(isFullFlavor = true, isAutomotive = false, isQuest = false, entityId) - assertEquals(1, actions.size) - assertEquals(EntityAddToAction.EntityWidget, actions.first()) - } - - @Test - fun `Given alarm_control_panel entityId on full flavor when getting actionsForEntity then returns EntityWidget and AndroidAutoFavorite`() = runTest { - val entityId = "alarm_control_panel.test" - - mockGetEntity(entityId) - - val actions = handler.actionsForEntity(isFullFlavor = true, isAutomotive = false, isQuest = false, entityId) - - assertEquals(listOf(EntityAddToAction.EntityWidget, EntityAddToAction.AndroidAutoFavorite), actions) - } - - @Test - fun `Given alarm_control_panel entityId on minimal flavor when getting actionsForEntity then returns EntityWidget`() = runTest { - val entityId = "alarm_control_panel.test" - - mockGetEntity(entityId) - - val actions = handler.actionsForEntity(isFullFlavor = false, isAutomotive = false, isQuest = false, entityId) - - assertEquals(listOf(EntityAddToAction.EntityWidget), actions) - } - - @Test - fun `Given media player entityId when getting actionsForEntity then returns EntityWidget and MediaPlayerWidget`() = runTest { - val entityId = "$MEDIA_PLAYER_DOMAIN.test" - - mockGetEntity(entityId) - - val actions = handler.actionsForEntity(isFullFlavor = true, isAutomotive = false, isQuest = false, entityId) - - assertEquals(listOf(EntityAddToAction.EntityWidget, EntityAddToAction.MediaPlayerWidget), actions) - } - - @Test - fun `Given todo entityId when getting actionsForEntity then returns EntityWidget and TodoWidget`() = runTest { - val entityId = "$TODO_DOMAIN.test" - - mockGetEntity(entityId) - - val actions = handler.actionsForEntity(isFullFlavor = true, isAutomotive = false, isQuest = false, entityId) - - assertEquals(listOf(EntityAddToAction.EntityWidget, EntityAddToAction.TodoWidget), actions) - } - - @Test - fun `Given camera entityId when getting actionsForEntity then returns EntityWidget and CameraWidget`() = runTest { - val entityId = "$CAMERA_DOMAIN.test" - - mockGetEntity(entityId) - - val actions = handler.actionsForEntity(isFullFlavor = true, isAutomotive = false, isQuest = false, entityId) - - assertEquals(listOf(EntityAddToAction.EntityWidget, EntityAddToAction.CameraWidget), actions) - } - - @Test - fun `Given image entityId when getting actionsForEntity then returns EntityWidget and CameraWidget`() = runTest { - val entityId = "$IMAGE_DOMAIN.test" - - mockGetEntity(entityId) - - val actions = handler.actionsForEntity(isFullFlavor = true, isAutomotive = false, isQuest = false, entityId) - assertEquals(listOf(EntityAddToAction.EntityWidget, EntityAddToAction.CameraWidget), actions) - } - - @Test - fun `Given EntityWidget action when executing then it starts EntityWidgetConfigureActivity`() = runTest { - val entityId = "light.test" - val action = EntityAddToAction.EntityWidget - val onShowSnackbar: suspend (String, String?) -> Boolean = mockk(relaxed = true) - - mockkObject(EntityWidgetConfigureActivity.Companion) - every { EntityWidgetConfigureActivity.newInstance(context, entityId) } returns mockk() - - handler.execute(context, action, entityId, onShowSnackbar) - - verify { context.startActivity(any()) } - verify { EntityWidgetConfigureActivity.newInstance(context, entityId) } - coVerify(exactly = 0) { onShowSnackbar(any(), any()) } - } - - @Test - fun `Given MediaPlayerWidget action when executing then it starts MediaPlayerControlsWidgetConfigureActivity`() = runTest { - val entityId = "$MEDIA_PLAYER_DOMAIN.test" - val action = EntityAddToAction.MediaPlayerWidget - val onShowSnackbar: suspend (String, String?) -> Boolean = mockk(relaxed = true) - - mockkObject(MediaPlayerControlsWidgetConfigureActivity.Companion) - every { MediaPlayerControlsWidgetConfigureActivity.newInstance(context, entityId) } returns mockk() - - handler.execute(context, action, entityId, onShowSnackbar) - verify { context.startActivity(any()) } - verify { MediaPlayerControlsWidgetConfigureActivity.newInstance(context, entityId) } - coVerify(exactly = 0) { onShowSnackbar(any(), any()) } - } - - @Test - fun `Given CameraWidget action when executing then it starts CameraWidgetConfigureActivity`() = runTest { - val entityId = "$CAMERA_DOMAIN.test" - val action = EntityAddToAction.CameraWidget - val onShowSnackbar: suspend (String, String?) -> Boolean = mockk(relaxed = true) - - mockkObject(CameraWidgetConfigureActivity.Companion) - every { CameraWidgetConfigureActivity.newInstance(context, entityId) } returns mockk() - - handler.execute(context, action, entityId, onShowSnackbar) - - verify { context.startActivity(any()) } - verify { CameraWidgetConfigureActivity.newInstance(context, entityId) } - coVerify(exactly = 0) { onShowSnackbar(any(), any()) } - } - - @Test - fun `Given TodoWidget action when executing then it starts TodoWidgetConfigureActivity`() = runTest { - val entityId = "$TODO_DOMAIN.test" - val action = EntityAddToAction.TodoWidget - val onShowSnackbar: suspend (String, String?) -> Boolean = mockk(relaxed = true) - - mockkObject(TodoWidgetConfigureActivity.Companion) - every { TodoWidgetConfigureActivity.newInstance(context, entityId) } returns mockk() - - handler.execute(context, action, entityId, onShowSnackbar) - - verify { context.startActivity(any()) } - verify { TodoWidgetConfigureActivity.newInstance(context, entityId) } - coVerify(exactly = 0) { onShowSnackbar(any(), any()) } - } - - @ParameterizedTest - @CsvSource( - value = [ - "true,light.test", - "false,light.test", - "true,alarm_control_panel.test", - "false,alarm_control_panel.test", - ], - ) - fun `Given standard entity on automotive when getting actionsForEntity then returns auto favorite`(isFullFlavor: Boolean, entityId: String) = runTest { - mockGetEntity(entityId) - - val actions = handler.actionsForEntity(isFullFlavor = isFullFlavor, isAutomotive = true, isQuest = false, entityId) - - assertEquals(listOf(EntityAddToAction.AndroidAutoFavorite), actions) - } - - @ParameterizedTest - @ValueSource( - strings = [ - "$MEDIA_PLAYER_DOMAIN.test", - "$TODO_DOMAIN.test", - "$CAMERA_DOMAIN.test", - "$IMAGE_DOMAIN.test", - ], - ) - fun `Given entity on automotive not vehicle domain with when getting actionsForEntity then returns empty list`(entityId: String) = runTest { - mockGetEntity(entityId) - - val actions = handler.actionsForEntity(isFullFlavor = true, isAutomotive = true, isQuest = false, entityId) - - assertEquals(emptyList(), actions) - } - - @ParameterizedTest - @ValueSource( - strings = [ - "light.test", - "alarm_control_panel.test", - "$MEDIA_PLAYER_DOMAIN.test", - "$TODO_DOMAIN.test", - "$CAMERA_DOMAIN.test", - "$IMAGE_DOMAIN.test", - ], - ) - fun `Given entity on Quest with when getting actionsForEntity then returns empty list`(entityId: String) = runTest { - mockGetEntity(entityId) - - val actions = handler.actionsForEntity(isFullFlavor = false, isAutomotive = false, isQuest = true, entityId) - - assertEquals(emptyList(), actions) - } - - private fun mockGetEntity(entityId: String) { - coEvery { integrationRepository.getEntity(entityId) } coAnswers { - delay(1) - createEntity(firstArg()) - } - } - - private fun createEntity(entityId: String): Entity { - return Entity(entityId = entityId, "", mapOf(), LocalDateTime.now(), LocalDateTime.now()) - } -} From 34f67f895430e431b07554f1778d99bba4713e73 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:14:51 +0200 Subject: [PATCH 3/4] Add support for Add to in FrontendScreen --- .../android/frontend/FrontendViewModel.kt | 6 +- .../frontend/handler/FrontendHandlerEvent.kt | 14 + .../handler/FrontendMessageHandler.kt | 20 ++ .../webview/externalbus/ExternalBusMessage.kt | 5 +- .../android/frontend/FrontendViewModelTest.kt | 72 +++- .../addto/FrontendEntityAddToHandlerTest.kt | 312 ++++++++++++++++++ .../FrontendExternalBusRepositoryImplTest.kt | 2 +- .../handler/FrontendMessageHandlerTest.kt | 78 +++++ .../navigation/FrontendEventHandlerTest.kt | 9 + 9 files changed, 513 insertions(+), 5 deletions(-) create mode 100644 app/src/test/kotlin/io/homeassistant/companion/android/frontend/addto/FrontendEntityAddToHandlerTest.kt 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 5b51f91c3db..5c437fc34ef 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 @@ -21,7 +21,6 @@ import io.homeassistant.companion.android.frontend.error.FrontendConnectionError import io.homeassistant.companion.android.frontend.error.FrontendConnectionErrorStateProvider import io.homeassistant.companion.android.frontend.exoplayer.FrontendExoPlayerManager import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalBusRepository -import io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage import io.homeassistant.companion.android.frontend.externalbus.outgoing.SuccessResultMessage import io.homeassistant.companion.android.frontend.filechooser.FileChooserManager import io.homeassistant.companion.android.frontend.filechooser.FileChooserRequest @@ -589,8 +588,13 @@ internal class FrontendViewModel @VisibleForTesting constructor( exoPlayerManager.handle(result) } + is FrontendHandlerEvent.EntityAddToExecuted -> { + result.event?.let { _events.tryEmit(it) } + } + is FrontendHandlerEvent.ConfigSent, is FrontendHandlerEvent.UnknownMessage, + is FrontendHandlerEvent.EntityAddToActionsSent, -> { // No-op } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendHandlerEvent.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendHandlerEvent.kt index 4f6d04207d7..92234d2ae97 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendHandlerEvent.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendHandlerEvent.kt @@ -4,6 +4,7 @@ import android.net.Uri import io.homeassistant.companion.android.frontend.download.DownloadResult import io.homeassistant.companion.android.frontend.error.FrontendConnectionError import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType +import io.homeassistant.companion.android.frontend.navigation.FrontendEvent /** * Events emitted by [FrontendMessageHandler]. @@ -108,4 +109,17 @@ sealed interface FrontendHandlerEvent { */ data class Resize(val left: Double, val top: Double, val right: Double, val bottom: Double) : ExoPlayerAction } + + /** + * Frontend requested available EntityAddTo actions and the response was sent. + */ + data object EntityAddToActionsSent : FrontendHandlerEvent + + /** + * Frontend requested execution of an EntityAddTo action. + * + * The ViewModel should forward the [event] to the navigation layer. + * When null, the action is unimplemented and should be ignored. + */ + data class EntityAddToExecuted(val event: FrontendEvent?) : FrontendHandlerEvent } 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 ab9ea55b7d6..fe07f5dd075 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 @@ -7,10 +7,13 @@ import io.homeassistant.companion.android.common.util.AppVersionProvider import io.homeassistant.companion.android.di.qualifiers.IsAutomotive import io.homeassistant.companion.android.frontend.EvaluateJavascriptUsage import io.homeassistant.companion.android.frontend.WebViewAction +import io.homeassistant.companion.android.frontend.addto.FrontendEntityAddToHandler import io.homeassistant.companion.android.frontend.download.FrontendDownloadManager import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalBusRepository import io.homeassistant.companion.android.frontend.externalbus.incoming.ConfigGetMessage import io.homeassistant.companion.android.frontend.externalbus.incoming.ConnectionStatusMessage +import io.homeassistant.companion.android.frontend.externalbus.incoming.EntityAddToGetActionsMessage +import io.homeassistant.companion.android.frontend.externalbus.incoming.EntityAddToMessage import io.homeassistant.companion.android.frontend.externalbus.incoming.ExoPlayerPlayHlsMessage import io.homeassistant.companion.android.frontend.externalbus.incoming.ExoPlayerResizeMessage import io.homeassistant.companion.android.frontend.externalbus.incoming.ExoPlayerStopMessage @@ -24,6 +27,7 @@ import io.homeassistant.companion.android.frontend.externalbus.incoming.TagWrite import io.homeassistant.companion.android.frontend.externalbus.incoming.ThemeUpdateMessage import io.homeassistant.companion.android.frontend.externalbus.incoming.UnknownIncomingMessage import io.homeassistant.companion.android.frontend.externalbus.outgoing.ConfigResultMessage +import io.homeassistant.companion.android.frontend.externalbus.outgoing.EntityAddToActionsResultMessage import io.homeassistant.companion.android.frontend.js.FrontendJsHandler import io.homeassistant.companion.android.frontend.session.AuthPayload import io.homeassistant.companion.android.frontend.session.ExternalAuthResult @@ -32,6 +36,7 @@ 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 io.homeassistant.companion.android.webview.externalbus.ExternalEntityAddToAction import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -63,6 +68,7 @@ class FrontendMessageHandler @Inject constructor( private val appVersionProvider: AppVersionProvider, private val sessionManager: ServerSessionManager, private val downloadManager: FrontendDownloadManager, + private val entityAddToHandler: FrontendEntityAddToHandler, @param:IsAutomotive private val isAutomotive: Boolean, ) : FrontendJsHandler, FrontendBusObserver { @@ -223,6 +229,20 @@ class FrontendMessageHandler @Inject constructor( FrontendHandlerEvent.DownloadCompleted(result) } + is EntityAddToGetActionsMessage -> { + Timber.d("Entity add_to get_actions request received for: ${message.payload.entityId}") + val actions = entityAddToHandler.getActionsForEntity(message.payload.entityId) + externalBusRepository.send(EntityAddToActionsResultMessage(id = message.id, actions = actions)) + FrontendHandlerEvent.EntityAddToActionsSent + } + + is EntityAddToMessage -> { + Timber.d("Entity add_to request received for: ${message.payload.entityId}") + val action = ExternalEntityAddToAction.appPayloadToAction(message.payload.appPayload) + val event = entityAddToHandler.execute(message.payload.entityId, action) + FrontendHandlerEvent.EntityAddToExecuted(event) + } + is UnknownIncomingMessage -> { Timber.d("Unknown message type received: ${message.content}") FrontendHandlerEvent.UnknownMessage diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/externalbus/ExternalBusMessage.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/externalbus/ExternalBusMessage.kt index aa5a407fba1..aebefd60b71 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/externalbus/ExternalBusMessage.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/externalbus/ExternalBusMessage.kt @@ -8,6 +8,7 @@ import io.homeassistant.companion.android.common.util.kotlinJsonMapper import io.homeassistant.companion.android.common.util.toJsonObject import io.homeassistant.companion.android.webview.addto.EntityAddToAction import kotlin.io.encoding.Base64 +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import timber.log.Timber @@ -85,11 +86,11 @@ class ExternalConfigResponse( @Serializable data class ExternalEntityAddToAction( - val appPayload: String, + @SerialName("app_payload") val appPayload: String, val enabled: Boolean, val name: String, val details: String?, - val mdiIcon: String, + @SerialName("mdi_icon") val mdiIcon: String, ) { companion object { fun fromAction(context: Context, action: EntityAddToAction): ExternalEntityAddToAction { 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 c6eff29ed6b..34ff4f9b6bf 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 @@ -28,7 +28,6 @@ import io.homeassistant.companion.android.frontend.exoplayer.ExoPlayerUiState import io.homeassistant.companion.android.frontend.exoplayer.FrontendExoPlayerManager import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalBusRepository import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType -import io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage import io.homeassistant.companion.android.frontend.externalbus.outgoing.SuccessResultMessage import io.homeassistant.companion.android.frontend.filechooser.FileChooserManager import io.homeassistant.companion.android.frontend.gesture.FrontendGestureHandler @@ -704,6 +703,77 @@ class FrontendViewModelTest { } } + @Test + fun `Given EntityAddToExecuted with event when collected then event is forwarded`() = runTest { + val messageFlow = MutableSharedFlow() + every { frontendBusObserver.messageResults() } returns messageFlow + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + val viewModel = createViewModel() + + viewModel.events.test { + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + + messageFlow.emit( + FrontendHandlerEvent.EntityAddToExecuted( + FrontendEvent.LaunchWidgetConfig( + entityId = "light.test", + widgetType = io.homeassistant.companion.android.frontend.navigation.WidgetType.Entity, + ), + ), + ) + + val event = awaitItem() + assertTrue(event is FrontendEvent.LaunchWidgetConfig) + assertEquals("light.test", (event as FrontendEvent.LaunchWidgetConfig).entityId) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given EntityAddToExecuted with null event when collected then no event is emitted`() = runTest { + val messageFlow = MutableSharedFlow() + every { frontendBusObserver.messageResults() } returns messageFlow + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + val viewModel = createViewModel() + + viewModel.events.test { + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + + messageFlow.emit(FrontendHandlerEvent.EntityAddToExecuted(event = null)) + advanceUntilIdle() + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given EntityAddToActionsSent when collected then no event is emitted`() = runTest { + val messageFlow = MutableSharedFlow() + every { frontendBusObserver.messageResults() } returns messageFlow + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + val viewModel = createViewModel() + + viewModel.events.test { + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + + messageFlow.emit(FrontendHandlerEvent.EntityAddToActionsSent) + advanceUntilIdle() + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `Given onNfcWriteCompleted when called then sends empty-result ResultMessage back to frontend`() = runTest { every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/addto/FrontendEntityAddToHandlerTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/addto/FrontendEntityAddToHandlerTest.kt new file mode 100644 index 00000000000..cf42d40f81e --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/addto/FrontendEntityAddToHandlerTest.kt @@ -0,0 +1,312 @@ +package io.homeassistant.companion.android.frontend.addto + +import android.content.Context +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.CAMERA_DOMAIN +import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.IMAGE_DOMAIN +import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.MEDIA_PLAYER_DOMAIN +import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.TODO_DOMAIN +import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.prefs.AutoFavorite +import io.homeassistant.companion.android.common.data.prefs.PrefsRepository +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.frontend.navigation.FrontendEvent +import io.homeassistant.companion.android.frontend.navigation.WidgetType +import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension +import io.homeassistant.companion.android.util.FailFastExtension +import io.homeassistant.companion.android.webview.addto.EntityAddToAction +import io.homeassistant.companion.android.webview.externalbus.ExternalEntityAddToAction +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import java.time.LocalDateTime +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.fail +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource + +@ExperimentalCoroutinesApi +@ExtendWith(ConsoleLogExtension::class, FailFastExtension::class) +class FrontendEntityAddToHandlerTest { + + private lateinit var serverManager: ServerManager + private lateinit var prefsRepository: PrefsRepository + private lateinit var integrationRepository: IntegrationRepository + private lateinit var context: Context + + private val serverId = 42 + + @BeforeEach + fun setUp() { + serverManager = mockk() + prefsRepository = mockk() + integrationRepository = mockk() + context = mockk(relaxed = true) + val server = mockk() + + every { server.id } returns serverId + coEvery { serverManager.integrationRepository(serverId) } returns integrationRepository + coEvery { serverManager.getServer() } returns server + + // Always override the handler so individual tests can decide to override it or not without impacting the others. + FailFast.setHandler { exception, _ -> + fail("Unhandled exception caught", exception) + } + } + + @Test + fun `Given AndroidAutoFavorite action when executing then it adds favorite and returns ShowSnackbar`() = runTest { + val entityId = "vehicle.test" + coJustRun { prefsRepository.addAutoFavorite(any()) } + + val event = createHandler().execute(entityId, EntityAddToAction.AndroidAutoFavorite) + + coVerify { prefsRepository.addAutoFavorite(AutoFavorite(serverId, entityId)) } + assertEquals(FrontendEvent.ShowSnackbar(commonR.string.add_to_android_auto_success), event) + } + + @Test + fun `Given null server when adding AndroidAutoFavorite then call FailFast and returns ShowSnackbar in release`() = runTest { + var throwableCaptured: Throwable? = null + FailFast.setHandler { throwable, _ -> throwableCaptured = throwable } + coEvery { serverManager.getServer() } returns null + + val event = createHandler().execute("vehicle.test", EntityAddToAction.AndroidAutoFavorite) + + assertNotNull(throwableCaptured) + coVerify(exactly = 0) { prefsRepository.addAutoFavorite(any()) } + // In debug this won't be returned since FailFast would throw + assertEquals(FrontendEvent.ShowSnackbar(commonR.string.add_to_android_auto_success), event) + } + + @Test + fun `Given null server when getting actionsForEntity then return empty list`() = runTest { + coEvery { serverManager.getServer() } returns null + + val actions = createHandler().getActionsForEntity("light.test") + + assertEquals(emptyList(), actions) + } + + @Test + fun `Given entity returns null when getting actionsForEntity then return empty list`() = runTest { + coEvery { integrationRepository.getEntity("light.nonexistent") } returns null + + val actions = createHandler().getActionsForEntity("light.nonexistent") + + assertEquals(emptyList(), actions) + } + + @Test + fun `Given entity throws when getting actionsForEntity then return empty list`() = runTest { + coEvery { integrationRepository.getEntity(any()) } throws RuntimeException("Not found") + + val actions = createHandler().getActionsForEntity("light.nonexistent") + + assertEquals(emptyList(), actions) + } + + @Test + fun `Given standard entityId when getting actionsForEntity then returns EntityWidget`() = runTest { + val entityId = "automation.test" + mockGetEntity(entityId) + + val actions = createHandler().getActionsForEntity(entityId) + + assertEquals(listOf(EntityAddToAction.EntityWidget), actions.unwrap()) + } + + @Test + fun `Given alarm_control_panel entityId on full flavor when getting actionsForEntity then returns EntityWidget and AndroidAutoFavorite`() = runTest { + val entityId = "alarm_control_panel.test" + mockGetEntity(entityId) + + val actions = createHandler(isFullFlavor = true).getActionsForEntity(entityId) + + assertEquals(listOf(EntityAddToAction.EntityWidget, EntityAddToAction.AndroidAutoFavorite), actions.unwrap()) + } + + @Test + fun `Given alarm_control_panel entityId on minimal flavor when getting actionsForEntity then returns EntityWidget`() = runTest { + val entityId = "alarm_control_panel.test" + mockGetEntity(entityId) + + val actions = createHandler(isFullFlavor = false).getActionsForEntity(entityId) + + assertEquals(listOf(EntityAddToAction.EntityWidget), actions.unwrap()) + } + + @Test + fun `Given media player entityId when getting actionsForEntity then returns EntityWidget and MediaPlayerWidget`() = runTest { + val entityId = "$MEDIA_PLAYER_DOMAIN.test" + mockGetEntity(entityId) + + val actions = createHandler().getActionsForEntity(entityId) + + assertEquals(listOf(EntityAddToAction.EntityWidget, EntityAddToAction.MediaPlayerWidget), actions.unwrap()) + } + + @Test + fun `Given todo entityId when getting actionsForEntity then returns EntityWidget and TodoWidget`() = runTest { + val entityId = "$TODO_DOMAIN.test" + mockGetEntity(entityId) + + val actions = createHandler().getActionsForEntity(entityId) + + assertEquals(listOf(EntityAddToAction.EntityWidget, EntityAddToAction.TodoWidget), actions.unwrap()) + } + + @Test + fun `Given camera entityId when getting actionsForEntity then returns EntityWidget and CameraWidget`() = runTest { + val entityId = "$CAMERA_DOMAIN.test" + mockGetEntity(entityId) + + val actions = createHandler().getActionsForEntity(entityId) + + assertEquals(listOf(EntityAddToAction.EntityWidget, EntityAddToAction.CameraWidget), actions.unwrap()) + } + + @Test + fun `Given image entityId when getting actionsForEntity then returns EntityWidget and CameraWidget`() = runTest { + val entityId = "$IMAGE_DOMAIN.test" + mockGetEntity(entityId) + + val actions = createHandler().getActionsForEntity(entityId) + + assertEquals(listOf(EntityAddToAction.EntityWidget, EntityAddToAction.CameraWidget), actions.unwrap()) + } + + @Test + fun `Given EntityWidget action when executing then returns LaunchWidgetConfig with Entity type`() = runTest { + val event = createHandler().execute("light.test", EntityAddToAction.EntityWidget) + + assertEquals(FrontendEvent.LaunchWidgetConfig("light.test", WidgetType.Entity), event) + } + + @Test + fun `Given MediaPlayerWidget action when executing then returns LaunchWidgetConfig with MediaPlayer type`() = runTest { + val event = createHandler().execute("media_player.tv", EntityAddToAction.MediaPlayerWidget) + + assertEquals(FrontendEvent.LaunchWidgetConfig("media_player.tv", WidgetType.MediaPlayer), event) + } + + @Test + fun `Given CameraWidget action when executing then returns LaunchWidgetConfig with Camera type`() = runTest { + val event = createHandler().execute("camera.front", EntityAddToAction.CameraWidget) + + assertEquals(FrontendEvent.LaunchWidgetConfig("camera.front", WidgetType.Camera), event) + } + + @Test + fun `Given TodoWidget action when executing then returns LaunchWidgetConfig with Todo type`() = runTest { + val event = createHandler().execute("todo.shopping", EntityAddToAction.TodoWidget) + + assertEquals(FrontendEvent.LaunchWidgetConfig("todo.shopping", WidgetType.Todo), event) + } + + @Test + fun `Given Tile action when executing then returns null`() = runTest { + assertNull(createHandler().execute("light.test", EntityAddToAction.Tile)) + } + + @Test + fun `Given Shortcut action when executing then returns null`() = runTest { + assertNull(createHandler().execute("light.test", EntityAddToAction.Shortcut(enabled = true))) + } + + @Test + fun `Given Watch action when executing then returns null`() = runTest { + assertNull(createHandler().execute("light.test", EntityAddToAction.Watch(name = "Pixel Watch", enabled = true))) + } + + @ParameterizedTest + @CsvSource( + value = [ + "true,light.test", + "false,light.test", + "true,alarm_control_panel.test", + "false,alarm_control_panel.test", + ], + ) + fun `Given standard entity on automotive when getting actionsForEntity then returns auto favorite`(isFullFlavor: Boolean, entityId: String) = runTest { + mockGetEntity(entityId) + + val actions = createHandler(isAutomotive = true, isFullFlavor = isFullFlavor).getActionsForEntity(entityId) + + assertEquals(listOf(EntityAddToAction.AndroidAutoFavorite), actions.unwrap()) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "$MEDIA_PLAYER_DOMAIN.test", + "$TODO_DOMAIN.test", + "$CAMERA_DOMAIN.test", + "$IMAGE_DOMAIN.test", + ], + ) + fun `Given entity on automotive not vehicle domain when getting actionsForEntity then returns empty list`(entityId: String) = runTest { + mockGetEntity(entityId) + + val actions = createHandler(isAutomotive = true).getActionsForEntity(entityId) + + assertEquals(emptyList(), actions) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "light.test", + "alarm_control_panel.test", + "$MEDIA_PLAYER_DOMAIN.test", + "$TODO_DOMAIN.test", + "$CAMERA_DOMAIN.test", + "$IMAGE_DOMAIN.test", + ], + ) + fun `Given entity on Quest when getting actionsForEntity then returns empty list`(entityId: String) = runTest { + mockGetEntity(entityId) + + val actions = createHandler(isQuest = true, isFullFlavor = false).getActionsForEntity(entityId) + + assertEquals(emptyList(), actions) + } + + private fun createHandler( + isAutomotive: Boolean = false, + isQuest: Boolean = false, + isFullFlavor: Boolean = true, + ) = FrontendEntityAddToHandler( + context = context, + serverManager = serverManager, + prefsRepository = prefsRepository, + isAutomotive = isAutomotive, + isQuest = isQuest, + isFullFlavor = isFullFlavor, + ) + + private fun mockGetEntity(entityId: String) { + coEvery { integrationRepository.getEntity(entityId) } coAnswers { + delay(1) + createEntity(firstArg()) + } + } + + private fun createEntity(entityId: String): Entity = Entity(entityId = entityId, "", mapOf(), LocalDateTime.now(), LocalDateTime.now()) + + private fun List.unwrap(): List = map { ExternalEntityAddToAction.appPayloadToAction(it.appPayload) } +} 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 fc5c5e8faeb..924b2559a3d 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 @@ -115,7 +115,7 @@ class FrontendExternalBusRepositoryImplTest { canCommissionMatter = false, canExportThread = true, hasBarCodeScanner = 2, - appVersion = AppVersion.from("1.0.0"), + appVersion = AppVersion.from("1.0.0 (1)"), ) repository.webViewActions().test { 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 d379ef51362..f798ae739d9 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 @@ -6,8 +6,10 @@ import app.cash.turbine.test import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.util.AppVersion import io.homeassistant.companion.android.common.util.AppVersionProvider +import io.homeassistant.companion.android.common.util.kotlinJsonMapper import io.homeassistant.companion.android.frontend.EvaluateJavascriptUsage import io.homeassistant.companion.android.frontend.WebViewAction +import io.homeassistant.companion.android.frontend.addto.FrontendEntityAddToHandler import io.homeassistant.companion.android.frontend.download.DownloadResult import io.homeassistant.companion.android.frontend.download.FrontendDownloadManager import io.homeassistant.companion.android.frontend.error.FrontendConnectionError @@ -15,6 +17,10 @@ import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalB import io.homeassistant.companion.android.frontend.externalbus.incoming.ConfigGetMessage import io.homeassistant.companion.android.frontend.externalbus.incoming.ConnectionStatusMessage import io.homeassistant.companion.android.frontend.externalbus.incoming.ConnectionStatusPayload +import io.homeassistant.companion.android.frontend.externalbus.incoming.EntityAddToGetActionsMessage +import io.homeassistant.companion.android.frontend.externalbus.incoming.EntityAddToGetActionsPayload +import io.homeassistant.companion.android.frontend.externalbus.incoming.EntityAddToMessage +import io.homeassistant.companion.android.frontend.externalbus.incoming.EntityAddToPayload import io.homeassistant.companion.android.frontend.externalbus.incoming.ExoPlayerPlayHlsMessage import io.homeassistant.companion.android.frontend.externalbus.incoming.ExoPlayerPlayHlsPayload import io.homeassistant.companion.android.frontend.externalbus.incoming.ExoPlayerResizeMessage @@ -33,6 +39,7 @@ 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.OutgoingExternalBusMessage import io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage +import io.homeassistant.companion.android.frontend.navigation.FrontendEvent import io.homeassistant.companion.android.frontend.session.AuthPayload import io.homeassistant.companion.android.frontend.session.ExternalAuthResult import io.homeassistant.companion.android.frontend.session.RevokeAuthResult @@ -40,6 +47,8 @@ import io.homeassistant.companion.android.frontend.session.ServerSessionManager import io.homeassistant.companion.android.matter.MatterManager import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension import io.homeassistant.companion.android.thread.ThreadManager +import io.homeassistant.companion.android.webview.addto.EntityAddToAction +import io.homeassistant.companion.android.webview.externalbus.ExternalEntityAddToAction import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -47,6 +56,7 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.slot import io.mockk.unmockkAll +import kotlin.io.encoding.Base64 import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf @@ -77,6 +87,8 @@ class FrontendMessageHandlerTest { private val appVersionProvider: AppVersionProvider = mockk() private val sessionManager: ServerSessionManager = mockk(relaxed = true) private val downloadManager: FrontendDownloadManager = mockk(relaxed = true) + private val entityAddToHandler: FrontendEntityAddToHandler = + mockk(relaxed = true) private lateinit var handler: FrontendMessageHandler @BeforeEach @@ -96,6 +108,7 @@ class FrontendMessageHandlerTest { appVersionProvider = appVersionProvider, sessionManager = sessionManager, downloadManager = downloadManager, + entityAddToHandler = entityAddToHandler, isAutomotive = false, ) } @@ -171,6 +184,7 @@ class FrontendMessageHandlerTest { appVersionProvider = appVersionProvider, sessionManager = sessionManager, downloadManager = downloadManager, + entityAddToHandler = entityAddToHandler, isAutomotive = false, ) @@ -209,6 +223,7 @@ class FrontendMessageHandlerTest { appVersionProvider = appVersionProvider, sessionManager = sessionManager, downloadManager = downloadManager, + entityAddToHandler = entityAddToHandler, isAutomotive = false, ) @@ -363,6 +378,7 @@ class FrontendMessageHandlerTest { appVersionProvider = appVersionProvider, sessionManager = sessionManager, downloadManager = downloadManager, + entityAddToHandler = entityAddToHandler, isAutomotive = true, ) @@ -395,6 +411,7 @@ class FrontendMessageHandlerTest { appVersionProvider = appVersionProvider, sessionManager = sessionManager, downloadManager = downloadManager, + entityAddToHandler = entityAddToHandler, isAutomotive = false, ) @@ -632,4 +649,65 @@ class FrontendMessageHandlerTest { expectNoEvents() } } + + @Test + fun `Given entity add_to get_actions message when messageResults then sends response and emits EntityAddToActionsSent`() = runTest { + val actions = listOf( + ExternalEntityAddToAction( + appPayload = "dGVzdA==", + enabled = true, + name = "Entity Widget", + details = null, + mdiIcon = "mdi:shape", + ), + ) + coEvery { entityAddToHandler.getActionsForEntity("light.living_room") } returns actions + + val message = EntityAddToGetActionsMessage( + id = 20, + payload = EntityAddToGetActionsPayload( + entityId = "light.living_room", + ), + ) + every { externalBusRepository.incomingMessages() } returns flowOf(message) + + handler.messageResults().test { + val result = awaitItem() + assertTrue(result is FrontendHandlerEvent.EntityAddToActionsSent) + expectNoEvents() + } + + val sentSlot = slot() + coVerify { externalBusRepository.send(capture(sentSlot)) } + val sent = sentSlot.captured as ResultMessage + assertEquals(20, sent.id) + assertTrue(sent.success) + } + + @Test + fun `Given entity add_to message when messageResults then emits EntityAddToExecuted with event from handler`() = runTest { + val entityWidgetPayload = Base64.UrlSafe.encode(kotlinJsonMapper.encodeToString(EntityAddToAction.EntityWidget).encodeToByteArray()) + coEvery { + entityAddToHandler.execute("light.living_room", any()) + } returns FrontendEvent.ShowSnackbar( + io.homeassistant.companion.android.common.R.string.add_to_android_auto_success, + ) + + val message = EntityAddToMessage( + id = 21, + payload = EntityAddToPayload( + entityId = "light.living_room", + appPayload = entityWidgetPayload, + ), + ) + every { externalBusRepository.incomingMessages() } returns flowOf(message) + + handler.messageResults().test { + val result = awaitItem() + assertTrue(result is FrontendHandlerEvent.EntityAddToExecuted) + val event = (result as FrontendHandlerEvent.EntityAddToExecuted).event + assertTrue(event is FrontendEvent.ShowSnackbar) + expectNoEvents() + } + } } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendEventHandlerTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendEventHandlerTest.kt index f36782709f3..7daddd4d6d3 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendEventHandlerTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendEventHandlerTest.kt @@ -50,6 +50,7 @@ class FrontendEventHandlerTest { onShowServerSwitcher = {}, onNavigateToNfcWrite = { _, _ -> }, onRequestFullscreen = {}, + onLaunchWidgetConfig = { _, _ -> }, ) } @@ -80,6 +81,7 @@ class FrontendEventHandlerTest { onShowServerSwitcher = {}, onNavigateToNfcWrite = { _, _ -> }, onRequestFullscreen = {}, + onLaunchWidgetConfig = { _, _ -> }, ) } @@ -112,6 +114,7 @@ class FrontendEventHandlerTest { onShowServerSwitcher = {}, onNavigateToNfcWrite = { _, _ -> }, onRequestFullscreen = {}, + onLaunchWidgetConfig = { _, _ -> }, ) } @@ -150,6 +153,7 @@ class FrontendEventHandlerTest { onShowServerSwitcher = {}, onNavigateToNfcWrite = { _, _ -> }, onRequestFullscreen = {}, + onLaunchWidgetConfig = { _, _ -> }, ) } @@ -176,6 +180,7 @@ class FrontendEventHandlerTest { onShowServerSwitcher = {}, onNavigateToNfcWrite = { _, _ -> }, onRequestFullscreen = {}, + onLaunchWidgetConfig = { _, _ -> }, ) } @@ -202,6 +207,7 @@ class FrontendEventHandlerTest { onShowServerSwitcher = { serverSwitcherShown = true }, onNavigateToNfcWrite = { _, _ -> }, onRequestFullscreen = {}, + onLaunchWidgetConfig = { _, _ -> }, ) } @@ -231,6 +237,7 @@ class FrontendEventHandlerTest { onShowServerSwitcher = {}, onNavigateToNfcWrite = { _, _ -> }, onRequestFullscreen = {}, + onLaunchWidgetConfig = { _, _ -> }, ) } @@ -261,6 +268,7 @@ class FrontendEventHandlerTest { capturedTagId = tagId }, onRequestFullscreen = {}, + onLaunchWidgetConfig = { _, _ -> }, ) } @@ -291,6 +299,7 @@ class FrontendEventHandlerTest { capturedTagId = tagId }, onRequestFullscreen = {}, + onLaunchWidgetConfig = { _, _ -> }, ) } From 45f78f55c8506b926290d36c9b791bc959beb15f Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 6 May 2026 18:58:19 +0200 Subject: [PATCH 4/4] Add missing test and fix after rebase --- .../handler/FrontendMessageHandler.kt | 3 +- .../navigation/FrontendEventHandlerTest.kt | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) 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 fe07f5dd075..b15e63c269c 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 @@ -28,6 +28,7 @@ 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.ConfigResultMessage import io.homeassistant.companion.android.frontend.externalbus.outgoing.EntityAddToActionsResultMessage +import io.homeassistant.companion.android.frontend.externalbus.outgoing.SuccessResultMessage import io.homeassistant.companion.android.frontend.js.FrontendJsHandler import io.homeassistant.companion.android.frontend.session.AuthPayload import io.homeassistant.companion.android.frontend.session.ExternalAuthResult @@ -199,7 +200,7 @@ class FrontendMessageHandler @Inject constructor( FrontendHandlerEvent.UnknownMessage } else { Timber.d("exoplayer/play_hls url=${sensitive(url)} muted=${message.payload.muted}") - externalBusRepository.send(ResultMessage(id = message.id, success = true)) + externalBusRepository.send(SuccessResultMessage(id = message.id)) FrontendHandlerEvent.ExoPlayerAction.PlayHls( messageId = message.id, url = url.toUri(), diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendEventHandlerTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendEventHandlerTest.kt index 7daddd4d6d3..e48cf9726e1 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendEventHandlerTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendEventHandlerTest.kt @@ -321,6 +321,42 @@ class FrontendEventHandlerTest { assertEquals(false, runRequestFullscreenTest(fullscreen = false)) } + @Test + fun `Given LaunchWidgetConfig event then onLaunchWidgetConfig is called with entityId and widgetType`() { + var capturedEntityId: String? = null + var capturedWidgetType: WidgetType? = null + val events = TestSharedFlow() + + composeTestRule.setContent { + FrontendEventHandler( + events = events, + onShowSnackbar = { _, _ -> false }, + onNavigateToSettings = {}, + onNavigateToAssist = { _, _, _ -> }, + onOpenExternalLink = {}, + onShowServerSwitcher = {}, + onNavigateToNfcWrite = { _, _ -> }, + onRequestFullscreen = {}, + onLaunchWidgetConfig = { entityId, widgetType -> + capturedEntityId = entityId + capturedWidgetType = widgetType + }, + ) + } + + composeTestRule.waitForIdle() + events.emit( + FrontendEvent.LaunchWidgetConfig( + entityId = "light.kitchen", + widgetType = WidgetType.MediaPlayer, + ), + ) + composeTestRule.waitForIdle() + + assertEquals("light.kitchen", capturedEntityId) + assertEquals(WidgetType.MediaPlayer, capturedWidgetType) + } + private fun runRequestFullscreenTest(fullscreen: Boolean): Boolean? { var captured: Boolean? = null val events = TestSharedFlow() @@ -335,6 +371,7 @@ class FrontendEventHandlerTest { onShowServerSwitcher = {}, onNavigateToNfcWrite = { _, _ -> }, onRequestFullscreen = { captured = it }, + onLaunchWidgetConfig = { _, _ -> }, ) }