ai-chat-android-auto / files / app / src / main / java / net / fuzzyworld / aichat / AiChatMediaService.kt
AiChatMediaService.kt
d522fe6d 2026-05-15 Pavel Safronov | 1 | package net.fuzzyworld.aichat |
26141d45 2026-05-14 Pavel Safronov | 2 | |
| 3 | import android.content.Intent | |
cc8b2bf6 2026-05-14 Pavel Safronov | 4 | import android.media.AudioAttributes |
| 5 | import android.media.AudioFocusRequest | |
| 6 | import android.media.AudioManager | |
| 7 | import android.os.Build | |
26141d45 2026-05-14 Pavel Safronov | 8 | import android.os.Bundle |
| 9 | import android.os.Handler | |
| 10 | import android.os.Looper | |
| 11 | import android.speech.RecognitionListener | |
| 12 | import android.speech.RecognizerIntent | |
| 13 | import android.speech.SpeechRecognizer | |
| 14 | import android.speech.tts.TextToSpeech | |
| 15 | import android.speech.tts.UtteranceProgressListener | |
| 16 | import android.support.v4.media.MediaBrowserCompat.MediaItem | |
| 17 | import android.support.v4.media.MediaDescriptionCompat | |
| 18 | import android.support.v4.media.MediaMetadataCompat | |
| 19 | import android.support.v4.media.session.MediaSessionCompat | |
| 20 | import android.support.v4.media.session.PlaybackStateCompat | |
| 21 | import androidx.media.MediaBrowserServiceCompat | |
| 22 | import java.util.Locale | |
| 23 | ||
d522fe6d 2026-05-15 Pavel Safronov | 24 | class AiChatMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListener { |
26141d45 2026-05-14 Pavel Safronov | 25 | |
cc8b2bf6 2026-05-14 Pavel Safronov | 26 | data class HistoryEntry(val question: String, val answer: String) |
| 27 | ||
26141d45 2026-05-14 Pavel Safronov | 28 | private enum class State { IDLE, LISTENING, PROCESSING, SPEAKING } |
| 29 | ||
| 30 | private var state = State.IDLE | |
cc8b2bf6 2026-05-14 Pavel Safronov | 31 | private val history = mutableListOf<HistoryEntry>() |
26141d45 2026-05-14 Pavel Safronov | 32 | |
| 33 | private lateinit var mediaSession: MediaSessionCompat | |
| 34 | private lateinit var tts: TextToSpeech | |
cc8b2bf6 2026-05-14 Pavel Safronov | 35 | private lateinit var audioManager: AudioManager |
26141d45 2026-05-14 Pavel Safronov | 36 | private var ttsReady = false |
| 37 | private var recognizer: SpeechRecognizer? = null | |
cc8b2bf6 2026-05-14 Pavel Safronov | 38 | private var focusRequest: AudioFocusRequest? = null |
26141d45 2026-05-14 Pavel Safronov | 39 | |
| 40 | private val mainHandler = Handler(Looper.getMainLooper()) | |
| 41 | ||
| 42 | companion object { | |
cc8b2bf6 2026-05-14 Pavel Safronov | 43 | private const val ROOT_ID = "__ROOT__" |
| 44 | private const val ASK_ID = "__ASK__" | |
| 45 | private const val CLEAR_ID = "__CLEAR__" | |
| 46 | private const val HISTORY_PREFIX = "__H__" | |
26141d45 2026-05-14 Pavel Safronov | 47 | } |
| 48 | ||
| 49 | // ── Lifecycle ────────────────────────────────────────────────────────────── | |
| 50 | ||
| 51 | override fun onCreate() { | |
| 52 | super.onCreate() | |
cc8b2bf6 2026-05-14 Pavel Safronov | 53 | audioManager = getSystemService(AUDIO_SERVICE) as AudioManager |
26141d45 2026-05-14 Pavel Safronov | 54 | tts = TextToSpeech(this, this) |
d522fe6d 2026-05-15 Pavel Safronov | 55 | mediaSession = MediaSessionCompat(this, "AiChat").apply { |
26141d45 2026-05-14 Pavel Safronov | 56 | setCallback(sessionCallback) |
| 57 | setFlags( | |
| 58 | MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or | |
| 59 | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS | |
| 60 | ) | |
| 61 | isActive = true | |
| 62 | } | |
| 63 | sessionToken = mediaSession.sessionToken | |
| 64 | pushState(State.IDLE) | |
| 65 | } | |
| 66 | ||
| 67 | override fun onDestroy() { | |
| 68 | super.onDestroy() | |
cc8b2bf6 2026-05-14 Pavel Safronov | 69 | releaseAudioFocus() |
26141d45 2026-05-14 Pavel Safronov | 70 | mediaSession.release() |
| 71 | tts.shutdown() | |
| 72 | recognizer?.destroy() | |
| 73 | } | |
| 74 | ||
| 75 | override fun onInit(status: Int) { | |
| 76 | ttsReady = status == TextToSpeech.SUCCESS | |
| 77 | if (!ttsReady) return | |
9090d7c8 2026-05-15 Pavel Safronov | 78 | val savedVoiceId = Prefs.voiceId(this) |
| 79 | val savedVoice = if (savedVoiceId.isNotBlank()) tts.voices?.find { it.name == savedVoiceId } else null | |
| 80 | if (savedVoice != null) tts.voice = savedVoice else tts.language = Locale.getDefault() | |
| 81 | tts.setSpeechRate(Prefs.speechRate(this)) | |
26141d45 2026-05-14 Pavel Safronov | 82 | tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() { |
| 83 | override fun onStart(id: String?) {} | |
cc8b2bf6 2026-05-14 Pavel Safronov | 84 | override fun onDone(id: String?) { |
| 85 | mainHandler.post { releaseAudioFocus(); pushState(State.IDLE) } | |
| 86 | } | |
| 87 | @Deprecated("") override fun onError(id: String?) { | |
| 88 | mainHandler.post { releaseAudioFocus(); pushState(State.IDLE) } | |
| 89 | } | |
26141d45 2026-05-14 Pavel Safronov | 90 | }) |
| 91 | } | |
| 92 | ||
| 93 | // ── MediaBrowserServiceCompat ────────────────────────────────────────────── | |
| 94 | ||
| 95 | override fun onGetRoot(pkg: String, uid: Int, hints: Bundle?): BrowserRoot = | |
| 96 | BrowserRoot(ROOT_ID, null) | |
| 97 | ||
| 98 | override fun onLoadChildren(parentId: String, result: Result<List<MediaItem>>) { | |
| 99 | if (parentId != ROOT_ID) { result.sendResult(emptyList()); return } | |
cc8b2bf6 2026-05-14 Pavel Safronov | 100 | |
| 101 | val items = mutableListOf<MediaItem>() | |
| 102 | ||
| 103 | items += MediaItem( | |
| 104 | MediaDescriptionCompat.Builder() | |
| 105 | .setMediaId(ASK_ID) | |
449ce939 2026-05-14 Pavel Safronov | 106 | .setTitle("Ask AI") |
cc8b2bf6 2026-05-14 Pavel Safronov | 107 | .setSubtitle("Tap ▶ to speak") |
| 108 | .build(), | |
| 109 | MediaItem.FLAG_PLAYABLE | |
| 110 | ) | |
| 111 | ||
| 112 | if (history.isNotEmpty()) { | |
| 113 | items += MediaItem( | |
26141d45 2026-05-14 Pavel Safronov | 114 | MediaDescriptionCompat.Builder() |
cc8b2bf6 2026-05-14 Pavel Safronov | 115 | .setMediaId(CLEAR_ID) |
| 116 | .setTitle("Clear History") | |
| 117 | .setSubtitle("${history.size} conversation${if (history.size == 1) "" else "s"}") | |
26141d45 2026-05-14 Pavel Safronov | 118 | .build(), |
| 119 | MediaItem.FLAG_PLAYABLE | |
| 120 | ) | |
cc8b2bf6 2026-05-14 Pavel Safronov | 121 | |
| 122 | history.indices.reversed().forEach { idx -> | |
| 123 | val entry = history[idx] | |
| 124 | items += MediaItem( | |
| 125 | MediaDescriptionCompat.Builder() | |
| 126 | .setMediaId("$HISTORY_PREFIX$idx") | |
| 127 | .setTitle(entry.question) | |
| 128 | .setSubtitle(entry.answer) | |
| 129 | .build(), | |
| 130 | MediaItem.FLAG_PLAYABLE | |
| 131 | ) | |
| 132 | } | |
| 133 | } | |
| 134 | ||
| 135 | result.sendResult(items) | |
26141d45 2026-05-14 Pavel Safronov | 136 | } |
| 137 | ||
| 138 | // ── Media session callbacks ──────────────────────────────────────────────── | |
| 139 | ||
| 140 | private val sessionCallback = object : MediaSessionCompat.Callback() { | |
| 141 | override fun onPlay() = startListening() | |
cc8b2bf6 2026-05-14 Pavel Safronov | 142 | |
26141d45 2026-05-14 Pavel Safronov | 143 | override fun onPlayFromMediaId(id: String?, extras: Bundle?) { |
cc8b2bf6 2026-05-14 Pavel Safronov | 144 | when { |
| 145 | id == ASK_ID -> startListening() | |
| 146 | id == CLEAR_ID -> clearHistory() | |
| 147 | id?.startsWith(HISTORY_PREFIX) == true -> { | |
| 148 | val idx = id.removePrefix(HISTORY_PREFIX).toIntOrNull() | |
449ce939 2026-05-14 Pavel Safronov | 149 | if (idx != null && idx in history.indices) sendToProvider(history[idx].question) |
cc8b2bf6 2026-05-14 Pavel Safronov | 150 | } |
| 151 | } | |
26141d45 2026-05-14 Pavel Safronov | 152 | } |
cc8b2bf6 2026-05-14 Pavel Safronov | 153 | |
26141d45 2026-05-14 Pavel Safronov | 154 | override fun onStop() { |
| 155 | tts.stop() | |
| 156 | recognizer?.cancel() | |
cc8b2bf6 2026-05-14 Pavel Safronov | 157 | releaseAudioFocus() |
26141d45 2026-05-14 Pavel Safronov | 158 | pushState(State.IDLE) |
| 159 | } | |
| 160 | } | |
| 161 | ||
cc8b2bf6 2026-05-14 Pavel Safronov | 162 | // ── History ──────────────────────────────────────────────────────────────── |
| 163 | ||
| 164 | private fun clearHistory() { | |
| 165 | history.clear() | |
| 166 | notifyChildrenChanged(ROOT_ID) | |
| 167 | pushState(State.IDLE) | |
| 168 | } | |
| 169 | ||
| 170 | // ── Audio focus ──────────────────────────────────────────────────────────── | |
| 171 | ||
| 172 | private fun requestAudioFocus() { | |
| 173 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | |
| 174 | val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) | |
| 175 | .setAudioAttributes( | |
| 176 | AudioAttributes.Builder() | |
| 177 | .setUsage(AudioAttributes.USAGE_MEDIA) | |
| 178 | .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) | |
| 179 | .build() | |
| 180 | ) | |
| 181 | .setWillPauseWhenDucked(true) | |
| 182 | .build() | |
| 183 | focusRequest = req | |
| 184 | audioManager.requestAudioFocus(req) | |
| 185 | } else { | |
| 186 | @Suppress("DEPRECATION") | |
| 187 | audioManager.requestAudioFocus( | |
| 188 | null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT | |
| 189 | ) | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | private fun releaseAudioFocus() { | |
| 194 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | |
| 195 | focusRequest?.let { audioManager.abandonAudioFocusRequest(it) } | |
| 196 | focusRequest = null | |
| 197 | } else { | |
| 198 | @Suppress("DEPRECATION") | |
| 199 | audioManager.abandonAudioFocus(null) | |
| 200 | } | |
| 201 | } | |
| 202 | ||
26141d45 2026-05-14 Pavel Safronov | 203 | // ── Voice input ──────────────────────────────────────────────────────────── |
| 204 | ||
| 205 | private fun startListening() { | |
449ce939 2026-05-14 Pavel Safronov | 206 | if (!Prefs.isReady(this)) { |
| 207 | speak("Please finish setting up the app on your phone before using it.") | |
26141d45 2026-05-14 Pavel Safronov | 208 | return |
| 209 | } | |
| 210 | if (!SpeechRecognizer.isRecognitionAvailable(this)) { | |
| 211 | speak("Speech recognition is not available on this device.") | |
| 212 | return | |
| 213 | } | |
| 214 | tts.stop() | |
cc8b2bf6 2026-05-14 Pavel Safronov | 215 | requestAudioFocus() |
26141d45 2026-05-14 Pavel Safronov | 216 | pushState(State.LISTENING) |
| 217 | mainHandler.post { | |
| 218 | recognizer?.destroy() | |
d522fe6d 2026-05-15 Pavel Safronov | 219 | recognizer = SpeechRecognizer.createSpeechRecognizer(this@AiChatMediaService).apply { |
26141d45 2026-05-14 Pavel Safronov | 220 | setRecognitionListener(recognitionListener) |
| 221 | startListening(Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { | |
| 222 | putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) | |
| 223 | putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) | |
| 224 | }) | |
| 225 | } | |
| 226 | } | |
| 227 | } | |
| 228 | ||
| 229 | private val recognitionListener = object : RecognitionListener { | |
| 230 | override fun onResults(r: Bundle?) { | |
| 231 | val text = r?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull() | |
449ce939 2026-05-14 Pavel Safronov | 232 | if (text != null) sendToProvider(text) |
cc8b2bf6 2026-05-14 Pavel Safronov | 233 | else mainHandler.post { releaseAudioFocus(); speak("Couldn't understand — please try again.") } |
26141d45 2026-05-14 Pavel Safronov | 234 | } |
| 235 | override fun onError(error: Int) { | |
| 236 | val msg = when (error) { | |
| 237 | SpeechRecognizer.ERROR_NO_MATCH, | |
| 238 | SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "No speech detected — please try again." | |
| 239 | SpeechRecognizer.ERROR_NETWORK, | |
| 240 | SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network error. Check your connection." | |
| 241 | SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> | |
d522fe6d 2026-05-15 Pavel Safronov | 242 | "Microphone permission required. Open AI Chat for Android Auto on your phone." |
26141d45 2026-05-14 Pavel Safronov | 243 | else -> "Couldn't hear you — please try again." |
| 244 | } | |
cc8b2bf6 2026-05-14 Pavel Safronov | 245 | mainHandler.post { releaseAudioFocus(); speak(msg) } |
26141d45 2026-05-14 Pavel Safronov | 246 | } |
| 247 | override fun onEndOfSpeech() { mainHandler.post { pushState(State.PROCESSING) } } | |
| 248 | override fun onReadyForSpeech(p: Bundle?) {} | |
| 249 | override fun onBeginningOfSpeech() {} | |
| 250 | override fun onRmsChanged(rms: Float) {} | |
| 251 | override fun onBufferReceived(b: ByteArray?) {} | |
| 252 | override fun onPartialResults(p: Bundle?) {} | |
| 253 | override fun onEvent(t: Int, p: Bundle?) {} | |
| 254 | } | |
| 255 | ||
| 256 | // ── API + TTS ────────────────────────────────────────────────────────────── | |
| 257 | ||
449ce939 2026-05-14 Pavel Safronov | 258 | private fun sendToProvider(question: String) { |
26141d45 2026-05-14 Pavel Safronov | 259 | pushState(State.PROCESSING, question) |
449ce939 2026-05-14 Pavel Safronov | 260 | val providerId = Prefs.selectedProviderId(this) |
| 261 | val provider = ProviderRegistry.byId(providerId) | |
| 262 | provider.ask( | |
| 263 | apiKey = Prefs.apiKey(this, providerId), | |
| 264 | baseUrl = Prefs.baseUrl(this, providerId), | |
| 265 | model = Prefs.model(this, providerId), | |
| 266 | question = question, | |
| 267 | ) { response -> | |
26141d45 2026-05-14 Pavel Safronov | 268 | mainHandler.post { |
449ce939 2026-05-14 Pavel Safronov | 269 | val answer = response ?: "Couldn't reach the AI. Check your internet connection." |
cc8b2bf6 2026-05-14 Pavel Safronov | 270 | history.add(HistoryEntry(question, answer)) |
| 271 | notifyChildrenChanged(ROOT_ID) | |
| 272 | speak(answer) | |
26141d45 2026-05-14 Pavel Safronov | 273 | } |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | private fun speak(text: String) { | |
| 278 | if (!ttsReady) return | |
| 279 | pushState(State.SPEAKING, text) | |
| 280 | tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "r") | |
| 281 | } | |
| 282 | ||
| 283 | // ── MediaSession state ───────────────────────────────────────────────────── | |
| 284 | ||
| 285 | private fun pushState(newState: State, text: String = "") { | |
| 286 | state = newState | |
| 287 | ||
| 288 | val (title, subtitle) = when (newState) { | |
449ce939 2026-05-14 Pavel Safronov | 289 | State.IDLE -> "Ask AI" to |
cc8b2bf6 2026-05-14 Pavel Safronov | 290 | (history.lastOrNull()?.answer ?: "Tap ▶ to speak") |
26141d45 2026-05-14 Pavel Safronov | 291 | State.LISTENING -> "Listening…" to "Speak your question" |
| 292 | State.PROCESSING -> "Thinking…" to (if (text.isNotEmpty()) "\"$text\"" else "") | |
449ce939 2026-05-14 Pavel Safronov | 293 | State.SPEAKING -> "AI" to text |
26141d45 2026-05-14 Pavel Safronov | 294 | } |
| 295 | ||
| 296 | val pbState = when (newState) { | |
| 297 | State.IDLE -> PlaybackStateCompat.STATE_PAUSED | |
| 298 | State.LISTENING, State.PROCESSING -> PlaybackStateCompat.STATE_BUFFERING | |
| 299 | State.SPEAKING -> PlaybackStateCompat.STATE_PLAYING | |
| 300 | } | |
| 301 | ||
| 302 | val actions = when (newState) { | |
| 303 | State.IDLE -> PlaybackStateCompat.ACTION_PLAY or | |
| 304 | PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID | |
cc8b2bf6 2026-05-14 Pavel Safronov | 305 | State.SPEAKING -> PlaybackStateCompat.ACTION_STOP |
26141d45 2026-05-14 Pavel Safronov | 306 | else -> 0L |
| 307 | } | |
| 308 | ||
| 309 | mediaSession.setMetadata( | |
| 310 | MediaMetadataCompat.Builder() | |
| 311 | .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) | |
| 312 | .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle) | |
d522fe6d 2026-05-15 Pavel Safronov | 313 | .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "AI Chat for Android Auto") |
26141d45 2026-05-14 Pavel Safronov | 314 | .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, subtitle) |
| 315 | .build() | |
| 316 | ) | |
| 317 | ||
| 318 | mediaSession.setPlaybackState( | |
| 319 | PlaybackStateCompat.Builder() | |
| 320 | .setState(pbState, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f) | |
| 321 | .setActions(actions) | |
| 322 | .build() | |
| 323 | ) | |
| 324 | } | |
| 325 | } |