mirror of
https://github.com/home-assistant/android
synced 2024-07-22 10:54:12 +00:00
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:
parent
8df504e1d7
commit
7f32738e10
|
@ -83,4 +83,6 @@ interface IntegrationRepository {
|
|||
suspend fun updateSensors(sensors: Array<SensorRegistration<Any>>): Boolean
|
||||
|
||||
suspend fun shouldNotifySecurityWarning(): Boolean
|
||||
|
||||
suspend fun getConversation(speech: String): String?
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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?
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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?
|
||||
)
|
|
@ -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?>
|
||||
)
|
|
@ -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?>?
|
||||
)
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
104
wear/src/main/java/io/homeassistant/companion/android/tiles/ConversationTile.kt
Executable file
104
wear/src/main/java/io/homeassistant/companion/android/tiles/ConversationTile.kt
Executable 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()
|
||||
}
|
BIN
wear/src/main/res/drawable/conversation_tile_example.png
Executable file
BIN
wear/src/main/res/drawable/conversation_tile_example.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
Loading…
Reference in a new issue