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
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ internal class CarPlayMediaSessionBridge(
private var pauseRequest = 0L
private var artwork: MediaArtwork? = null
private var cover: Bitmap? = null
private data class PublishedMetadata(
val connection: Long, val mediaId: String, val title: String?, val artist: String?,
val album: String?, val duration: Long?, val artwork: Bitmap,
)
private var publishedMetadata: PublishedMetadata? = null
private var closed = false
private val localRequestQueued = AtomicBoolean(false)
private val attributes = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
Expand Down Expand Up @@ -195,20 +200,30 @@ internal class CarPlayMediaSessionBridge(
private fun render() {
session.isActive = state.available
if (!state.available) {
session.setMetadata(null)
if (publishedMetadata != null) session.setMetadata(null)
publishedMetadata = null
session.setPlaybackState(PlaybackState.Builder().setState(PlaybackState.STATE_NONE, 0, 0f).build())
changed(state, null, null)
return
}
val metadata = MediaMetadata.Builder()
.putString(MediaMetadata.METADATA_KEY_MEDIA_ID, "${epoch}:${state.trackRevision}:${state.persistentId}")
.putString(MediaMetadata.METADATA_KEY_TITLE, state.title?.takeIf { it.isNotBlank() } ?: state.appName)
.putString(MediaMetadata.METADATA_KEY_ARTIST, state.artist)
.putString(MediaMetadata.METADATA_KEY_ALBUM, state.album)
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, cover ?: defaultArtwork)
.putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, cover ?: defaultArtwork)
state.durationMs?.takeIf { it > 0 }?.let { metadata.putLong(MediaMetadata.METADATA_KEY_DURATION, it) }
session.setMetadata(metadata.build())
val nextMetadata = PublishedMetadata(
state.connectionGeneration, "${epoch}:${state.trackRevision}:${state.persistentId}",
state.title?.takeIf { it.isNotBlank() } ?: state.appName, state.artist, state.album,
state.durationMs?.takeIf { it > 0 }, cover ?: defaultArtwork,
)
// DiPlay #162: position-only updates must not repeatedly parcel album bitmaps.
if (nextMetadata != publishedMetadata) {
val metadata = MediaMetadata.Builder()
.putString(MediaMetadata.METADATA_KEY_MEDIA_ID, nextMetadata.mediaId)
.putString(MediaMetadata.METADATA_KEY_TITLE, nextMetadata.title)
.putString(MediaMetadata.METADATA_KEY_ARTIST, nextMetadata.artist)
.putString(MediaMetadata.METADATA_KEY_ALBUM, nextMetadata.album)
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, nextMetadata.artwork)
.putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, nextMetadata.artwork)
nextMetadata.duration?.let { metadata.putLong(MediaMetadata.METADATA_KEY_DURATION, it) }
session.setMetadata(metadata.build())
publishedMetadata = nextMetadata
}
val playback = when (state.status) {
PhonePlaybackStatus.PLAYING -> PlaybackState.STATE_PLAYING
PhonePlaybackStatus.PAUSED -> PlaybackState.STATE_PAUSED
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.shilapi.xcertplay.airplay

import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.util.ReflectionHelpers

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [29])
class CarPlayAudioFeedbackClockTest {
@Test fun realAudioSetupAndEncryptedRtpReportTheNegotiatedClockThroughFeedbackAndMicrophone() {
for ((bits, rate) in listOf(0x10000000L to 16000, 0x20000000L to 24000,
0x40000000L to 48000, 0x4000L to 48000, 0x800000L to 48000)) {
val loopback = InetAddress.getLoopbackAddress()
ServerSocket(0, 1, loopback).use { server ->
Socket(loopback, server.localPort).use { client ->
val peer = server.accept()
DatagramSocket(0, loopback).use { sender ->
val started = CountDownLatch(1)
val microphoneStarted = CountDownLatch(1)
var microphone: MicrophoneConfig? = null
var decodedFormat: AudioFormat? = null
val clock = AtomicLong(1000000000L)
val engine = CarPlayMediaEngine(object : MediaSink {
override fun onAudioStarted(type: Int, format: AudioFormat, firstSample: Int) {
decodedFormat = format; started.countDown()
}
override fun onMicrophoneStarted(type: Int, config: MicrophoneConfig) {
microphone = config; microphoneStarted.countDown()
}
}, microphoneEnabled = true, nanoTime = clock::get)
val session = AirPlaySession(peer,
AirPlayConfig("test", "02:00:00:00:00:02", "02:00:00:00:00:01", "1",
AirPlayDisplayConfig(800, 480)),
AirPlayIdentity.generate(), PairingStore(), null,
object : AirPlaySessionListener {}, engine)
val shared = ByteArray(32) { it.toByte() }
ReflectionHelpers.setField(ReflectionHelpers.getField<Any>(session, "pairVerify"), "sharedSecret", shared)
try {
val audioType = if (bits == 0x800000L) "media" else "telephony"
val setup = engine.onAudio(session, 100, mapOf("streamConnectionID" to 1L,
"audioFormat" to bits, "audioType" to audioType,
"dataPort" to sender.localPort))!!
val key = AirPlayCrypto.hkdfSha512(shared, "DataStream-Salt1".toByteArray(),
"DataStream-Output-Encryption-Key".toByteArray(), 32)
val firstSample = -16
val packet = MicrophonePacketizer.sealPacket(key, 100,
MicrophoneCounters(timestamp = firstSample), byteArrayOf(1, 2, 3), 1)
sender.send(DatagramPacket(packet, packet.size, loopback, setup["dataPort"] as Int))
assertTrue("Encrypted audio must reach the media engine", started.await(3, TimeUnit.SECONDS))
assertEquals(48000, decodedFormat!!.sampleRate)
clock.addAndGet(20000000000L)
val feedback = (engine.onFeedback(session)!!["streams"] as List<*>).single() as Map<*, *>
assertEquals(rate, feedback["sampleRate"])
assertEquals((0xfffffff0L + 20L * rate) and 0xffffffffL, feedback["sampleTime"])
if (audioType == "telephony") {
assertTrue(microphoneStarted.await(3, TimeUnit.SECONDS))
assertEquals(rate, microphone!!.opusClockRate)
if (decodedFormat!!.codec == AudioCodecKind.OPUS) {
assertEquals(1920, microphone!!.frameBytes)
assertEquals(rate / 50, microphone!!.rtpSamplesPerPacket)
}
} else {
assertNull(microphone)
}
} finally {
engine.onSessionClosed(session); session.close()
}
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,82 @@ import java.util.concurrent.CompletableFuture
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [29], shadows = [RecordingMediaSession::class, LinkedMediaController::class, LinkedTransportControls::class])
class CarPlayMediaSessionBridgeTest {
@Test fun positionStatusAndOutputRouteChangesDoNotRepublishArtworkMetadata() {
val context = RuntimeEnvironment.getApplication()
val source = Source(); val output = Output()
val bridge = CarPlayMediaSessionBridge(context) { _, _, _ -> }
try {
bridge.bind(source, output); source.update(playing()); idle()
val recorded = org.robolectric.shadow.api.Shadow.extract<RecordingMediaSession>(bridge.session)
val first = recorded.recordedMetadata
val publications = recorded.metadataPublications
repeat(20) {
source.update(playing().copy(positionMs = 3000L + it, positionAtMs = 2000L + it)); idle()
}
source.update(source.state.copy(status = PhonePlaybackStatus.PAUSED)); idle()
output.route!!(true); idle()
assertEquals(publications, recorded.metadataPublications)
assertSame(first, recorded.recordedMetadata)
assertEquals(3019L, recorded.playback!!.position)
assertEquals(PlaybackState.STATE_PAUSED, recorded.playback!!.state)
source.update(source.state.copy(title = "New title")); idle()
assertEquals(publications + 1, recorded.metadataPublications)
assertEquals("New title", recorded.recordedMetadata!!.getString(MediaMetadata.METADATA_KEY_TITLE))
source.update(source.state.copy(durationMs = 200000)); idle()
assertEquals(publications + 2, recorded.metadataPublications)
} finally { bridge.close() }
}

@Test fun disconnectAndIdenticalSongFromReplacementSourceRepublishMetadata() {
val context = RuntimeEnvironment.getApplication()
val old = Source(); val next = Source()
val bridge = CarPlayMediaSessionBridge(context) { _, _, _ -> }
try {
bridge.bind(old, Output()); old.update(playing()); idle()
val recorded = org.robolectric.shadow.api.Shadow.extract<RecordingMediaSession>(bridge.session)
val firstId = recorded.recordedMetadata!!.getString(MediaMetadata.METADATA_KEY_MEDIA_ID)
old.update(NowPlayingState()); idle()
assertNull(recorded.recordedMetadata)
val count = recorded.metadataPublications
old.update(NowPlayingState()); idle()
assertEquals(count, recorded.metadataPublications)
bridge.bind(next, Output()); next.update(playing()); idle()
assertEquals(count + 1, recorded.metadataPublications)
assertNotEquals(firstId, recorded.recordedMetadata!!.getString(MediaMetadata.METADATA_KEY_MEDIA_ID))
val afterBind = recorded.metadataPublications
next.update(playing().copy(connectionGeneration = 1)); idle()
assertEquals(afterBind + 1, recorded.metadataPublications)
} finally { bridge.close() }
}

@Test fun completedArtworkDecodePublishesOnceAndPositionUpdatesReuseTheBitmap() {
val context = RuntimeEnvironment.getApplication()
val source = Source()
val bridge = CarPlayMediaSessionBridge(context) { _, _, _ -> }
try {
bridge.bind(source, Output()); source.update(playing()); idle()
val recorded = org.robolectric.shadow.api.Shadow.extract<RecordingMediaSession>(bridge.session)
val fallback = recorded.recordedMetadata!!.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
val bitmap = android.graphics.Bitmap.createBitmap(2, 2, android.graphics.Bitmap.Config.ARGB_8888)
val bytes = java.io.ByteArrayOutputStream().use {
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it); it.toByteArray()
}
bitmap.recycle()
source.update(playing().copy(artwork = MediaArtwork(bytes))); idle()
val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(3)
while (recorded.recordedMetadata!!.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) === fallback &&
System.nanoTime() < deadline) {
Thread.sleep(10); idle()
}
val cover = recorded.recordedMetadata!!.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
assertNotSame(fallback, cover)
val count = recorded.metadataPublications
source.update(source.state.copy(positionMs = 4000)); idle()
assertEquals(count, recorded.metadataPublications)
assertSame(cover, recorded.recordedMetadata!!.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART))
} finally { bridge.close() }
}

private class Source : CarPlayMediaSource {
var state = NowPlayingState()
val listeners = mutableListOf<(NowPlayingState) -> Unit>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import org.robolectric.shadows.ShadowTransportControls
class RecordingMediaSession : ShadowMediaSession() {
@RealObject lateinit var real: MediaSession
var recordedMetadata: MediaMetadata? = null
var metadataPublications = 0
var playback: PlaybackState? = null
var callback: MediaSession.Callback? = null
var handler: Handler? = null
Expand All @@ -35,7 +36,9 @@ class RecordingMediaSession : ShadowMediaSession() {
var volume: VolumeProvider? = null
private fun register() { sessions[real.sessionToken] = this }

@Implementation fun setMetadata(value: MediaMetadata?) { register(); recordedMetadata = value }
@Implementation fun setMetadata(value: MediaMetadata?) {
register(); recordedMetadata = value; metadataPublications++
}
@Implementation fun setPlaybackState(value: PlaybackState?) { register(); playback = value }
@Implementation fun setCallback(value: MediaSession.Callback?, handler: Handler?) {
register(); callback = value; this.handler = handler
Expand Down
Loading
Loading