diff --git a/.github/workflows/gradle_wrapper_validation.yml b/.github/workflows/gradle_wrapper_validation.yml index bad869271..7a5093e2d 100644 --- a/.github/workflows/gradle_wrapper_validation.yml +++ b/.github/workflows/gradle_wrapper_validation.yml @@ -1,6 +1,7 @@ name: Gradle Wrapper validation on: + merge_group: push: branches: - main diff --git a/.idea/kotlinScripting.xml b/.idea/kotlinScripting.xml index 363eea6af..1ff683d26 100644 --- a/.idea/kotlinScripting.xml +++ b/.idea/kotlinScripting.xml @@ -20,4 +20,4 @@ 5 - \ No newline at end of file + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4072e1175..0f18e8959 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,8 +101,8 @@ androidx-media3-ui-leanback = { group = "androidx.media3", name = "media3-ui-lea androidx-media3-dash = { group = "androidx.media3", name = "media3-exoplayer-dash", version.ref = "androidx-media3" } androidx-media3-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "androidx-media3" } androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "androidx-media3" } -androidx-media3-test-utils = { group = "androidx.media3", name = "media3-test-utils", version.ref = "androidx-media3" } -androidx-media3-test-utils-robolectric = { group = "androidx.media3", name = "media3-test-utils-robolectric", version.ref = "androidx-media3" } +androidx-media3-test-utils = { module = "androidx.media3:media3-test-utils", version.ref = "androidx-media3" } +androidx-media3-test-utils-robolectric = { module = "androidx.media3:media3-test-utils-robolectric", version.ref = "androidx-media3" } androidx-media = { group = "androidx.media", name = "media", version.ref = "androidx-media" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } diff --git a/lint.xml b/lint.xml index 081bddf69..29a04b29c 100644 --- a/lint.xml +++ b/lint.xml @@ -4,11 +4,13 @@ --> - - - - - + + + + + + + diff --git a/pillarbox-core-business/build.gradle.kts b/pillarbox-core-business/build.gradle.kts index c2e30aff7..3e6834564 100644 --- a/pillarbox-core-business/build.gradle.kts +++ b/pillarbox-core-business/build.gradle.kts @@ -85,8 +85,13 @@ dependencies { implementation(libs.okhttp.logging.interceptor) api(libs.tagcommander.core) + testImplementation(project(":pillarbox-player-testutils")) + + testImplementation(libs.androidx.media3.test.utils) + testImplementation(libs.androidx.media3.test.utils.robolectric) testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.androidx.test.monitor) testImplementation(libs.junit) testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) @@ -94,6 +99,8 @@ dependencies { testImplementation(libs.mockk) testImplementation(libs.mockk.dsl) testRuntimeOnly(libs.robolectric) + testImplementation(libs.robolectric.annotations) + testRuntimeOnly(libs.robolectric.shadows.framework) androidTestImplementation(project(":pillarbox-player-testutils")) diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt index 8ccdf93b2..41f4b639f 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt @@ -5,6 +5,8 @@ package ch.srgssr.pillarbox.core.business import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.media3.common.util.Clock import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.LoadControl @@ -32,7 +34,7 @@ object DefaultPillarbox { * @param mediaItemTrackerRepository The provider of MediaItemTracker, by default [DefaultMediaItemTrackerRepository]. * @param mediaItemSource The MediaItem source by default [MediaCompositionMediaItemSource]. * @param dataSourceFactory The Http exoplayer data source factory, by default [AkamaiTokenDataSource.Factory]. - * @param loadControl The load control, bye default [DefaultLoadControl]. + * @param loadControl The load control, by default [DefaultLoadControl]. * @return [PillarboxPlayer] suited for SRG. */ operator fun invoke( @@ -44,6 +46,41 @@ object DefaultPillarbox { ), dataSourceFactory: DataSource.Factory = AkamaiTokenDataSource.Factory(), loadControl: LoadControl = PillarboxLoadControl(), + ): PillarboxPlayer { + return DefaultPillarbox( + context = context, + seekIncrement = seekIncrement, + mediaItemTrackerRepository = mediaItemTrackerRepository, + mediaItemSource = mediaItemSource, + dataSourceFactory = dataSourceFactory, + loadControl = loadControl, + clock = Clock.DEFAULT, + ) + } + + /** + * Invoke create an instance of [PillarboxPlayer] + * + * @param context The context. + * @param seekIncrement The seek increment. + * @param mediaItemTrackerRepository The provider of MediaItemTracker, by default [DefaultMediaItemTrackerRepository]. + * @param mediaItemSource The MediaItem source by default [MediaCompositionMediaItemSource]. + * @param dataSourceFactory The Http exoplayer data source factory, by default [AkamaiTokenDataSource.Factory]. + * @param loadControl The load control, by default [DefaultLoadControl]. + * @param clock The internal clock used by the player. + * @return [PillarboxPlayer] suited for SRG. + */ + @VisibleForTesting + operator fun invoke( + context: Context, + seekIncrement: SeekIncrement = defaultSeekIncrement, + mediaItemTrackerRepository: MediaItemTrackerProvider = DefaultMediaItemTrackerRepository(), + mediaItemSource: MediaItemSource = MediaCompositionMediaItemSource( + mediaCompositionDataSource = DefaultMediaCompositionDataSource(), + ), + dataSourceFactory: DataSource.Factory = AkamaiTokenDataSource.Factory(), + loadControl: LoadControl = PillarboxLoadControl(), + clock: Clock, ): PillarboxPlayer { return PillarboxPlayer( context = context, @@ -51,7 +88,8 @@ object DefaultPillarbox { dataSourceFactory = dataSourceFactory, mediaItemSource = mediaItemSource, mediaItemTrackerProvider = mediaItemTrackerRepository, - loadControl = loadControl + loadControl = loadControl, + clock = clock, ) } } diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/VectorTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/VectorTest.kt new file mode 100644 index 000000000..5e4f52760 --- /dev/null +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/VectorTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.core.business.integrationlayer.service + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import ch.srgssr.pillarbox.core.business.integrationlayer.service.Vector.getVector +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +class VectorTest { + private lateinit var context: Context + + @BeforeTest + fun setup() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun getVector() { + assertEquals(Vector.MOBILE, context.getVector()) + } + + @Test + @Config(qualifiers = "appliance") + fun `getVector appliance`() { + assertEquals(Vector.MOBILE, context.getVector()) + } + + @Test + @Config(qualifiers = "car") + fun `getVector car`() { + assertEquals(Vector.MOBILE, context.getVector()) + } + + @Test + @Config(qualifiers = "desk") + fun `getVector desk`() { + assertEquals(Vector.MOBILE, context.getVector()) + } + + @Test + @Config(qualifiers = "television") + fun `getVector television`() { + assertEquals(Vector.TV, context.getVector()) + } + + @Test + @Config(qualifiers = "vrheadset") + fun `getVector vrheadset`() { + assertEquals(Vector.MOBILE, context.getVector()) + } + + @Test + @Config(qualifiers = "watch") + fun `getVector watch`() { + assertEquals(Vector.MOBILE, context.getVector()) + } +} diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt new file mode 100644 index 000000000..36d9ae1e8 --- /dev/null +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt @@ -0,0 +1,507 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.core.business.tracker.commandersact + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.test.utils.FakeClock +import androidx.media3.test.utils.robolectric.TestPlayerRunHelper +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import ch.srgssr.pillarbox.analytics.commandersact.CommandersAct +import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType +import ch.srgssr.pillarbox.analytics.commandersact.TCMediaEvent +import ch.srgssr.pillarbox.core.business.DefaultPillarbox +import ch.srgssr.pillarbox.core.business.MediaCompositionMediaItemSource +import ch.srgssr.pillarbox.core.business.MediaItemUrn +import ch.srgssr.pillarbox.core.business.integrationlayer.data.isValidMediaUrn +import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultMediaCompositionDataSource +import ch.srgssr.pillarbox.core.business.tracker.DefaultMediaItemTrackerRepository +import ch.srgssr.pillarbox.core.business.tracker.comscore.ComScoreTracker +import ch.srgssr.pillarbox.player.data.MediaItemSource +import ch.srgssr.pillarbox.player.test.utils.TestPillarboxRunHelper +import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerRepository +import io.mockk.Called +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.runner.RunWith +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@RunWith(AndroidJUnit4::class) +class CommandersActTrackerIntegrationTest { + private lateinit var clock: FakeClock + private lateinit var commandersAct: CommandersAct + private lateinit var player: ExoPlayer + + @BeforeTest + fun setup() { + clock = FakeClock(true) + commandersAct = mockk(relaxed = true) + + val mediaItemTrackerRepository = DefaultMediaItemTrackerRepository( + trackerRepository = MediaItemTrackerRepository(), + commandersAct = commandersAct, + ) + mediaItemTrackerRepository.registerFactory(ComScoreTracker::class.java) { + mockk(relaxed = true) + } + + val urnMediaItemSource = MediaCompositionMediaItemSource( + mediaCompositionDataSource = DefaultMediaCompositionDataSource() + ) + val mediaItemSource = object : MediaItemSource { + override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { + return if (mediaItem.mediaId.isValidMediaUrn()) { + urnMediaItemSource.loadMediaItem(mediaItem) + } else { + mediaItem + } + } + } + + player = DefaultPillarbox( + context = ApplicationProvider.getApplicationContext(), + mediaItemTrackerRepository = mediaItemTrackerRepository, + mediaItemSource = mediaItemSource, + clock = clock, + ) + } + + @Test + fun `player unprepared`() { + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_IDLE) + + verify { commandersAct wasNot Called } + } + + @Test + fun `player prepared and playing, changing media item`() { + val tcMediaEvents = mutableListOf() + + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) + + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + } + confirmVerified(commandersAct) + + assertEquals(3, tcMediaEvents.size) + + assertEquals(MediaEventType.Play, tcMediaEvents[0].eventType) + assertTrue(tcMediaEvents[0].assets.isNotEmpty()) + assertNull(tcMediaEvents[0].sourceId) + + assertEquals(MediaEventType.Stop, tcMediaEvents[1].eventType) + assertTrue(tcMediaEvents[1].assets.isNotEmpty()) + assertNull(tcMediaEvents[1].sourceId) + + assertEquals(MediaEventType.Play, tcMediaEvents[2].eventType) + assertTrue(tcMediaEvents[2].assets.isNotEmpty()) + assertNull(tcMediaEvents[2].sourceId) + } + + @Test + fun `audio URN don't send any analytics`() { + val tcMediaEventSlot = slot() + + player.setMediaItem(MediaItemUrn(URN_AUDIO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEventSlot)) + } + confirmVerified(commandersAct) + + val tcMediaEvent = tcMediaEventSlot.captured + + assertEquals(MediaEventType.Play, tcMediaEvent.eventType) + assertTrue(tcMediaEvent.assets.isNotEmpty()) + assertNull(tcMediaEvent.sourceId) + } + + @Test + fun `URL don't send any analytics`() { + player.setMediaItem(MediaItem.fromUri(URL)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verify { commandersAct wasNot Called } + } + + @Test + fun `player prepared but not playing`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + commandersAct.enableRunningInBackground() + } + confirmVerified(commandersAct) + } + + @Test + fun `player prepared and playing`() { + val tcMediaEventSlot = slot() + + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEventSlot)) + } + confirmVerified(commandersAct) + + val tcMediaEvent = tcMediaEventSlot.captured + + assertEquals(MediaEventType.Play, tcMediaEvent.eventType) + assertTrue(tcMediaEvent.assets.isNotEmpty()) + assertNull(tcMediaEvent.sourceId) + } + + @Test + fun `player prepared and playing, change playback speed`() { + val tcMediaEventSlot = slot() + + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + player.setPlaybackSpeed(2f) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEventSlot)) + } + confirmVerified(commandersAct) + + val tcMediaEvent = tcMediaEventSlot.captured + + assertEquals(MediaEventType.Play, tcMediaEvent.eventType) + assertTrue(tcMediaEvent.assets.isNotEmpty()) + assertNull(tcMediaEvent.sourceId) + } + + @Test + fun `player prepared and playing, change playback speed while playing`() { + val tcMediaEventSlot = slot() + + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) + + clock.advanceTime(5.minutes.inWholeMilliseconds) + player.setPlaybackSpeed(2f) + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEventSlot)) + } + confirmVerified(commandersAct) + + val tcMediaEvent = tcMediaEventSlot.captured + + assertEquals(MediaEventType.Play, tcMediaEvent.eventType) + assertTrue(tcMediaEvent.assets.isNotEmpty()) + assertNull(tcMediaEvent.sourceId) + } + + @Test + fun `player prepared, playing and paused`() { + val tcMediaEvents = mutableListOf() + + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) + + clock.advanceTime(2.minutes.inWholeMilliseconds) + player.playWhenReady = false + + TestPlayerRunHelper.runUntilPlayWhenReady(player, false) + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + } + confirmVerified(commandersAct) + + assertEquals(2, tcMediaEvents.size) + + assertEquals(MediaEventType.Pause, tcMediaEvents[0].eventType) + assertTrue(tcMediaEvents[0].assets.isNotEmpty()) + assertNull(tcMediaEvents[0].sourceId) + + assertEquals(MediaEventType.Play, tcMediaEvents[1].eventType) + assertTrue(tcMediaEvents[1].assets.isNotEmpty()) + assertNull(tcMediaEvents[1].sourceId) + } + + @Test + fun `player prepared, playing, paused, playing again`() { + val tcMediaEvents = mutableListOf() + + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) + + clock.advanceTime(2.minutes.inWholeMilliseconds) + player.playWhenReady = false + + TestPlayerRunHelper.runUntilPlayWhenReady(player, false) + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + clock.advanceTime(4.minutes.inWholeMilliseconds) + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlayWhenReady(player, true) + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + } + confirmVerified(commandersAct) + + assertEquals(3, tcMediaEvents.size) + + assertEquals(MediaEventType.Play, tcMediaEvents[0].eventType) + assertTrue(tcMediaEvents[0].assets.isNotEmpty()) + assertNull(tcMediaEvents[0].sourceId) + + assertEquals(MediaEventType.Pause, tcMediaEvents[1].eventType) + assertTrue(tcMediaEvents[1].assets.isNotEmpty()) + assertNull(tcMediaEvents[1].sourceId) + + assertEquals(MediaEventType.Play, tcMediaEvents[2].eventType) + assertTrue(tcMediaEvents[2].assets.isNotEmpty()) + assertNull(tcMediaEvents[2].sourceId) + } + + @Test + fun `player prepared, playing and stopped`() { + val tcMediaEvents = mutableListOf() + + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) + + clock.advanceTime(2.minutes.inWholeMilliseconds) + player.stop() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_IDLE) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + } + confirmVerified(commandersAct) + + assertEquals(2, tcMediaEvents.size) + + assertEquals(MediaEventType.Stop, tcMediaEvents[0].eventType) + assertTrue(tcMediaEvents[0].assets.isNotEmpty()) + assertNull(tcMediaEvents[0].sourceId) + + assertEquals(MediaEventType.Play, tcMediaEvents[1].eventType) + assertTrue(tcMediaEvents[1].assets.isNotEmpty()) + assertNull(tcMediaEvents[1].sourceId) + } + + @Test + fun `player prepared, playing and seeking`() { + val tcMediaEvents = mutableListOf() + + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) + + clock.advanceTime(2.minutes.inWholeMilliseconds) + player.seekTo(30.seconds.inWholeMilliseconds) + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + } + confirmVerified(commandersAct) + + assertEquals(2, tcMediaEvents.size) + + assertEquals(MediaEventType.Seek, tcMediaEvents[0].eventType) + assertTrue(tcMediaEvents[0].assets.isNotEmpty()) + assertNull(tcMediaEvents[0].sourceId) + + assertEquals(MediaEventType.Play, tcMediaEvents[1].eventType) + assertTrue(tcMediaEvents[1].assets.isNotEmpty()) + assertNull(tcMediaEvents[1].sourceId) + } + + @Test + fun `player prepared and seek`() { + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.seekTo(3.minutes.inWholeMilliseconds) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + commandersAct.enableRunningInBackground() + } + confirmVerified(commandersAct) + } + + @Test + fun `player prepared and stopped`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.stop() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_IDLE) + + verify { commandersAct wasNot Called } + } + + @Test + fun `check uptime and position updates`() { + val delay = 2.seconds + val tcMediaEvents = mutableListOf() + + CommandersActStreaming.HEART_BEAT_DELAY = 0.5.seconds + CommandersActStreaming.POS_PERIOD = 0.5.seconds + CommandersActStreaming.UPTIME_PERIOD = 1.seconds + + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + TestPillarboxRunHelper.runUntilStartOfMediaItem(player, 0) + + clock.advanceTime(delay.inWholeMilliseconds) + Thread.sleep(delay.inWholeMilliseconds) + player.playWhenReady = false + + TestPlayerRunHelper.runUntilPlayWhenReady(player, false) + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + verifyOrder { + commandersAct.enableRunningInBackground() + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + commandersAct.sendTcMediaEvent(capture(tcMediaEvents)) + } + confirmVerified(commandersAct) + + assertEquals(8, tcMediaEvents.size) + + assertEquals(MediaEventType.Pause, tcMediaEvents[0].eventType) + assertTrue(tcMediaEvents[0].assets.isNotEmpty()) + assertNull(tcMediaEvents[0].sourceId) + + assertEquals(MediaEventType.Pos, tcMediaEvents[1].eventType) + assertTrue(tcMediaEvents[1].assets.isNotEmpty()) + assertNull(tcMediaEvents[1].sourceId) + + assertEquals(MediaEventType.Uptime, tcMediaEvents[2].eventType) + assertTrue(tcMediaEvents[2].assets.isNotEmpty()) + assertNull(tcMediaEvents[2].sourceId) + + assertEquals(MediaEventType.Pos, tcMediaEvents[3].eventType) + assertTrue(tcMediaEvents[3].assets.isNotEmpty()) + assertNull(tcMediaEvents[3].sourceId) + + assertEquals(MediaEventType.Pos, tcMediaEvents[4].eventType) + assertTrue(tcMediaEvents[4].assets.isNotEmpty()) + assertNull(tcMediaEvents[4].sourceId) + + assertEquals(MediaEventType.Uptime, tcMediaEvents[5].eventType) + assertTrue(tcMediaEvents[5].assets.isNotEmpty()) + assertNull(tcMediaEvents[5].sourceId) + + assertEquals(MediaEventType.Pos, tcMediaEvents[6].eventType) + assertTrue(tcMediaEvents[6].assets.isNotEmpty()) + assertNull(tcMediaEvents[6].sourceId) + + assertEquals(MediaEventType.Play, tcMediaEvents[7].eventType) + assertTrue(tcMediaEvents[7].assets.isNotEmpty()) + assertNull(tcMediaEvents[7].sourceId) + } + + private companion object { + private const val URL = "https://swi-vod.akamaized.net/videoJson/47603186/master.m3u8" + private const val URN_AUDIO = "urn:rts:audio:13598743" + private const val URN_LIVE_VIDEO = "urn:rts:video:8841634" + private const val URN_NOT_LIVE_VIDEO = "urn:rsi:video:15916771" + } +} diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt new file mode 100644 index 000000000..20489d537 --- /dev/null +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt @@ -0,0 +1,752 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.core.business.tracker.comscore + +import android.view.SurfaceView +import android.view.ViewGroup +import androidx.core.view.updateLayoutParams +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.test.utils.FakeClock +import androidx.media3.test.utils.robolectric.TestPlayerRunHelper +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import ch.srgssr.pillarbox.analytics.BuildConfig +import ch.srgssr.pillarbox.core.business.DefaultPillarbox +import ch.srgssr.pillarbox.core.business.MediaCompositionMediaItemSource +import ch.srgssr.pillarbox.core.business.MediaItemUrn +import ch.srgssr.pillarbox.core.business.integrationlayer.data.isValidMediaUrn +import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultMediaCompositionDataSource +import ch.srgssr.pillarbox.core.business.tracker.DefaultMediaItemTrackerRepository +import ch.srgssr.pillarbox.player.data.MediaItemSource +import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerRepository +import com.comscore.streaming.AssetMetadata +import com.comscore.streaming.StreamingAnalytics +import io.mockk.Called +import io.mockk.MockKVerificationScope +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.runner.RunWith +import kotlin.test.BeforeTest +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@RunWith(AndroidJUnit4::class) +class ComScoreTrackerIntegrationTest { + private lateinit var clock: FakeClock + private lateinit var streamingAnalytics: StreamingAnalytics + private lateinit var player: Player + + @BeforeTest + fun setup() { + clock = FakeClock(true) + streamingAnalytics = mockk(relaxed = true) + + val mediaItemTrackerRepository = DefaultMediaItemTrackerRepository( + trackerRepository = MediaItemTrackerRepository(), + commandersAct = null, + ) + mediaItemTrackerRepository.registerFactory(ComScoreTracker::class.java) { + ComScoreTracker(streamingAnalytics) + } + + val urnMediaItemSource = MediaCompositionMediaItemSource( + mediaCompositionDataSource = DefaultMediaCompositionDataSource(), + ) + val mediaItemSource = object : MediaItemSource { + override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { + return if (mediaItem.mediaId.isValidMediaUrn()) { + urnMediaItemSource.loadMediaItem(mediaItem) + } else { + mediaItem + } + } + } + + player = DefaultPillarbox( + context = ApplicationProvider.getApplicationContext(), + mediaItemTrackerRepository = mediaItemTrackerRepository, + mediaItemSource = mediaItemSource, + clock = clock, + ) + } + + @Test + fun `player unprepared`() { + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_IDLE) + + verify { streamingAnalytics wasNot Called } + } + + @Test + fun `player prepared and playing, changing media item`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifyLiveInformation() + verifyPlayEvent() + verifyEndEvent() + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifySeekEvent(0L) + verifyPlayEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `audio URN don't send any analytics`() { + player.setMediaItem(MediaItemUrn(URN_AUDIO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verify { streamingAnalytics wasNot Called } + } + + @Test + fun `URL don't send any analytics`() { + player.setMediaItem(MediaItem.fromUri(URL)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verify { streamingAnalytics wasNot Called } + } + + @Test + @Ignore("SurfaceView/SurfaceHolder not implemented in Robolectric") + fun `surface size changed`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + // Attach the Player to a surface + val surfaceView = SurfaceView(ApplicationProvider.getApplicationContext()) + surfaceView.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + + player.setVideoSurfaceView(surfaceView) + + // The surface has a non-zero size + surfaceView.updateLayoutParams { + width = 1280 + height = 720 + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + + assertEquals(1280, surfaceView.width) + + // The surface now has a size of 0 + surfaceView.updateLayoutParams { + width = 0 + height = 0 + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + + // The surface has a non-zero size again + surfaceView.updateLayoutParams { + width = 1920 + height = 1080 + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + + // Verify that the proper events are sent + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifyLiveInformation() + verifyPlayEvent() + verifyPauseEvent() + } + confirmVerified(streamingAnalytics) + } + + // region Live media + @Test + fun `live - player prepared but not playing`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `live - player prepared and playing`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifyLiveInformation() + verifyPlayEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `live - player prepared and playing, change playback speed`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + player.setPlaybackSpeed(2f) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 2f) + verifyBufferStartEvent() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStopEvent() + verifyLiveInformation() + verifyPlayEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `live - player prepared and playing, change playback speed while playing`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + clock.advanceTime(5.minutes.inWholeMilliseconds) + player.setPlaybackSpeed(2f) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifyLiveInformation() + verifyPlayEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `live - player prepared, playing and paused`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + clock.advanceTime(2.minutes.inWholeMilliseconds) + player.pause() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifyLiveInformation() + verifyPlayEvent() + verifyPauseEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `live - player prepared, playing, paused, playing again`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + clock.advanceTime(2.minutes.inWholeMilliseconds) + player.pause() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + clock.advanceTime(4.minutes.inWholeMilliseconds) + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifyLiveInformation() + verifyPlayEvent() + verifyPauseEvent() + verifyLiveInformation() + verifyPlayEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `live - player prepared, playing and stopped`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + clock.advanceTime(2.minutes.inWholeMilliseconds) + player.stop() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_IDLE) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifyLiveInformation() + verifyPlayEvent() + verifyEndEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + @Ignore("Need a live DVR available outside of Switzerland") + fun `live - player prepared, playing and seeking`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + player.seekTo(30.seconds.inWholeMilliseconds) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifyLiveInformation() + verifyPlayEvent() + verifySeekStart() + verifyLiveInformation() + verifyBufferStartEvent() + verifyBufferStopEvent() + verifyLiveInformation() + verifyPlayEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + @Ignore("Need a live DVR available outside of Switzerland") + fun `live - player prepared and seek`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.seekTo(3.minutes.inWholeMilliseconds) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `live - player prepared and stopped`() { + player.setMediaItem(MediaItemUrn(URN_LIVE_VIDEO)) + player.prepare() + player.stop() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_IDLE) + + verify { streamingAnalytics wasNot Called } + } + // endregion + + // region Not live media + @Test + fun `not live - player prepared but not playing`() { + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `not live - player prepared and playing`() { + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifySeekEvent(0L) + verifyPlayEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `not live - player prepared and playing, change playback speed`() { + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + player.setPlaybackSpeed(2f) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 2f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifySeekEvent(0L) + verifyPlayEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `not live - player prepared and playing, change playback speed while playing`() { + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + clock.advanceTime(5.minutes.inWholeMilliseconds) + player.setPlaybackSpeed(2f) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifySeekEvent(0L) + verifyPlayEvent() + verifyPlaybackRate(playbackRate = 2f) + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `not live - player prepared, playing and paused`() { + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + clock.advanceTime(2.minutes.inWholeMilliseconds) + player.pause() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifySeekEvent(0L) + verifyPlayEvent() + verifyPauseEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `not live - player prepared, playing, paused, playing again`() { + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + clock.advanceTime(2.minutes.inWholeMilliseconds) + player.pause() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + clock.advanceTime(4.minutes.inWholeMilliseconds) + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifySeekEvent(0L) + verifyPlayEvent() + verifyPauseEvent() + verifySeekEvent(0L) + verifyPlayEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `not live - player prepared, playing and stopped`() { + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + clock.advanceTime(2.minutes.inWholeMilliseconds) + player.stop() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_IDLE) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifySeekEvent(0L) + verifyPlayEvent() + verifyEndEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `not live - player prepared, playing and seeking`() { + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.playWhenReady = true + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + player.seekTo(30.seconds.inWholeMilliseconds) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifySeekEvent(0L) + verifyPlayEvent() + verifySeekStart() + verifySeekEvent(30_000L) + verifyBufferStartEvent() + verifyBufferStopEvent() + verifySeekEvent(30_000L) + verifyPlayEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `not live - player prepared and seek`() { + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.seekTo(3.minutes.inWholeMilliseconds) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + verifyPlayerInformation() + verifyCreatePlaybackSession() + verifyMetadata() + verifyPlaybackRate(playbackRate = 1f) + verifyBufferStartEvent() + verifyBufferStopEvent() + } + confirmVerified(streamingAnalytics) + } + + @Test + fun `not live - player prepared and stopped`() { + player.setMediaItem(MediaItemUrn(URN_NOT_LIVE_VIDEO)) + player.prepare() + player.stop() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_IDLE) + + verify { streamingAnalytics wasNot Called } + } + // endregion + + // region Events verification + @Suppress("UnusedReceiverParameter") + private fun MockKVerificationScope.verifyPlayerInformation( + mediaPlayerName: String = "Pillarbox", + mediaPlayerVersion: String = BuildConfig.VERSION_NAME, + ) { + streamingAnalytics.setMediaPlayerName(mediaPlayerName) + streamingAnalytics.setMediaPlayerVersion(mediaPlayerVersion) + } + + @Suppress("UnusedReceiverParameter") + private fun MockKVerificationScope.verifyCreatePlaybackSession() { + streamingAnalytics.createPlaybackSession() + } + + private fun MockKVerificationScope.verifyMetadata(metadata: AssetMetadata = any()) { + streamingAnalytics.setMetadata(metadata) + } + + @Suppress("UnusedReceiverParameter") + private fun MockKVerificationScope.verifyPlaybackRate(playbackRate: Float) { + streamingAnalytics.notifyChangePlaybackRate(playbackRate) + } + + @Suppress("UnusedReceiverParameter") + private fun MockKVerificationScope.verifyPauseEvent() { + streamingAnalytics.notifyPause() + } + + @Suppress("UnusedReceiverParameter") + private fun MockKVerificationScope.verifyPlayEvent() { + streamingAnalytics.notifyPlay() + } + + @Suppress("UnusedReceiverParameter") + private fun MockKVerificationScope.verifyEndEvent() { + streamingAnalytics.notifyEnd() + } + + @Suppress("UnusedReceiverParameter") + private fun MockKVerificationScope.verifyBufferStartEvent() { + streamingAnalytics.notifyBufferStart() + } + + @Suppress("UnusedReceiverParameter") + private fun MockKVerificationScope.verifyBufferStopEvent() { + streamingAnalytics.notifyBufferStop() + } + + @Suppress("UnusedReceiverParameter") + private fun MockKVerificationScope.verifySeekEvent(position: Long) { + streamingAnalytics.startFromPosition(position) + } + + @Suppress("UnusedReceiverParameter") + private fun MockKVerificationScope.verifySeekStart() { + streamingAnalytics.notifySeekStart() + } + + private fun MockKVerificationScope.verifyLiveInformation( + dvrWindowLength: Long = any(), + dvrWindowOffset: Long = any(), + ) { + streamingAnalytics.setDvrWindowLength(dvrWindowLength) + streamingAnalytics.startFromDvrWindowOffset(dvrWindowOffset) + } + // endregion + + private companion object { + private const val URL = "https://swi-vod.akamaized.net/videoJson/47603186/master.m3u8" + private const val URN_AUDIO = "urn:rts:audio:13598743" + private const val URN_LIVE_VIDEO = "urn:rts:video:8841634" + private const val URN_NOT_LIVE_VIDEO = "urn:rsi:video:15916771" + } +} diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerTest.kt index 64737b547..6b5b8c51b 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerTest.kt @@ -22,6 +22,7 @@ import org.junit.runner.RunWith import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertIs @RunWith(AndroidJUnit4::class) class ComScoreTrackerTest { @@ -187,4 +188,11 @@ class ComScoreTrackerTest { streamingAnalytics.setMetadata(any()) } } + + @Test + fun `ComScoreTracker$Factory returns an instance of ComScoreTracker`() { + val mediaItemTracker = ComScoreTracker.Factory().create() + + assertIs(mediaItemTracker) + } } diff --git a/pillarbox-player-testutils/build.gradle.kts b/pillarbox-player-testutils/build.gradle.kts index f0a91a529..ba5be0ad1 100644 --- a/pillarbox-player-testutils/build.gradle.kts +++ b/pillarbox-player-testutils/build.gradle.kts @@ -47,6 +47,8 @@ android { dependencies { api(libs.androidx.media3.common) compileOnly(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.test.utils.robolectric) + implementation(libs.guava) runtimeOnly(libs.kotlinx.coroutines.android) compileOnly(libs.kotlinx.coroutines.core) } diff --git a/pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/TestPillarboxRunHelper.kt b/pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/TestPillarboxRunHelper.kt new file mode 100644 index 000000000..e74221496 --- /dev/null +++ b/pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/TestPillarboxRunHelper.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.test.utils + +import android.os.Looper +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.util.Assertions +import androidx.media3.common.util.Clock +import androidx.media3.common.util.ConditionVariable +import androidx.media3.common.util.Util +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.test.utils.robolectric.RobolectricUtil +import androidx.media3.test.utils.robolectric.TestPlayerRunHelper +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicBoolean + +object TestPillarboxRunHelper { + private fun verifyMainTestThread(player: Player) { + check(!(Looper.myLooper() != Looper.getMainLooper() || player.applicationLooper != Looper.getMainLooper())) + } + + private fun verifyPlaybackThreadIsAlive(player: ExoPlayer) { + Assertions.checkState(player.playbackLooper.thread.isAlive, "Playback thread is not alive, has the player been released?") + } + + /** + * Runs tasks of the main Looper until [Player.Listener.onEvents] matches the + * expected state or a playback error occurs. + * + *

If a playback error occurs it will be thrown wrapped in an {@link IllegalStateException}. + * + * @param player The [Player]. + * @param expectedEvent The expected [Player.Event] if null wait until first [Player.Listener.onEvents]. + * @throws TimeoutException If the [RobolectricUtil.DEFAULT_TIMEOUT_MS] is exceeded. + */ + @Throws(TimeoutException::class) + fun runUntilEvent(player: Player, expectedEvent: @Player.Event Int? = null) { + verifyMainTestThread(player) + if (player is ExoPlayer) { + verifyPlaybackThreadIsAlive(player) + } + val receivedCallback = AtomicBoolean(false) + val listener: Player.Listener = object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + if (expectedEvent?.let { events.contains(it) } != false) { + receivedCallback.set(true) + } + } + } + player.addListener(listener) + RobolectricUtil.runMainLooperUntil({ receivedCallback.get() || player.playerError != null }, 20_000, Clock.DEFAULT) + player.removeListener(listener) + if (player.playerError != null) { + throw IllegalStateException(player.playerError) + } + } + + /** + * Runs tasks of the main Looper until [Player.Listener.onPlaybackParametersChanged] is called or a playback error occurs. + * + *

If a playback error occurs it will be thrown wrapped in an {@link IllegalStateException}. + * + * @param player The [Player]. + * @throws TimeoutException If the [RobolectricUtil.DEFAULT_TIMEOUT_MS] is exceeded. + */ + @Throws(TimeoutException::class) + fun runUntilPlaybackParametersChanged(player: Player) { + verifyMainTestThread(player) + if (player is ExoPlayer) { + verifyPlaybackThreadIsAlive(player) + } + val receivedCallback = AtomicBoolean(false) + val listener: Player.Listener = object : Player.Listener { + override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { + receivedCallback.set(true) + } + } + player.addListener(listener) + RobolectricUtil.runMainLooperUntil { receivedCallback.get() || player.playerError != null } + player.removeListener(listener) + if (player.playerError != null) { + throw IllegalStateException(player.playerError) + } + } + + /** + * Same as [TestPlayerRunHelper.playUntilStartOfMediaItem], but doesn't pause the player afterwards. + * + * @param player The [Player]. + * @param mediaItemIndex The index of the media item. + * + * @throws TimeoutException If the [default timeout][RobolectricUtil.DEFAULT_TIMEOUT_MS] is exceeded. + * + * @see TestPlayerRunHelper.playUntilStartOfMediaItem + */ + @Throws(TimeoutException::class) + fun runUntilStartOfMediaItem(player: ExoPlayer, mediaItemIndex: Int) { + verifyMainTestThread(player) + verifyPlaybackThreadIsAlive(player) + + val applicationLooper = Util.getCurrentOrMainLooper() + val messageHandled = AtomicBoolean(false) + + player + .createMessage { _, _ -> + // Block playback thread until pause command has been sent from test thread. + val blockPlaybackThreadCondition = ConditionVariable() + + player.clock + .createHandler(applicationLooper, null) + .post { + messageHandled.set(true) + blockPlaybackThreadCondition.open() + } + + try { + player.clock.onThreadBlocked() + blockPlaybackThreadCondition.block() + } catch (e: InterruptedException) { + // Ignore. + } + } + .setPosition(mediaItemIndex, 0L) + .send() + player.play() + RobolectricUtil.runMainLooperUntil { messageHandled.get() || player.playerError != null } + if (player.playerError != null) { + throw IllegalStateException(player.playerError) + } + } +} diff --git a/pillarbox-player/build.gradle.kts b/pillarbox-player/build.gradle.kts index 9c9b7ca26..539edc6da 100644 --- a/pillarbox-player/build.gradle.kts +++ b/pillarbox-player/build.gradle.kts @@ -90,6 +90,8 @@ dependencies { testImplementation(libs.androidx.media3.test.utils) testImplementation(libs.androidx.media3.test.utils.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.junit) testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt index dc919c9ce..07cfcceff 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt @@ -102,10 +102,10 @@ class PillarboxPlayer internal constructor( constructor( context: Context, mediaItemSource: MediaItemSource, - dataSourceFactory: DataSource.Factory, - loadControl: LoadControl, - mediaItemTrackerProvider: MediaItemTrackerProvider, - seekIncrement: SeekIncrement, + dataSourceFactory: DataSource.Factory = DefaultHttpDataSource.Factory(), + loadControl: LoadControl = PillarboxLoadControl(), + mediaItemTrackerProvider: MediaItemTrackerProvider = MediaItemTrackerRepository(), + seekIncrement: SeekIncrement = SeekIncrement(), clock: Clock, ) : this( ExoPlayer.Builder(context) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/extension/MediaItem.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/extension/MediaItem.kt index 50ffbd09c..6728d0247 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/extension/MediaItem.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/extension/MediaItem.kt @@ -12,13 +12,8 @@ import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerData * * @return null if localConfiguration.tag is null or tag is not a [MediaItemTrackerData]. */ -@Suppress("SwallowedException") fun MediaItem.getMediaItemTrackerDataOrNull(): MediaItemTrackerData? { - return try { - return localConfiguration?.tag as MediaItemTrackerData? - } catch (e: ClassCastException) { - null - } + return localConfiguration?.tag as? MediaItemTrackerData } /** diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTracker.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTracker.kt index 838cf6364..bb8843b93 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTracker.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTracker.kt @@ -47,7 +47,7 @@ interface MediaItemTracker { /** * Factory */ - interface Factory { + fun interface Factory { /** * Create a new instance of a [MediaItemTracker] * diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPillarboxPlayerPlaybackSpeed.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPillarboxPlayerPlaybackSpeed.kt new file mode 100644 index 000000000..9015ca60f --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPillarboxPlayerPlaybackSpeed.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player + +import android.content.Context +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Timeline.Window +import androidx.media3.test.utils.FakeClock +import androidx.media3.test.utils.robolectric.TestPlayerRunHelper +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import ch.srgssr.pillarbox.player.data.MediaItemSource +import ch.srgssr.pillarbox.player.extension.getPlaybackSpeed +import ch.srgssr.pillarbox.player.test.utils.TestPillarboxRunHelper +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TestPillarboxPlayerPlaybackSpeed { + private lateinit var player: PillarboxPlayer + + @Before + fun createPlayer() { + val context = ApplicationProvider.getApplicationContext() + player = PillarboxPlayer( + context = context, + clock = FakeClock(true), + mediaItemSource = object : MediaItemSource { + override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { + return mediaItem + } + } + ) + } + + @After + fun releasePlayer() { + player.release() + } + + @Test + fun `playback speed is always 1x when playing live without dvr`() { + player.apply { + setMediaItem(MediaItem.fromUri(LIVE_ONLY_URL)) + prepare() + play() + } + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + Assert.assertEquals(Player.STATE_READY, player.playbackState) + + player.setPlaybackSpeed(2f) + Assert.assertEquals(1f, player.getPlaybackSpeed()) + + player.seekTo(0) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + player.setPlaybackSpeed(2f) + Assert.assertEquals(1f, player.getPlaybackSpeed()) + } + + @Test + fun `playback speed is at 1x when at live edge otherwise it can be changed`() { + player.apply { + setMediaItem(MediaItem.fromUri(LIVE_DVR_URL)) + prepare() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + player.setPlaybackSpeed(2f) + Assert.assertEquals(1f, player.getPlaybackSpeed()) + + player.seekTo(0) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + player.setPlaybackSpeed(2f) + Assert.assertEquals(2f, player.getPlaybackSpeed()) + TestPillarboxRunHelper.runUntilEvent(player, Player.EVENT_IS_LOADING_CHANGED) + Assert.assertEquals(2f, player.getPlaybackSpeed()) + } + + @Test + fun `playback speed goes to 1x when reaching live edge`() { + player.setMediaItem(MediaItem.fromUri(LIVE_DVR_URL)) + player.prepare() + player.play() + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + val liveEdgePosition = player.currentTimeline.getWindow(0, Window()).defaultPositionMs + Assert.assertNotEquals(C.TIME_UNSET, liveEdgePosition) + player.seekTo(liveEdgePosition - 5_000) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + val speed = 2f + player.setPlaybackSpeed(speed) + Assert.assertEquals(speed, player.getPlaybackSpeed()) + + TestPillarboxRunHelper.runUntilPlaybackParametersChanged(player) + Assert.assertEquals(1f, player.getPlaybackSpeed()) + } + + @Test + fun `playback speed changes when not playing live content`() { + player.setMediaItem(MediaItem.fromUri(VOD_URL)) + player.prepare() + player.play() + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + val speed = 2f + player.setPlaybackSpeed(speed) + TestPillarboxRunHelper.runUntilEvent(player) + Assert.assertEquals(speed, player.getPlaybackSpeed()) + + player.setPlaybackSpeed(1f) + TestPillarboxRunHelper.runUntilEvent(player) + Assert.assertEquals(1f, player.getPlaybackSpeed()) + } + + companion object { + const val LIVE_DVR_URL = "https://rtsc3video.akamaized.net/hls/live/2042837/c3video/3/playlist.m3u8" + const val LIVE_ONLY_URL = "https://rtsc3video.akamaized.net/hls/live/2042837/c3video/3/playlist.m3u8?dw=0" + const val VOD_URL = "https://rts-vod-amd.akamaized.net/ww/13317145/f1d49f18-f302-37ce-866c-1c1c9b76a824/master.m3u8" + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/extension/VideoSizeTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/extension/VideoSizeTest.kt index 3a5a4fb87..838a73a40 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/extension/VideoSizeTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/extension/VideoSizeTest.kt @@ -8,7 +8,6 @@ import android.util.Rational import androidx.media3.common.VideoSize import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.runner.RunWith -import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals @@ -79,21 +78,18 @@ class VideoSizeTest { } @Test - @Ignore("https://github.com/robolectric/robolectric/issues/8786") fun `toRational unknown video size`() { val input = VideoSize.UNKNOWN assertEquals(RATIONAL_ONE, input.toRational()) } @Test - @Ignore("https://github.com/robolectric/robolectric/issues/8786") fun `toRational with a 16-9 aspect ratio`() { val input = VideoSize(1920, 1080) assertEquals(Rational(1920, 1080), input.toRational()) } @Test - @Ignore("https://github.com/robolectric/robolectric/issues/8786") fun `toRational with a square aspect ratio`() { val input = VideoSize(500, 500) assertEquals(Rational(500, 500), input.toRational())