@file:androidx.annotation.OptIn(markerClass = [androidx.media3.common.util.UnstableApi::class]) package link.roadio.riff.player.playback import android.graphics.Bitmap import android.net.Uri import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.SilenceMediaSource import androidx.test.platform.app.InstrumentationRegistry import link.roadio.riff.indexer.core.media.MediaId import link.roadio.riff.player.artwork.ArtworkKey import link.roadio.riff.player.artwork.ArtworkResolution import link.roadio.riff.player.artwork.PlayerArtworkLoader import link.roadio.riff.player.library.PlaybackCatalogTrack import link.roadio.riff.player.library.PlayerLibraryProjection import link.roadio.riff.player.library.PlayerPlaybackCatalog import link.roadio.riff.player.storage.PlaybackCheckpoint import link.roadio.riff.player.storage.PlaybackQueueKind import link.roadio.riff.player.storage.PlaybackQueueOccurrence import link.roadio.riff.player.storage.PlaybackQueueSnapshot import link.roadio.riff.ui.ArtworkUiState import link.roadio.riff.ui.BrowserItem import link.roadio.riff.ui.BrowserKind import link.roadio.riff.ui.PlaybackSourceRef import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ExecutorService import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference /** A prefetched binding becomes current without opening another DataSource. */ class BufferedBindingArtworkInstrumentedTest { private fun field(name: String) = RiffPlaybackService::class.java.getDeclaredField(name).apply { isAccessible = true } @Test fun bufferedTransitionPublishesBindingWithoutReopeningAudioOrLosingCachedArtwork() { val instrumentation = InstrumentationRegistry.getInstrumentation() val source = PlaybackSourceRef(31, 1, emptyList()) val media = MediaId(41) val queue = PlaybackQueueSnapshot( 17, PlaybackQueueKind.MANUAL, 1, false, listOf(PlaybackQueueOccurrence(21, 0, 0, media), PlaybackQueueOccurrence(23, 1, 1, media)), PlaybackCheckpoint(21), ) val catalog = object : PlayerPlaybackCatalog { override val generation = 1L override val projection = PlayerLibraryProjection() override fun lookup( mediaId: MediaId, contextKey: String?, ordinal: Int, ) = PlaybackCatalogTrack(BrowserItem("track", "Fixture", BrowserKind.TRACK, mediaId = media), listOf(source), emptyMap()) } val prepared = prepareQueue(queue, catalog) val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888) val loader = PlayerArtworkLoader({ selected, _ -> ArtworkResolution(ArtworkUiState(selected.bindingId, bitmap)) }) val key = ArtworkKey(17, 23, 31, 0) val factory = object : MediaSource.Factory by DefaultMediaSourceFactory(instrumentation.targetContext) { override fun createMediaSource(mediaItem: MediaItem): MediaSource = SilenceMediaSource(300_000_000) } lateinit var service: RiffPlaybackService lateinit var player: ExoPlayer val timeline = QueueMediaSource(prepared, factory) instrumentation.runOnMainSync { service = RiffPlaybackService() player = ExoPlayer.Builder(instrumentation.targetContext).build() field("exoPlayer").set(service, player) field("preparedQueue").set(service, prepared) field("queueMediaSource").set(service, timeline) field("artworkLoader").set(service, loader) field("installingQueue").setBoolean(service, true) @Suppress("UNCHECKED_CAST") val plan = field("executionPlan").get(service) as AtomicReference plan.set(prepared.plan) player.setMediaSource(timeline) player.prepare() } fun await(condition: () -> Boolean) { val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) var ready = false while (!ready && System.nanoTime() < deadline) { instrumentation.runOnMainSync { ready = condition() } if (!ready) Thread.sleep(10) } assertTrue("Buffered binding was not promoted", ready) } try { await { player.playbackState == Player.STATE_READY } val type = RiffPlaybackService::class.java.declaredClasses.single { it.simpleName == "ResolvedBinding" } val binding = type.declaredConstructors .single() .apply { isAccessible = true } .newInstance(17L, 23L, media, source, Uri.parse("content://fixture/audio")) @Suppress("UNCHECKED_CAST") val bindings = field("resolvedBindings").get(service) as ConcurrentHashMap bindings[23] = binding loader.request(key, source) await { loader.artworkFor(key)?.bitmap === bitmap } val bind = service.javaClass.getDeclaredMethod("bindingResolved", type).apply { isAccessible = true } instrumentation.runOnMainSync { bind.invoke(service, binding) assertFalse( player .getMediaItemAt(1) .mediaMetadata.extras ?.containsKey(PlaybackMetadata.EXTRA_BINDING_ID) == true, ) player.seekTo(1, 0) } await { player.playbackState == Player.STATE_READY && player.currentMediaItemIndex == 1 } val select = service.javaClass .getDeclaredMethod("selectArtwork", Long::class.javaObjectType, Long::class.javaObjectType) .apply { isAccessible = true } instrumentation.runOnMainSync { select.invoke(service, 17L, 23L) } await { player.currentMediaItem ?.mediaMetadata ?.extras ?.getLong(PlaybackMetadata.EXTRA_BINDING_ID) == 31L } assertSame(bitmap, loader.artworkFor(key)?.bitmap) instrumentation.runOnMainSync { assertEquals(Player.STATE_READY, player.playbackState) } } finally { instrumentation.runOnMainSync { player.release() } loader.close() for (name in listOf("stateExecutor", "queueExecutor", "sourceExecutor")) { (field(name).get(service) as ExecutorService).shutdownNow() } bitmap.recycle() } } }