ai-chat-android-auto / commit
Add Android Auto media service — voice assistant MVP
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>