ai-chat-android-auto / files / app / src / main / java / net / fuzzyworld / aichat / AiChatMediaService.kt
AiChatMediaService.kt
| 1 | package net.fuzzyworld.aichat |
| 2 | |
| 3 | import android.content.Intent |
| 4 | import android.media.AudioAttributes |
| 5 | import android.media.AudioFocusRequest |
| 6 | import android.media.AudioManager |
| 7 | import android.os.Build |
| 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 | |
| 24 | class AiChatMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListener { |
| 25 | |
| 26 | data class HistoryEntry(val question: String, val answer: String) |
| 27 | |
| 28 | private enum class State { IDLE, LISTENING, PROCESSING, SPEAKING } |
| 29 | |
| 30 | private var state = State.IDLE |
| 31 | private val history = mutableListOf<HistoryEntry>() |
| 32 | |
| 33 | private lateinit var mediaSession: MediaSessionCompat |
| 34 | private lateinit var tts: TextToSpeech |
| 35 | private lateinit var audioManager: AudioManager |
| 36 | private var ttsReady = false |
| 37 | private var recognizer: SpeechRecognizer? = null |
| 38 | private var focusRequest: AudioFocusRequest? = null |
| 39 | |
| 40 | private val mainHandler = Handler(Looper.getMainLooper()) |
| 41 | |
| 42 | companion object { |
| 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__" |
| 47 | } |
| 48 | |
| 49 | // ── Lifecycle ────────────────────────────────────────────────────────────── |
| 50 | |
| 51 | override fun onCreate() { |
| 52 | super.onCreate() |
| 53 | audioManager = getSystemService(AUDIO_SERVICE) as AudioManager |
| 54 | tts = TextToSpeech(this, this) |
| 55 | mediaSession = MediaSessionCompat(this, "AiChat").apply { |
| 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() |
| 69 | releaseAudioFocus() |
| 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 |
| 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)) |
| 82 | tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() { |
| 83 | override fun onStart(id: String?) {} |
| 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 | } |
| 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 } |
| 100 | |
| 101 | val items = mutableListOf<MediaItem>() |
| 102 | |
| 103 | items += MediaItem( |
| 104 | MediaDescriptionCompat.Builder() |
| 105 | .setMediaId(ASK_ID) |
| 106 | .setTitle("Ask AI") |
| 107 | .setSubtitle("Tap ▶ to speak") |
| 108 | .build(), |
| 109 | MediaItem.FLAG_PLAYABLE |
| 110 | ) |
| 111 | |
| 112 | if (history.isNotEmpty()) { |
| 113 | items += MediaItem( |
| 114 | MediaDescriptionCompat.Builder() |
| 115 | .setMediaId(CLEAR_ID) |
| 116 | .setTitle("Clear History") |
| 117 | .setSubtitle("${history.size} conversation${if (history.size == 1) "" else "s"}") |
| 118 | .build(), |
| 119 | MediaItem.FLAG_PLAYABLE |
| 120 | ) |
| 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) |
| 136 | } |
| 137 | |
| 138 | // ── Media session callbacks ──────────────────────────────────────────────── |
| 139 | |
| 140 | private val sessionCallback = object : MediaSessionCompat.Callback() { |
| 141 | override fun onPlay() = startListening() |
| 142 | |
| 143 | override fun onPlayFromMediaId(id: String?, extras: Bundle?) { |
| 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() |
| 149 | if (idx != null && idx in history.indices) sendToProvider(history[idx].question) |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | override fun onStop() { |
| 155 | tts.stop() |
| 156 | recognizer?.cancel() |
| 157 | releaseAudioFocus() |
| 158 | pushState(State.IDLE) |
| 159 | } |
| 160 | } |
| 161 | |
| 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 | |
| 203 | // ── Voice input ──────────────────────────────────────────────────────────── |
| 204 | |
| 205 | private fun startListening() { |
| 206 | if (!Prefs.isReady(this)) { |
| 207 | speak("Please finish setting up the app on your phone before using it.") |
| 208 | return |
| 209 | } |
| 210 | if (!SpeechRecognizer.isRecognitionAvailable(this)) { |
| 211 | speak("Speech recognition is not available on this device.") |
| 212 | return |
| 213 | } |
| 214 | tts.stop() |
| 215 | requestAudioFocus() |
| 216 | pushState(State.LISTENING) |
| 217 | mainHandler.post { |
| 218 | recognizer?.destroy() |
| 219 | recognizer = SpeechRecognizer.createSpeechRecognizer(this@AiChatMediaService).apply { |
| 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() |
| 232 | if (text != null) sendToProvider(text) |
| 233 | else mainHandler.post { releaseAudioFocus(); speak("Couldn't understand — please try again.") } |
| 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 -> |
| 242 | "Microphone permission required. Open AI Chat for Android Auto on your phone." |
| 243 | else -> "Couldn't hear you — please try again." |
| 244 | } |
| 245 | mainHandler.post { releaseAudioFocus(); speak(msg) } |
| 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 | |
| 258 | private fun sendToProvider(question: String) { |
| 259 | pushState(State.PROCESSING, question) |
| 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 -> |
| 268 | mainHandler.post { |
| 269 | val answer = response ?: "Couldn't reach the AI. Check your internet connection." |
| 270 | history.add(HistoryEntry(question, answer)) |
| 271 | notifyChildrenChanged(ROOT_ID) |
| 272 | speak(answer) |
| 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) { |
| 289 | State.IDLE -> "Ask AI" to |
| 290 | (history.lastOrNull()?.answer ?: "Tap ▶ to speak") |
| 291 | State.LISTENING -> "Listening…" to "Speak your question" |
| 292 | State.PROCESSING -> "Thinking…" to (if (text.isNotEmpty()) "\"$text\"" else "") |
| 293 | State.SPEAKING -> "AI" to text |
| 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 |
| 305 | State.SPEAKING -> PlaybackStateCompat.ACTION_STOP |
| 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) |
| 313 | .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "AI Chat for Android Auto") |
| 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 | } |