mirror of
https://github.com/home-assistant/android
synced 2024-10-04 15:19:30 +00:00
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:
parent
b586bf9955
commit
1cae83c40e
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package io.homeassistant.companion.android.assist.ui
|
||||
|
||||
data class AssistMessage(
|
||||
val message: String,
|
||||
val isInput: Boolean,
|
||||
val isError: Boolean = false
|
||||
)
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
)
|
|
@ -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?
|
||||
)
|
|
@ -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?
|
||||
)
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue