ai-chat-android-auto / commit
Add conversation history, audio focus management, and web search
.../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 }