diff --git a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml
index e824a3ac..fa901b8c 100644
--- a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml
+++ b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml
@@ -22,6 +22,13 @@
android:theme="@style/Theme.readest"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="${usesCleartextTraffic}">
+
+
+
+
+
+
+
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/MediaPlaybackService.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/MediaPlaybackService.kt
index 2e19d561..f5516b2b 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/MediaPlaybackService.kt
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/MediaPlaybackService.kt
@@ -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,64 +280,112 @@ class MediaPlaybackService : MediaBrowserServiceCompat() {
}
override fun onLoadChildren(parentId: String, result: Result>) {
- result.sendResult(null)
+ val items = mutableListOf()
+ 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")
+ 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)
+ } 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()
mediaSession?.release()
}
-}
\ No newline at end of file
+}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt
index 30bd8667..08935d20 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt
@@ -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()
diff --git a/apps/readest-app/src/__tests__/android/android-auto-declarations.test.ts b/apps/readest-app/src/__tests__/android/android-auto-declarations.test.ts
new file mode 100644
index 00000000..d6124d1f
--- /dev/null
+++ b/apps/readest-app/src/__tests__/android/android-auto-declarations.test.ts
@@ -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('');
+ expect(desc).toMatch(//);
+ });
+
+ it('exports the MediaBrowserService Android Auto binds to', () => {
+ const serviceBlock = manifest
+ .split(' block.includes('com.readest.native_tts.MediaPlaybackService'));
+ expect(serviceBlock).toBeDefined();
+ expect(serviceBlock).toContain('android.media.browse.MediaBrowserService');
+ expect(serviceBlock).toContain('android:exported="true"');
+ });
+});