fuzzyworld

ai-chat-android-auto / files / app / src / main / java / net / fuzzyworld / aichat / AiChatMediaService.kt

AiChatMediaService.kt

Blame: the commit that last changed each line · file · history

d522fe6d 2026-05-15 Pavel Safronov1package net.fuzzyworld.aichat
26141d45 2026-05-14 Pavel Safronov2
3import android.content.Intent
cc8b2bf6 2026-05-14 Pavel Safronov4import android.media.AudioAttributes
5import android.media.AudioFocusRequest
6import android.media.AudioManager
7import android.os.Build
26141d45 2026-05-14 Pavel Safronov8import android.os.Bundle
9import android.os.Handler
10import android.os.Looper
11import android.speech.RecognitionListener
12import android.speech.RecognizerIntent
13import android.speech.SpeechRecognizer
14import android.speech.tts.TextToSpeech
15import android.speech.tts.UtteranceProgressListener
16import android.support.v4.media.MediaBrowserCompat.MediaItem
17import android.support.v4.media.MediaDescriptionCompat
18import android.support.v4.media.MediaMetadataCompat
19import android.support.v4.media.session.MediaSessionCompat
20import android.support.v4.media.session.PlaybackStateCompat
21import androidx.media.MediaBrowserServiceCompat
22import java.util.Locale
23
d522fe6d 2026-05-15 Pavel Safronov24class AiChatMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListener {
26141d45 2026-05-14 Pavel Safronov25
cc8b2bf6 2026-05-14 Pavel Safronov26 data class HistoryEntry(val question: String, val answer: String)
27
26141d45 2026-05-14 Pavel Safronov28 private enum class State { IDLE, LISTENING, PROCESSING, SPEAKING }
29
30 private var state = State.IDLE
cc8b2bf6 2026-05-14 Pavel Safronov31 private val history = mutableListOf<HistoryEntry>()
26141d45 2026-05-14 Pavel Safronov32
33 private lateinit var mediaSession: MediaSessionCompat
34 private lateinit var tts: TextToSpeech
cc8b2bf6 2026-05-14 Pavel Safronov35 private lateinit var audioManager: AudioManager
26141d45 2026-05-14 Pavel Safronov36 private var ttsReady = false
37 private var recognizer: SpeechRecognizer? = null
cc8b2bf6 2026-05-14 Pavel Safronov38 private var focusRequest: AudioFocusRequest? = null
26141d45 2026-05-14 Pavel Safronov39
40 private val mainHandler = Handler(Looper.getMainLooper())
41
42 companion object {
cc8b2bf6 2026-05-14 Pavel Safronov43 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 Safronov47 }
48
49 // ── Lifecycle ──────────────────────────────────────────────────────────────
50
51 override fun onCreate() {
52 super.onCreate()
cc8b2bf6 2026-05-14 Pavel Safronov53 audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
26141d45 2026-05-14 Pavel Safronov54 tts = TextToSpeech(this, this)
d522fe6d 2026-05-15 Pavel Safronov55 mediaSession = MediaSessionCompat(this, "AiChat").apply {
26141d45 2026-05-14 Pavel Safronov56 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 Safronov69 releaseAudioFocus()
26141d45 2026-05-14 Pavel Safronov70 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 Safronov78 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 Safronov82 tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
83 override fun onStart(id: String?) {}
cc8b2bf6 2026-05-14 Pavel Safronov84 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 Safronov90 })
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 Safronov100
101 val items = mutableListOf<MediaItem>()
102
103 items += MediaItem(
104 MediaDescriptionCompat.Builder()
105 .setMediaId(ASK_ID)
449ce939 2026-05-14 Pavel Safronov106 .setTitle("Ask AI")
cc8b2bf6 2026-05-14 Pavel Safronov107 .setSubtitle("Tap ▶ to speak")
108 .build(),
109 MediaItem.FLAG_PLAYABLE
110 )
111
112 if (history.isNotEmpty()) {
113 items += MediaItem(
26141d45 2026-05-14 Pavel Safronov114 MediaDescriptionCompat.Builder()
cc8b2bf6 2026-05-14 Pavel Safronov115 .setMediaId(CLEAR_ID)
116 .setTitle("Clear History")
117 .setSubtitle("${history.size} conversation${if (history.size == 1) "" else "s"}")
26141d45 2026-05-14 Pavel Safronov118 .build(),
119 MediaItem.FLAG_PLAYABLE
120 )
cc8b2bf6 2026-05-14 Pavel Safronov121
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 Safronov136 }
137
138 // ── Media session callbacks ────────────────────────────────────────────────
139
140 private val sessionCallback = object : MediaSessionCompat.Callback() {
141 override fun onPlay() = startListening()
cc8b2bf6 2026-05-14 Pavel Safronov142
26141d45 2026-05-14 Pavel Safronov143 override fun onPlayFromMediaId(id: String?, extras: Bundle?) {
cc8b2bf6 2026-05-14 Pavel Safronov144 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 Safronov149 if (idx != null && idx in history.indices) sendToProvider(history[idx].question)
cc8b2bf6 2026-05-14 Pavel Safronov150 }
151 }
26141d45 2026-05-14 Pavel Safronov152 }
cc8b2bf6 2026-05-14 Pavel Safronov153
26141d45 2026-05-14 Pavel Safronov154 override fun onStop() {
155 tts.stop()
156 recognizer?.cancel()
cc8b2bf6 2026-05-14 Pavel Safronov157 releaseAudioFocus()
26141d45 2026-05-14 Pavel Safronov158 pushState(State.IDLE)
159 }
160 }
161
cc8b2bf6 2026-05-14 Pavel Safronov162 // ── 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 Safronov203 // ── Voice input ────────────────────────────────────────────────────────────
204
205 private fun startListening() {
449ce939 2026-05-14 Pavel Safronov206 if (!Prefs.isReady(this)) {
207 speak("Please finish setting up the app on your phone before using it.")
26141d45 2026-05-14 Pavel Safronov208 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 Safronov215 requestAudioFocus()
26141d45 2026-05-14 Pavel Safronov216 pushState(State.LISTENING)
217 mainHandler.post {
218 recognizer?.destroy()
d522fe6d 2026-05-15 Pavel Safronov219 recognizer = SpeechRecognizer.createSpeechRecognizer(this@AiChatMediaService).apply {
26141d45 2026-05-14 Pavel Safronov220 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 Safronov232 if (text != null) sendToProvider(text)
cc8b2bf6 2026-05-14 Pavel Safronov233 else mainHandler.post { releaseAudioFocus(); speak("Couldn't understand — please try again.") }
26141d45 2026-05-14 Pavel Safronov234 }
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 Safronov242 "Microphone permission required. Open AI Chat for Android Auto on your phone."
26141d45 2026-05-14 Pavel Safronov243 else -> "Couldn't hear you — please try again."
244 }
cc8b2bf6 2026-05-14 Pavel Safronov245 mainHandler.post { releaseAudioFocus(); speak(msg) }
26141d45 2026-05-14 Pavel Safronov246 }
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 Safronov258 private fun sendToProvider(question: String) {
26141d45 2026-05-14 Pavel Safronov259 pushState(State.PROCESSING, question)
449ce939 2026-05-14 Pavel Safronov260 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 Safronov268 mainHandler.post {
449ce939 2026-05-14 Pavel Safronov269 val answer = response ?: "Couldn't reach the AI. Check your internet connection."
cc8b2bf6 2026-05-14 Pavel Safronov270 history.add(HistoryEntry(question, answer))
271 notifyChildrenChanged(ROOT_ID)
272 speak(answer)
26141d45 2026-05-14 Pavel Safronov273 }
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 Safronov289 State.IDLE -> "Ask AI" to
cc8b2bf6 2026-05-14 Pavel Safronov290 (history.lastOrNull()?.answer ?: "Tap ▶ to speak")
26141d45 2026-05-14 Pavel Safronov291 State.LISTENING -> "Listening…" to "Speak your question"
292 State.PROCESSING -> "Thinking…" to (if (text.isNotEmpty()) "\"$text\"" else "")
449ce939 2026-05-14 Pavel Safronov293 State.SPEAKING -> "AI" to text
26141d45 2026-05-14 Pavel Safronov294 }
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 Safronov305 State.SPEAKING -> PlaybackStateCompat.ACTION_STOP
26141d45 2026-05-14 Pavel Safronov306 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 Safronov313 .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "AI Chat for Android Auto")
26141d45 2026-05-14 Pavel Safronov314 .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}