Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ configurations.configureEach {
exclude(module = "commons-logging")
}

val canonicalVersionCode = 454
val canonicalVersionName = "1.33.6"
val canonicalVersionCode = 455
val canonicalVersionName = "1.33.7"

val postFixSize = 10
val abiPostFix = mapOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.audio

import android.content.ComponentName
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.audio.model.AudioCommands
import java.util.concurrent.TimeUnit

/**
* A controller that connects but is granted no player commands fails silently: MediaController drops
* each call with a logcat warning and playback never starts. Connecting through the real service is
* the only way to see the commands the session actually grants.
*/
@RunWith(AndroidJUnit4::class)
class AudioMediaServiceConnectionTest {

private val instrumentation = InstrumentationRegistry.getInstrumentation()
private lateinit var controller: MediaController

@Before
fun connect() {
val context = instrumentation.targetContext
val token = SessionToken(context, ComponentName(context, AudioMediaService::class.java))

lateinit var future: com.google.common.util.concurrent.ListenableFuture<MediaController>
instrumentation.runOnMainSync {
future = MediaController.Builder(context, token).buildAsync()
}
controller = future.get(10, TimeUnit.SECONDS)
}

@After
fun release() {
instrumentation.runOnMainSync { controller.release() }
}

@Test
fun inAppControllerIsGrantedPlaybackCommands() {
for ((name, command) in listOf(
"COMMAND_SET_MEDIA_ITEM" to Player.COMMAND_SET_MEDIA_ITEM,
"COMMAND_PREPARE" to Player.COMMAND_PREPARE,
"COMMAND_PLAY_PAUSE" to Player.COMMAND_PLAY_PAUSE,
)) {
var available = false
instrumentation.runOnMainSync { available = controller.isCommandAvailable(command) }
assertTrue("$name not available to the in-app controller", available)
}
}

@Test
fun inAppControllerIsGrantedScrubCommands() {
for (command in listOf(AudioCommands.ScrubStart, AudioCommands.ScrubStop)) {
var available = false
instrumentation.runOnMainSync { available = controller.isSessionCommandAvailable(command) }
assertTrue("${command.customAction} not available to the in-app controller", available)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -160,32 +160,28 @@ class AudioMediaService : MediaSessionService() {
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
val base = super.onConnect(session, controller)
// Must not be derived from super.onConnect(). Since media3 1.11.0 the default
// onConnect() returns a placeholder with EMPTY commands, which media3 only replaces
// with real defaults if that placeholder is returned unmodified. A result built from
// it grants controllers no player commands, so play/prepare/setMediaItem are dropped
// and voice messages never play.
val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS
.buildUpon()
.add(AudioCommands.ScrubStart)
.add(AudioCommands.ScrubStop)
.build()

// Tighten commands for the system notification controller (no next/previous).
if (session.isMediaNotificationController(controller)) {
val playerCommands = base.availablePlayerCommands
val playerCommands = if (session.isMediaNotificationController(controller)) {
MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS
.buildUpon()
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.build()

val sessionCommands = base.availableSessionCommands
.buildUpon()
.add(AudioCommands.ScrubStart)
.add(AudioCommands.ScrubStop)
.build()

return MediaSession.ConnectionResult.accept(sessionCommands, playerCommands)
} else {
MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS
}

// In-app UI controller: allow scrubbing commands too.
return MediaSession.ConnectionResult.accept(
base.availableSessionCommands.buildUpon()
.add(AudioCommands.ScrubStart)
.add(AudioCommands.ScrubStop)
.build(),
base.availablePlayerCommands
)
return MediaSession.ConnectionResult.accept(sessionCommands, playerCommands)
}

@OptIn(UnstableApi::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.os.Bundle
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
Expand Down Expand Up @@ -97,6 +98,8 @@ class AudioPlaybackManager @Inject constructor(

fun play(playable: PlayableAudio, startPositionMs: Long = -1L) {
ensureController { c ->
logMissingPlaybackCommands(c)

val currentId = c.currentMediaItem?.mediaId
val newId = playable.messageId.serialize()

Expand Down Expand Up @@ -143,6 +146,21 @@ class AudioPlaybackManager @Inject constructor(
}
}

// MediaController drops unavailable commands with only a logcat warning, which never reaches
// exported logs.
private fun logMissingPlaybackCommands(c: MediaController) {
val setMediaItem = c.isCommandAvailable(Player.COMMAND_SET_MEDIA_ITEM)
val prepare = c.isCommandAvailable(Player.COMMAND_PREPARE)
val playPause = c.isCommandAvailable(Player.COMMAND_PLAY_PAUSE)
if (!setMediaItem || !prepare || !playPause) {
Log.w(
TAG,
"Controller is missing playback commands: setMediaItem=$setMediaItem, " +
"prepare=$prepare, playPause=$playPause, connected=${c.isConnected}"
)
}
}

fun togglePlayPause() {
val c = controller ?: return
if (c.isPlaying) c.pause() else c.play()
Expand Down Expand Up @@ -465,6 +483,12 @@ class AudioPlaybackManager @Inject constructor(

if (!isScrubbing) updateFromController()
}

override fun onPlayerError(error: PlaybackException) {
Log.w(TAG, "Playback error: ${error.errorCodeName} (${error.errorCode}), " +
"messageId=${currentPlayable?.messageId?.serialize()}", error)
stop()
}
}

private fun startProgressTracking() {
Expand Down
Loading