July 11, 2026
Building an Android Auto Voice Chatbot with Flutter and Native Bridging from Scratch — The Bugs…
Introduction

By Bilal Ali
24 min read
Introduction
I was enjoying my life as a Flutter developer, building mobile apps — a craft that is genuinely respected across platforms including web, desktop, and Linux. Then reality hit me hard and reminded me that I am a problem solver, not just an app developer.
My client asked me to put his AI chatbot inside Android Auto. And just like that, a tough journey started.
As a Flutter developer who has written native Android code but is no expert in it, I had to fulfill every native code requirement through research, debugging, and a lot of stubbornness. This task proved to be one of the most difficult things I have tackled — because even though you can build Android Auto apps through Flutter's native bridging system, nobody is actually talking about it. A Flutter app running on a car screen? Almost no documentation. Zero community discussion. My questions on Stack Overflow about errors in building this chatbot for the car screen went completely unanswered.
This article is what I wish had existed before I started. Not just the happy path — but every real error, why it happened, and exactly how I fixed it. By the end, you will have a fully working Android Auto voice chatbot that:
- Shows a native car screen with Start/Stop voice controls
- Captures voice from the car microphone using Android's built-in Speech-to-Text
- Sends transcribed text to your Flutter AI backend
- Speaks the AI response aloud through the car speaker using native Text-to-Speech
- Auto-restarts listening after each AI response for a completely hands-free experience
What Android Auto Actually Is (And What It Isn't)
Before writing a single line of code, there is one thing the official documentation does not make obvious enough:
You cannot render Flutter widgets on the Android Auto screen.
Android Auto is a projection system. Your phone connects to a car head unit via USB or wireless, and the car screen shows a restricted interface controlled entirely by the Android Auto host. This host only accepts a small set of pre-built templates from the Android Car App Library — things like MessageTemplate, ListTemplate, and PaneTemplate.
Flutter lives on the phone. The car screen is native Android. They communicate through a bridge you build yourself using Flutter's MethodChannel and EventChannel.
┌─────────────────────┐ ┌─────────────────────┐
│ PHONE SCREEN │ │ CAR SCREEN │
│ (Flutter UI) │◄───────►│ (Native AA UI) │
│ │ Bridge │ │
│ AI logic │ Events │ Start/Stop button │
│ STT → LLM → TTS │ Methods │ Status heading │
└─────────────────────┘ └─────────────────────┘┌─────────────────────┐ ┌─────────────────────┐
│ PHONE SCREEN │ │ CAR SCREEN │
│ (Flutter UI) │◄───────►│ (Native AA UI) │
│ │ Bridge │ │
│ AI logic │ Events │ Start/Stop button │
│ STT → LLM → TTS │ Methods │ Status heading │
└─────────────────────┘ └─────────────────────┘Project Setup
Prerequisites
- Flutter installed and working
- Android Studio
- A physical Android device — Android Auto does not work on emulators
- Android Auto app installed on your phone
- Desktop Head Unit (DHU) for testing — covered below
Create Your Flutter Project
flutter create --org com.androidauto.flutter voicechatbot
cd voicechatbotflutter create --org com.androidauto.flutter voicechatbot
cd voicechatbotAdd the Car App Library Dependency
Open android/app/build.gradle.kts and add:
dependencies {
implementation("androidx.car.app:app:1.4.0")
// your existing dependencies
}dependencies {
implementation("androidx.car.app:app:1.4.0")
// your existing dependencies
}Project Structure
Here is every file we will create:
lib/
├── main.dart
├── services/
│ └── native_flutter_bridge.dart
└── ui/
└── home_page.dart
android/app/src/main/
├── kotlin/com/androidauto/flutter/
│ ├── MainActivity.kt
│ ├── CarServiceBridge.kt
│ ├── CarTtsManager.kt
│ ├── ChatBotNotificationListener.kt
│ └── auto/
│ ├── ChatBotCarService.kt
│ ├── ChatBotSession.kt
│ └── ChatBotScreen.kt
└── res/
└── xml/
└── automotive_app_desc.xmllib/
├── main.dart
├── services/
│ └── native_flutter_bridge.dart
└── ui/
└── home_page.dart
android/app/src/main/
├── kotlin/com/androidauto/flutter/
│ ├── MainActivity.kt
│ ├── CarServiceBridge.kt
│ ├── CarTtsManager.kt
│ ├── ChatBotNotificationListener.kt
│ └── auto/
│ ├── ChatBotCarService.kt
│ ├── ChatBotSession.kt
│ └── ChatBotScreen.kt
└── res/
└── xml/
└── automotive_app_desc.xmlThe Bugs Nobody Tells You About
Before we look at the code, let me be honest about what the debugging experience actually feels like.
If you think the Android documentation will help you fix bugs related to this integration, prepare yourself — it will be your nightmare. I tried everything: reading the official docs, posting on Stack Overflow, reaching out to senior developers, and using LLMs like ChatGPT, Gemini, and Copilot. I was just making changes without any real direction, shooting in the dark and hoping something would stick.
Things only started to make sense when I began working through the problems step by step with Claude. That is when the errors stopped feeling random and started having explanations.
I have marked every bug directly inside the code with comments below, so you understand not just what to write — but why.
Step 1 — AndroidManifest.xml
This is where most people hit their first invisible wall. Every single line here matters and missing even one causes a silent failure with no useful error in the UI.
File: android/app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Microphone access for voice input -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<!-- Required for a long-running car session -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<!-- Required for Speech-to-Text network calls -->
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher">
<!--
❌ BUG #1 - SILENT BIND FAILURE
Without this meta-data tag, the Android Auto host connects
to your service but immediately drops it with no error in the UI.
You only see it in Logcat as:
IllegalArgumentException: Min API level not declared in manifest
Add this and the host will negotiate the API level correctly.
-->
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1"/>
<!--
Points Android Auto to your capability declaration file.
Without this the host does not know what your app supports.
-->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<!-- Your existing Flutter activity -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!--
The Android Auto entry point - declared with the CarAppService
intent filter so the host can find and bind to it.
MESSAGING is the category that covers voice chatbot use cases.
-->
<service
android:name="com.androidauto.flutter.auto.ChatBotCarService"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="androidx.car.app.CarAppService"/>
<category android:name="androidx.car.app.category.MESSAGING"/>
</intent-filter>
</service>
<!--
❌ BUG #2 - APP INVISIBLE IN CUSTOMIZE LAUNCHER
On Android Auto 13+ with the MESSAGING category, your app
will simply not appear in the Customize Launcher list
without this NotificationListenerService declared AND
notification access granted by the user.
No error is shown anywhere - the app is just silently hidden.
-->
<service
android:name="com.androidauto.flutter.ChatBotNotificationListener"
android:exported="true"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService"/>
</intent-filter>
</service>
</application>
</manifest><?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Microphone access for voice input -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<!-- Required for a long-running car session -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<!-- Required for Speech-to-Text network calls -->
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher">
<!--
❌ BUG #1 - SILENT BIND FAILURE
Without this meta-data tag, the Android Auto host connects
to your service but immediately drops it with no error in the UI.
You only see it in Logcat as:
IllegalArgumentException: Min API level not declared in manifest
Add this and the host will negotiate the API level correctly.
-->
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1"/>
<!--
Points Android Auto to your capability declaration file.
Without this the host does not know what your app supports.
-->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<!-- Your existing Flutter activity -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!--
The Android Auto entry point - declared with the CarAppService
intent filter so the host can find and bind to it.
MESSAGING is the category that covers voice chatbot use cases.
-->
<service
android:name="com.androidauto.flutter.auto.ChatBotCarService"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="androidx.car.app.CarAppService"/>
<category android:name="androidx.car.app.category.MESSAGING"/>
</intent-filter>
</service>
<!--
❌ BUG #2 - APP INVISIBLE IN CUSTOMIZE LAUNCHER
On Android Auto 13+ with the MESSAGING category, your app
will simply not appear in the Customize Launcher list
without this NotificationListenerService declared AND
notification access granted by the user.
No error is shown anywhere - the app is just silently hidden.
-->
<service
android:name="com.androidauto.flutter.ChatBotNotificationListener"
android:exported="true"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService"/>
</intent-filter>
</service>
</application>
</manifest>Step 2 — automotive_app_desc.xml
Create this file at android/app/src/main/res/xml/automotive_app_desc.xml.
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<!--
❌ BUG #3 — WRONG CAPABILITY DECLARATION
Using only <uses name="notification"/> causes the Android Auto
host to silently reject your Car App Library based app.
You need BOTH declarations for a MESSAGING category app:notification = enables notification-based messaging access
template = enables Car App Library template screens (what we use)
Missing "template" and the host will ignore your app entirely.
-->
<uses name="notification"/>
<uses name="template"/>
</automotiveApp>
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<!--
❌ BUG #3 — WRONG CAPABILITY DECLARATION
Using only <uses name="notification"/> causes the Android Auto
host to silently reject your Car App Library based app.
You need BOTH declarations for a MESSAGING category app:notification = enables notification-based messaging access
template = enables Car App Library template screens (what we use)
Missing "template" and the host will ignore your app entirely.
-->
<uses name="notification"/>
<uses name="template"/>
</automotiveApp>
Step 3 — CarServiceBridge.kt
This singleton is the central nervous system of the entire integration. Both the car screen (native Kotlin) and Flutter (via platform channels) read and write state through it. Think of it as a shared memory object that both sides of the bridge can access at any time.
File: android/app/src/main/kotlin/com/androidauto/flutter/CarServiceBridge.kt
package com.androidauto.flutter
import io.flutter.plugin.common.EventChannel
/**
* Singleton bridge between the Android Auto car screen and Flutter.
*
* This object is the single source of truth for the entire voice session.
* It is accessed by:
* - ChatBotScreen: reads state to render UI, writes state after STT results
* - ChatBotSession: sets ttsManager reference on car connect
* - MainActivity: sets eventSink when Flutter starts listening
* - Flutter (via MethodChannel): calls updateState() with AI responses
*
* Data flow summary:
* Car mic captures voice
* → ChatBotScreen calls updateState(spokenText)
* → eventSink pushes spokenText to Flutter
* → Flutter calls AI, gets response
* → Flutter calls updateCarState() via MethodChannel
* → updateState(aiResponse) triggers onAiResponseReceived
* → TTS speaks the response
* → onSpeakingFinished restarts the mic
*/
object CarServiceBridge {
// Current session state - read by ChatBotScreen.onGetTemplate() to render UI
var isListening: Boolean = false
var statusText: String = "AI Voice Chat"
var lastAiResponse: String = ""
var lastSpokenText: String = ""
// EventChannel sink - holds the Flutter event stream listener.
// Set by MainActivity when Flutter calls carEvents.listen().
// Used to push car screen events (spokenText, state changes) to Flutter.
var eventSink: EventChannel.EventSink? = null
// Callback set by MainActivity - allows car screen to invoke Flutter logic
var onToggleVoice: (() -> Unit)? = null
// Callback set by ChatBotScreen - called after updateState() to trigger UI redraw
var onStateChanged: (() -> Unit)? = null
// TTS manager instance - set from ChatBotSession when car connects,
// so TTS audio is routed through the car speaker via CarContext
var ttsManager: CarTtsManager? = null
// Callback set by ChatBotScreen - fires when a new AI response arrives,
// which triggers TTS to speak the response aloud
var onAiResponseReceived: ((String) -> Unit)? = null
/** Called by car screen when user taps the Start/Stop button */
fun toggleVoice() {
onToggleVoice?.invoke()
}
/**
* The central state update function - called by both sides of the bridge.
*
* Called by car screen (ChatBotScreen) after:
* - STT produces a result (spokenText non-empty, aiResponse empty)
* - Status changes (Listening, Hearing you, Thinking, etc.)
*
* Called by Flutter (via MethodChannel → MainActivity) after:
* - AI response is ready (aiResponse non-empty, spokenText empty)
*
* @param listening Whether the mic is currently active
* @param status The heading text shown on the car screen
* @param aiResponse The AI text to speak via TTS (empty when sending spokenText)
* @param spokenText The transcribed voice input to send to Flutter (empty when sending aiResponse)
*/
fun updateState(
listening: Boolean,
status: String,
aiResponse: String,
spokenText: String = ""
) {
isListening = listening
statusText = status
if (spokenText.isNotEmpty()) lastSpokenText = spokenText
/*
❌ BUG #4 - TTS NEVER FIRES (COMPARISON ORDER BUG)
The broken version did this:
lastAiResponse = aiResponse ← sets them equal first
if (aiResponse != lastAiResponse) ← always false, TTS never fires!
The fix: compare FIRST, then update lastAiResponse.
This ensures TTS only triggers when a genuinely new and different
AI response arrives - not on every status update call.
*/
if (aiResponse.isNotEmpty() && aiResponse != lastAiResponse) {
lastAiResponse = aiResponse // update AFTER comparison
onAiResponseReceived?.invoke(aiResponse)
} else if (aiResponse.isEmpty()) {
lastAiResponse = "" // reset so next response triggers TTS again
}
// Tell ChatBotScreen to redraw with the latest state values
onStateChanged?.invoke()
// Push all state to Flutter via the EventChannel stream
// Flutter's carEvents.listen() receives this as a Map
eventSink?.success(mapOf(
"isListening" to listening,
"statusText" to status,
"aiResponse" to aiResponse,
"spokenText" to spokenText
))
}
}package com.androidauto.flutter
import io.flutter.plugin.common.EventChannel
/**
* Singleton bridge between the Android Auto car screen and Flutter.
*
* This object is the single source of truth for the entire voice session.
* It is accessed by:
* - ChatBotScreen: reads state to render UI, writes state after STT results
* - ChatBotSession: sets ttsManager reference on car connect
* - MainActivity: sets eventSink when Flutter starts listening
* - Flutter (via MethodChannel): calls updateState() with AI responses
*
* Data flow summary:
* Car mic captures voice
* → ChatBotScreen calls updateState(spokenText)
* → eventSink pushes spokenText to Flutter
* → Flutter calls AI, gets response
* → Flutter calls updateCarState() via MethodChannel
* → updateState(aiResponse) triggers onAiResponseReceived
* → TTS speaks the response
* → onSpeakingFinished restarts the mic
*/
object CarServiceBridge {
// Current session state - read by ChatBotScreen.onGetTemplate() to render UI
var isListening: Boolean = false
var statusText: String = "AI Voice Chat"
var lastAiResponse: String = ""
var lastSpokenText: String = ""
// EventChannel sink - holds the Flutter event stream listener.
// Set by MainActivity when Flutter calls carEvents.listen().
// Used to push car screen events (spokenText, state changes) to Flutter.
var eventSink: EventChannel.EventSink? = null
// Callback set by MainActivity - allows car screen to invoke Flutter logic
var onToggleVoice: (() -> Unit)? = null
// Callback set by ChatBotScreen - called after updateState() to trigger UI redraw
var onStateChanged: (() -> Unit)? = null
// TTS manager instance - set from ChatBotSession when car connects,
// so TTS audio is routed through the car speaker via CarContext
var ttsManager: CarTtsManager? = null
// Callback set by ChatBotScreen - fires when a new AI response arrives,
// which triggers TTS to speak the response aloud
var onAiResponseReceived: ((String) -> Unit)? = null
/** Called by car screen when user taps the Start/Stop button */
fun toggleVoice() {
onToggleVoice?.invoke()
}
/**
* The central state update function - called by both sides of the bridge.
*
* Called by car screen (ChatBotScreen) after:
* - STT produces a result (spokenText non-empty, aiResponse empty)
* - Status changes (Listening, Hearing you, Thinking, etc.)
*
* Called by Flutter (via MethodChannel → MainActivity) after:
* - AI response is ready (aiResponse non-empty, spokenText empty)
*
* @param listening Whether the mic is currently active
* @param status The heading text shown on the car screen
* @param aiResponse The AI text to speak via TTS (empty when sending spokenText)
* @param spokenText The transcribed voice input to send to Flutter (empty when sending aiResponse)
*/
fun updateState(
listening: Boolean,
status: String,
aiResponse: String,
spokenText: String = ""
) {
isListening = listening
statusText = status
if (spokenText.isNotEmpty()) lastSpokenText = spokenText
/*
❌ BUG #4 - TTS NEVER FIRES (COMPARISON ORDER BUG)
The broken version did this:
lastAiResponse = aiResponse ← sets them equal first
if (aiResponse != lastAiResponse) ← always false, TTS never fires!
The fix: compare FIRST, then update lastAiResponse.
This ensures TTS only triggers when a genuinely new and different
AI response arrives - not on every status update call.
*/
if (aiResponse.isNotEmpty() && aiResponse != lastAiResponse) {
lastAiResponse = aiResponse // update AFTER comparison
onAiResponseReceived?.invoke(aiResponse)
} else if (aiResponse.isEmpty()) {
lastAiResponse = "" // reset so next response triggers TTS again
}
// Tell ChatBotScreen to redraw with the latest state values
onStateChanged?.invoke()
// Push all state to Flutter via the EventChannel stream
// Flutter's carEvents.listen() receives this as a Map
eventSink?.success(mapOf(
"isListening" to listening,
"statusText" to status,
"aiResponse" to aiResponse,
"spokenText" to spokenText
))
}
}Step 4 — CarTtsManager.kt
This class handles all Text-to-Speech output through the car speaker. It also manages audio focus — the mechanism that tells the car to pause music or navigation while the AI is speaking.
File: android/app/src/main/kotlin/com/androidauto/flutter/CarTtsManager.kt
package com.androidauto.flutter
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import java.util.Locale
/**
* Manages Text-to-Speech output through the car speaker.
*
* Key responsibilities:
* 1. Initialize Android's TTS engine with the car's audio context
* 2. Request audio focus before speaking (pauses music/navigation)
* 3. Release audio focus after speaking (resumes music/navigation)
* 4. Fire onSpeakingFinished callback when done (triggers mic restart)
*
* Audio routing: by setting USAGE_ASSISTANT on the AudioAttributes,
* Android routes the TTS audio through the car's voice assistant channel
* rather than the media channel - correct behaviour for a chatbot.
*
* Created in ChatBotSession using CarContext so the audio routes
* correctly to the car speaker (not the phone speaker).
*/
class CarTtsManager(private val context: Context) {
private var tts: TextToSpeech? = null
private var isReady = false
private var audioManager: AudioManager? = null
private var audioFocusRequest: AudioFocusRequest? = null
/**
* Called when TTS finishes speaking.
* ChatBotSession wires this to screen.restartListening()
* to automatically restart the microphone after each response.
*/
var onSpeakingFinished: (() -> Unit)? = null
init {
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// TTS engine initialisation is asynchronous - isReady is set only on SUCCESS
tts = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
val result = tts?.setLanguage(Locale.US)
// Mark ready only if the language pack is installed on this device
isReady = result != TextToSpeech.LANG_MISSING_DATA
&& result != TextToSpeech.LANG_NOT_SUPPORTED
// Route TTS audio through the car's voice assistant channel
tts?.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
// Monitor speech completion to trigger mic restart
tts?.setOnUtteranceProgressListener(
object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
// Speech started - nothing to do here
}
override fun onDone(utteranceId: String?) {
// Speech finished normally - release focus and restart mic
releaseAudioFocus()
onSpeakingFinished?.invoke()
}
override fun onError(utteranceId: String?) {
// Speech failed - still release focus and restart mic
// so the conversation loop is not broken by a TTS error
releaseAudioFocus()
onSpeakingFinished?.invoke()
}
}
)
}
}
}
/**
* Speaks the given text through the car speaker.
*
* Requests audio focus first so music/navigation pauses.
* QUEUE_FLUSH interrupts any currently playing speech - ensures
* a new response immediately replaces any previous one.
*
* If TTS is not ready (engine not initialized or language missing),
* fires onSpeakingFinished anyway so the mic restart still happens.
*/
fun speak(text: String) {
if (!isReady || text.isEmpty()) {
onSpeakingFinished?.invoke() // don't leave the conversation stuck
return
}
requestAudioFocus()
tts?.speak(
text,
TextToSpeech.QUEUE_FLUSH, // replace any current speech immediately
null,
"car_tts_utterance" // utterance ID used by UtteranceProgressListener
)
}
/** Stops current speech immediately without triggering onSpeakingFinished */
fun stop() {
tts?.stop()
releaseAudioFocus()
}
/**
* Fully shuts down the TTS engine.
* Call this when the car session ends to free system resources.
*/
fun destroy() {
tts?.stop()
tts?.shutdown()
tts = null
releaseAudioFocus()
}
/**
* Requests exclusive transient audio focus.
* AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE means:
* - Other audio (music, navigation) pauses completely
* - Focus is temporary - released after TTS finishes
*/
private fun requestAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest = AudioFocusRequest.Builder(
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.build()
audioManager?.requestAudioFocus(audioFocusRequest!!)
} else {
@Suppress("DEPRECATION")
audioManager?.requestAudioFocus(
null,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
)
}
}
/** Releases audio focus so music/navigation can resume after TTS finishes */
private fun releaseAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { audioManager?.abandonAudioFocusRequest(it) }
} else {
@Suppress("DEPRECATION")
audioManager?.abandonAudioFocus(null)
}
}
}package com.androidauto.flutter
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import java.util.Locale
/**
* Manages Text-to-Speech output through the car speaker.
*
* Key responsibilities:
* 1. Initialize Android's TTS engine with the car's audio context
* 2. Request audio focus before speaking (pauses music/navigation)
* 3. Release audio focus after speaking (resumes music/navigation)
* 4. Fire onSpeakingFinished callback when done (triggers mic restart)
*
* Audio routing: by setting USAGE_ASSISTANT on the AudioAttributes,
* Android routes the TTS audio through the car's voice assistant channel
* rather than the media channel - correct behaviour for a chatbot.
*
* Created in ChatBotSession using CarContext so the audio routes
* correctly to the car speaker (not the phone speaker).
*/
class CarTtsManager(private val context: Context) {
private var tts: TextToSpeech? = null
private var isReady = false
private var audioManager: AudioManager? = null
private var audioFocusRequest: AudioFocusRequest? = null
/**
* Called when TTS finishes speaking.
* ChatBotSession wires this to screen.restartListening()
* to automatically restart the microphone after each response.
*/
var onSpeakingFinished: (() -> Unit)? = null
init {
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// TTS engine initialisation is asynchronous - isReady is set only on SUCCESS
tts = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
val result = tts?.setLanguage(Locale.US)
// Mark ready only if the language pack is installed on this device
isReady = result != TextToSpeech.LANG_MISSING_DATA
&& result != TextToSpeech.LANG_NOT_SUPPORTED
// Route TTS audio through the car's voice assistant channel
tts?.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
// Monitor speech completion to trigger mic restart
tts?.setOnUtteranceProgressListener(
object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
// Speech started - nothing to do here
}
override fun onDone(utteranceId: String?) {
// Speech finished normally - release focus and restart mic
releaseAudioFocus()
onSpeakingFinished?.invoke()
}
override fun onError(utteranceId: String?) {
// Speech failed - still release focus and restart mic
// so the conversation loop is not broken by a TTS error
releaseAudioFocus()
onSpeakingFinished?.invoke()
}
}
)
}
}
}
/**
* Speaks the given text through the car speaker.
*
* Requests audio focus first so music/navigation pauses.
* QUEUE_FLUSH interrupts any currently playing speech - ensures
* a new response immediately replaces any previous one.
*
* If TTS is not ready (engine not initialized or language missing),
* fires onSpeakingFinished anyway so the mic restart still happens.
*/
fun speak(text: String) {
if (!isReady || text.isEmpty()) {
onSpeakingFinished?.invoke() // don't leave the conversation stuck
return
}
requestAudioFocus()
tts?.speak(
text,
TextToSpeech.QUEUE_FLUSH, // replace any current speech immediately
null,
"car_tts_utterance" // utterance ID used by UtteranceProgressListener
)
}
/** Stops current speech immediately without triggering onSpeakingFinished */
fun stop() {
tts?.stop()
releaseAudioFocus()
}
/**
* Fully shuts down the TTS engine.
* Call this when the car session ends to free system resources.
*/
fun destroy() {
tts?.stop()
tts?.shutdown()
tts = null
releaseAudioFocus()
}
/**
* Requests exclusive transient audio focus.
* AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE means:
* - Other audio (music, navigation) pauses completely
* - Focus is temporary - released after TTS finishes
*/
private fun requestAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest = AudioFocusRequest.Builder(
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.build()
audioManager?.requestAudioFocus(audioFocusRequest!!)
} else {
@Suppress("DEPRECATION")
audioManager?.requestAudioFocus(
null,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
)
}
}
/** Releases audio focus so music/navigation can resume after TTS finishes */
private fun releaseAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { audioManager?.abandonAudioFocusRequest(it) }
} else {
@Suppress("DEPRECATION")
audioManager?.abandonAudioFocus(null)
}
}
}Step 5 — ChatBotNotificationListener.kt
This file looks almost too simple to matter. But skip it and your app will be completely invisible in Android Auto's Customize Launcher on Android 13+. No warning. No error. Just silently gone.
File: android/app/src/main/kotlin/com/androidauto/flutter/ChatBotNotificationListener.kt
package com.androidauto.flutter
import android.service.notification.NotificationListenerService
/**
* Required by Android Auto 13+ for apps using the MESSAGING category.
*
* Android Auto uses the presence of this service to verify the app
* has notification access before showing it in the Customize Launcher.
*
* Without this:
* - Your service IS registered (pm query-services shows it)
* - Your service IS discoverable (no manifest errors)
* - But Android Auto SILENTLY hides your app from the launcher
* - No error in UI, no warning in Logcat - just invisible
*
* With this declared AND notification access granted by the user,
* your app appears correctly in Customize Launcher.
*
* No implementation is needed - the declaration alone satisfies the check.
*/
class ChatBotNotificationListener : NotificationListenerService()package com.androidauto.flutter
import android.service.notification.NotificationListenerService
/**
* Required by Android Auto 13+ for apps using the MESSAGING category.
*
* Android Auto uses the presence of this service to verify the app
* has notification access before showing it in the Customize Launcher.
*
* Without this:
* - Your service IS registered (pm query-services shows it)
* - Your service IS discoverable (no manifest errors)
* - But Android Auto SILENTLY hides your app from the launcher
* - No error in UI, no warning in Logcat - just invisible
*
* With this declared AND notification access granted by the user,
* your app appears correctly in Customize Launcher.
*
* No implementation is needed - the declaration alone satisfies the check.
*/
class ChatBotNotificationListener : NotificationListenerService()Step 6 — ChatBotCarService.kt
This is the Android Auto entry point — the service the host binds to when your app is launched from the car screen.
File: android/app/src/main/kotlin/com/androidauto/flutter/auto/ChatBotCarService.kt
package com.androidauto.flutter.auto
import android.content.pm.ApplicationInfo
import androidx.car.app.CarAppService
import androidx.car.app.Session
import androidx.car.app.validation.HostValidator
/**
* The Android Auto entry point declared in the manifest with the
* CarAppService intent filter so the host can find and bind to it.
*
* createHostValidator():
* Controls which Android Auto hosts are allowed to connect.
* In debug builds we allow all hosts for DHU testing convenience.
* In production, replace with a proper signed host allowlist.
*
* onCreateSession():
* Called once per car connection. Returns the Session that manages
* the car screen lifecycle for the duration of that connection.
*/
class ChatBotCarService : CarAppService() {
override fun createHostValidator(): HostValidator {
// Allow all hosts in debug builds to simplify DHU testing
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
} else {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR // replace with allowlist for production
}
}
override fun onCreateSession(): Session {
return ChatBotSession()
}
}package com.androidauto.flutter.auto
import android.content.pm.ApplicationInfo
import androidx.car.app.CarAppService
import androidx.car.app.Session
import androidx.car.app.validation.HostValidator
/**
* The Android Auto entry point declared in the manifest with the
* CarAppService intent filter so the host can find and bind to it.
*
* createHostValidator():
* Controls which Android Auto hosts are allowed to connect.
* In debug builds we allow all hosts for DHU testing convenience.
* In production, replace with a proper signed host allowlist.
*
* onCreateSession():
* Called once per car connection. Returns the Session that manages
* the car screen lifecycle for the duration of that connection.
*/
class ChatBotCarService : CarAppService() {
override fun createHostValidator(): HostValidator {
// Allow all hosts in debug builds to simplify DHU testing
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
} else {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR // replace with allowlist for production
}
}
override fun onCreateSession(): Session {
return ChatBotSession()
}
}Step 7 — ChatBotSession.kt
The session manages the full lifecycle of a single car connection. TTS is initialised here — at session level — because CarContext is available and ensures audio routes correctly to the car speaker rather than the phone speaker.
File: android/app/src/main/kotlin/com/androidauto/flutter/auto/ChatBotSession.kt
package com.androidauto.flutter.auto
import android.content.Intent
import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.androidauto.flutter.CarServiceBridge
import com.androidauto.flutter.CarTtsManager
/**
* Manages the lifecycle of a single Android Auto car connection.
*
* onCreateScreen() is called when Android Auto connects and needs
* to display the first screen. This is where we:
*
* 1. Create CarTtsManager using CarContext
* → ensures TTS audio routes to the car speaker, not phone speaker
*
* 2. Store the TTS reference in CarServiceBridge
* → allows ChatBotScreen to trigger TTS when AI response arrives
*
* 3. Create ChatBotScreen and wire TTS completion → mic restart
* → creates the hands-free conversation loop:
* speak response → finish → restart mic → listen → speak → repeat
*
* 4. Register lifecycle observer for cleanup
* → Session has no direct onDestroy() override, so we use
* DefaultLifecycleObserver to clean up TTS when session ends
*/
class ChatBotSession : Session() {
private var ttsManager: CarTtsManager? = null
override fun onCreateScreen(intent: Intent): Screen {
// Initialize TTS with CarContext so audio routes to car/DHU speaker
ttsManager = CarTtsManager(carContext)
CarServiceBridge.ttsManager = ttsManager
val screen = ChatBotScreen(carContext)
// Wire TTS completion to mic restart - this creates the conversation loop.
// After TTS speaks the AI response, the mic automatically reopens.
ttsManager?.onSpeakingFinished = {
carContext.mainExecutor.execute {
screen.restartListening()
}
}
// Clean up TTS when user disconnects from car.
// IMPORTANT: Session has no overridable onDestroy() method -
// lifecycle observer is the correct cleanup pattern here.
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
ttsManager?.destroy()
CarServiceBridge.ttsManager = null
}
})
return screen
}
}package com.androidauto.flutter.auto
import android.content.Intent
import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.androidauto.flutter.CarServiceBridge
import com.androidauto.flutter.CarTtsManager
/**
* Manages the lifecycle of a single Android Auto car connection.
*
* onCreateScreen() is called when Android Auto connects and needs
* to display the first screen. This is where we:
*
* 1. Create CarTtsManager using CarContext
* → ensures TTS audio routes to the car speaker, not phone speaker
*
* 2. Store the TTS reference in CarServiceBridge
* → allows ChatBotScreen to trigger TTS when AI response arrives
*
* 3. Create ChatBotScreen and wire TTS completion → mic restart
* → creates the hands-free conversation loop:
* speak response → finish → restart mic → listen → speak → repeat
*
* 4. Register lifecycle observer for cleanup
* → Session has no direct onDestroy() override, so we use
* DefaultLifecycleObserver to clean up TTS when session ends
*/
class ChatBotSession : Session() {
private var ttsManager: CarTtsManager? = null
override fun onCreateScreen(intent: Intent): Screen {
// Initialize TTS with CarContext so audio routes to car/DHU speaker
ttsManager = CarTtsManager(carContext)
CarServiceBridge.ttsManager = ttsManager
val screen = ChatBotScreen(carContext)
// Wire TTS completion to mic restart - this creates the conversation loop.
// After TTS speaks the AI response, the mic automatically reopens.
ttsManager?.onSpeakingFinished = {
carContext.mainExecutor.execute {
screen.restartListening()
}
}
// Clean up TTS when user disconnects from car.
// IMPORTANT: Session has no overridable onDestroy() method -
// lifecycle observer is the correct cleanup pattern here.
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
ttsManager?.destroy()
CarServiceBridge.ttsManager = null
}
})
return screen
}
}Step 8 — ChatBotScreen.kt
This is the most complex file in the project. It manages the car UI template, the SpeechRecognizer lifecycle, and the full conversation state machine, including the manual stop behaviour that took the most debugging to get right.
File: android/app/src/main/kotlin/com/androidauto/flutter/auto/ChatBotScreen.kt
package com.androidauto.flutter.auto
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.Template
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.androidauto.flutter.CarServiceBridge
/**
* The car screen - the only UI the driver sees on the head unit.
*
* Template choice: MessageTemplate (required for MESSAGING category).
* PaneTemplate would be rejected by the host for this category.
*
* Manages SpeechRecognizer directly so voice capture happens natively
* using the car or DHU microphone, with results forwarded to Flutter
* via CarServiceBridge → EventChannel.
*
* Full status flow:
* Start tapped → "Starting..." (recognizer warming up)
* Mic truly open → "Listening..." (onReadyForSpeech fires)
* User speaks → "Hearing you..." (onBeginningOfSpeech fires)
* User stops → "Thinking..." (onEndOfSpeech fires)
* Flutter calls AI → AI response sent back via MethodChannel
* AI response arrives → "Speaking..." (TTS plays through car speaker)
* TTS finishes → "Listening..." (auto-restart, hands-free loop)
* Stop tapped → "Tap Start to speak" (no auto-restart)
*/
class ChatBotScreen(carContext: CarContext) : Screen(carContext) {
private var speechRecognizer: SpeechRecognizer? = null
private var isListening = false
/*
❌ BUG #6 - STOP BUTTON RESTARTS THE MIC AUTOMATICALLY
When the user taps Stop, TTS may still be finishing its current sentence.
When TTS completes, onSpeakingFinished fires and calls restartListening()
- which restarts the mic even though the user explicitly tapped Stop.
Fix: manuallyStopped flag. Set to true on stopListening(), checked in
restartListening() before doing anything. Cleared to false when the
user taps Start again to begin a new session.
*/
private var manuallyStopped = false
init {
// Redraw the car screen whenever CarServiceBridge state is updated.
// invalidate() tells the host to call onGetTemplate() again.
CarServiceBridge.onStateChanged = {
carContext.mainExecutor.execute { invalidate() }
}
// When a new AI response arrives from Flutter, speak it via TTS.
// Sets status to "Speaking..." and triggers CarTtsManager.speak().
// onSpeakingFinished will fire when done → restartListening() is called.
CarServiceBridge.onAiResponseReceived = { aiResponse ->
carContext.mainExecutor.execute {
CarServiceBridge.statusText = "Speaking..."
invalidate()
CarServiceBridge.ttsManager?.speak(aiResponse)
}
}
// Clean up SpeechRecognizer when this screen is removed.
// Screen has no overridable onDestroy() - use lifecycle observer.
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
speechRecognizer?.destroy()
speechRecognizer = null
CarServiceBridge.ttsManager?.stop()
}
})
}
/**
* Called by the Android Auto host every time it needs to render the screen.
* Must return a template synchronously - never do async work here.
*
* Reads current state from CarServiceBridge to build the template.
* The button switches between Start and Stop based on isListening.
* The message body shows the latest AI response, or a prompt if empty.
*/
override fun onGetTemplate(): Template {
val status = CarServiceBridge.statusText
val aiResponse = CarServiceBridge.lastAiResponse
val toggleAction = Action.Builder()
.setTitle(if (isListening) "⏹ Stop" else "🎤 Start")
.setBackgroundColor(if (isListening) CarColor.RED else CarColor.GREEN)
.setOnClickListener {
if (isListening) stopListening() else startListening()
}
.build()
return MessageTemplate.Builder(
if (aiResponse.isNotEmpty()) aiResponse else "Tap Start to speak"
)
.setTitle(status)
.setHeaderAction(Action.APP_ICON)
.addAction(toggleAction)
.build()
}
/**
* Starts a new voice recognition session.
*
* Key constraints for SpeechRecognizer inside a CarAppService (Service context):
*
* ❌ BUG #5 - ERROR 9 and ERROR 11 inside CarAppService
*
* ERROR_INSUFFICIENT_PERMISSIONS (9):
* The RECORD_AUDIO permission is not granted at runtime.
* Services cannot show permission dialogs - permission must be
* granted from MainActivity before the car session ever starts.
* Fix: requestMicPermissionIfNeeded() in MainActivity.
*
* ERROR_CLIENT (11):
* SpeechRecognizer was created or called from the wrong thread,
* or the previous recognizer was not fully released before
* creating a new one.
* Fix: always create inside Handler(Looper.getMainLooper()).post{}
* and add a 300ms delay after destroying the previous recognizer.
*/
private fun startListening() {
manuallyStopped = false // clear the manual stop flag for this new session
if (!SpeechRecognizer.isRecognitionAvailable(carContext)) {
CarServiceBridge.isListening = false
CarServiceBridge.statusText = "Speech not available"
invalidate()
return
}
isListening = true
CarServiceBridge.isListening = true
// Show "Starting..." while the recognizer service connects.
// This avoids the long "Listening..." that appears before the mic is ready.
CarServiceBridge.statusText = "Starting..."
invalidate()
// Destroy and null the previous recognizer before creating a new one.
// Failing to null it can cause ERROR_CLIENT on rapid stop/start cycles.
speechRecognizer?.destroy()
speechRecognizer = null
// 300ms delay gives the old recognizer time to fully release resources
// before the new one is created - prevents ERROR_CLIENT (11).
Handler(Looper.getMainLooper()).postDelayed({
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(carContext)
speechRecognizer?.setRecognitionListener(object : RecognitionListener {
/**
* Fires when the microphone is genuinely open and actively listening.
* This is the correct place to show "Listening..." - not before,
* because the recognizer service takes time to initialise and connect.
*/
override fun onReadyForSpeech(params: Bundle?) {
carContext.mainExecutor.execute {
CarServiceBridge.statusText = "Listening..."
invalidate()
}
}
/** Fires the moment the recognizer detects the user has begun speaking */
override fun onBeginningOfSpeech() {
carContext.mainExecutor.execute {
CarServiceBridge.statusText = "Hearing you..."
invalidate()
}
}
/** Fires when the user stops speaking - recognition processing begins */
override fun onEndOfSpeech() {
carContext.mainExecutor.execute {
CarServiceBridge.statusText = "Thinking..."
isListening = false
invalidate()
}
}
/**
* Fires with the final transcription result.
*
* CRITICAL: aiResponse must always be empty string here.
* If aiResponse is non-empty, CarServiceBridge will trigger TTS
* immediately - causing it to speak the transcription back to the
* user instead of the AI response. Always pass "" for aiResponse
* when forwarding spokenText.
*/
override fun onResults(results: Bundle?) {
val matches = results
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
val spokenText = matches?.firstOrNull() ?: ""
carContext.mainExecutor.execute {
CarServiceBridge.updateState(
listening = false,
status = "You said: $spokenText",
aiResponse = "", // ← must be empty - do NOT trigger TTS here
spokenText = spokenText // ← travels to Flutter via EventChannel
)
isListening = false
invalidate()
}
}
/**
* Handles recognition errors with automatic retry for recoverable cases.
*
* Recoverable (auto-retry silently):
* ERROR_NO_MATCH - no speech detected in the timeout window
* ERROR_CLIENT - recognizer state issue, retry fixes it
* ERROR_SPEECH_TIMEOUT - user did not speak in time
* These are common on DHU due to laptop mic sensitivity.
* In a real car these are far rarer due to dedicated mic hardware.
*
* Non-recoverable (show error message, stop):
* ERROR_INSUFFICIENT_PERMISSIONS - mic access denied
* ERROR_NETWORK - no internet for cloud STT
* ERROR_AUDIO - hardware mic problem
*
* manuallyStopped check prevents retry when user tapped Stop.
*/
override fun onError(error: Int) {
val shouldRetry = error == SpeechRecognizer.ERROR_NO_MATCH ||
error == SpeechRecognizer.ERROR_CLIENT ||
error == SpeechRecognizer.ERROR_SPEECH_TIMEOUT
carContext.mainExecutor.execute {
isListening = false
if (shouldRetry && !manuallyStopped) {
// Retry silently - update status and restart after delay
CarServiceBridge.statusText = "Listening..."
invalidate()
Handler(Looper.getMainLooper()).postDelayed({
if (!manuallyStopped) startListening()
}, 500L)
} else if (!manuallyStopped) {
// Non-recoverable - show message and stop
val message = when (error) {
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ->
"Mic permission denied"
SpeechRecognizer.ERROR_NETWORK -> "Network error"
SpeechRecognizer.ERROR_AUDIO -> "Microphone error"
else -> "Error ($error)"
}
CarServiceBridge.statusText = message
invalidate()
}
}
}
// Required interface members - no action needed for these callbacks
override fun onRmsChanged(rmsdB: Float) {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onPartialResults(partialResults: Bundle?) {}
override fun onEvent(eventType: Int, params: Bundle?) {}
})
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
// FREE_FORM = natural conversational language model (best for chatbots)
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "en-US")
// Only need the top result - no need for multiple alternatives
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
// Partial results keep the recognizer active longer - reduces NO_MATCH errors
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
// Extended timeouts - important for DHU testing on laptop mic
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, 3000L)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 2000L)
putExtra(
RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,
2000L
)
}
speechRecognizer?.startListening(intent)
}, 300L) // 300ms delay after destroy before creating new recognizer
}
/**
* Stops the voice session when the user taps Stop.
*
* Sets manuallyStopped = true FIRST, before anything else.
* This prevents the onError retry logic and onSpeakingFinished
* from restarting the mic after this method returns.
*
* State is set directly on CarServiceBridge fields rather than
* going through updateState() to avoid accidentally triggering
* onAiResponseReceived (which would fire TTS).
*
* Also calls ttsManager.stop() to cut off any speech mid-sentence
* if the user taps Stop while TTS is playing.
*/
private fun stopListening() {
manuallyStopped = true // block all auto-restart paths first
speechRecognizer?.stopListening()
speechRecognizer?.destroy()
speechRecognizer = null
isListening = false
CarServiceBridge.isListening = false
CarServiceBridge.statusText = "Tap Start to speak"
CarServiceBridge.lastAiResponse = "" // reset so next session starts clean
CarServiceBridge.ttsManager?.stop() // cut off TTS immediately if playing
invalidate()
}
/**
* Called by ChatBotSession.ttsManager.onSpeakingFinished after TTS completes.
* Restarts the microphone to continue the hands-free conversation loop.
*
* Checks manuallyStopped before doing anything - if the user tapped Stop
* while TTS was speaking, this method returns immediately without restarting.
*/
fun restartListening() {
if (manuallyStopped) return // user explicitly stopped - respect that decision
CarServiceBridge.updateState(
listening = true,
status = "Listening...",
aiResponse = CarServiceBridge.lastAiResponse,
spokenText = ""
)
startListening()
}
}package com.androidauto.flutter.auto
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.Template
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.androidauto.flutter.CarServiceBridge
/**
* The car screen - the only UI the driver sees on the head unit.
*
* Template choice: MessageTemplate (required for MESSAGING category).
* PaneTemplate would be rejected by the host for this category.
*
* Manages SpeechRecognizer directly so voice capture happens natively
* using the car or DHU microphone, with results forwarded to Flutter
* via CarServiceBridge → EventChannel.
*
* Full status flow:
* Start tapped → "Starting..." (recognizer warming up)
* Mic truly open → "Listening..." (onReadyForSpeech fires)
* User speaks → "Hearing you..." (onBeginningOfSpeech fires)
* User stops → "Thinking..." (onEndOfSpeech fires)
* Flutter calls AI → AI response sent back via MethodChannel
* AI response arrives → "Speaking..." (TTS plays through car speaker)
* TTS finishes → "Listening..." (auto-restart, hands-free loop)
* Stop tapped → "Tap Start to speak" (no auto-restart)
*/
class ChatBotScreen(carContext: CarContext) : Screen(carContext) {
private var speechRecognizer: SpeechRecognizer? = null
private var isListening = false
/*
❌ BUG #6 - STOP BUTTON RESTARTS THE MIC AUTOMATICALLY
When the user taps Stop, TTS may still be finishing its current sentence.
When TTS completes, onSpeakingFinished fires and calls restartListening()
- which restarts the mic even though the user explicitly tapped Stop.
Fix: manuallyStopped flag. Set to true on stopListening(), checked in
restartListening() before doing anything. Cleared to false when the
user taps Start again to begin a new session.
*/
private var manuallyStopped = false
init {
// Redraw the car screen whenever CarServiceBridge state is updated.
// invalidate() tells the host to call onGetTemplate() again.
CarServiceBridge.onStateChanged = {
carContext.mainExecutor.execute { invalidate() }
}
// When a new AI response arrives from Flutter, speak it via TTS.
// Sets status to "Speaking..." and triggers CarTtsManager.speak().
// onSpeakingFinished will fire when done → restartListening() is called.
CarServiceBridge.onAiResponseReceived = { aiResponse ->
carContext.mainExecutor.execute {
CarServiceBridge.statusText = "Speaking..."
invalidate()
CarServiceBridge.ttsManager?.speak(aiResponse)
}
}
// Clean up SpeechRecognizer when this screen is removed.
// Screen has no overridable onDestroy() - use lifecycle observer.
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
speechRecognizer?.destroy()
speechRecognizer = null
CarServiceBridge.ttsManager?.stop()
}
})
}
/**
* Called by the Android Auto host every time it needs to render the screen.
* Must return a template synchronously - never do async work here.
*
* Reads current state from CarServiceBridge to build the template.
* The button switches between Start and Stop based on isListening.
* The message body shows the latest AI response, or a prompt if empty.
*/
override fun onGetTemplate(): Template {
val status = CarServiceBridge.statusText
val aiResponse = CarServiceBridge.lastAiResponse
val toggleAction = Action.Builder()
.setTitle(if (isListening) "⏹ Stop" else "🎤 Start")
.setBackgroundColor(if (isListening) CarColor.RED else CarColor.GREEN)
.setOnClickListener {
if (isListening) stopListening() else startListening()
}
.build()
return MessageTemplate.Builder(
if (aiResponse.isNotEmpty()) aiResponse else "Tap Start to speak"
)
.setTitle(status)
.setHeaderAction(Action.APP_ICON)
.addAction(toggleAction)
.build()
}
/**
* Starts a new voice recognition session.
*
* Key constraints for SpeechRecognizer inside a CarAppService (Service context):
*
* ❌ BUG #5 - ERROR 9 and ERROR 11 inside CarAppService
*
* ERROR_INSUFFICIENT_PERMISSIONS (9):
* The RECORD_AUDIO permission is not granted at runtime.
* Services cannot show permission dialogs - permission must be
* granted from MainActivity before the car session ever starts.
* Fix: requestMicPermissionIfNeeded() in MainActivity.
*
* ERROR_CLIENT (11):
* SpeechRecognizer was created or called from the wrong thread,
* or the previous recognizer was not fully released before
* creating a new one.
* Fix: always create inside Handler(Looper.getMainLooper()).post{}
* and add a 300ms delay after destroying the previous recognizer.
*/
private fun startListening() {
manuallyStopped = false // clear the manual stop flag for this new session
if (!SpeechRecognizer.isRecognitionAvailable(carContext)) {
CarServiceBridge.isListening = false
CarServiceBridge.statusText = "Speech not available"
invalidate()
return
}
isListening = true
CarServiceBridge.isListening = true
// Show "Starting..." while the recognizer service connects.
// This avoids the long "Listening..." that appears before the mic is ready.
CarServiceBridge.statusText = "Starting..."
invalidate()
// Destroy and null the previous recognizer before creating a new one.
// Failing to null it can cause ERROR_CLIENT on rapid stop/start cycles.
speechRecognizer?.destroy()
speechRecognizer = null
// 300ms delay gives the old recognizer time to fully release resources
// before the new one is created - prevents ERROR_CLIENT (11).
Handler(Looper.getMainLooper()).postDelayed({
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(carContext)
speechRecognizer?.setRecognitionListener(object : RecognitionListener {
/**
* Fires when the microphone is genuinely open and actively listening.
* This is the correct place to show "Listening..." - not before,
* because the recognizer service takes time to initialise and connect.
*/
override fun onReadyForSpeech(params: Bundle?) {
carContext.mainExecutor.execute {
CarServiceBridge.statusText = "Listening..."
invalidate()
}
}
/** Fires the moment the recognizer detects the user has begun speaking */
override fun onBeginningOfSpeech() {
carContext.mainExecutor.execute {
CarServiceBridge.statusText = "Hearing you..."
invalidate()
}
}
/** Fires when the user stops speaking - recognition processing begins */
override fun onEndOfSpeech() {
carContext.mainExecutor.execute {
CarServiceBridge.statusText = "Thinking..."
isListening = false
invalidate()
}
}
/**
* Fires with the final transcription result.
*
* CRITICAL: aiResponse must always be empty string here.
* If aiResponse is non-empty, CarServiceBridge will trigger TTS
* immediately - causing it to speak the transcription back to the
* user instead of the AI response. Always pass "" for aiResponse
* when forwarding spokenText.
*/
override fun onResults(results: Bundle?) {
val matches = results
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
val spokenText = matches?.firstOrNull() ?: ""
carContext.mainExecutor.execute {
CarServiceBridge.updateState(
listening = false,
status = "You said: $spokenText",
aiResponse = "", // ← must be empty - do NOT trigger TTS here
spokenText = spokenText // ← travels to Flutter via EventChannel
)
isListening = false
invalidate()
}
}
/**
* Handles recognition errors with automatic retry for recoverable cases.
*
* Recoverable (auto-retry silently):
* ERROR_NO_MATCH - no speech detected in the timeout window
* ERROR_CLIENT - recognizer state issue, retry fixes it
* ERROR_SPEECH_TIMEOUT - user did not speak in time
* These are common on DHU due to laptop mic sensitivity.
* In a real car these are far rarer due to dedicated mic hardware.
*
* Non-recoverable (show error message, stop):
* ERROR_INSUFFICIENT_PERMISSIONS - mic access denied
* ERROR_NETWORK - no internet for cloud STT
* ERROR_AUDIO - hardware mic problem
*
* manuallyStopped check prevents retry when user tapped Stop.
*/
override fun onError(error: Int) {
val shouldRetry = error == SpeechRecognizer.ERROR_NO_MATCH ||
error == SpeechRecognizer.ERROR_CLIENT ||
error == SpeechRecognizer.ERROR_SPEECH_TIMEOUT
carContext.mainExecutor.execute {
isListening = false
if (shouldRetry && !manuallyStopped) {
// Retry silently - update status and restart after delay
CarServiceBridge.statusText = "Listening..."
invalidate()
Handler(Looper.getMainLooper()).postDelayed({
if (!manuallyStopped) startListening()
}, 500L)
} else if (!manuallyStopped) {
// Non-recoverable - show message and stop
val message = when (error) {
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ->
"Mic permission denied"
SpeechRecognizer.ERROR_NETWORK -> "Network error"
SpeechRecognizer.ERROR_AUDIO -> "Microphone error"
else -> "Error ($error)"
}
CarServiceBridge.statusText = message
invalidate()
}
}
}
// Required interface members - no action needed for these callbacks
override fun onRmsChanged(rmsdB: Float) {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onPartialResults(partialResults: Bundle?) {}
override fun onEvent(eventType: Int, params: Bundle?) {}
})
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
// FREE_FORM = natural conversational language model (best for chatbots)
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "en-US")
// Only need the top result - no need for multiple alternatives
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
// Partial results keep the recognizer active longer - reduces NO_MATCH errors
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
// Extended timeouts - important for DHU testing on laptop mic
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, 3000L)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 2000L)
putExtra(
RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,
2000L
)
}
speechRecognizer?.startListening(intent)
}, 300L) // 300ms delay after destroy before creating new recognizer
}
/**
* Stops the voice session when the user taps Stop.
*
* Sets manuallyStopped = true FIRST, before anything else.
* This prevents the onError retry logic and onSpeakingFinished
* from restarting the mic after this method returns.
*
* State is set directly on CarServiceBridge fields rather than
* going through updateState() to avoid accidentally triggering
* onAiResponseReceived (which would fire TTS).
*
* Also calls ttsManager.stop() to cut off any speech mid-sentence
* if the user taps Stop while TTS is playing.
*/
private fun stopListening() {
manuallyStopped = true // block all auto-restart paths first
speechRecognizer?.stopListening()
speechRecognizer?.destroy()
speechRecognizer = null
isListening = false
CarServiceBridge.isListening = false
CarServiceBridge.statusText = "Tap Start to speak"
CarServiceBridge.lastAiResponse = "" // reset so next session starts clean
CarServiceBridge.ttsManager?.stop() // cut off TTS immediately if playing
invalidate()
}
/**
* Called by ChatBotSession.ttsManager.onSpeakingFinished after TTS completes.
* Restarts the microphone to continue the hands-free conversation loop.
*
* Checks manuallyStopped before doing anything - if the user tapped Stop
* while TTS was speaking, this method returns immediately without restarting.
*/
fun restartListening() {
if (manuallyStopped) return // user explicitly stopped - respect that decision
CarServiceBridge.updateState(
listening = true,
status = "Listening...",
aiResponse = CarServiceBridge.lastAiResponse,
spokenText = ""
)
startListening()
}
}Step 9 — MainActivity.kt
MainActivity has two jobs: register the platform channels so Flutter and native Kotlin can talk to each other, and request the microphone permission before any car session starts.
File: android/app/src/main/kotlin/com/androidauto/flutter/MainActivity.kt
package com.androidauto.flutter
import android.Manifest
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
/**
* Flutter's host Activity on the phone.
*
* Registers two platform channels that form the Flutter ↔ Native bridge:
*
* METHOD_CHANNEL (Flutter → Native):
* Flutter calls updateCarState() to push AI responses to the car screen.
* Receives: isListening, statusText, aiResponse
* Forwards to: CarServiceBridge.updateState() → triggers TTS
*
* EVENT_CHANNEL (Native → Flutter):
* Car screen pushes spokenText and state changes to Flutter.
* Flutter receives these via AutoBridge.carEvents stream in Dart.
*
* IMPORTANT: Channel name strings must match EXACTLY between Kotlin and Dart.
* Even a single character difference causes MissingPluginException in Flutter.
*
* Also requests RECORD_AUDIO permission here - CarAppService (a Service context)
* cannot show permission dialogs. The permission must be granted from an Activity
* before the car session ever starts.
*/
class MainActivity : FlutterActivity() {
companion object {
const val METHOD_CHANNEL = "com.androidauto.flutter/auto_bridge"
const val EVENT_CHANNEL = "com.androidauto.flutter/auto_events"
const val MIC_PERMISSION_CODE = 100
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
// Always call super first - this registers Flutter's own plugins
super.configureFlutterEngine(flutterEngine)
// Request mic permission immediately - must be done before any car session
requestMicPermissionIfNeeded()
// METHOD_CHANNEL: receives Flutter calls, forwards to CarServiceBridge
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
METHOD_CHANNEL
).setMethodCallHandler { call, result ->
when (call.method) {
"updateCarState" -> {
val listening = call.argument<Boolean>("isListening") ?: false
val status = call.argument<String>("statusText") ?: ""
val aiResponse = call.argument<String>("aiResponse") ?: ""
// Forward to CarServiceBridge - if aiResponse is non-empty,
// this will trigger onAiResponseReceived → TTS speaks it
CarServiceBridge.updateState(listening, status, aiResponse)
result.success(null)
}
else -> result.notImplemented()
}
}
// EVENT_CHANNEL: stores the Flutter event sink so CarServiceBridge
// can push car screen events (spokenText, state) to Flutter at any time
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
EVENT_CHANNEL
).setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(args: Any?, sink: EventChannel.EventSink) {
// Flutter started listening - store the sink for later use
CarServiceBridge.eventSink = sink
}
override fun onCancel(args: Any?) {
// Flutter stopped listening - clear the sink reference
CarServiceBridge.eventSink = null
}
})
}
/**
* Checks and requests RECORD_AUDIO permission at runtime.
*
* Open the Flutter app on your phone BEFORE connecting to DHU to ensure
* this dialog appears and the user accepts it. Without this, SpeechRecognizer
* inside the CarAppService will fail with ERROR_INSUFFICIENT_PERMISSIONS (9).
*
* Alternatively, grant via adb for testing:
* adb shell pm grant com.androidauto.flutter android.permission.RECORD_AUDIO
*/
private fun requestMicPermissionIfNeeded() {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.RECORD_AUDIO
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.RECORD_AUDIO),
MIC_PERMISSION_CODE
)
}
}
}package com.androidauto.flutter
import android.Manifest
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
/**
* Flutter's host Activity on the phone.
*
* Registers two platform channels that form the Flutter ↔ Native bridge:
*
* METHOD_CHANNEL (Flutter → Native):
* Flutter calls updateCarState() to push AI responses to the car screen.
* Receives: isListening, statusText, aiResponse
* Forwards to: CarServiceBridge.updateState() → triggers TTS
*
* EVENT_CHANNEL (Native → Flutter):
* Car screen pushes spokenText and state changes to Flutter.
* Flutter receives these via AutoBridge.carEvents stream in Dart.
*
* IMPORTANT: Channel name strings must match EXACTLY between Kotlin and Dart.
* Even a single character difference causes MissingPluginException in Flutter.
*
* Also requests RECORD_AUDIO permission here - CarAppService (a Service context)
* cannot show permission dialogs. The permission must be granted from an Activity
* before the car session ever starts.
*/
class MainActivity : FlutterActivity() {
companion object {
const val METHOD_CHANNEL = "com.androidauto.flutter/auto_bridge"
const val EVENT_CHANNEL = "com.androidauto.flutter/auto_events"
const val MIC_PERMISSION_CODE = 100
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
// Always call super first - this registers Flutter's own plugins
super.configureFlutterEngine(flutterEngine)
// Request mic permission immediately - must be done before any car session
requestMicPermissionIfNeeded()
// METHOD_CHANNEL: receives Flutter calls, forwards to CarServiceBridge
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
METHOD_CHANNEL
).setMethodCallHandler { call, result ->
when (call.method) {
"updateCarState" -> {
val listening = call.argument<Boolean>("isListening") ?: false
val status = call.argument<String>("statusText") ?: ""
val aiResponse = call.argument<String>("aiResponse") ?: ""
// Forward to CarServiceBridge - if aiResponse is non-empty,
// this will trigger onAiResponseReceived → TTS speaks it
CarServiceBridge.updateState(listening, status, aiResponse)
result.success(null)
}
else -> result.notImplemented()
}
}
// EVENT_CHANNEL: stores the Flutter event sink so CarServiceBridge
// can push car screen events (spokenText, state) to Flutter at any time
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
EVENT_CHANNEL
).setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(args: Any?, sink: EventChannel.EventSink) {
// Flutter started listening - store the sink for later use
CarServiceBridge.eventSink = sink
}
override fun onCancel(args: Any?) {
// Flutter stopped listening - clear the sink reference
CarServiceBridge.eventSink = null
}
})
}
/**
* Checks and requests RECORD_AUDIO permission at runtime.
*
* Open the Flutter app on your phone BEFORE connecting to DHU to ensure
* this dialog appears and the user accepts it. Without this, SpeechRecognizer
* inside the CarAppService will fail with ERROR_INSUFFICIENT_PERMISSIONS (9).
*
* Alternatively, grant via adb for testing:
* adb shell pm grant com.androidauto.flutter android.permission.RECORD_AUDIO
*/
private fun requestMicPermissionIfNeeded() {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.RECORD_AUDIO
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.RECORD_AUDIO),
MIC_PERMISSION_CODE
)
}
}
}Step 10 — Flutter Side
lib/services/native_flutter_bridge.dart
// lib/services/native_flutter_bridge.dart
import 'package:flutter/services.dart';
/// Bridge between Flutter and the Android Auto native layer.
///
/// Wraps two platform channels:
///
/// METHOD_CHANNEL (Flutter → Native):
/// updateCarState() sends AI responses to the car screen.
/// The native side receives this and triggers TTS to speak the response.
///
/// EVENT_CHANNEL (Native → Flutter):
/// carEvents stream receives events pushed by the car screen.
/// Listen for 'spokenText' to get transcribed voice input from the car mic.
/// Also receives status updates like 'isListening', 'aiResponse'.
///
/// Channel name strings must match EXACTLY with those in MainActivity.kt.
/// A single character difference will cause MissingPluginException.
class AutoBridge {
static const _method = MethodChannel(
'com.androidauto.flutter/auto_bridge',
);
static const _events = EventChannel(
'com.androidauto.flutter/auto_events',
);
/// Stream of all events from the car screen.
///
/// Each event is a Map with keys:
/// 'spokenText' - transcribed voice input (non-empty when user spoke)
/// 'isListening' - whether the mic is currently active
/// 'statusText' - current status heading on the car screen
/// 'aiResponse' - last AI response shown on car screen
///
/// Listen for 'spokenText' to trigger your AI call.
static Stream<Map<String, dynamic>> get carEvents =>
_events.receiveBroadcastStream()
.map((e) => Map<String, dynamic>.from(e));
/// Sends the AI response back to the car screen.
///
/// When aiResponse is non-empty, the native side triggers TTS
/// to speak the response aloud through the car speaker.
/// After TTS finishes, the mic auto-restarts (unless user tapped Stop).
static Future<void> updateCarState({
required bool isListening,
required String statusText,
required String aiResponse,
}) async {
await _method.invokeMethod('updateCarState', {
'isListening': isListening,
'statusText': statusText,
'aiResponse': aiResponse,
});
}
}// lib/services/native_flutter_bridge.dart
import 'package:flutter/services.dart';
/// Bridge between Flutter and the Android Auto native layer.
///
/// Wraps two platform channels:
///
/// METHOD_CHANNEL (Flutter → Native):
/// updateCarState() sends AI responses to the car screen.
/// The native side receives this and triggers TTS to speak the response.
///
/// EVENT_CHANNEL (Native → Flutter):
/// carEvents stream receives events pushed by the car screen.
/// Listen for 'spokenText' to get transcribed voice input from the car mic.
/// Also receives status updates like 'isListening', 'aiResponse'.
///
/// Channel name strings must match EXACTLY with those in MainActivity.kt.
/// A single character difference will cause MissingPluginException.
class AutoBridge {
static const _method = MethodChannel(
'com.androidauto.flutter/auto_bridge',
);
static const _events = EventChannel(
'com.androidauto.flutter/auto_events',
);
/// Stream of all events from the car screen.
///
/// Each event is a Map with keys:
/// 'spokenText' - transcribed voice input (non-empty when user spoke)
/// 'isListening' - whether the mic is currently active
/// 'statusText' - current status heading on the car screen
/// 'aiResponse' - last AI response shown on car screen
///
/// Listen for 'spokenText' to trigger your AI call.
static Stream<Map<String, dynamic>> get carEvents =>
_events.receiveBroadcastStream()
.map((e) => Map<String, dynamic>.from(e));
/// Sends the AI response back to the car screen.
///
/// When aiResponse is non-empty, the native side triggers TTS
/// to speak the response aloud through the car speaker.
/// After TTS finishes, the mic auto-restarts (unless user tapped Stop).
static Future<void> updateCarState({
required bool isListening,
required String statusText,
required String aiResponse,
}) async {
await _method.invokeMethod('updateCarState', {
'isListening': isListening,
'statusText': statusText,
'aiResponse': aiResponse,
});
}
}lib/ui/home_page.dart
// lib/ui/home_page.dart
import 'package:flutter/material.dart';
import '../services/native_flutter_bridge.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
String _statusText = "Ready";
String _spokenText = "";
String _aiResponse = "";
@override
void initState() {
super.initState();
/*
❌ BUG #7 - MissingPluginException on EventChannel
If you call AutoBridge.carEvents.listen() directly inside initState(),
Flutter tries to activate the EventChannel before MainActivity has
finished registering it. The result:
MissingPluginException(No implementation found for method listen
on channel com.androidauto.flutter/auto_events)
Fix: wrap the listener setup in addPostFrameCallback.
This defers execution until after the first frame is rendered,
by which point MainActivity.configureFlutterEngine() has completed
and the EventChannel is registered and ready.
*/
WidgetsBinding.instance.addPostFrameCallback((_) {
_setupCarAutoListener();
});
}
/// Sets up the listener for all events from the Android Auto car screen.
///
/// When 'spokenText' arrives (user spoke into car mic):
/// 1. Updates phone UI to show "Thinking..."
/// 2. Notifies car screen we are processing
/// 3. Calls your AI with the transcribed text
/// 4. Sends AI response back to car screen → triggers TTS
///
/// cancelOnError: false keeps the stream alive even if one event fails.
void _setupCarAutoListener() {
try {
AutoBridge.carEvents.listen(
(event) async {
final spokenText = event['spokenText'] as String? ?? '';
// Only process events that contain actual voice transcription
if (spokenText.isNotEmpty) {
setState(() {
_spokenText = spokenText;
_statusText = "Thinking...";
_aiResponse = "";
});
// Tell the car screen we are processing the request
await AutoBridge.updateCarState(
isListening: false,
statusText: "Thinking...",
aiResponse: "",
);
// Call your AI with the transcribed voice input
final response = await _callYourAI(spokenText);
setState(() {
_aiResponse = response;
_statusText = "AI responded";
});
// Send AI response to car screen.
// This triggers TTS on the native side - the car speaks the response.
// After TTS finishes, the mic auto-restarts for the next turn.
await AutoBridge.updateCarState(
isListening: false,
statusText: "Tap Start to speak again",
aiResponse: response,
);
}
},
onError: (error) {
debugPrint('AutoBridge stream error: $error');
},
cancelOnError: false, // keep listening even if a single event errors
);
} catch (e) {
debugPrint('AutoBridge setup error: $e');
}
}
/// Replace this with your actual AI API call.
/// This stub simulates a 1-second network delay and echoes the input.
/// Wire in your LLM of choice: OpenAI, Gemini, Claude API, etc.
Future<String> _callYourAI(String userText) async {
await Future.delayed(const Duration(seconds: 1));
return "You asked: $userText. This is the AI response.";
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("AI Voice Chat")),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_statusText,
style: const TextStyle(fontSize: 18, color: Colors.grey),
),
const SizedBox(height: 24),
if (_spokenText.isNotEmpty) ...[
const Text(
"You said:",
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(_spokenText, textAlign: TextAlign.center),
const SizedBox(height: 16),
],
if (_aiResponse.isNotEmpty) ...[
const Text(
"AI Response:",
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(_aiResponse, textAlign: TextAlign.center),
],
],
),
),
);
}
}// lib/ui/home_page.dart
import 'package:flutter/material.dart';
import '../services/native_flutter_bridge.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
String _statusText = "Ready";
String _spokenText = "";
String _aiResponse = "";
@override
void initState() {
super.initState();
/*
❌ BUG #7 - MissingPluginException on EventChannel
If you call AutoBridge.carEvents.listen() directly inside initState(),
Flutter tries to activate the EventChannel before MainActivity has
finished registering it. The result:
MissingPluginException(No implementation found for method listen
on channel com.androidauto.flutter/auto_events)
Fix: wrap the listener setup in addPostFrameCallback.
This defers execution until after the first frame is rendered,
by which point MainActivity.configureFlutterEngine() has completed
and the EventChannel is registered and ready.
*/
WidgetsBinding.instance.addPostFrameCallback((_) {
_setupCarAutoListener();
});
}
/// Sets up the listener for all events from the Android Auto car screen.
///
/// When 'spokenText' arrives (user spoke into car mic):
/// 1. Updates phone UI to show "Thinking..."
/// 2. Notifies car screen we are processing
/// 3. Calls your AI with the transcribed text
/// 4. Sends AI response back to car screen → triggers TTS
///
/// cancelOnError: false keeps the stream alive even if one event fails.
void _setupCarAutoListener() {
try {
AutoBridge.carEvents.listen(
(event) async {
final spokenText = event['spokenText'] as String? ?? '';
// Only process events that contain actual voice transcription
if (spokenText.isNotEmpty) {
setState(() {
_spokenText = spokenText;
_statusText = "Thinking...";
_aiResponse = "";
});
// Tell the car screen we are processing the request
await AutoBridge.updateCarState(
isListening: false,
statusText: "Thinking...",
aiResponse: "",
);
// Call your AI with the transcribed voice input
final response = await _callYourAI(spokenText);
setState(() {
_aiResponse = response;
_statusText = "AI responded";
});
// Send AI response to car screen.
// This triggers TTS on the native side - the car speaks the response.
// After TTS finishes, the mic auto-restarts for the next turn.
await AutoBridge.updateCarState(
isListening: false,
statusText: "Tap Start to speak again",
aiResponse: response,
);
}
},
onError: (error) {
debugPrint('AutoBridge stream error: $error');
},
cancelOnError: false, // keep listening even if a single event errors
);
} catch (e) {
debugPrint('AutoBridge setup error: $e');
}
}
/// Replace this with your actual AI API call.
/// This stub simulates a 1-second network delay and echoes the input.
/// Wire in your LLM of choice: OpenAI, Gemini, Claude API, etc.
Future<String> _callYourAI(String userText) async {
await Future.delayed(const Duration(seconds: 1));
return "You asked: $userText. This is the AI response.";
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("AI Voice Chat")),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_statusText,
style: const TextStyle(fontSize: 18, color: Colors.grey),
),
const SizedBox(height: 24),
if (_spokenText.isNotEmpty) ...[
const Text(
"You said:",
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(_spokenText, textAlign: TextAlign.center),
const SizedBox(height: 16),
],
if (_aiResponse.isNotEmpty) ...[
const Text(
"AI Response:",
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(_aiResponse, textAlign: TextAlign.center),
],
],
),
),
);
}
}Testing Without a Car — Desktop Head Unit (DHU)
The DHU is a free tool from Google that simulates an Android Auto head unit on your computer. Your phone connects to it exactly like a real car, and the DHU uses your laptop's microphone and speakers.
# Step 1 — Install DHU from Android Studio
# SDK Manager → SDK Tools → Android Auto Desktop Head Unit
# Step 2 - Enable developer mode on your phone
# Open Android Auto app → tap the version number 10 times → tap OK
# Step 3 - Enable Unknown sources in Developer settings
# Android Auto → ⋮ → Developer settings → Unknown sources ON
#
# Without this, your debug APK hits PACKAGE_FAILED_ALL_CHECKS in Logcat:
# pkg=com.androidauto.flutter error=PACKAGE_FAILED_ALL_CHECKS
# Package DENIED; failed all other checks
# No UI error is shown - the app is just silently rejected by the host.
# This developer bypass disables the Google Play policy check for local builds.
# Step 4 - Build and install your debug APK
flutter build apk --debug
adb install build/app/outputs/flutter-apk/app-debug.apk
# Step 5 - Grant microphone permission explicitly
adb shell pm grant com.androidauto.flutter android.permission.RECORD_AUDIO
# Step 6 - Grant notification access (required for Customize Launcher visibility)
adb shell am start -a android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS
# Find your app in the list and toggle it ON
# Step 7 - Connect to DHU
adb forward tcp:5277 tcp:5277
# Android Auto on phone → ⋮ → Start head unit server
# Launch DHU on your computer
# Step 8 - Enable your app in the car launcher
# Android Auto app on phone → Settings → Customize launcher
# Toggle your app ON
#
# NOTE: Your app will NOT appear in Customize Launcher until you connect
# to the DHU at least once. The list only refreshes during an active session.
# Install app → connect DHU → check Customize Launcher (in that order).cmmcc# Step 1 — Install DHU from Android Studio
# SDK Manager → SDK Tools → Android Auto Desktop Head Unit
# Step 2 - Enable developer mode on your phone
# Open Android Auto app → tap the version number 10 times → tap OK
# Step 3 - Enable Unknown sources in Developer settings
# Android Auto → ⋮ → Developer settings → Unknown sources ON
#
# Without this, your debug APK hits PACKAGE_FAILED_ALL_CHECKS in Logcat:
# pkg=com.androidauto.flutter error=PACKAGE_FAILED_ALL_CHECKS
# Package DENIED; failed all other checks
# No UI error is shown - the app is just silently rejected by the host.
# This developer bypass disables the Google Play policy check for local builds.
# Step 4 - Build and install your debug APK
flutter build apk --debug
adb install build/app/outputs/flutter-apk/app-debug.apk
# Step 5 - Grant microphone permission explicitly
adb shell pm grant com.androidauto.flutter android.permission.RECORD_AUDIO
# Step 6 - Grant notification access (required for Customize Launcher visibility)
adb shell am start -a android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS
# Find your app in the list and toggle it ON
# Step 7 - Connect to DHU
adb forward tcp:5277 tcp:5277
# Android Auto on phone → ⋮ → Start head unit server
# Launch DHU on your computer
# Step 8 - Enable your app in the car launcher
# Android Auto app on phone → Settings → Customize launcher
# Toggle your app ON
#
# NOTE: Your app will NOT appear in Customize Launcher until you connect
# to the DHU at least once. The list only refreshes during an active session.
# Install app → connect DHU → check Customize Launcher (in that order).cmmccAll 8 Bugs Summarised
Bug Symptom Fix 1 Missing minCarApiLevel meta-data Silent bind failure, no UI error Add meta-data tag to manifest 2 Missing NotificationListenerService App invisible in Customize Launcher Declare service and grant notification access 3 Wrong automotive_app_desc.xml Host silently rejects app. Add both notification and template 4 Comparison order bug in CarServiceBridge TTS never fires Compare aiResponse before updating lastAiResponse 5 SpeechRecognizer in CarAppService ERROR 9 and ERROR 11 Grant mic from Activity, create recognizer on main Looper 6 Stop button auto-restarts mic "Listening..." after tapping Stop manuallyStopped flag blocks restartListening() 7 EventChannel setup too early MissingPluginException Wrap listen() in addPostFrameCallback 8 Duplicate init blocks in Screen State updates stop working randomly. Merge into a single init block
Conclusion
After completing my client's requirements, I was reminded — for what feels like the hundredth time — that I am a problem solver, not just an app developer.
What surprised me most through this entire journey was how Claude became a genuine teammate. Not just an answer machine that spits out code snippets, but something that could actually show me direction when I had drifted off the right path, explain why errors were happening instead of just patching them, and help me build understanding rather than just copy-paste solutions.
If you want to develop your app for the car screen, feel free to use these code files as your starting point before trying to adapt things for your own requirements. You can ask questions in the comments below or use Claude as your debugging partner the same way I did.
I have intentionally not added a GitHub repository — because you really need to understand this code before using it, especially if you have no prior experience with native Android development. Read through each file, understand what every comment is telling you, and then adapt it to your specific use case. That understanding is what will save you when something breaks in your specific environment.
Good luck. The bugs are part of the journey.
If this article saved you hours of debugging, consider following me for more real-world Flutter and Android development content.