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:
@@ -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>
|
||||
+145
-31
@@ -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,18 +64,66 @@ 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
|
||||
player = ExoPlayer.Builder(this).build()
|
||||
|
||||
mediaSession = MediaSessionCompat(baseContext, "ReadestMediaSession").apply {
|
||||
stateBuilder = PlaybackStateCompat.Builder().setActions(
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_STOP or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||
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)
|
||||
}
|
||||
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
updatePlaybackState()
|
||||
}
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
updatePlaybackState()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun activateSession() {
|
||||
if (!sessionActive) {
|
||||
sessionActive = true
|
||||
|
||||
val result = audioManager.requestAudioFocus(
|
||||
afChangeListener,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
@@ -78,41 +135,41 @@ class MediaPlaybackService : MediaBrowserServiceCompat() {
|
||||
Log.w("MediaPlaybackService", "Failed to gain audio focus")
|
||||
}
|
||||
|
||||
player = ExoPlayer.Builder(this).build()
|
||||
|
||||
mediaSession = MediaSessionCompat(baseContext, "ReadestMediaSession").apply {
|
||||
stateBuilder = PlaybackStateCompat.Builder().setActions(
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_STOP or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||
)
|
||||
setPlaybackState(stateBuilder.build())
|
||||
setCallback(SessionCallback())
|
||||
setSessionToken(sessionToken)
|
||||
isActive = true
|
||||
}
|
||||
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
updatePlaybackState()
|
||||
}
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
updatePlaybackState()
|
||||
}
|
||||
})
|
||||
|
||||
// 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,13 +280,53 @@ 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 {
|
||||
when (intent?.action) {
|
||||
ACTION_ACTIVATE_SESSION -> {
|
||||
activateSession()
|
||||
}
|
||||
Intent.ACTION_MEDIA_BUTTON -> {
|
||||
if (sessionActive) {
|
||||
MediaButtonReceiver.handleIntent(mediaSession, intent)
|
||||
|
||||
if (intent?.action == "UPDATE_METADATA") {
|
||||
} 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
|
||||
@@ -244,9 +350,14 @@ class MediaPlaybackService : MediaBrowserServiceCompat() {
|
||||
.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)
|
||||
}
|
||||
} else if (intent?.action == "UPDATE_PLAYBACK_STATE") {
|
||||
}
|
||||
}
|
||||
"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
|
||||
@@ -264,11 +375,14 @@ class MediaPlaybackService : MediaBrowserServiceCompat() {
|
||||
)
|
||||
showNotification(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
instance = null
|
||||
serviceScope.cancel()
|
||||
super.onDestroy()
|
||||
player.release()
|
||||
|
||||
+10
-6
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user