diff --git a/player/src/androidTest/kotlin/com/tidal/sdk/player/playlog/Extensions.kt b/player/src/androidTest/kotlin/com/tidal/sdk/player/playlog/Extensions.kt new file mode 100644 index 00000000..4864d802 --- /dev/null +++ b/player/src/androidTest/kotlin/com/tidal/sdk/player/playlog/Extensions.kt @@ -0,0 +1,7 @@ +package com.tidal.sdk.player.playlog + +import assertk.Assert +import assertk.assertions.isCloseTo + +internal fun Assert.isAssetPositionEqualTo(targetPosition: Double) = + isCloseTo(targetPosition, 0.5) diff --git a/player/src/androidTest/kotlin/com/tidal/sdk/player/playlog/SingleMediaProductPlayLogTest.kt b/player/src/androidTest/kotlin/com/tidal/sdk/player/playlog/SingleMediaProductPlayLogTest.kt index f487a106..72b1efb3 100644 --- a/player/src/androidTest/kotlin/com/tidal/sdk/player/playlog/SingleMediaProductPlayLogTest.kt +++ b/player/src/androidTest/kotlin/com/tidal/sdk/player/playlog/SingleMediaProductPlayLogTest.kt @@ -2,10 +2,8 @@ package com.tidal.sdk.player.playlog import android.app.Application import androidx.test.platform.app.InstrumentationRegistry -import assertk.Assert import assertk.assertThat import assertk.assertions.isBetween -import assertk.assertions.isCloseTo import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import com.google.gson.Gson @@ -775,10 +773,6 @@ internal class SingleMediaProductPlayLogTest { eq(emptyMap()), ) } - - private fun Assert.isAssetPositionEqualTo(targetPosition: Double) = run { - isCloseTo(targetPosition, 0.5) - } } private const val MEDIA_PRODUCT_DURATION_SECONDS = 5.055 diff --git a/player/src/androidTest/kotlin/com/tidal/sdk/player/playlog/TwoMediaProductsPlayLogTest.kt b/player/src/androidTest/kotlin/com/tidal/sdk/player/playlog/TwoMediaProductsPlayLogTest.kt new file mode 100644 index 00000000..b5764522 --- /dev/null +++ b/player/src/androidTest/kotlin/com/tidal/sdk/player/playlog/TwoMediaProductsPlayLogTest.kt @@ -0,0 +1,305 @@ +package com.tidal.sdk.player.playlog + +import android.app.Application +import androidx.test.platform.app.InstrumentationRegistry +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.tidal.sdk.auth.CredentialsProvider +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.Credentials +import com.tidal.sdk.auth.util.isLoggedIn +import com.tidal.sdk.common.TidalMessage +import com.tidal.sdk.eventproducer.EventSender +import com.tidal.sdk.eventproducer.model.ConsentCategory +import com.tidal.sdk.player.Player +import com.tidal.sdk.player.common.model.MediaProduct +import com.tidal.sdk.player.common.model.ProductType +import com.tidal.sdk.player.events.EventReporterModuleRoot +import com.tidal.sdk.player.events.di.DefaultEventReporterComponent +import com.tidal.sdk.player.events.playlogtest.PlayLogTestDefaultEventReporterComponentFactory +import com.tidal.sdk.player.events.reflectionComponentFactoryF +import com.tidal.sdk.player.playbackengine.model.Event +import com.tidal.sdk.player.setBodyFromFile +import kotlin.math.absoluteValue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test +import org.junit.runners.Parameterized +import org.mockito.Mockito.atMost +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify + +internal class TwoMediaProductsPlayLogTest { + + @get:Rule + val server = MockWebServer() + + private val eventReporterCoroutineScope = + TestScope(StandardTestDispatcher(TestCoroutineScheduler())) + private val responseDispatcher = PlayLogTestMockWebServerDispatcher(server) + private val eventSender = mock() + private val mediaProduct1 = MediaProduct(ProductType.TRACK, "1", "TEST_1", "456") + private val mediaProduct2 = MediaProduct(ProductType.TRACK, "2", "TEST_2", "789") + private lateinit var player: Player + + @Before + fun setUp() { + responseDispatcher[ + "https://api.tidal.com/v1/tracks/${mediaProduct1.productId}/playbackinfo?playbackmode=STREAM&assetpresentation=FULL&audioquality=LOW&immersiveaudio=true".toHttpUrl(), + ] = { + MockResponse().setBodyFromFile( + "api-responses/playbackinfo/tracks/playlogtest/get_1_bts.json", + ) + } + responseDispatcher["https://test.audio.tidal.com/1_bts.m4a".toHttpUrl()] = { + MockResponse().setBodyFromFile("raw/playlogtest/1_bts.m4a") + } + responseDispatcher[ + "https://api.tidal.com/v1/tracks/${mediaProduct2.productId}/playbackinfo?playbackmode=STREAM&assetpresentation=FULL&audioquality=LOW&immersiveaudio=true".toHttpUrl(), + ] = { + MockResponse().setBodyFromFile( + "api-responses/playbackinfo/tracks/playlogtest/get_2_bts.json", + ) + } + responseDispatcher["https://test.audio.tidal.com/test_1min.m4a".toHttpUrl()] = { + MockResponse().setBodyFromFile("raw/playlogtest/test_1min.m4a") + } + EventReporterModuleRoot.reflectionComponentFactoryF = { + PlayLogTestDefaultEventReporterComponentFactory(eventReporterCoroutineScope) + } + server.dispatcher = responseDispatcher + + player = Player( + InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + as Application, + object : CredentialsProvider { + private val CREDENTIALS = Credentials( + clientId = "a client id", + requestedScopes = emptySet(), + clientUniqueKey = null, + grantedScopes = emptySet(), + userId = "a non-null user id", + expires = null, + token = "a non-null token", + ) + + override val bus: Flow = emptyFlow() + + override suspend fun getCredentials(apiErrorSubStatus: String?) = + AuthResult.Success(CREDENTIALS) + + override fun isUserLoggedIn() = CREDENTIALS.isLoggedIn() + }, + eventSender = eventSender, + okHttpClient = OkHttpClient.Builder() + .addInterceptor { + val request = it.request() + val mockWebServerUrl = responseDispatcher.urlRecords[request.url] + ?: return@addInterceptor it.proceed(request) + it.proceed( + request.newBuilder() + .url(mockWebServerUrl) + .build(), + ) + }.build(), + ) + } + + companion object { + private lateinit var originalEventReporterComponentFactoryF: + () -> DefaultEventReporterComponent.Factory + private const val MEDIA_PRODUCT_1_DURATION_SECONDS = 5.055 + private const val MEDIA_PRODUCT_2_DURATION_SECONDS = 60.606667 + + @BeforeClass + @JvmStatic + fun beforeAll() { + originalEventReporterComponentFactoryF = + EventReporterModuleRoot.reflectionComponentFactoryF + } + + @JvmStatic + @Parameterized.Parameters + fun parameters(): List> { + val mediaProduct1s = setOf( + MediaProduct(ProductType.TRACK, "1", "TESTA", "456"), + MediaProduct(ProductType.TRACK, "1", null, "789"), + MediaProduct(ProductType.TRACK, "1", "TESTB", null), + MediaProduct(ProductType.TRACK, "1", null, null), + ) + val mediaProduct2s = setOf( + MediaProduct(ProductType.TRACK, "2", "TESTA", "456"), + MediaProduct(ProductType.TRACK, "2", null, "789"), + MediaProduct(ProductType.TRACK, "2", "TESTB", null), + MediaProduct(ProductType.TRACK, "2", null, null), + ) + return mediaProduct1s.flatMap { mediaProduct1 -> + mediaProduct2s.map { mediaProduct2 -> + arrayOf(mediaProduct1, mediaProduct2) + } + } + } + } + + @After + fun afterEach() { + runBlocking { + val job = launch { player.playbackEngine.events.first { it is Event.Release } } + player.release() + job.join() + } + verify(eventSender, atMost(Int.MAX_VALUE)) + .sendEvent( + argThat { !contentEquals("playback_session") }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + verifyNoMoreInteractions(eventSender) + } + + @Test + fun playSequentially() = runTest(timeout = 3.minutes) { + val gson = Gson() + + player.playbackEngine.load(mediaProduct1) + player.playbackEngine.setNext(mediaProduct2) + player.playbackEngine.play() + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(2.minutes) { + player.playbackEngine.events.filter { it is Event.MediaProductEnded }.first() + } + } + + eventReporterCoroutineScope.advanceUntilIdle() + verify(eventSender).sendEvent( + eq("playback_session"), + eq(ConsentCategory.NECESSARY), + argThat { + with(gson.fromJson(this, JsonObject::class.java)["payload"].asJsonObject) { + // https://github.com/androidx/media/issues/1252 + get("startAssetPosition").asDouble.isAssetPositionEqualTo(0.0) && + // https://github.com/androidx/media/issues/1253 + get("endAssetPosition").asDouble + .isAssetPositionEqualTo(MEDIA_PRODUCT_1_DURATION_SECONDS) && + get("actualProductId")?.asString.contentEquals(mediaProduct1.productId) && + get("sourceType")?.asString.contentEquals(mediaProduct1.sourceType) && + get("sourceId")?.asString.contentEquals(mediaProduct1.sourceId) && + get("actions").asJsonArray.isEmpty + } + }, + eq(emptyMap()), + ) + verify(eventSender).sendEvent( + eq("playback_session"), + eq(ConsentCategory.NECESSARY), + argThat { + with(gson.fromJson(this, JsonObject::class.java)["payload"].asJsonObject) { + // https://github.com/androidx/media/issues/1252 + get("startAssetPosition").asDouble.isAssetPositionEqualTo(0.0) && + // https://github.com/androidx/media/issues/1253 + get("endAssetPosition").asDouble + .isAssetPositionEqualTo(MEDIA_PRODUCT_2_DURATION_SECONDS) && + get("actualProductId")?.asString.contentEquals(mediaProduct2.productId) && + get("sourceType")?.asString.contentEquals(mediaProduct2.sourceType) && + get("sourceId")?.asString.contentEquals(mediaProduct2.sourceId) && + get("actions").asJsonArray.isEmpty + } + }, + eq(emptyMap()), + ) + } + + @Test + fun repeatOneWithNext() = runTest { + val gson = Gson() + + player.playbackEngine.load(mediaProduct1) + player.playbackEngine.setNext(mediaProduct2) + player.playbackEngine.setRepeatOne(true) + player.playbackEngine.play() + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(8.seconds) { + player.playbackEngine.events.filter { it is Event.MediaProductTransition } + .take(2) + .collect() + } + player.playbackEngine.setRepeatOne(false) + withTimeout(8.seconds) { + player.playbackEngine.events.filter { it is Event.MediaProductTransition }.first() + } + delay(1.seconds) + while (player.playbackEngine.assetPosition < 1) { + delay(10.milliseconds) + } + player.playbackEngine.reset() + } + + eventReporterCoroutineScope.advanceUntilIdle() + verify(eventSender, times(2)).sendEvent( + eq("playback_session"), + eq(ConsentCategory.NECESSARY), + argThat { + with(gson.fromJson(this, JsonObject::class.java)["payload"].asJsonObject) { + get("startAssetPosition").asDouble.isAssetPositionEqualTo(0.0) && + get("endAssetPosition").asDouble + .isAssetPositionEqualTo(MEDIA_PRODUCT_1_DURATION_SECONDS) && + get("actualProductId")?.asString.contentEquals(mediaProduct1.productId) && + get("sourceType")?.asString.contentEquals(mediaProduct1.sourceType) && + get("sourceId")?.asString.contentEquals(mediaProduct1.sourceId) && + get("actions").asJsonArray.isEmpty + } + }, + eq(emptyMap()), + ) + verify(eventSender).sendEvent( + eq("playback_session"), + eq(ConsentCategory.NECESSARY), + argThat { + with(gson.fromJson(this, JsonObject::class.java)["payload"].asJsonObject) { + get("startAssetPosition").asDouble.isAssetPositionEqualTo(0.0) && + get("endAssetPosition").asDouble.isAssetPositionEqualTo(1.0) && + get("actualProductId")?.asString.contentEquals(mediaProduct2.productId) && + get("sourceType")?.asString.contentEquals(mediaProduct2.sourceType) && + get("sourceId")?.asString.contentEquals(mediaProduct2.sourceId) && + get("actions").asJsonArray.isEmpty + } + }, + eq(emptyMap()), + ) + } + + private fun Double.isAssetPositionEqualTo(targetPosition: Double) = + (this - targetPosition).absoluteValue < 0.5 +} diff --git a/player/src/androidTest/resources/api-responses/playbackinfo/tracks/playlogtest/get_2_bts.json b/player/src/androidTest/resources/api-responses/playbackinfo/tracks/playlogtest/get_2_bts.json new file mode 100644 index 00000000..241b1e4f --- /dev/null +++ b/player/src/androidTest/resources/api-responses/playbackinfo/tracks/playlogtest/get_2_bts.json @@ -0,0 +1,16 @@ +{ + "trackId": 2, + "assetPresentation": "FULL", + "audioMode": "STEREO", + "audioQuality": "LOW", + "streamingSessionId": "356", + "manifestMimeType": "application/vnd.tidal.bts", + "manifestHash": "a manifest hash", + "manifest": "eyJtaW1lVHlwZSI6ImF1ZGlvL21wNCIsImNvZGVjcyI6Im1wNGEuNDAuNSIsInVybHMiOlsiaHR0cHM6Ly90ZXN0LmF1ZGlvLnRpZGFsLmNvbS90ZXN0XzFtaW4ubTRhIl19", + "albumReplayGain": -9.8, + "albumPeakAmplitude": 0.999923, + "trackReplayGain": -9.8, + "trackPeakAmplitude": 0.999923, + "bitDepth": 16, + "sampleRate": 44100 +} diff --git a/player/src/androidTest/resources/raw/playlogtest/test_1min.m4a b/player/src/androidTest/resources/raw/playlogtest/test_1min.m4a new file mode 100644 index 00000000..334f9b24 Binary files /dev/null and b/player/src/androidTest/resources/raw/playlogtest/test_1min.m4a differ