Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ 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
import io.homeassistant.companion.android.frontend.gesture.FrontendGestureHandler
Expand Down Expand Up @@ -431,7 +431,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
*/
fun onNfcWriteCompleted(messageId: Int) {
viewModelScope.launch {
externalBusRepository.send(ResultMessage.success(messageId))
externalBusRepository.send(SuccessResultMessage(messageId))
}
}

Expand Down Expand Up @@ -588,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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ExternalEntityAddToAction> =
withContext(Dispatchers.Default) {
val server = serverManager.getServer() ?: return@withContext emptyList()
val entity = try {
serverManager.integrationRepository(server.id).getEntity(entityId)
} catch (e: CancellationException) {
Comment thread
TimoPtr marked this conversation as resolved.
throw e
} catch (e: Exception) {
Timber.e(e, "Failed to get entity for id $entityId")
null
Comment thread
TimoPtr marked this conversation as resolved.
} ?: return@withContext emptyList()

val actions = mutableListOf<EntityAddToAction>()

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" }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -31,70 +32,106 @@ 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
@VisibleForTesting
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<ExternalEntityAddToAction>): OutgoingExternalBusMessage {
return ResultMessage(
id = id,
result = frontendExternalBusJson.encodeToJsonElement(
EntityAddToActionsResult(actions = actions),
),
)
}

@Serializable
private data class EntityAddToActionsResult(val actions: List<ExternalEntityAddToAction>)
}
Loading
Loading