fuzzyworld

ai-chat-android-auto / commit

Add conversation history, audio focus management, and web search

cc8b2bf6b306acf4a2df95f9343a63d85785d418 · Pavel Safronov · 2026-05-14 16:25:50 -0700 · parents d5e383a661ac

Add conversation history, audio focus management, and web search

History: each Q&A pair is stored in memory. The browse list shows a
"Clear History" button (with count) and all past exchanges in reverse
chronological order; tapping any entry re-asks that question. The IDLE
state subtitle shows the last answer instead of the static "Tap to speak"
prompt.

Audio focus: requestAudioFocus(AUDIOFOCUS_GAIN_TRANSIENT) is called before
starting the mic and released after TTS finishes (or on error). This causes
Spotify and other media apps to pause automatically, matching expected car
audio behavior.

Stop button: the SPEAKING state now advertises ACTION_STOP instead of
ACTION_PAUSE, so the car UI shows a stop icon while Claude is talking.

Web search: the web_search_20250305 built-in tool is added to every API
request so Claude can answer questions about current events, sports scores,
etc. Response parsing now collects all text-type content blocks (not just
content[0]) to handle multi-block replies that interleave search results.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 .../net/fuzzyworld/claudeauto/ClaudeApiClient.kt   |  22 +++-
 .../fuzzyworld/claudeauto/ClaudeMediaService.kt    | 131 ++++++++++++++++++---
 2 files changed, 132 insertions(+), 21 deletions(-)

diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeApiClient.kt b/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeApiClient.kt
index d49c4a4..22d3719 100644
--- a/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeApiClient.kt
+++ b/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeApiClient.kt
@@ -32,6 +32,12 @@ class ClaudeApiClient {
                     put("content", question)
                 })
             })
+            put("tools", JSONArray().apply {
+                put(JSONObject().apply {
+                    put("type", "web_search_20250305")
+                    put("name", "web_search")
+                })
+            })
         }.toString().toRequestBody("application/json".toMediaType())
 
         val request = Request.Builder()
@@ -39,6 +45,7 @@ class ClaudeApiClient {
             .post(payload)
             .addHeader("x-api-key", apiKey)
             .addHeader("anthropic-version", "2023-06-01")
+            .addHeader("anthropic-beta", "web-search-2025-03-05")
             .build()
 
         http.newCall(request).enqueue(object : Callback {
@@ -48,11 +55,16 @@ class ClaudeApiClient {
                 response.use {
                     if (!it.isSuccessful) { onResult(null); return }
                     try {
-                        val text = JSONObject(it.body!!.string())
-                            .getJSONArray("content")
-                            .getJSONObject(0)
-                            .getString("text")
-                        onResult(text)
+                        val content = JSONObject(it.body!!.string()).getJSONArray("content")
+                        val text = buildString {
+                            for (i in 0 until content.length()) {
+                                val block = content.getJSONObject(i)
+                                if (block.getString("type") == "text") {
+                                    append(block.getString("text"))
+                                }
+                            }
+                        }.trim()
+                        onResult(text.ifEmpty { null })
                     } 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
index 4abb9a8..155e394 100644
--- a/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeMediaService.kt
+++ b/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeMediaService.kt
@@ -1,6 +1,10 @@
 package net.fuzzyworld.claudeauto
 
 import android.content.Intent
+import android.media.AudioAttributes
+import android.media.AudioFocusRequest
+import android.media.AudioManager
+import android.os.Build
 import android.os.Bundle
 import android.os.Handler
 import android.os.Looper
@@ -19,27 +23,35 @@ import java.util.Locale
 
 class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListener {
 
+    data class HistoryEntry(val question: String, val answer: String)
+
     private enum class State { IDLE, LISTENING, PROCESSING, SPEAKING }
 
     private var state = State.IDLE
+    private val history = mutableListOf<HistoryEntry>()
 
     private lateinit var mediaSession: MediaSessionCompat
     private lateinit var tts: TextToSpeech
+    private lateinit var audioManager: AudioManager
     private var ttsReady = false
     private var recognizer: SpeechRecognizer? = null
+    private var focusRequest: AudioFocusRequest? = 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__"
+        private const val ROOT_ID          = "__ROOT__"
+        private const val ASK_ID           = "__ASK__"
+        private const val CLEAR_ID         = "__CLEAR__"
+        private const val HISTORY_PREFIX   = "__H__"
     }
 
     // ── Lifecycle ──────────────────────────────────────────────────────────────
 
     override fun onCreate() {
         super.onCreate()
+        audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
         tts = TextToSpeech(this, this)
         mediaSession = MediaSessionCompat(this, "ClaudeAuto").apply {
             setCallback(sessionCallback)
@@ -55,6 +67,7 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
 
     override fun onDestroy() {
         super.onDestroy()
+        releaseAudioFocus()
         mediaSession.release()
         tts.shutdown()
         recognizer?.destroy()
@@ -66,8 +79,12 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
         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) } }
+            override fun onDone(id: String?) {
+                mainHandler.post { releaseAudioFocus(); pushState(State.IDLE) }
+            }
+            @Deprecated("") override fun onError(id: String?) {
+                mainHandler.post { releaseAudioFocus(); pushState(State.IDLE) }
+            }
         })
     }
 
@@ -78,32 +95,109 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
 
     override fun onLoadChildren(parentId: String, result: Result<List<MediaItem>>) {
         if (parentId != ROOT_ID) { result.sendResult(emptyList()); return }
-        result.sendResult(listOf(
-            MediaItem(
+
+        val items = mutableListOf<MediaItem>()
+
+        items += MediaItem(
+            MediaDescriptionCompat.Builder()
+                .setMediaId(ASK_ID)
+                .setTitle("Ask Claude")
+                .setSubtitle("Tap ▶ to speak")
+                .build(),
+            MediaItem.FLAG_PLAYABLE
+        )
+
+        if (history.isNotEmpty()) {
+            items += MediaItem(
                 MediaDescriptionCompat.Builder()
-                    .setMediaId(ASK_ID)
-                    .setTitle("Ask Claude")
-                    .setSubtitle("Tap ▶ to speak")
+                    .setMediaId(CLEAR_ID)
+                    .setTitle("Clear History")
+                    .setSubtitle("${history.size} conversation${if (history.size == 1) "" else "s"}")
                     .build(),
                 MediaItem.FLAG_PLAYABLE
             )
-        ))
+
+            history.indices.reversed().forEach { idx ->
+                val entry = history[idx]
+                items += MediaItem(
+                    MediaDescriptionCompat.Builder()
+                        .setMediaId("$HISTORY_PREFIX$idx")
+                        .setTitle(entry.question)
+                        .setSubtitle(entry.answer)
+                        .build(),
+                    MediaItem.FLAG_PLAYABLE
+                )
+            }
+        }
+
+        result.sendResult(items)
     }
 
     // ── 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()
+            when {
+                id == ASK_ID   -> startListening()
+                id == CLEAR_ID -> clearHistory()
+                id?.startsWith(HISTORY_PREFIX) == true -> {
+                    val idx = id.removePrefix(HISTORY_PREFIX).toIntOrNull()
+                    if (idx != null && idx in history.indices) sendToClaude(history[idx].question)
+                }
+            }
         }
+
         override fun onStop() {
             tts.stop()
             recognizer?.cancel()
+            releaseAudioFocus()
             pushState(State.IDLE)
         }
     }
 
+    // ── History ────────────────────────────────────────────────────────────────
+
+    private fun clearHistory() {
+        history.clear()
+        notifyChildrenChanged(ROOT_ID)
+        pushState(State.IDLE)
+    }
+
+    // ── Audio focus ────────────────────────────────────────────────────────────
+
+    private fun requestAudioFocus() {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
+                .setAudioAttributes(
+                    AudioAttributes.Builder()
+                        .setUsage(AudioAttributes.USAGE_MEDIA)
+                        .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+                        .build()
+                )
+                .setWillPauseWhenDucked(true)
+                .build()
+            focusRequest = req
+            audioManager.requestAudioFocus(req)
+        } else {
+            @Suppress("DEPRECATION")
+            audioManager.requestAudioFocus(
+                null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
+            )
+        }
+    }
+
+    private fun releaseAudioFocus() {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            focusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
+            focusRequest = null
+        } else {
+            @Suppress("DEPRECATION")
+            audioManager.abandonAudioFocus(null)
+        }
+    }
+
     // ── Voice input ────────────────────────────────────────────────────────────
 
     private fun startListening() {
@@ -116,6 +210,7 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
             return
         }
         tts.stop()
+        requestAudioFocus()
         pushState(State.LISTENING)
         mainHandler.post {
             recognizer?.destroy()
@@ -133,7 +228,7 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
         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.") }
+            else mainHandler.post { releaseAudioFocus(); speak("Couldn't understand — please try again.") }
         }
         override fun onError(error: Int) {
             val msg = when (error) {
@@ -145,7 +240,7 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
                     "Microphone permission required. Open Claude Auto on your phone."
                 else -> "Couldn't hear you — please try again."
             }
-            mainHandler.post { speak(msg) }
+            mainHandler.post { releaseAudioFocus(); speak(msg) }
         }
         override fun onEndOfSpeech() { mainHandler.post { pushState(State.PROCESSING) } }
         override fun onReadyForSpeech(p: Bundle?) {}
@@ -162,7 +257,10 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
         pushState(State.PROCESSING, question)
         api.ask(Prefs.apiKey(this), question) { response ->
             mainHandler.post {
-                speak(response ?: "Couldn't reach Claude. Check your internet connection.")
+                val answer = response ?: "Couldn't reach Claude. Check your internet connection."
+                history.add(HistoryEntry(question, answer))
+                notifyChildrenChanged(ROOT_ID)
+                speak(answer)
             }
         }
     }
@@ -179,7 +277,8 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
         state = newState
 
         val (title, subtitle) = when (newState) {
-            State.IDLE       -> "Ask Claude" to "Tap ▶ to speak"
+            State.IDLE       -> "Ask Claude" to
+                                (history.lastOrNull()?.answer ?: "Tap ▶ to speak")
             State.LISTENING  -> "Listening…"  to "Speak your question"
             State.PROCESSING -> "Thinking…"   to (if (text.isNotEmpty()) "\"$text\"" else "")
             State.SPEAKING   -> "Claude"       to text
@@ -194,7 +293,7 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
         val actions = when (newState) {
             State.IDLE     -> PlaybackStateCompat.ACTION_PLAY or
                               PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID
-            State.SPEAKING -> PlaybackStateCompat.ACTION_PAUSE
+            State.SPEAKING -> PlaybackStateCompat.ACTION_STOP
             else           -> 0L
         }