Add a tile to start a conversation (#3158)

* Add a tile to interact with conversation integration

* Check HA core version and that conversation is enabled

* Rename search to conversation

* Add some TODOs, review comments for more clean up

* Review comment

* Add back private set
This commit is contained in:
Daniel Shokouhi 2022-12-18 12:25:00 -08:00 committed by GitHub
parent 8df504e1d7
commit 7f32738e10
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 338 additions and 0 deletions

View file

@ -83,4 +83,6 @@ interface IntegrationRepository {
suspend fun updateSensors(sensors: Array<SensorRegistration<Any>>): Boolean
suspend fun shouldNotifySecurityWarning(): Boolean
suspend fun getConversation(speech: String): String?
}

View file

@ -583,6 +583,13 @@ class IntegrationRepositoryImpl @Inject constructor(
}?.toList()
}
override suspend fun getConversation(speech: String): String? {
// TODO: Also send back conversation ID for dialogue
val response = webSocketRepository.getConversation(speech)
return response?.response?.speech?.plain?.get("speech")
}
override suspend fun getEntities(): List<Entity<Any>>? {
val response = webSocketRepository.getStates()

View file

@ -4,6 +4,7 @@ import io.homeassistant.companion.android.common.data.integration.impl.entities.
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryUpdatedEvent
import io.homeassistant.companion.android.common.data.websocket.impl.entities.CompressedStateChangedEvent
import io.homeassistant.companion.android.common.data.websocket.impl.entities.ConversationResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryUpdatedEvent
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DomainResponse
@ -36,4 +37,5 @@ interface WebSocketRepository {
suspend fun ackNotification(confirmId: String): Boolean
suspend fun commissionMatterDevice(code: String): Boolean
suspend fun commissionMatterDeviceOnNetwork(pin: Long): Boolean
suspend fun getConversation(speech: String): ConversationResponse?
}

View file

@ -23,6 +23,7 @@ import io.homeassistant.companion.android.common.data.websocket.WebSocketState
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryUpdatedEvent
import io.homeassistant.companion.android.common.data.websocket.impl.entities.CompressedStateChangedEvent
import io.homeassistant.companion.android.common.data.websocket.impl.entities.ConversationResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryUpdatedEvent
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DomainResponse
@ -179,6 +180,18 @@ class WebSocketRepositoryImpl @Inject constructor(
}
}
override suspend fun getConversation(speech: String): ConversationResponse? {
// TODO: Send default locale of device with request.
val socketResponse = sendMessage(
mapOf(
"type" to "conversation/process",
"text" to speech
)
)
return mapResponse(socketResponse)
}
override suspend fun getStateChanges(): Flow<StateChangedEvent>? =
subscribeToEventsForType(EVENT_STATE_CHANGED)

View file

@ -0,0 +1,9 @@
package io.homeassistant.companion.android.common.data.websocket.impl.entities
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class ConversationResponse(
val response: ConversationSpeechResponse,
val conversationId: String?
)

View file

@ -0,0 +1,8 @@
package io.homeassistant.companion.android.common.data.websocket.impl.entities
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class ConversationSpeechPlainResponse(
val plain: Map<String, String?>
)

View file

@ -0,0 +1,12 @@
package io.homeassistant.companion.android.common.data.websocket.impl.entities
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class ConversationSpeechResponse(
val speech: ConversationSpeechPlainResponse,
val card: Any?,
val language: String?,
val responseType: String?,
val data: Map<String, String?>?
)

View file

@ -965,4 +965,8 @@
<string name="sensor_name_daily_steps">Daily Steps</string>
<string name="sensor_description_daily_steps">The total step count over a day, where the previous day ends and a new day begins at 12:00 AM local time.</string>
<string name="tile_vibrate">Vibrate when clicked</string>
<string name="no_results">No results yet</string>
<string name="speak">Speak</string>
<string name="no_conversation_support">You must be at least on Home Assistant 2023.1 and have the conversation integration enabled</string>
<string name="conversation">Conversation</string>
</resources>

View file

@ -75,6 +75,9 @@
<!-- To show confirmations and failures -->
<activity android:name="androidx.wear.activity.ConfirmationActivity" />
<activity
android:name=".conversation.ConversationActivity"
android:exported="true" />
<!-- Tiles -->
<service
android:name=".tiles.ShortcutsTile"
@ -102,6 +105,18 @@
<meta-data android:name="androidx.wear.tiles.PREVIEW"
android:resource="@drawable/template_tile_example" />
</service>
<service
android:name=".tiles.ConversationTile"
android:label="@string/conversation"
android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER"
android:exported="true">
<intent-filter>
<action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
</intent-filter>
<meta-data android:name="androidx.wear.tiles.PREVIEW"
android:resource="@drawable/conversation_tile_example" />
</service>
<receiver android:name=".tiles.TileActionReceiver"
android:exported="false">
<intent-filter>

View file

@ -0,0 +1,59 @@
package io.homeassistant.companion.android.conversation
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.speech.RecognizerIntent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.conversation.views.ConversationResultView
import kotlinx.coroutines.launch
@AndroidEntryPoint
class ConversationActivity : ComponentActivity() {
private val conversationViewModel by viewModels<ConversationViewModel>()
companion object {
private const val TAG = "ConvActivity"
fun newInstance(context: Context): Intent {
return Intent(context, ConversationActivity::class.java)
}
}
private var searchResults = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
conversationViewModel.updateSpeechResult(
result.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS).let {
it?.get(0) ?: ""
}
)
conversationViewModel.getConversation()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
conversationViewModel.isSupportConversation()
if (conversationViewModel.supportsConversation) {
val searchIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
}
searchResults.launch(searchIntent)
}
}
setContent {
ConversationResultView(conversationViewModel)
}
}
}

View file

@ -0,0 +1,46 @@
package io.homeassistant.companion.android.conversation
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ConversationViewModel @Inject constructor(
application: Application,
private val integrationUseCase: IntegrationRepository,
private val webSocketRepository: WebSocketRepository
) : AndroidViewModel(application) {
var speechResult by mutableStateOf("")
private set
var conversationResult by mutableStateOf("")
private set
var supportsConversation by mutableStateOf(false)
private set
fun getConversation() {
viewModelScope.launch {
conversationResult = integrationUseCase.getConversation(speechResult) ?: ""
}
}
suspend fun isSupportConversation() {
supportsConversation =
integrationUseCase.isHomeAssistantVersionAtLeast(2023, 1, 0) &&
webSocketRepository.getConfig()?.components?.contains("conversation") == true
}
fun updateSpeechResult(result: String) {
speechResult = result
}
}

View file

@ -0,0 +1,57 @@
package io.homeassistant.companion.android.conversation.views
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material.PositionIndicator
import androidx.wear.compose.material.Scaffold
import androidx.wear.compose.material.Text
import androidx.wear.compose.material.rememberScalingLazyListState
import io.homeassistant.companion.android.common.R
import io.homeassistant.companion.android.conversation.ConversationViewModel
import io.homeassistant.companion.android.home.views.TimeText
import io.homeassistant.companion.android.theme.WearAppTheme
import io.homeassistant.companion.android.views.ThemeLazyColumn
@Composable
fun ConversationResultView(
conversationViewModel: ConversationViewModel
) {
val scrollState = rememberScalingLazyListState()
WearAppTheme {
Scaffold(
positionIndicator = {
if (scrollState.isScrollInProgress)
PositionIndicator(scalingLazyListState = scrollState)
},
timeText = { TimeText(visible = !scrollState.isScrollInProgress) }
) {
ThemeLazyColumn(
state = scrollState
) {
item {
Text(
text = conversationViewModel.speechResult.ifEmpty {
if (conversationViewModel.supportsConversation)
stringResource(R.string.no_results)
else
stringResource(R.string.no_conversation_support)
},
modifier = Modifier.padding(40.dp)
)
}
if (conversationViewModel.conversationResult.isNotEmpty())
item {
Text(
text = conversationViewModel.conversationResult,
modifier = Modifier.padding(top = 8.dp, start = 32.dp)
)
}
}
}
}
}

View file

@ -0,0 +1,104 @@
package io.homeassistant.companion.android.tiles
import androidx.core.content.ContextCompat
import androidx.wear.tiles.ActionBuilders
import androidx.wear.tiles.ColorBuilders.argb
import androidx.wear.tiles.DimensionBuilders.dp
import androidx.wear.tiles.DimensionBuilders.sp
import androidx.wear.tiles.LayoutElementBuilders
import androidx.wear.tiles.LayoutElementBuilders.Layout
import androidx.wear.tiles.LayoutElementBuilders.LayoutElement
import androidx.wear.tiles.ModifiersBuilders
import androidx.wear.tiles.RequestBuilders.ResourcesRequest
import androidx.wear.tiles.RequestBuilders.TileRequest
import androidx.wear.tiles.ResourceBuilders.Resources
import androidx.wear.tiles.TileBuilders.Tile
import androidx.wear.tiles.TileService
import androidx.wear.tiles.TimelineBuilders.Timeline
import androidx.wear.tiles.TimelineBuilders.TimelineEntry
import com.google.common.util.concurrent.ListenableFuture
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.R
import io.homeassistant.companion.android.conversation.ConversationActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.guava.future
@AndroidEntryPoint
class ConversationTile : TileService() {
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
override fun onTileRequest(requestParams: TileRequest): ListenableFuture<Tile> =
serviceScope.future {
Tile.Builder()
.setResourcesVersion("1")
.setTimeline(
Timeline.Builder().addTimelineEntry(
TimelineEntry.Builder().setLayout(
Layout.Builder().setRoot(
boxLayout()
).build()
).build()
).build()
).build()
}
override fun onResourcesRequest(requestParams: ResourcesRequest): ListenableFuture<Resources> =
serviceScope.future {
Resources.Builder()
.setVersion("1")
.build()
}
override fun onDestroy() {
super.onDestroy()
// Cleans up the coroutine
serviceJob.cancel()
}
private fun boxLayout(): LayoutElement =
LayoutElementBuilders.Box.Builder()
.addContent(tappableElement())
.setHeight(dp(50f))
.setWidth(dp(100f))
.setModifiers(
ModifiersBuilders.Modifiers.Builder()
.setClickable(
ModifiersBuilders.Clickable.Builder()
.setId("conversation")
.setOnClick(
ActionBuilders.LaunchAction.Builder()
.setAndroidActivity(
ActionBuilders.AndroidActivity.Builder()
.setClassName(ConversationActivity::class.java.name)
.setPackageName(this.packageName)
.build()
).build()
).build()
)
.setBackground(
ModifiersBuilders.Background.Builder()
.setColor(argb(ContextCompat.getColor(baseContext, R.color.colorAccent)))
.setCorner(
ModifiersBuilders.Corner.Builder()
.setRadius(dp(10f))
.build()
)
.build()
)
.build()
)
.build()
private fun tappableElement(): LayoutElement =
LayoutElementBuilders.Text.Builder()
.setText(getString(R.string.speak))
.setFontStyle(
LayoutElementBuilders.FontStyle.Builder()
.setSize(sp(30f))
.build()
)
.build()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB