From 7f32738e10e5c3b7baeb9bf112557a4ee826ed50 Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Sun, 18 Dec 2022 12:25:00 -0800 Subject: [PATCH] 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 --- .../data/integration/IntegrationRepository.kt | 2 + .../impl/IntegrationRepositoryImpl.kt | 7 ++ .../data/websocket/WebSocketRepository.kt | 2 + .../websocket/impl/WebSocketRepositoryImpl.kt | 13 +++ .../impl/entities/ConversationResponse.kt | 9 ++ .../ConversationSpeechPlainResponse.kt | 8 ++ .../entities/ConversationSpeechResponse.kt | 12 ++ common/src/main/res/values/strings.xml | 4 + wear/src/main/AndroidManifest.xml | 15 +++ .../conversation/ConversationActivity.kt | 59 ++++++++++ .../conversation/ConversationViewModel.kt | 46 ++++++++ .../conversation/views/ConversationView.kt | 57 ++++++++++ .../android/tiles/ConversationTile.kt | 104 ++++++++++++++++++ .../drawable/conversation_tile_example.png | Bin 0 -> 12316 bytes 14 files changed, 338 insertions(+) create mode 100755 common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ConversationResponse.kt create mode 100755 common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ConversationSpeechPlainResponse.kt create mode 100755 common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ConversationSpeechResponse.kt create mode 100755 wear/src/main/java/io/homeassistant/companion/android/conversation/ConversationActivity.kt create mode 100755 wear/src/main/java/io/homeassistant/companion/android/conversation/ConversationViewModel.kt create mode 100755 wear/src/main/java/io/homeassistant/companion/android/conversation/views/ConversationView.kt create mode 100755 wear/src/main/java/io/homeassistant/companion/android/tiles/ConversationTile.kt create mode 100755 wear/src/main/res/drawable/conversation_tile_example.png diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt index 324637557..b65f98df0 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt @@ -83,4 +83,6 @@ interface IntegrationRepository { suspend fun updateSensors(sensors: Array>): Boolean suspend fun shouldNotifySecurityWarning(): Boolean + + suspend fun getConversation(speech: String): String? } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt index 83bf824bc..0cef7b2bd 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt @@ -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>? { val response = webSocketRepository.getStates() diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt index 6bed1f357..539596808 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt @@ -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? } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt index 19fe2985a..df1aa70d7 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt @@ -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? = subscribeToEventsForType(EVENT_STATE_CHANGED) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ConversationResponse.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ConversationResponse.kt new file mode 100755 index 000000000..04353b36d --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ConversationResponse.kt @@ -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? +) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ConversationSpeechPlainResponse.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ConversationSpeechPlainResponse.kt new file mode 100755 index 000000000..fef6ec926 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ConversationSpeechPlainResponse.kt @@ -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 +) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ConversationSpeechResponse.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ConversationSpeechResponse.kt new file mode 100755 index 000000000..c21ee96e9 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ConversationSpeechResponse.kt @@ -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? +) diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 0d4b9041c..999d802b9 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -965,4 +965,8 @@ 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. Vibrate when clicked + No results yet + Speak + You must be at least on Home Assistant 2023.1 and have the conversation integration enabled + Conversation diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index b20a06848..e902841fc 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -75,6 +75,9 @@ + + + + + + + + diff --git a/wear/src/main/java/io/homeassistant/companion/android/conversation/ConversationActivity.kt b/wear/src/main/java/io/homeassistant/companion/android/conversation/ConversationActivity.kt new file mode 100755 index 000000000..5163eb646 --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/conversation/ConversationActivity.kt @@ -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() + 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) + } + } +} diff --git a/wear/src/main/java/io/homeassistant/companion/android/conversation/ConversationViewModel.kt b/wear/src/main/java/io/homeassistant/companion/android/conversation/ConversationViewModel.kt new file mode 100755 index 000000000..7ba4b412d --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/conversation/ConversationViewModel.kt @@ -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 + } +} diff --git a/wear/src/main/java/io/homeassistant/companion/android/conversation/views/ConversationView.kt b/wear/src/main/java/io/homeassistant/companion/android/conversation/views/ConversationView.kt new file mode 100755 index 000000000..ffcc91c7f --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/conversation/views/ConversationView.kt @@ -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) + ) + } + } + } + } +} diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/ConversationTile.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/ConversationTile.kt new file mode 100755 index 000000000..2eb989b43 --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/ConversationTile.kt @@ -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 = + 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 = + 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() +} diff --git a/wear/src/main/res/drawable/conversation_tile_example.png b/wear/src/main/res/drawable/conversation_tile_example.png new file mode 100755 index 0000000000000000000000000000000000000000..c4f551811568b97c84becaa51eba098b367d71e0 GIT binary patch literal 12316 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