Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,14 @@ class AssistActivity : BaseActivity() {
private var contextIsLocked = true

companion object {
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"
private const val EXTRA_FROM_WAKE_WORD_PHRASE = "from_wake_word_phrase"
const val EXTRA_SERVER = "server"
const val EXTRA_PIPELINE = "pipeline"
const val EXTRA_START_LISTENING = "start_listening"
const val EXTRA_FROM_FRONTEND = "from_frontend"
const val EXTRA_FROM_WAKE_WORD_PHRASE = "from_wake_word_phrase"
const val EXTRA_TRIGGER_SOURCE = "trigger_source"
const val TRIGGER_SOURCE_ASSIST = "assist"
const val ACTION_TRIGGER_AUTOMOTIVE_ASSIST = "ACTION_TRIGGER_AUTOMOTIVE_ASSIST"
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACTION_TRIGGER_AUTOMOTIVE_ASSIST is a very generic intent action string. To avoid collisions (and accidental triggering by other apps), intent action constants should be fully qualified (e.g., include the application package).

Suggested change
const val ACTION_TRIGGER_AUTOMOTIVE_ASSIST = "ACTION_TRIGGER_AUTOMOTIVE_ASSIST"
const val ACTION_TRIGGER_AUTOMOTIVE_ASSIST =
"io.homeassistant.companion.android.assist.action.TRIGGER_AUTOMOTIVE_ASSIST"

Copilot uses AI. Check for mistakes.

fun newInstance(
context: Context,
Expand All @@ -61,13 +64,15 @@ class AssistActivity : BaseActivity() {
startListening: Boolean = true,
fromFrontend: Boolean = true,
wakeWordPhrase: String? = null,
triggerSource: String? = null,
): 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)
putExtra(EXTRA_FROM_WAKE_WORD_PHRASE, wakeWordPhrase)
putExtra(EXTRA_TRIGGER_SOURCE, triggerSource)
}
}
}
Expand Down Expand Up @@ -113,6 +118,27 @@ class AssistActivity : BaseActivity() {
}

val fromFrontend = intent.getBooleanExtra(EXTRA_FROM_FRONTEND, false)
val triggerSource = intent.getStringExtra(EXTRA_TRIGGER_SOURCE)

if (triggerSource == TRIGGER_SOURCE_ASSIST) {
if (io.homeassistant.companion.android.vehicle.HaCarAppService.carInfo != null) {
val automotiveIntent = Intent(
io.homeassistant.companion.android.vehicle.HaCarAppService.ACTION_NAVIGATE_TO_AUTOMOTIVE_ASSIST,
).apply {
putExtra(
io.homeassistant.companion.android.vehicle.HaCarAppService.EXTRA_SERVER,
if (intent.hasExtra(EXTRA_SERVER)) {
intent.getIntExtra(EXTRA_SERVER, ServerManager.SERVER_ID_ACTIVE)
} else {
ServerManager.SERVER_ID_ACTIVE
},
)
}
sendBroadcast(automotiveIntent)
finish()
Comment on lines +125 to +138
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This broadcast to navigate into automotive assist is implicit. Even with an app-specific action, it’s safer to scope it to your app (e.g., set the package/component) so no other app can receive/spoof it. Consider using an explicit intent targeting HaCarAppService (or at least setPackage(packageName)).

Copilot uses AI. Check for mistakes.
return
}
}

setContent {
if (viewModel.shouldFinish) {
Expand Down Expand Up @@ -166,6 +192,9 @@ class AssistActivity : BaseActivity() {

override fun onPause() {
super.onPause()
// The error says onPause() is protected in AssistViewModel.
// We need to call it, but it's protected.
// Let's see if we can change the visibility in AssistViewModel.
Comment on lines +195 to +197
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These comments are an internal note about a previous visibility issue and are now outdated (the ViewModel methods are overridden). Please remove them to keep production code clean.

Suggested change
// The error says onPause() is protected in AssistViewModel.
// We need to call it, but it's protected.
// Let's see if we can change the visibility in AssistViewModel.

Copilot uses AI. Check for mistakes.
viewModel.onPause()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,13 +506,13 @@ class AssistViewModel @AssistedInject constructor(
if (!proactive) requestSilently = false
}

fun onPause() {
override fun onPause() {
requestPermission = null
inactivityTimerJob?.cancel()
stopRecording()
}

fun onDestroy() {
override fun onDestroy() {
requestPermission = null
inactivityTimerJob?.cancel()
stopRecording()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
package io.homeassistant.companion.android.assist

import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.homeassistant.companion.android.assist.ui.AssistMessage
import io.homeassistant.companion.android.common.assist.AssistAudioStrategy
import io.homeassistant.companion.android.common.assist.AssistEvent
import io.homeassistant.companion.android.common.assist.AssistViewModelBase
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.common.util.AudioUrlPlayer
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber

/** A ViewModel for the automotive Assist UI. It provides a simplified state
* compared to [AssistViewModel], focusing on voice-only interaction.
*/
class AutomotiveAssistViewModel @AssistedInject constructor(
@Assisted override val serverManager: ServerManager,
@Assisted override val audioStrategy: AssistAudioStrategy,
@Assisted private val audioUrlPlayer: AudioUrlPlayer,
@Assisted private val application: Application,
) : AssistViewModelBase(serverManager, audioStrategy, audioUrlPlayer, application) {

var isAudioPlaying by mutableStateOf(false)
private set



private var pipelineJob: Job? = null
private var activeUserMessage: AssistMessage? = null
private var activeHaMessage: AssistMessage? = null
private var isContinuationTurn = false

var isProcessing by mutableStateOf(false)
private set

@AssistedFactory
interface Factory {
fun create(
serverManager: ServerManager,
audioStrategy: AssistAudioStrategy,
audioUrlPlayer: AudioUrlPlayer,
application: Application,
): AutomotiveAssistViewModel
}

private val _conversation = MutableStateFlow<List<AssistMessage>>(emptyList())
val conversation: StateFlow<List<AssistMessage>> = _conversation.asStateFlow()

Comment on lines +27 to +60
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new ViewModel introduces non-trivial state handling (pipeline selection, conversation mutation, continuation turns, recording/pipeline lifecycle). There are existing unit tests for AssistViewModel under app/src/test/.../assist/; adding a focused AutomotiveAssistViewModel test suite would help prevent regressions (e.g., processing state toggling, placeholder replacement, continue-conversation behavior).

Copilot uses AI. Check for mistakes.
var inputMode by mutableStateOf<AssistInputMode?>(null)
private set

var shouldFinish by mutableStateOf(false)
private set

var recorderAutoStart by mutableStateOf(false)
private set

override fun getInput(): AssistInputMode? = inputMode

override fun setInput(inputMode: AssistInputMode) {
this.inputMode = inputMode
}

init {
viewModelScope.launch {
audioStrategy.wakeWordDetected.collect { detectedPhrase ->
if (inputMode != AssistInputMode.VOICE_ACTIVE) {
onMicrophoneInput(clearConversation = false)
}
}
}
}

fun onCreate(hasPermission: Boolean, serverId: Int?, pipelineId: String?, startListening: Boolean?) {
viewModelScope.launch {
this@AutomotiveAssistViewModel.hasPermission = hasPermission
serverId?.let {
selectedServerId = it
}
startListening?.let { recorderAutoStart = it }

if (!serverManager.isRegistered()) {
inputMode = AssistInputMode.BLOCKED
_conversation.value = listOf(
AssistMessage(
app.getString(io.homeassistant.companion.android.common.R.string.not_registered),
isInput = false,
),
)
return@launch
}

if (pipelineId != null) {
setPipeline(pipelineId)
} else if (
serverManager.integrationRepository(selectedServerId).getLastUsedPipelineId() != null
) {
setPipeline(serverManager.integrationRepository(selectedServerId).getLastUsedPipelineId())
} else {
inputMode = AssistInputMode.BLOCKED
_conversation.value = listOf(
AssistMessage(
app.getString(io.homeassistant.companion.android.common.R.string.assist_error),
isInput = false,
),
)
}

if (hasPermission && recorderAutoStart) {
onMicrophoneInput(proactive = true, clearConversation = true)
}
}
}

private suspend fun setPipeline(id: String?) {
val pipeline = try {
serverManager.webSocketRepository(selectedServerId).getAssistPipeline(id)
} catch (e: Exception) {
Timber.e(e, "Failed to get assist pipeline")
null
}

if (pipeline != null) {
_conversation.value = emptyList()
activeUserMessage = null
activeHaMessage = null
inputMode = if (pipeline.sttEngine != null) AssistInputMode.VOICE_INACTIVE else AssistInputMode.TEXT_ONLY
} else {
inputMode = AssistInputMode.BLOCKED
}
}

fun onMicrophoneInput(
proactive: Boolean = false,
isContinuation: Boolean = false,
clearConversation: Boolean = false,
) {
Timber.d(
"onMicrophoneInput called " +
"(proactive=$proactive, isContinuation=$isContinuation, clearConversation=$clearConversation)",
)
if (!hasPermission) {
Timber.w("onMicrophoneInput aborted: no permission")
return
}

if (clearConversation) {
_conversation.value = emptyList()
activeUserMessage = null
activeHaMessage = null
pipelineJob?.cancel()
}

stopPlayback()
setupRecorder(onError = {
stopRecording()
_conversation.value = _conversation.value + AssistMessage(
app.getString(io.homeassistant.companion.android.common.R.string.assist_error),
isInput = false,
isError = true,
)
Timber.e(it, "Recorder setup failed")
})
if (!isContinuation) {
inputMode = AssistInputMode.VOICE_ACTIVE
}

if (proactive) {
if (isContinuation) {
// Just add user placeholder, pipeline already running
activeUserMessage = AssistMessage.placeholder(isInput = true)
_conversation.value = _conversation.value + activeUserMessage!!
activeHaMessage = AssistMessage.placeholder(isInput = false)
} else {
// New pipeline, add placeholders and start pipeline
activeUserMessage = AssistMessage.placeholder(isInput = true)
activeHaMessage = AssistMessage.placeholder(isInput = false)
_conversation.value = _conversation.value + activeUserMessage!! + activeHaMessage!!
runAssistPipeline(null)
}
}
}

private fun runAssistPipeline(text: String?, skipStopPlayback: Boolean = false) {
val isVoice = text == null
if (!skipStopPlayback) {
stopPlayback()
}

pipelineJob = viewModelScope.launch {
val pipeline = try {
val lastPipelineId = serverManager.integrationRepository(selectedServerId).getLastUsedPipelineId()
lastPipelineId?.let {
serverManager.webSocketRepository(selectedServerId).getAssistPipeline(it)
}
} catch (e: Exception) {
Timber.e(e, "Failed to get assist pipeline")
null
}

isProcessing = true
runAssistPipelineInternal(
Comment on lines +36 to +248
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_processingState is never set to true, so processingState will not emit when processing starts. At the same time, isProcessing is kept separately as Compose state, which the car screen does not observe reliably. Please consolidate to a single observable source of truth (preferably a StateFlow) and update it both on start and on all end paths (pipeline end, dismiss, errors).

Copilot uses AI. Check for mistakes.
text = text,
pipeline = pipeline,
wakeWordPhrase = null,
) { event ->
when (event) {
is AssistEvent.Message -> {
val currentList = _conversation.value.toMutableList()
if (event is AssistEvent.Message.Error) {
if (activeHaMessage != null) {
val haIndex = currentList.indexOf(activeHaMessage)
if (haIndex != -1) {
currentList[haIndex] = activeHaMessage!!.copy(
message = event.message.trim(),
isError = true,
)
_conversation.value = currentList
}
}
} else if (event is AssistEvent.Message.Input) {
if (activeUserMessage != null) {
val userIndex = currentList.indexOf(activeUserMessage)
if (userIndex != -1) {
currentList[userIndex] = activeUserMessage!!.copy(
message = event.message.trim(),
isError = false,
)
// Add assistant placeholder for the response if not already in list
if (currentList.indexOf(activeHaMessage) == -1) {
activeHaMessage = AssistMessage.placeholder(isInput = false)
currentList.add(activeHaMessage!!)
}
_conversation.value = currentList
}
}
} else if (event is AssistEvent.Message.Output) {
if (activeHaMessage != null) {
val haIndex = currentList.indexOf(activeHaMessage)
if (haIndex != -1) {
currentList[haIndex] = activeHaMessage!!.copy(
message = event.message.trim(),
isError = false,
)
_conversation.value = currentList
}
}
Comment on lines +255 to +293
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you replace entries in _conversation with activeUserMessage!!.copy(...) / activeHaMessage!!.copy(...), the active*Message fields are not updated to the new instance. Subsequent indexOf(activeHaMessage) / indexOf(activeUserMessage) calls may fail because the list now contains the copied instance, not the old one. Please update activeUserMessage/activeHaMessage to the new copied value (or track messages by stable IDs) whenever you mutate the list.

Copilot uses AI. Check for mistakes.
}
}

is AssistEvent.MessageChunk -> {
val currentList = _conversation.value.toMutableList()
if (activeHaMessage != null) {
val haIndex = currentList.indexOf(activeHaMessage)
if (haIndex != -1) {
activeHaMessage = activeHaMessage!!.copy(
message = activeHaMessage!!.message + event.chunk,
)
currentList[haIndex] = activeHaMessage!!
_conversation.value = currentList
}
}
}

is AssistEvent.Dismiss -> {
isProcessing = false
shouldFinish = true
}

is AssistEvent.ContinueConversation -> {
onMicrophoneInput(proactive = true, isContinuation = true)
isContinuationTurn = true
runAssistPipeline(null, skipStopPlayback = true)
}

is AssistEvent.PipelineEnded -> {
isProcessing = false
if (!isContinuationTurn) {
activeUserMessage = null
activeHaMessage = null
}
isContinuationTurn = false
}

else -> {}
}
}
}
}
}
Loading