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"'); + }); +});