Implement native Assist (#3589)

* Native Assist setup + text input

* Minor (text input) UI tweaks and fixes

 - Set a very light window tint to increase contrast on white backgrounds
 - Keyboard to outline for consistency
 - Text input: keyboard action, keyboard icon to outline for consistency, show keyboard when switching
 - Fix reset conversation on recreation

* Voice input

 - Add basic voice input support to the native Assist interface

* Voice input bugfixes

 - Don't block voice output while sending data via websocket
 - Drop voice output data if there is a subscriber and the buffer is full by specifying a buffer + overflow strategy that matches behavior when there are no subscribers
 - Properly stop AudioRecord reading when job is cancelled (non-suspending function)
 - Stop recorder before stopping output collection

* Voice responses (generated TTS) playback

 - Play received tts-end events when using voice input
 - Update permission info on resume to catch granted permissions while in the background

* Pipeline switcher

 - Allow switching between all different pipelines
 - Add icon content descriptions

* Check + show attribution

* Prevent sheet that is too high pushing controls away

* UI feedback: pipelines and attribution

* Update Automotive manifest

* Fix speech bubble size for larger responses

* Update manifest to handle tasks/backstack better

 - Set the affinity to a value to make sure Assist is always launched in another task than the main app
 - Automatically remove from recents as Assist will be the only thing in it's task, after finishing there's nothing left to (re)start

* App-specific feature checks and error handling

 - Check for microphone support on device
 - Handle connectivity errors
 - Handle argument errors
 - Handle errors for pipelines that are no longer visible

* More UI feedback

 - Add a title to the sheet to make sure people know this is the Home Assistant app
 - Fix TextField alignment

* More header means max height adjustment
This commit is contained in:
Joris Pelgröm 2023-06-19 20:50:06 +02:00 committed by GitHub
parent b586bf9955
commit 1cae83c40e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1439 additions and 18 deletions

View file

@ -200,6 +200,7 @@ dependencies {
implementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.7.1")
implementation("androidx.navigation:navigation-compose:2.5.3")
implementation("com.google.accompanist:accompanist-systemuicontroller:0.30.1")
implementation("com.google.accompanist:accompanist-themeadapter-material:0.30.1")
implementation("com.mikepenz:iconics-core:5.4.0")

View file

@ -383,6 +383,20 @@
</intent-filter>
</activity>
<activity android:name=".assist.AssistActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:taskAffinity="io.homeassistant.companion.android.assist"
android:autoRemoveFromRecents="true"
android:showWhenLocked="true"
android:theme="@style/Theme.HomeAssistant.Assist">
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View file

@ -0,0 +1,129 @@
package io.homeassistant.companion.android.assist
import android.Manifest
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.view.WindowCompat
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.accompanist.themeadapter.material.MdcTheme
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.BaseActivity
import io.homeassistant.companion.android.assist.ui.AssistSheetView
import io.homeassistant.companion.android.common.data.servers.ServerManager
@AndroidEntryPoint
class AssistActivity : BaseActivity() {
private val viewModel: AssistViewModel by viewModels()
companion object {
const val TAG = "AssistActivity"
private const val EXTRA_SERVER = "server"
private const val EXTRA_PIPELINE = "pipeline"
private const val EXTRA_START_LISTENING = "start_listening"
private const val EXTRA_FROM_FRONTEND = "from_frontend"
fun newInstance(
context: Context,
serverId: Int = -1,
pipelineId: String? = null,
startListening: Boolean = true,
fromFrontend: Boolean = true
): Intent {
return Intent(context, AssistActivity::class.java).apply {
putExtra(EXTRA_SERVER, serverId)
putExtra(EXTRA_PIPELINE, pipelineId)
putExtra(EXTRA_START_LISTENING, startListening)
putExtra(EXTRA_FROM_FRONTEND, fromFrontend)
}
}
}
private val requestPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
{ viewModel.onPermissionResult(it) }
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O) {
@Suppress("DEPRECATION")
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
} // else handled by manifest attribute
val isLocked = getSystemService<KeyguardManager>()?.isKeyguardLocked ?: false
if (isLocked) {
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WALLPAPER)
}
if (savedInstanceState == null) {
viewModel.onCreate(
serverId = if (intent.hasExtra(EXTRA_SERVER)) {
intent.getIntExtra(EXTRA_SERVER, ServerManager.SERVER_ID_ACTIVE)
} else {
null
},
pipelineId = if (intent.hasExtra(EXTRA_PIPELINE)) {
intent.getStringExtra(EXTRA_PIPELINE)
} else {
null
},
startListening = if (intent.hasExtra(EXTRA_START_LISTENING)) {
intent.getBooleanExtra(EXTRA_START_LISTENING, true)
} else {
null
}
)
}
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
MdcTheme {
val systemUiController = rememberSystemUiController()
val useDarkIcons = MaterialTheme.colors.isLight
SideEffect {
systemUiController.setSystemBarsColor(Color.Transparent, darkIcons = useDarkIcons)
}
AssistSheetView(
conversation = viewModel.conversation,
pipelines = viewModel.pipelines,
inputMode = viewModel.inputMode,
fromFrontend = intent.getBooleanExtra(EXTRA_FROM_FRONTEND, false),
currentPipeline = viewModel.currentPipeline,
onSelectPipeline = viewModel::changePipeline,
onChangeInput = viewModel::onChangeInput,
onTextInput = viewModel::onTextInput,
onMicrophoneInput = viewModel::onMicrophoneInput,
onHide = { finish() }
)
}
}
}
override fun onResume() {
super.onResume()
viewModel.setPermissionInfo(
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
) { requestPermission.launch(Manifest.permission.RECORD_AUDIO) }
}
override fun onPause() {
super.onPause()
viewModel.onPause()
}
}

View file

@ -0,0 +1,414 @@
package io.homeassistant.companion.android.assist
import android.app.Application
import android.content.pm.PackageManager
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.assist.ui.AssistMessage
import io.homeassistant.companion.android.assist.ui.AssistUiPipeline
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineError
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineEventType
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineIntentEnd
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineRunStart
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineSttEnd
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineTtsEnd
import io.homeassistant.companion.android.common.util.AudioRecorder
import io.homeassistant.companion.android.common.util.AudioUrlPlayer
import io.homeassistant.companion.android.util.UrlHandler
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import javax.inject.Inject
import io.homeassistant.companion.android.common.R as commonR
@HiltViewModel
class AssistViewModel @Inject constructor(
val serverManager: ServerManager,
private val audioRecorder: AudioRecorder,
private val audioUrlPlayer: AudioUrlPlayer,
application: Application
) : AndroidViewModel(application) {
companion object {
const val TAG = "AssistViewModel"
}
enum class AssistInputMode {
TEXT,
TEXT_ONLY,
VOICE_INACTIVE,
VOICE_ACTIVE,
BLOCKED
}
private val app = application
private var filteredServerId: Int? = null
private var selectedServerId = ServerManager.SERVER_ID_ACTIVE
private val allPipelines = mutableMapOf<Int, List<AssistPipelineResponse>>()
private var selectedPipeline: AssistPipelineResponse? = null
private var recorderJob: Job? = null
private var recorderQueue: MutableList<ByteArray>? = null
private var recorderAutoStart = true
private var hasMicrophone = true
private var hasPermission = false
private var requestPermission: (() -> Unit)? = null
private var requestSilently = true
private var binaryHandlerId: Int? = null
private var conversationId: String? = null
private val startMessage = AssistMessage(application.getString(commonR.string.assist_how_can_i_assist), isInput = false)
private val _conversation = mutableStateListOf(startMessage)
val conversation: List<AssistMessage> = _conversation
private val _pipelines = mutableStateListOf<AssistUiPipeline>()
val pipelines: List<AssistUiPipeline> = _pipelines
var currentPipeline by mutableStateOf<AssistUiPipeline?>(null)
private set
var inputMode by mutableStateOf<AssistInputMode?>(null)
private set
init {
hasMicrophone = app.packageManager.hasSystemFeature(PackageManager.FEATURE_MICROPHONE)
}
fun onCreate(serverId: Int?, pipelineId: String?, startListening: Boolean?) {
viewModelScope.launch {
serverId?.let {
filteredServerId = serverId
selectedServerId = serverId
}
startListening?.let { recorderAutoStart = it }
val supported = checkSupport()
if (!serverManager.isRegistered()) {
inputMode = AssistInputMode.BLOCKED
_conversation.clear()
_conversation.add(
AssistMessage(app.getString(commonR.string.not_registered), isInput = false)
)
} else if (supported == null) { // Couldn't get config
inputMode = AssistInputMode.BLOCKED
_conversation.clear()
_conversation.add(
AssistMessage(app.getString(commonR.string.assist_connnect), isInput = false)
)
} else if (!supported) { // Core too old or doesn't include assist pipeline
inputMode = AssistInputMode.BLOCKED
_conversation.clear()
_conversation.add(
AssistMessage(
app.getString(commonR.string.no_assist_support, "2023.5", app.getString(commonR.string.no_assist_support_assist_pipeline)),
isInput = false
)
)
} else {
setPipeline(pipelineId?.ifBlank { null })
}
if (serverManager.isRegistered()) {
viewModelScope.launch {
loadPipelines()
}
}
}
}
private suspend fun checkSupport(): Boolean? {
if (!serverManager.isRegistered()) return false
if (!serverManager.integrationRepository(selectedServerId).isHomeAssistantVersionAtLeast(2023, 5, 0)) return false
return serverManager.webSocketRepository(selectedServerId).getConfig()?.components?.contains("assist_pipeline")
}
private suspend fun loadPipelines() {
val serverIds = filteredServerId?.let { listOf(it) } ?: serverManager.defaultServers.map { it.id }
serverIds.forEach { serverId ->
viewModelScope.launch {
val server = serverManager.getServer(serverId)
val serverPipelines = serverManager.webSocketRepository(serverId).getAssistPipelines()
allPipelines[serverId] = serverPipelines?.pipelines ?: emptyList()
_pipelines.addAll(
serverPipelines?.pipelines.orEmpty().map {
AssistUiPipeline(
serverId = serverId,
serverName = server?.friendlyName ?: "",
id = it.id,
name = it.name
)
}
)
}
}
}
fun changePipeline(serverId: Int, id: String) = viewModelScope.launch {
if (serverId == selectedServerId && id == selectedPipeline?.id) return@launch
stopRecording()
stopPlayback()
selectedServerId = serverId
setPipeline(id)
}
private suspend fun setPipeline(id: String?) {
selectedPipeline =
allPipelines[selectedServerId]?.firstOrNull { it.id == id } ?: serverManager.webSocketRepository(selectedServerId).getAssistPipeline(id)
selectedPipeline?.let {
val attribution = serverManager.webSocketRepository(selectedServerId).getConversationAgentInfo(it.conversationEngine)?.attribution
currentPipeline = AssistUiPipeline(
serverId = selectedServerId,
serverName = serverManager.getServer(selectedServerId)?.friendlyName ?: "",
id = it.id,
name = it.name,
attributionName = attribution?.name,
attributionUrl = attribution?.url
)
_conversation.clear()
_conversation.add(startMessage)
binaryHandlerId = null
conversationId = null
if (hasMicrophone && it.sttEngine != null) {
if (recorderAutoStart && (hasPermission || requestSilently)) {
inputMode = AssistInputMode.VOICE_INACTIVE
onMicrophoneInput()
} else { // already requested permission once and was denied
inputMode = AssistInputMode.TEXT
}
} else {
inputMode = AssistInputMode.TEXT_ONLY
}
} ?: run {
if (!id.isNullOrBlank()) {
setPipeline(null) // Try falling back to default pipeline
} else {
Log.w(TAG, "Server $selectedServerId does not have any pipelines")
inputMode = AssistInputMode.BLOCKED
_conversation.clear()
_conversation.add(
AssistMessage(app.getString(commonR.string.assist_error), isInput = false)
)
}
}
}
fun onChangeInput() {
when (inputMode) {
null, AssistInputMode.BLOCKED, AssistInputMode.TEXT_ONLY -> { /* Do nothing */ }
AssistInputMode.TEXT -> {
inputMode = AssistInputMode.VOICE_INACTIVE
if (hasPermission || requestSilently) {
onMicrophoneInput()
}
}
AssistInputMode.VOICE_INACTIVE -> {
inputMode = AssistInputMode.TEXT
}
AssistInputMode.VOICE_ACTIVE -> {
stopRecording()
inputMode = AssistInputMode.TEXT
}
}
}
fun onTextInput(input: String) = runAssistPipeline(input)
fun onMicrophoneInput() {
if (!hasPermission) {
requestPermission?.let { it() }
return
}
if (inputMode == AssistInputMode.VOICE_ACTIVE) {
stopRecording()
return
}
val recording = try {
audioRecorder.startRecording()
} catch (e: Exception) {
Log.e(TAG, "Exception while starting recording", e)
false
}
if (recording) {
recorderQueue = mutableListOf()
recorderJob = viewModelScope.launch {
audioRecorder.audioBytes.collect {
recorderQueue?.add(it) ?: sendVoiceData(it)
}
}
inputMode = AssistInputMode.VOICE_ACTIVE
runAssistPipeline(null)
} else {
_conversation.add(AssistMessage(app.getString(commonR.string.assist_error), isInput = false, isError = true))
}
}
private fun runAssistPipeline(text: String?) {
val isVoice = text == null
val userMessage = AssistMessage(text ?: "", isInput = true)
_conversation.add(userMessage)
val haMessage = AssistMessage("", isInput = false)
if (!isVoice) _conversation.add(haMessage)
var message = if (isVoice) userMessage else haMessage
var job: Job? = null
job = viewModelScope.launch {
val flow = if (isVoice) {
serverManager.webSocketRepository(selectedServerId).runAssistPipelineForVoice(
sampleRate = AudioRecorder.SAMPLE_RATE,
outputTts = selectedPipeline?.ttsEngine?.isNotBlank() == true,
pipelineId = selectedPipeline?.id,
conversationId = conversationId
)
} else {
serverManager.webSocketRepository(selectedServerId).runAssistPipelineForText(
text = text!!,
pipelineId = selectedPipeline?.id,
conversationId = conversationId
)
}
flow?.collect {
when (it.type) {
AssistPipelineEventType.RUN_START -> {
if (!isVoice) return@collect
val data = (it.data as? AssistPipelineRunStart)?.runnerData
binaryHandlerId = data?.get("stt_binary_handler_id") as? Int
}
AssistPipelineEventType.STT_START -> {
viewModelScope.launch {
recorderQueue?.forEach { item ->
sendVoiceData(item)
}
recorderQueue = null
}
}
AssistPipelineEventType.STT_END -> {
stopRecording()
(it.data as? AssistPipelineSttEnd)?.sttOutput?.let { response ->
_conversation.indexOf(message).takeIf { pos -> pos >= 0 }?.let { index ->
_conversation[index] = message.copy(message = response["text"] as String)
}
}
_conversation.add(haMessage)
message = haMessage
}
AssistPipelineEventType.INTENT_END -> {
val data = (it.data as? AssistPipelineIntentEnd)?.intentOutput ?: return@collect
conversationId = data.conversationId
data.response.speech.plain["speech"]?.let { response ->
_conversation.indexOf(message).takeIf { pos -> pos >= 0 }?.let { index ->
_conversation[index] = message.copy(message = response)
}
}
}
AssistPipelineEventType.TTS_END -> {
if (!isVoice) return@collect
val audioPath = (it.data as? AssistPipelineTtsEnd)?.ttsOutput?.url
if (!audioPath.isNullOrBlank()) {
playAudio(audioPath)
}
}
AssistPipelineEventType.RUN_END -> {
stopRecording()
job?.cancel()
}
AssistPipelineEventType.ERROR -> {
val errorMessage = (it.data as? AssistPipelineError)?.message ?: return@collect
_conversation.indexOf(message).takeIf { pos -> pos >= 0 }?.let { index ->
_conversation[index] = message.copy(message = errorMessage, isError = true)
}
stopRecording()
job?.cancel()
}
else -> { /* Do nothing */ }
}
} ?: run {
_conversation.indexOf(message).takeIf { pos -> pos >= 0 }?.let { index ->
_conversation[index] = message.copy(message = app.getString(commonR.string.assist_error), isError = true)
}
stopRecording()
}
}
}
private fun sendVoiceData(data: ByteArray) {
binaryHandlerId?.let {
viewModelScope.launch {
// Launch to prevent blocking the output flow if the network is slow
serverManager.webSocketRepository(selectedServerId).sendVoiceData(it, data)
}
}
}
private fun playAudio(path: String) {
UrlHandler.handle(serverManager.getServer(selectedServerId)?.connection?.getUrl(), path)?.let {
viewModelScope.launch {
audioUrlPlayer.playAudio(it.toString())
}
}
}
fun setPermissionInfo(hasPermission: Boolean, callback: () -> Unit) {
this.hasPermission = hasPermission
requestPermission = callback
}
fun onPermissionResult(granted: Boolean) {
hasPermission = granted
if (granted) {
inputMode = AssistInputMode.VOICE_INACTIVE
onMicrophoneInput()
} else if (requestSilently) { // Don't notify the user if they haven't explicitly requested
inputMode = AssistInputMode.TEXT
} else {
_conversation.add(AssistMessage(app.getString(commonR.string.assist_permission), isInput = false))
}
requestSilently = false
}
fun onPause() {
requestPermission = null
stopRecording()
stopPlayback()
}
private fun stopRecording() {
audioRecorder.stopRecording()
recorderJob?.cancel()
recorderJob = null
if (binaryHandlerId != null) {
viewModelScope.launch {
recorderQueue?.forEach {
sendVoiceData(it)
}
recorderQueue = null
sendVoiceData(byteArrayOf()) // Empty message to indicate end of recording
binaryHandlerId = null
}
} else {
recorderQueue = null
}
if (inputMode == AssistInputMode.VOICE_ACTIVE) {
inputMode = AssistInputMode.VOICE_INACTIVE
}
}
private fun stopPlayback() = audioUrlPlayer.stop()
}

View file

@ -0,0 +1,7 @@
package io.homeassistant.companion.android.assist.ui
data class AssistMessage(
val message: String,
val isInput: Boolean,
val isError: Boolean = false
)

View file

@ -0,0 +1,376 @@
package io.homeassistant.companion.android.assist.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetLayout
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.outlined.Keyboard
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mikepenz.iconics.compose.Image
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.assist.AssistViewModel
import kotlinx.coroutines.launch
import io.homeassistant.companion.android.common.R as commonR
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun AssistSheetView(
conversation: List<AssistMessage>,
pipelines: List<AssistUiPipeline>,
inputMode: AssistViewModel.AssistInputMode?,
currentPipeline: AssistUiPipeline?,
fromFrontend: Boolean,
onSelectPipeline: (Int, String) -> Unit,
onChangeInput: () -> Unit,
onTextInput: (String) -> Unit,
onMicrophoneInput: () -> Unit,
onHide: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val state = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded,
skipHalfExpanded = true,
confirmValueChange = {
if (it == ModalBottomSheetValue.Hidden) {
coroutineScope.launch { onHide() }
}
true
}
)
val configuration = LocalConfiguration.current
val sheetCornerRadius = dimensionResource(R.dimen.bottom_sheet_corner_radius)
ModalBottomSheetLayout(
sheetState = state,
sheetShape = RoundedCornerShape(topStart = sheetCornerRadius, topEnd = sheetCornerRadius),
scrimColor = Color.Transparent,
modifier = Modifier.fillMaxSize(),
sheetContent = {
Box(
Modifier
.navigationBarsPadding()
.imePadding()
.padding(16.dp)
) {
Column {
val lazyListState = rememberLazyListState()
LaunchedEffect(conversation.size) {
lazyListState.animateScrollToItem(conversation.size)
}
AssistSheetHeader(
pipelines = pipelines,
currentPipeline = currentPipeline,
fromFrontend = fromFrontend,
onSelectPipeline = onSelectPipeline
)
LazyColumn(
state = lazyListState,
modifier = Modifier
.padding(vertical = 16.dp)
.heightIn(
max = configuration.screenHeightDp.dp -
WindowInsets.safeContent.asPaddingValues().calculateBottomPadding() -
WindowInsets.safeContent.asPaddingValues().calculateTopPadding() -
96.dp
)
) {
items(conversation) {
SpeechBubble(text = it.message, isResponse = !it.isInput)
}
}
AssistSheetControls(
inputMode,
onChangeInput,
onTextInput,
onMicrophoneInput
)
}
}
}
) {
// The rest of the screen is empty
}
}
@Composable
fun AssistSheetHeader(
pipelines: List<AssistUiPipeline>,
currentPipeline: AssistUiPipeline?,
fromFrontend: Boolean,
onSelectPipeline: (Int, String) -> Unit
) = Column(verticalArrangement = Arrangement.Center) {
Text(
text = stringResource(if (fromFrontend) commonR.string.assist else commonR.string.app_name),
fontSize = 20.sp,
letterSpacing = 0.25.sp
)
if (currentPipeline != null) {
val color = colorResource(commonR.color.colorOnSurfaceVariant)
val weight = if (currentPipeline.attributionName != null) 0.5f else 1f
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Box(Modifier.weight(weight, fill = false)) {
var pipelineShowList by remember { mutableStateOf(false) }
val pipelineShowServer by rememberSaveable(pipelines.size) {
mutableStateOf(pipelines.distinctBy { it.serverId }.size > 1)
}
Row(
modifier = Modifier.clickable { pipelineShowList = !pipelineShowList }
) {
Text(
text = if (pipelineShowServer) "${currentPipeline.serverName}: ${currentPipeline.name}" else currentPipeline.name,
color = color,
style = MaterialTheme.typography.caption
)
Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = stringResource(commonR.string.assist_change_pipeline),
modifier = Modifier
.height(16.dp)
.padding(start = 4.dp),
tint = color
)
}
DropdownMenu(
expanded = pipelineShowList,
onDismissRequest = { pipelineShowList = false }
) {
pipelines.forEach {
val isSelected =
it.serverId == currentPipeline.serverId && it.id == currentPipeline.id
DropdownMenuItem(onClick = {
onSelectPipeline(it.serverId, it.id)
pipelineShowList = false
}) {
Text(
text = if (pipelineShowServer) "${it.serverName}: ${it.name}" else it.name,
fontWeight = if (isSelected) FontWeight.SemiBold else null
)
}
}
}
}
if (currentPipeline.attributionName != null) {
val uriHandler = LocalUriHandler.current
val baseModifier = Modifier.weight(weight, fill = false).padding(start = 8.dp)
val modifier = currentPipeline.attributionUrl?.let {
Modifier
.clickable { uriHandler.openUri(it) }
.then(baseModifier)
} ?: baseModifier
Text(
text = currentPipeline.attributionName,
textDecoration = if (currentPipeline.attributionUrl != null) TextDecoration.Underline else null,
color = color,
style = MaterialTheme.typography.caption,
modifier = modifier
)
}
}
}
}
@Composable
fun AssistSheetControls(
inputMode: AssistViewModel.AssistInputMode?,
onChangeInput: () -> Unit,
onTextInput: (String) -> Unit,
onMicrophoneInput: () -> Unit
) = Row(verticalAlignment = Alignment.CenterVertically) {
if (inputMode == null) { // Pipeline info has not yet loaded, empty space for now
Spacer(modifier = Modifier.height(64.dp))
return
}
if (inputMode == AssistViewModel.AssistInputMode.BLOCKED) { // No info and not recoverable, no space
return
}
val focusRequester = remember { FocusRequester() }
LaunchedEffect(inputMode) {
if (inputMode == AssistViewModel.AssistInputMode.TEXT || inputMode == AssistViewModel.AssistInputMode.TEXT_ONLY) {
focusRequester.requestFocus()
}
}
if (inputMode == AssistViewModel.AssistInputMode.TEXT || inputMode == AssistViewModel.AssistInputMode.TEXT_ONLY) {
var text by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue())
}
TextField(
value = text,
onValueChange = { text = it },
label = { Text(stringResource(commonR.string.assist_enter_a_request)) },
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (text.text.isNotBlank()) {
onTextInput(text.text)
text = TextFieldValue("")
}
})
)
IconButton(
onClick = {
if (text.text.isNotBlank()) {
onTextInput(text.text)
text = TextFieldValue("")
} else if (inputMode != AssistViewModel.AssistInputMode.TEXT_ONLY) {
onChangeInput()
}
},
enabled = (inputMode != AssistViewModel.AssistInputMode.TEXT_ONLY || text.text.isNotBlank())
) {
val inputIsSend = text.text.isNotBlank() || inputMode == AssistViewModel.AssistInputMode.TEXT_ONLY
Image(
asset = if (inputIsSend) CommunityMaterial.Icon3.cmd_send else CommunityMaterial.Icon3.cmd_microphone,
contentDescription = stringResource(
if (inputIsSend) commonR.string.assist_send_text else commonR.string.assist_start_listening
),
colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface),
modifier = Modifier.size(24.dp)
)
}
} else {
Spacer(Modifier.size(48.dp))
Spacer(Modifier.weight(0.5f))
OutlinedButton({ onMicrophoneInput() }) {
val inputIsActive = inputMode == AssistViewModel.AssistInputMode.VOICE_ACTIVE
Image(
asset = CommunityMaterial.Icon3.cmd_microphone,
contentDescription = stringResource(
if (inputIsActive) commonR.string.assist_stop_listening else commonR.string.assist_start_listening
),
colorFilter = ColorFilter.tint(
if (inputIsActive) {
LocalContentColor.current
} else {
MaterialTheme.colors.onSurface
}
),
modifier = Modifier.size(32.dp)
)
}
Spacer(Modifier.weight(0.5f))
IconButton({ onChangeInput() }) {
Icon(
imageVector = Icons.Outlined.Keyboard,
contentDescription = stringResource(commonR.string.assist_enter_text),
tint = MaterialTheme.colors.onSurface
)
}
}
}
@Composable
fun SpeechBubble(text: String, isResponse: Boolean) {
Row(
horizontalArrangement = if (isResponse) Arrangement.Start else Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(
start = if (isResponse) 0.dp else 24.dp,
end = if (isResponse) 24.dp else 0.dp,
top = 8.dp,
bottom = 8.dp
)
) {
Box(
modifier = Modifier
.background(
if (isResponse) {
colorResource(commonR.color.colorAccent)
} else {
colorResource(commonR.color.colorSpeechText)
},
AbsoluteRoundedCornerShape(
topLeft = 12.dp,
topRight = 12.dp,
bottomLeft = if (isResponse) 0.dp else 12.dp,
bottomRight = if (isResponse) 12.dp else 0.dp
)
)
.padding(4.dp)
) {
Text(
text = text,
color = if (isResponse) {
Color.White
} else {
Color.Black
},
modifier = Modifier
.padding(2.dp)
)
}
}
}

View file

@ -0,0 +1,10 @@
package io.homeassistant.companion.android.assist.ui
data class AssistUiPipeline(
val serverId: Int,
val serverName: String,
val id: String,
val name: String,
val attributionName: String? = null,
val attributionUrl: String? = null
)

View file

@ -75,6 +75,7 @@ import eightbitlab.com.blurview.RenderScriptBlur
import io.homeassistant.companion.android.BaseActivity
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.assist.AssistActivity
import io.homeassistant.companion.android.authenticator.Authenticator
import io.homeassistant.companion.android.common.data.HomeAssistantApis
import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository
@ -630,7 +631,8 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
"hasSettingsScreen" to true,
"canWriteTag" to hasNfc,
"hasExoPlayer" to true,
"canCommissionMatter" to canCommissionMatter
"canCommissionMatter" to canCommissionMatter,
"hasAssist" to true
)
)
) {
@ -650,6 +652,17 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
null
)
}
"assist/show" -> {
val payload = if (json.has("payload")) json.getJSONObject("payload") else null
startActivity(
AssistActivity.newInstance(
this@WebViewActivity,
serverId = presenter.getActiveServer(),
pipelineId = if (payload?.has("pipeline_id") == true) payload.getString("pipeline_id") else null,
startListening = if (payload?.has("start_listening") == true) payload.getBoolean("start_listening") else true
)
)
}
"config_screen/show" ->
startActivity(
SettingsActivity.newInstance(this@WebViewActivity)

View file

@ -23,6 +23,15 @@
<style name="Theme.HomeAssistant.Config" parent="Theme.HomeAssistant" />
<style name="Theme.HomeAssistant.Assist" parent="Theme.HomeAssistant" >
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:backgroundDimEnabled">true</item>
<item name="android:backgroundDimAmount">0.1</item>
</style>
<style name="TextAppearance.HomeAssistant.Headline" parent="TextAppearance.MaterialComponents.Headline1">
<item name="android:textSize">20sp</item>
<item name="android:textColor">@color/colorHeadline1</item>

View file

@ -226,6 +226,7 @@ dependencies {
implementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.7.1")
implementation("androidx.navigation:navigation-compose:2.5.3")
implementation("com.google.accompanist:accompanist-systemuicontroller:0.30.1")
implementation("com.google.accompanist:accompanist-themeadapter-material:0.30.1")
implementation("com.mikepenz:iconics-core:5.4.0")

View file

@ -407,6 +407,20 @@
<meta-data android:name="distractionOptimized" android:value="true"/>
</activity>
<activity android:name=".assist.AssistActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:taskAffinity="io.homeassistant.companion.android.assist"
android:autoRemoveFromRecents="true"
android:showWhenLocked="true"
android:theme="@style/Theme.HomeAssistant.Assist">
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View file

@ -538,7 +538,7 @@ class IntegrationRepositoryImpl @AssistedInject constructor(
var job: Job? = null
val response = suspendCancellableCoroutine { cont ->
job = ioScope.launch {
webSocketRepository.runAssistPipeline(speech)?.collect {
webSocketRepository.runAssistPipelineForText(speech)?.collect {
if (!cont.isActive) return@collect
when (it.type) {
AssistPipelineEventType.INTENT_END ->

View file

@ -6,7 +6,10 @@ import io.homeassistant.companion.android.common.data.websocket.impl.WebSocketRe
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.AssistPipelineEvent
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineListResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.CompressedStateChangedEvent
import io.homeassistant.companion.android.common.data.websocket.impl.entities.ConversationAgentInfoResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.ConversationResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.CurrentUserResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryResponse
@ -79,16 +82,57 @@ interface WebSocketRepository {
suspend fun addThreadDataset(tlv: ByteArray): Boolean
/**
* Get an Assist response for the given text input. For core >= 2023.5, use [runAssistPipeline]
* Get an Assist response for the given text input. For core >= 2023.5, use [runAssistPipelineForText]
* instead.
*/
suspend fun getConversation(speech: String): ConversationResponse?
/**
* Get information about the conversation agent.
* @param agentId Should be an [AssistPipelineResponse.conversationEngine]
*/
suspend fun getConversationAgentInfo(agentId: String): ConversationAgentInfoResponse?
/**
* Get information about an Assist pipeline.
* @param pipelineId the ID of the pipeline to get details for, if not specified the preferred
* pipeline will be returned
* @return [AssistPipelineResponse] detailing the Assist pipeline, or `null` if not found or no
* response.
*/
suspend fun getAssistPipeline(pipelineId: String? = null): AssistPipelineResponse?
/**
* @return [AssistPipelineListResponse] listing all Assist pipelines and which one is preferred.
*/
suspend fun getAssistPipelines(): AssistPipelineListResponse?
/**
* Run the Assist pipeline for the given text input
* @return a Flow that will emit all events for the pipeline
*/
suspend fun runAssistPipeline(text: String): Flow<AssistPipelineEvent>?
suspend fun runAssistPipelineForText(
text: String,
pipelineId: String? = null,
conversationId: String? = null
): Flow<AssistPipelineEvent>?
/**
* Run the Assist pipeline for voice input
* @return a Flow that will emit all events for the pipeline
*/
suspend fun runAssistPipelineForVoice(
sampleRate: Int,
outputTts: Boolean,
pipelineId: String? = null,
conversationId: String? = null
): Flow<AssistPipelineEvent>?
/**
* Send voice data for an active Assist pipeline
* @return `true`/`false` indicating if it was enqueued, or `null` on unexpected failures
*/
suspend fun sendVoiceData(binaryHandlerId: Int, data: ByteArray): Boolean?
}
@AssistedFactory

View file

@ -23,12 +23,18 @@ import io.homeassistant.companion.android.common.data.websocket.WebSocketRequest
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.AssistPipelineError
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineEvent
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineEventType
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineIntentEnd
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineIntentStart
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineListResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineRunStart
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineSttEnd
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineTtsEnd
import io.homeassistant.companion.android.common.data.websocket.impl.entities.CompressedStateChangedEvent
import io.homeassistant.companion.android.common.data.websocket.impl.entities.ConversationAgentInfoResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.ConversationResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.CurrentUserResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryResponse
@ -72,6 +78,7 @@ import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import okio.ByteString.Companion.toByteString
import java.io.IOException
import java.util.Collections
import java.util.concurrent.atomic.AtomicLong
@ -215,18 +222,82 @@ class WebSocketRepositoryImpl @AssistedInject constructor(
return mapResponse(socketResponse)
}
override suspend fun runAssistPipeline(text: String): Flow<AssistPipelineEvent>? =
subscribeTo(
SUBSCRIBE_TYPE_ASSIST_PIPELINE_RUN,
override suspend fun getConversationAgentInfo(agentId: String): ConversationAgentInfoResponse? {
val socketResponse = sendMessage(
mapOf(
"start_stage" to "intent",
"end_stage" to "intent",
"input" to mapOf(
"text" to text
)
"type" to "conversation/agent/info",
"agent_id" to agentId
)
)
return mapResponse(socketResponse)
}
override suspend fun getAssistPipeline(pipelineId: String?): AssistPipelineResponse? {
val data = mapOf(
"type" to "assist_pipeline/pipeline/get"
)
val socketResponse = sendMessage(
if (pipelineId != null) data.plus("pipeline_id" to pipelineId) else data
)
return mapResponse(socketResponse)
}
override suspend fun getAssistPipelines(): AssistPipelineListResponse? {
val socketResponse = sendMessage(
mapOf(
"type" to "assist_pipeline/pipeline/list"
)
)
return mapResponse(socketResponse)
}
@Suppress("UNCHECKED_CAST")
override suspend fun runAssistPipelineForText(
text: String,
pipelineId: String?,
conversationId: String?
): Flow<AssistPipelineEvent>? {
val data = mapOf(
"start_stage" to "intent",
"end_stage" to "intent",
"input" to mapOf(
"text" to text
),
"conversation_id" to conversationId
)
return subscribeTo(
SUBSCRIBE_TYPE_ASSIST_PIPELINE_RUN,
(pipelineId?.let { data.plus("pipeline" to it) } ?: data) as Map<Any, Any>
)
}
@Suppress("UNCHECKED_CAST")
override suspend fun runAssistPipelineForVoice(
sampleRate: Int,
outputTts: Boolean,
pipelineId: String?,
conversationId: String?
): Flow<AssistPipelineEvent>? {
val data = mapOf(
"start_stage" to "stt",
"end_stage" to (if (outputTts) "tts" else "intent"),
"input" to mapOf(
"sample_rate" to sampleRate
),
"conversation_id" to conversationId
)
return subscribeTo(
SUBSCRIBE_TYPE_ASSIST_PIPELINE_RUN,
(pipelineId?.let { data.plus("pipeline" to it) } ?: data) as Map<Any, Any>
)
}
override suspend fun sendVoiceData(binaryHandlerId: Int, data: ByteArray): Boolean? =
sendBytes(byteArrayOf(binaryHandlerId.toByte()) + data)
override suspend fun getStateChanges(): Flow<StateChangedEvent>? =
subscribeToEventsForType(EVENT_STATE_CHANGED)
@ -569,7 +640,27 @@ class WebSocketRepositoryImpl @AssistedInject constructor(
}
}
} else {
Log.e(TAG, "Unable to send message $request")
Log.w(TAG, "Unable to send message, not connected: $request")
null
}
}
private suspend fun sendBytes(data: ByteArray): Boolean? {
return if (connect()) {
withTimeoutOrNull(30_000) {
try {
connection?.let {
synchronized(it) {
it.send(data.toByteString())
}
}
} catch (e: Exception) {
Log.e(TAG, "Exception while sending bytes", e)
null
}
}
} else {
Log.w(TAG, "Unable to send bytes, not connected")
null
}
}
@ -630,8 +721,11 @@ class WebSocketRepositoryImpl @AssistedInject constructor(
val eventDataMap = response.event.get("data")
val eventData = when (eventType.textValue()) {
AssistPipelineEventType.RUN_START -> mapper.convertValue(eventDataMap, AssistPipelineRunStart::class.java)
AssistPipelineEventType.STT_END -> mapper.convertValue(eventDataMap, AssistPipelineSttEnd::class.java)
AssistPipelineEventType.INTENT_START -> mapper.convertValue(eventDataMap, AssistPipelineIntentStart::class.java)
AssistPipelineEventType.INTENT_END -> mapper.convertValue(eventDataMap, AssistPipelineIntentEnd::class.java)
AssistPipelineEventType.TTS_END -> mapper.convertValue(eventDataMap, AssistPipelineTtsEnd::class.java)
AssistPipelineEventType.ERROR -> mapper.convertValue(eventDataMap, AssistPipelineError::class.java)
else -> null
}
AssistPipelineEvent(eventType.textValue(), eventData)

View file

@ -28,6 +28,11 @@ data class AssistPipelineRunStart(
val runnerData: Map<String, Any?>
) : AssistPipelineEventData
@JsonIgnoreProperties(ignoreUnknown = true)
data class AssistPipelineSttEnd(
val sttOutput: Map<String, Any?>
) : AssistPipelineEventData
@JsonIgnoreProperties(ignoreUnknown = true)
data class AssistPipelineIntentStart(
val engine: String,
@ -39,3 +44,14 @@ data class AssistPipelineIntentStart(
data class AssistPipelineIntentEnd(
val intentOutput: ConversationResponse
) : AssistPipelineEventData
@JsonIgnoreProperties
data class AssistPipelineTtsEnd(
val ttsOutput: TtsOutputResponse
) : AssistPipelineEventData
@JsonIgnoreProperties(ignoreUnknown = true)
data class AssistPipelineError(
val code: String?,
val message: String?
) : AssistPipelineEventData

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 AssistPipelineListResponse(
val pipelines: List<AssistPipelineResponse>,
val preferredPipeline: String?
)

View file

@ -0,0 +1,17 @@
package io.homeassistant.companion.android.common.data.websocket.impl.entities
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class AssistPipelineResponse(
val id: String,
val language: String,
val name: String,
val conversationEngine: String,
val conversationLanguage: String,
val sttEngine: String?,
val sttLanguage: String?,
val ttsEngine: String?,
val ttsLanguage: String?,
val ttsVoice: String?
)

View file

@ -0,0 +1,14 @@
package io.homeassistant.companion.android.common.data.websocket.impl.entities
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class ConversationAgentInfoResponse(
val attribution: ConversationAgentAttribution?
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ConversationAgentAttribution(
val name: String,
val url: String?
)

View file

@ -0,0 +1,10 @@
package io.homeassistant.companion.android.common.data.websocket.impl.entities
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class TtsOutputResponse(
val mediaId: String,
val mimeType: String,
val url: String
)

View file

@ -0,0 +1,104 @@
package io.homeassistant.companion.android.common.util
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder.AudioSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Wrapper around [AudioRecord] providing pre-configured audio recording functionality.
*/
class AudioRecorder {
companion object {
// Docs: 'currently the only rate that is guaranteed to work on all devices'
const val SAMPLE_RATE = 44100
// Docs: only format '[g]uaranteed to be supported by devices'
private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
private const val AUDIO_SOURCE = AudioSource.MIC
private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
}
private val ioScope = CoroutineScope(Dispatchers.IO + Job())
private var recorder: AudioRecord? = null
private var recorderJob: Job? = null
private val _audioBytes = MutableSharedFlow<ByteArray>(
extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
/** Flow emitting audio recording bytes as they come in */
val audioBytes = _audioBytes.asSharedFlow()
/**
* Start the recorder. After calling this function, data will be available via [audioBytes].
* @throws SecurityException when missing permission to record audio
* @return `true` if the recorder started, or `false` if not
*/
fun startRecording(): Boolean {
if (recorder == null) {
setupRecorder()
}
val ready = recorder?.state == AudioRecord.STATE_INITIALIZED
if (!ready) return false
if (recorderJob == null || recorderJob?.isActive == false) {
recorder?.startRecording()
recorderJob = ioScope.launch {
val dataSize = minBufferSize()
while (isActive) {
// We're recording in 16-bit as that is guaranteed to be supported but bytes are
// 8-bit. So first read as shorts, then manually split them into two bytes, and
// finally send all pairs of two as one array to the flow.
// Split/conversion based on https://stackoverflow.com/a/47905328/4214819.
val data = ShortArray(dataSize)
recorder?.read(data, 0, dataSize) // blocking!
_audioBytes.emit(
data
.flatMap {
val first = (it.toInt() and 0x00FF).toByte()
val last = ((it.toInt() and 0xFF00) shr 8).toByte()
listOf(first, last)
}
.toByteArray()
)
}
}
}
return true
}
fun stopRecording() {
recorder?.stop()
recorderJob?.cancel()
recorderJob = null
releaseRecorder()
}
@SuppressLint("MissingPermission")
private fun setupRecorder() {
if (recorder != null) stopRecording()
val bufferSize = minBufferSize() * 10
recorder = AudioRecord(AUDIO_SOURCE, SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT, bufferSize)
}
private fun releaseRecorder() {
recorder?.release()
recorder = null
}
private fun minBufferSize() = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
}

View file

@ -0,0 +1,93 @@
package io.homeassistant.companion.android.common.util
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.os.Build
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* Simple interface for playing short streaming audio (from URLs).
*/
class AudioUrlPlayer {
companion object {
private const val TAG = "AudioUrlPlayer"
}
private var player: MediaPlayer? = null
/**
* Stream and play audio from the provided [url]. Any currently playing audio will be stopped.
* This function will suspend until playback has started.
* @param isAssistant whether the usage/stream should be set to Assistant on supported versions
* @return `true` if the audio playback started, or `false` if not
*/
suspend fun playAudio(url: String, isAssistant: Boolean = true): Boolean = withContext(Dispatchers.IO) {
if (player != null) {
stop()
}
return@withContext suspendCoroutine { cont ->
player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(
if (isAssistant) AudioAttributes.CONTENT_TYPE_SPEECH else AudioAttributes.CONTENT_TYPE_MUSIC
)
.setUsage(
if (isAssistant && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
AudioAttributes.USAGE_ASSISTANT
} else {
AudioAttributes.USAGE_MEDIA
}
)
.build()
)
setOnPreparedListener {
if (isActive) {
it.start()
cont.resume(true)
} else {
releasePlayer()
cont.resume(false)
}
}
setOnErrorListener { _, what, extra ->
Log.e(TAG, "Media player encountered error: $what ($extra)")
releasePlayer()
cont.resume(false)
return@setOnErrorListener true
}
setOnCompletionListener {
releasePlayer()
}
}
try {
player?.setDataSource(url)
player?.prepareAsync()
} catch (e: Exception) {
Log.e(TAG, "Media player couldn't be prepared", e)
cont.resume(false)
}
}
}
fun stop() {
try {
player?.stop()
} catch (e: IllegalStateException) {
// Player wasn't initialized, ignore
}
releasePlayer()
}
private fun releasePlayer() {
player?.release()
player = null
}
}

View file

@ -0,0 +1,20 @@
package io.homeassistant.companion.android.common.util
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object UtilModule {
@Provides
@Singleton
fun provideAudioRecorder(): AudioRecorder = AudioRecorder()
@Provides
@Singleton
fun provideAudioUrlPlayer(): AudioUrlPlayer = AudioUrlPlayer()
}

View file

@ -23,5 +23,6 @@
<color name="colorActionBarPopupBackground">#2B2B2B</color>
<color name="colorLaunchScreenBackground">#111111</color>
<color name="colorCodeBackground">#282828</color>
<color name="colorOnSurfaceVariant">#CAC4D0</color> <!-- M3 On Surface Variant -->
<color name="colorBottomSheetHandle">#66CAC4D0</color> <!-- M3 On Surface Variant 40% opacity -->
</resources>

View file

@ -34,5 +34,6 @@
<color name="colorDeviceControlsThermostatHeat">#FF8B66</color>
<color name="colorDeviceControlsCamera">#F1F3F4</color>
<color name="colorSpeechText">#B3E5FC</color>
<color name="colorOnSurfaceVariant">#49454E</color> <!-- M3 On Surface Variant -->
<color name="colorBottomSheetHandle">#6649454E</color> <!-- M3 On Surface Variant 40% opacity -->
</resources>

View file

@ -1058,8 +1058,18 @@
<string name="no_assist_support_assist_pipeline">Assist pipeline</string>
<string name="conversation">Conversation</string>
<string name="assist">Assist</string>
<string name="assist_connnect">Assist couldn\'t reach Home Assistant, check your connection</string>
<string name="assist_change_pipeline">Change assistant</string>
<string name="assist_enter_text">Enter text</string>
<string name="assist_enter_a_request">Enter a request</string>
<string name="assist_error">Oops, an error has occurred</string>
<string name="assist_how_can_i_assist">How can I assist?</string>
<string name="assist_log_in">Log in to Home Assistant to start using Assist</string>
<string name="not_registered">Please launch the Home Assistant app and log in to start using Assist.</string>
<string name="assist_permission">To use Assist with your voice, allow Home Assistant to access the microphone</string>
<string name="assist_send_text">Send text</string>
<string name="assist_start_listening">Start listening</string>
<string name="assist_stop_listening">Stop listening</string>
<string name="not_registered">Please open the Home Assistant app and log in to start using Assist</string>
<string name="ha_assist">HA: Assist</string>
<string name="only_favorites">Only Show Favorites</string>
<string name="beacon_scanning">Beacon Monitor Scanning</string>

View file

@ -113,10 +113,10 @@ fun SpeechBubble(text: String, isResponse: Boolean) {
colorResource(R.color.colorSpeechText)
},
AbsoluteRoundedCornerShape(
topLeftPercent = 30,
topRightPercent = 30,
bottomLeftPercent = if (isResponse) 0 else 30,
bottomRightPercent = if (isResponse) 30 else 0
topLeft = 12.dp,
topRight = 12.dp,
bottomLeft = if (isResponse) 0.dp else 12.dp,
bottomRight = if (isResponse) 12.dp else 0.dp
)
)
.padding(4.dp)