fuzzyworld

ai-chat-android-auto / commit

Add Android Auto media service — voice assistant MVP

26141d4523a8b27ef0afbab1028f22097da8263f · Pavel Safronov · 2026-05-14 16:20:18 -0700 · parents 80eca30b1694

Add Android Auto media service — voice assistant MVP

Implements ClaudeMediaService (MediaBrowserServiceCompat) with a four-state
machine: IDLE → LISTENING → PROCESSING → SPEAKING. The browse list shows a
single "Ask Claude" item; tapping it starts SpeechRecognizer, sends the
transcribed question to the Anthropic Messages API via OkHttp, and reads
the response back through TextToSpeech.

Uses android.media.browse.MediaBrowserService (not Car App Library) so the
app appears in Android Auto without Play Store approval. automotive_app_desc
declares the media category. INTERNET and foreground service permissions added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 app/src/main/AndroidManifest.xml                   |  16 ++
 .../net/fuzzyworld/claudeauto/ClaudeApiClient.kt   |  63 ++++++
 .../fuzzyworld/claudeauto/ClaudeMediaService.kt    | 217 +++++++++++++++++++++
 app/src/main/res/xml/automotive_app_desc.xml       |   4 +
 4 files changed, 300 insertions(+)

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4a71f4f..aacde4a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,7 +1,10 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
 
+    <uses-permission android:name="android.permission.INTERNET" />
     <uses-permission android:name="android.permission.RECORD_AUDIO" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
 
     <application
         android:allowBackup="true"
@@ -10,6 +13,19 @@
         android:label="@string/app_name"
         android:supportsRtl="true">
 
+        <meta-data
+            android:name="com.google.android.gms.car.application"
+            android:resource="@xml/automotive_app_desc" />
+
+        <service
+            android:name=".ClaudeMediaService"
+            android:exported="true"
+            android:foregroundServiceType="mediaPlayback">
+            <intent-filter>
+                <action android:name="android.media.browse.MediaBrowserService" />
+            </intent-filter>
+        </service>
+
         <activity
             android:name=".SetupActivity"
             android:exported="true"
diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeApiClient.kt b/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeApiClient.kt
new file mode 100644
index 0000000..d49c4a4
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeApiClient.kt
@@ -0,0 +1,63 @@
+package net.fuzzyworld.claudeauto
+
+import okhttp3.*
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+class ClaudeApiClient {
+
+    private val http = OkHttpClient.Builder()
+        .connectTimeout(30, TimeUnit.SECONDS)
+        .readTimeout(90, TimeUnit.SECONDS)
+        .build()
+
+    fun ask(apiKey: String, question: String, onResult: (String?) -> Unit) {
+        val payload = JSONObject().apply {
+            put("model", "claude-sonnet-4-6")
+            put("max_tokens", 1024)
+            put(
+                "system",
+                "You are a voice assistant in a car. Be concise and conversational — " +
+                    "keep responses to 1-3 sentences unless genuinely more is needed. " +
+                    "Never use markdown, bullet points, code blocks, or lists. " +
+                    "Speak naturally, as if talking to a driver."
+            )
+            put("messages", JSONArray().apply {
+                put(JSONObject().apply {
+                    put("role", "user")
+                    put("content", question)
+                })
+            })
+        }.toString().toRequestBody("application/json".toMediaType())
+
+        val request = Request.Builder()
+            .url("https://api.anthropic.com/v1/messages")
+            .post(payload)
+            .addHeader("x-api-key", apiKey)
+            .addHeader("anthropic-version", "2023-06-01")
+            .build()
+
+        http.newCall(request).enqueue(object : Callback {
+            override fun onFailure(call: Call, e: IOException) = onResult(null)
+
+            override fun onResponse(call: Call, response: Response) {
+                response.use {
+                    if (!it.isSuccessful) { onResult(null); return }
+                    try {
+                        val text = JSONObject(it.body!!.string())
+                            .getJSONArray("content")
+                            .getJSONObject(0)
+                            .getString("text")
+                        onResult(text)
+                    } catch (_: Exception) {
+                        onResult(null)
+                    }
+                }
+            }
+        })
+    }
+}
diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeMediaService.kt b/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeMediaService.kt
new file mode 100644
index 0000000..4abb9a8
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeMediaService.kt
@@ -0,0 +1,217 @@
+package net.fuzzyworld.claudeauto
+
+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 android.speech.tts.TextToSpeech
+import android.speech.tts.UtteranceProgressListener
+import android.support.v4.media.MediaBrowserCompat.MediaItem
+import android.support.v4.media.MediaDescriptionCompat
+import android.support.v4.media.MediaMetadataCompat
+import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.session.PlaybackStateCompat
+import androidx.media.MediaBrowserServiceCompat
+import java.util.Locale
+
+class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListener {
+
+    private enum class State { IDLE, LISTENING, PROCESSING, SPEAKING }
+
+    private var state = State.IDLE
+
+    private lateinit var mediaSession: MediaSessionCompat
+    private lateinit var tts: TextToSpeech
+    private var ttsReady = false
+    private var recognizer: SpeechRecognizer? = null
+
+    private val mainHandler = Handler(Looper.getMainLooper())
+    private val api = ClaudeApiClient()
+
+    companion object {
+        private const val ROOT_ID = "__ROOT__"
+        private const val ASK_ID  = "__ASK__"
+    }
+
+    // ── Lifecycle ──────────────────────────────────────────────────────────────
+
+    override fun onCreate() {
+        super.onCreate()
+        tts = TextToSpeech(this, this)
+        mediaSession = MediaSessionCompat(this, "ClaudeAuto").apply {
+            setCallback(sessionCallback)
+            setFlags(
+                MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
+                MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
+            )
+            isActive = true
+        }
+        sessionToken = mediaSession.sessionToken
+        pushState(State.IDLE)
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        mediaSession.release()
+        tts.shutdown()
+        recognizer?.destroy()
+    }
+
+    override fun onInit(status: Int) {
+        ttsReady = status == TextToSpeech.SUCCESS
+        if (!ttsReady) return
+        tts.language = Locale.getDefault()
+        tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
+            override fun onStart(id: String?) {}
+            override fun onDone(id: String?) { mainHandler.post { pushState(State.IDLE) } }
+            @Deprecated("") override fun onError(id: String?) { mainHandler.post { pushState(State.IDLE) } }
+        })
+    }
+
+    // ── MediaBrowserServiceCompat ──────────────────────────────────────────────
+
+    override fun onGetRoot(pkg: String, uid: Int, hints: Bundle?): BrowserRoot =
+        BrowserRoot(ROOT_ID, null)
+
+    override fun onLoadChildren(parentId: String, result: Result<List<MediaItem>>) {
+        if (parentId != ROOT_ID) { result.sendResult(emptyList()); return }
+        result.sendResult(listOf(
+            MediaItem(
+                MediaDescriptionCompat.Builder()
+                    .setMediaId(ASK_ID)
+                    .setTitle("Ask Claude")
+                    .setSubtitle("Tap ▶ to speak")
+                    .build(),
+                MediaItem.FLAG_PLAYABLE
+            )
+        ))
+    }
+
+    // ── Media session callbacks ────────────────────────────────────────────────
+
+    private val sessionCallback = object : MediaSessionCompat.Callback() {
+        override fun onPlay() = startListening()
+        override fun onPlayFromMediaId(id: String?, extras: Bundle?) {
+            if (id == ASK_ID) startListening()
+        }
+        override fun onStop() {
+            tts.stop()
+            recognizer?.cancel()
+            pushState(State.IDLE)
+        }
+    }
+
+    // ── Voice input ────────────────────────────────────────────────────────────
+
+    private fun startListening() {
+        if (!Prefs.hasApiKey(this)) {
+            speak("No API key configured. Open the Claude Auto app on your phone to add it.")
+            return
+        }
+        if (!SpeechRecognizer.isRecognitionAvailable(this)) {
+            speak("Speech recognition is not available on this device.")
+            return
+        }
+        tts.stop()
+        pushState(State.LISTENING)
+        mainHandler.post {
+            recognizer?.destroy()
+            recognizer = SpeechRecognizer.createSpeechRecognizer(this@ClaudeMediaService).apply {
+                setRecognitionListener(recognitionListener)
+                startListening(Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
+                    putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
+                    putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
+                })
+            }
+        }
+    }
+
+    private val recognitionListener = object : RecognitionListener {
+        override fun onResults(r: Bundle?) {
+            val text = r?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull()
+            if (text != null) sendToClaude(text)
+            else mainHandler.post { speak("Couldn't understand — please try again.") }
+        }
+        override fun onError(error: Int) {
+            val msg = when (error) {
+                SpeechRecognizer.ERROR_NO_MATCH,
+                SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "No speech detected — please try again."
+                SpeechRecognizer.ERROR_NETWORK,
+                SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network error. Check your connection."
+                SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ->
+                    "Microphone permission required. Open Claude Auto on your phone."
+                else -> "Couldn't hear you — please try again."
+            }
+            mainHandler.post { speak(msg) }
+        }
+        override fun onEndOfSpeech() { mainHandler.post { pushState(State.PROCESSING) } }
+        override fun onReadyForSpeech(p: Bundle?) {}
+        override fun onBeginningOfSpeech() {}
+        override fun onRmsChanged(rms: Float) {}
+        override fun onBufferReceived(b: ByteArray?) {}
+        override fun onPartialResults(p: Bundle?) {}
+        override fun onEvent(t: Int, p: Bundle?) {}
+    }
+
+    // ── API + TTS ──────────────────────────────────────────────────────────────
+
+    private fun sendToClaude(question: String) {
+        pushState(State.PROCESSING, question)
+        api.ask(Prefs.apiKey(this), question) { response ->
+            mainHandler.post {
+                speak(response ?: "Couldn't reach Claude. Check your internet connection.")
+            }
+        }
+    }
+
+    private fun speak(text: String) {
+        if (!ttsReady) return
+        pushState(State.SPEAKING, text)
+        tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "r")
+    }
+
+    // ── MediaSession state ─────────────────────────────────────────────────────
+
+    private fun pushState(newState: State, text: String = "") {
+        state = newState
+
+        val (title, subtitle) = when (newState) {
+            State.IDLE       -> "Ask Claude" to "Tap ▶ to speak"
+            State.LISTENING  -> "Listening…"  to "Speak your question"
+            State.PROCESSING -> "Thinking…"   to (if (text.isNotEmpty()) "\"$text\"" else "")
+            State.SPEAKING   -> "Claude"       to text
+        }
+
+        val pbState = when (newState) {
+            State.IDLE                        -> PlaybackStateCompat.STATE_PAUSED
+            State.LISTENING, State.PROCESSING -> PlaybackStateCompat.STATE_BUFFERING
+            State.SPEAKING                    -> PlaybackStateCompat.STATE_PLAYING
+        }
+
+        val actions = when (newState) {
+            State.IDLE     -> PlaybackStateCompat.ACTION_PLAY or
+                              PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID
+            State.SPEAKING -> PlaybackStateCompat.ACTION_PAUSE
+            else           -> 0L
+        }
+
+        mediaSession.setMetadata(
+            MediaMetadataCompat.Builder()
+                .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
+                .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle)
+                .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Claude Auto")
+                .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, subtitle)
+                .build()
+        )
+
+        mediaSession.setPlaybackState(
+            PlaybackStateCompat.Builder()
+                .setState(pbState, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
+                .setActions(actions)
+                .build()
+        )
+    }
+}
diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 0000000..e64f442
--- /dev/null
+++ b/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<automotiveApp>
+    <uses name="media" />
+</automotiveApp>