feat(android): Android Auto media support for TTS playback (#3919) (#4907)

Readest now shows up in the Android Auto launcher as a media app and
projects the TTS media session so playback can be controlled from the
car display (play/pause, previous/next sentence).

- Declare the com.google.android.gms.car.application meta-data and the
  automotive_app_desc media capability that Android Auto requires to
  list the app
- Make MediaPlaybackService safe to bind for browsing: audio focus, the
  silent keep-alive player, and the foreground notification no longer
  start in onCreate but on an explicit ACTIVATE_SESSION command, so a
  car client connecting to browse does not steal audio focus or post a
  phantom playing notification
- Deactivate the session with an in-process call instead of
  stopService, which would neither run onDestroy nor clear the
  foreground state while a media browser keeps the service bound
- Serve the current book as a playable browse item (cover downscaled to
  stay under the binder transaction limit) and handle
  onPlayFromMediaId/onPlayFromSearch
- Honor the foreground service contract when MediaButtonReceiver
  cold-starts the service with no active session

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-03 10:58:53 +09:00
committed by GitHub
parent 4b2c5f93ab
commit 71cb3ace91
5 changed files with 241 additions and 70 deletions
@@ -22,6 +22,13 @@
android:theme="@style/Theme.readest"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<!-- Android Auto: advertise the media capability so the Readest icon
shows up in the car launcher and projects the TTS media session. -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
@@ -9,16 +9,19 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.KeyEvent
import android.graphics.Bitmap
import android.media.AudioManager
import android.media.AudioManager.OnAudioFocusChangeListener
import android.support.v4.media.MediaBrowserCompat
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.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.session.MediaButtonReceiver
@@ -35,6 +38,12 @@ class MediaPlaybackService : MediaBrowserServiceCompat() {
private lateinit var audioManager: AudioManager
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// True only between session activation (TTS playback started) and
// deactivation. Android Auto can bind this service at any time to browse,
// so every playback side effect (audio focus, the silent keep-alive
// player, the foreground notification) must be gated on this flag.
private var sessionActive = false
private val afChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
Log.i("MediaPlaybackService", "Audio focus changed: $focusChange, $player.isPlaying")
when (focusChange) {
@@ -55,29 +64,34 @@ class MediaPlaybackService : MediaBrowserServiceCompat() {
private const val CHANNEL_ID = "media2_playback_channel"
private const val NOTIFICATION_ID = 1002
private const val MEDIA_ROOT_ID = "media_root_id"
private const val CURRENT_READING_MEDIA_ID = "readest_current_reading"
const val ACTION_ACTIVATE_SESSION = "ACTIVATE_SESSION"
var pluginEventTrigger: ((String, JSObject) -> Unit)? = null
var currentTitle: String = "Read Aloud"
var currentArtist: String = "Reading your content"
var currentArtwork: Bitmap? = null
@Volatile
private var instance: MediaPlaybackService? = null
// Deactivate via an in-process call instead of stopService: while a
// media browser client (Android Auto) keeps the service bound,
// stopService neither runs onDestroy nor clears the foreground
// notification, so playback teardown has to happen on the live
// instance.
fun requestDeactivation() {
val service = instance ?: return
Handler(Looper.getMainLooper()).post { service.deactivateSession() }
}
}
override fun onCreate() {
super.onCreate()
instance = this
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val result = audioManager.requestAudioFocus(
afChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.d("MediaPlaybackService", "Audio focus granted")
} else {
Log.w("MediaPlaybackService", "Failed to gain audio focus")
}
player = ExoPlayer.Builder(this).build()
mediaSession = MediaSessionCompat(baseContext, "ReadestMediaSession").apply {
@@ -87,12 +101,13 @@ class MediaPlaybackService : MediaBrowserServiceCompat() {
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
)
setPlaybackState(stateBuilder.build())
setCallback(SessionCallback())
setSessionToken(sessionToken)
isActive = true
}
player.addListener(object : Player.Listener {
@@ -103,16 +118,58 @@ class MediaPlaybackService : MediaBrowserServiceCompat() {
updatePlaybackState()
}
})
}
val mediaItem = MediaItem.fromUri("asset:///silence.mp3")
player.setMediaItem(mediaItem)
player.repeatMode = Player.REPEAT_MODE_ONE
player.prepare()
player.playWhenReady = true
private fun activateSession() {
if (!sessionActive) {
sessionActive = true
val result = audioManager.requestAudioFocus(
afChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.d("MediaPlaybackService", "Audio focus granted")
} else {
Log.w("MediaPlaybackService", "Failed to gain audio focus")
}
// Silent keep-alive track: holds the audio route and drives the
// session's playing/paused state while the actual TTS audio comes
// from the WebView or the TextToSpeech engine.
val mediaItem = MediaItem.fromUri("asset:///silence.mp3")
player.setMediaItem(mediaItem)
player.repeatMode = Player.REPEAT_MODE_ONE
player.prepare()
player.playWhenReady = true
mediaSession?.isActive = true
notifyChildrenChanged(MEDIA_ROOT_ID)
}
// Always post the notification: activation arrives through
// startForegroundService, which requires startForeground promptly.
showNotification(PlaybackStateCompat.STATE_PLAYING)
}
private fun deactivateSession() {
if (!sessionActive) return
sessionActive = false
player.playWhenReady = false
player.stop()
audioManager.abandonAudioFocus(afChangeListener)
mediaSession?.isActive = false
mediaSession?.setPlaybackState(
stateBuilder.setState(PlaybackStateCompat.STATE_STOPPED, 0L, 1f).build()
)
notifyChildrenChanged(MEDIA_ROOT_ID)
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
private inner class SessionCallback : MediaSessionCompat.Callback() {
override fun onPlay() {
player.play()
@@ -135,9 +192,18 @@ class MediaPlaybackService : MediaBrowserServiceCompat() {
player.seekTo(0)
pluginEventTrigger?.invoke("media-session-previous", JSObject())
}
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
onPlay()
}
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
onPlay()
}
}
private fun updatePlaybackState() {
if (!sessionActive) return
val state = if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED
mediaSession?.setPlaybackState(
stateBuilder.setState(state, player.currentPosition, 1f).build()
@@ -214,61 +280,109 @@ class MediaPlaybackService : MediaBrowserServiceCompat() {
}
override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
result.sendResult(null)
val items = mutableListOf<MediaBrowserCompat.MediaItem>()
if (parentId == MEDIA_ROOT_ID && sessionActive) {
// Downscale the cover for the browse item: MediaItems are parceled
// across binder to the car client, which caps transactions at ~1MB.
val icon = currentArtwork?.let { art ->
val maxSide = maxOf(art.width, art.height)
if (maxSide > 512) {
val scale = 512f / maxSide
Bitmap.createScaledBitmap(
art,
(art.width * scale).toInt().coerceAtLeast(1),
(art.height * scale).toInt().coerceAtLeast(1),
true
)
} else {
art
}
}
val description = MediaDescriptionCompat.Builder()
.setMediaId(CURRENT_READING_MEDIA_ID)
.setTitle(currentTitle)
.setSubtitle(currentArtist)
.setIconBitmap(icon)
.build()
items.add(MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE))
}
result.sendResult(items)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
MediaButtonReceiver.handleIntent(mediaSession, intent)
if (intent?.action == "UPDATE_METADATA") {
currentTitle = intent.getStringExtra("title") ?: currentTitle
currentArtist = intent.getStringExtra("artist") ?: currentArtist
// Unmarshal the artwork Bitmap off the main thread; copying its
// pixel buffer out of the Parcel can stall the UI thread and trip
// an ANR for large covers.
serviceScope.launch {
val newArtwork = withContext(Dispatchers.Default) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("artwork", Bitmap::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra("artwork")
when (intent?.action) {
ACTION_ACTIVATE_SESSION -> {
activateSession()
}
Intent.ACTION_MEDIA_BUTTON -> {
if (sessionActive) {
MediaButtonReceiver.handleIntent(mediaSession, intent)
} else {
// MediaButtonReceiver cold-starts this service with
// startForegroundService; honor the foreground contract,
// then back out — there is no TTS session to control.
showNotification(PlaybackStateCompat.STATE_PAUSED)
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf(startId)
}
}
"UPDATE_METADATA" -> {
currentTitle = intent.getStringExtra("title") ?: currentTitle
currentArtist = intent.getStringExtra("artist") ?: currentArtist
// Unmarshal the artwork Bitmap off the main thread; copying its
// pixel buffer out of the Parcel can stall the UI thread and trip
// an ANR for large covers.
serviceScope.launch {
val newArtwork = withContext(Dispatchers.Default) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("artwork", Bitmap::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra("artwork")
}
}
if (newArtwork != null) {
currentArtwork = newArtwork
}
if (!isActive) return@launch
val metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentTitle)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentArtist)
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, currentArtwork)
mediaSession?.setMetadata(metadataBuilder.build())
notifyChildrenChanged(MEDIA_ROOT_ID)
if (sessionActive) {
showNotification(if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED)
}
}
if (newArtwork != null) {
currentArtwork = newArtwork
}
"UPDATE_PLAYBACK_STATE" -> {
if (sessionActive) {
val isPlaying = intent.getBooleanExtra("playing", false)
val position = intent.getLongExtra("position", 0L) // in milliseconds
val duration = intent.getLongExtra("duration", 0L) // in milliseconds
if (isPlaying && !player.isPlaying) {
player.play()
} else if (!isPlaying && player.isPlaying) {
player.pause()
}
player.seekTo(position)
val state = if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED
mediaSession?.setPlaybackState(
stateBuilder.setState(state, position, 1f).build()
)
showNotification(state)
}
if (!isActive) return@launch
val metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentTitle)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentArtist)
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, currentArtwork)
mediaSession?.setMetadata(metadataBuilder.build())
showNotification(if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED)
}
} else if (intent?.action == "UPDATE_PLAYBACK_STATE") {
val isPlaying = intent.getBooleanExtra("playing", false)
val position = intent.getLongExtra("position", 0L) // in milliseconds
val duration = intent.getLongExtra("duration", 0L) // in milliseconds
if (isPlaying && !player.isPlaying) {
player.play()
} else if (!isPlaying && player.isPlaying) {
player.pause()
}
player.seekTo(position)
val state = if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED
mediaSession?.setPlaybackState(
stateBuilder.setState(state, position, 1f).build()
)
showNotification(state)
}
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
instance = null
serviceScope.cancel()
super.onDestroy()
player.release()
@@ -549,15 +549,20 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
args.foregroundServiceText?.let { FOREGROUND_SERVICE_TEXT = it }
try {
val intent = Intent(activity, MediaPlaybackService::class.java)
if (active) {
cancelIdleTimer()
MediaPlaybackService.pluginEventTrigger = { event, data -> trigger(event, data) }
MediaPlaybackService.currentTitle = FOREGROUND_SERVICE_TITLE
MediaPlaybackService.currentArtist = FOREGROUND_SERVICE_TEXT
val intent = Intent(activity, MediaPlaybackService::class.java).apply {
action = MediaPlaybackService.ACTION_ACTIVATE_SESSION
}
ContextCompat.startForegroundService(activity, intent)
} else {
activity.stopService(intent)
// Not stopService: Android Auto may keep the service bound for
// browsing, in which case stopService would leave the foreground
// notification and the keep-alive player running.
MediaPlaybackService.requestDeactivation()
MediaPlaybackService.pluginEventTrigger = null
}
invoke.resolve()
@@ -577,8 +582,7 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
private fun shutdownTTSEngine() {
try {
val intent = Intent(activity, MediaPlaybackService::class.java)
activity.stopService(intent)
MediaPlaybackService.requestDeactivation()
MediaPlaybackService.pluginEventTrigger = null
textToSpeech?.shutdown()
@@ -602,8 +606,8 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
try {
cancelIdleTimer()
val intent = Intent(activity, MediaPlaybackService::class.java)
activity.stopService(intent)
MediaPlaybackService.requestDeactivation()
MediaPlaybackService.pluginEventTrigger = null
coroutineScope.cancel()
textToSpeech?.shutdown()
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
/**
* Android Auto support (#3919): for Readest to appear in the Android Auto
* launcher as a media app, the manifest must opt in to car projection via the
* `com.google.android.gms.car.application` meta-data pointing at an
* automotive descriptor that declares the `media` capability. Android Auto
* then connects to the exported MediaBrowserService
* (com.readest.native_tts.MediaPlaybackService) to drive TTS playback.
*/
const manifest = readFileSync(
resolve(process.cwd(), 'src-tauri/gen/android/app/src/main/AndroidManifest.xml'),
'utf-8',
);
describe('Android Auto declarations (#3919)', () => {
it('declares the car application meta-data pointing at the automotive descriptor', () => {
expect(manifest).toContain('com.google.android.gms.car.application');
expect(manifest).toContain('@xml/automotive_app_desc');
});
it('ships an automotive descriptor with the media capability', () => {
const desc = readFileSync(
resolve(process.cwd(), 'src-tauri/gen/android/app/src/main/res/xml/automotive_app_desc.xml'),
'utf-8',
);
expect(desc).toContain('<automotiveApp>');
expect(desc).toMatch(/<uses\s+name="media"\s*\/>/);
});
it('exports the MediaBrowserService Android Auto binds to', () => {
const serviceBlock = manifest
.split('<service')
.find((block) => block.includes('com.readest.native_tts.MediaPlaybackService'));
expect(serviceBlock).toBeDefined();
expect(serviceBlock).toContain('android.media.browse.MediaBrowserService');
expect(serviceBlock).toContain('android:exported="true"');
});
});