diff --git a/connectors/uplynk/src/main/java/com/theoplayer/android/connector/uplynk/internal/AdHandler.kt b/connectors/uplynk/src/main/java/com/theoplayer/android/connector/uplynk/internal/AdHandler.kt index d2e9e1a7..1bace28a 100644 --- a/connectors/uplynk/src/main/java/com/theoplayer/android/connector/uplynk/internal/AdHandler.kt +++ b/connectors/uplynk/src/main/java/com/theoplayer/android/connector/uplynk/internal/AdHandler.kt @@ -10,24 +10,21 @@ import java.util.WeakHashMap import kotlin.time.Duration import kotlin.time.DurationUnit -private val Duration.secToMs: Int - get() = this.toInt(DurationUnit.MILLISECONDS) - @Suppress("UnstableApiUsage") internal class AdHandler(private val controller: ServerSideAdIntegrationController) { private val scheduledAds = WeakHashMap() fun createAdBreak(adBreak: UplynkAdBreak) { val adBreakInit = AdBreakInit( - timeOffset = adBreak.timeOffset.secToMs, - maxDuration = adBreak.duration.secToMs, + timeOffset = adBreak.timeOffset.inWholeSeconds.toInt(), + maxDuration = adBreak.duration.inWholeSeconds.toInt(), customData = adBreak ) val currentAdBreak = controller.createAdBreak(adBreakInit) adBreak.ads.forEach { val adInit = AdInit( type = adBreak.type, - duration = it.duration.secToMs, + duration = it.duration.inWholeSeconds.toInt(), customData = it ) scheduledAds[it] = controller.createAd(adInit, currentAdBreak) @@ -61,5 +58,4 @@ internal class AdHandler(private val controller: ServerSideAdIntegrationControll controller.updateAdProgress(ad, progress) } - } diff --git a/connectors/uplynk/src/main/java/com/theoplayer/android/connector/uplynk/internal/UplynkSsaiDescriptionConverter.kt b/connectors/uplynk/src/main/java/com/theoplayer/android/connector/uplynk/internal/UplynkSsaiDescriptionConverter.kt index 87081742..089da78a 100644 --- a/connectors/uplynk/src/main/java/com/theoplayer/android/connector/uplynk/internal/UplynkSsaiDescriptionConverter.kt +++ b/connectors/uplynk/src/main/java/com/theoplayer/android/connector/uplynk/internal/UplynkSsaiDescriptionConverter.kt @@ -1,6 +1,5 @@ package com.theoplayer.android.connector.uplynk.internal -import com.theoplayer.android.connector.uplynk.UplynkAssetType import com.theoplayer.android.connector.uplynk.UplynkSsaiDescription import kotlin.time.Duration @@ -10,59 +9,15 @@ internal class UplynkSsaiDescriptionConverter { fun buildPreplayVodUrl(ssaiDescription: UplynkSsaiDescription): String = with(ssaiDescription) { val prefix = prefix ?: DEFAULT_PREFIX - var url = "$prefix/preplay/$urlAssetId?v=2" - if (ssaiDescription.contentProtected) { - url += "&manifest=mpd" - url += "&rmt=wv" - } - - url += "&$pingParameters&$urlParameters" - - return url + return "$prefix/preplay/$urlAssetId?v=2$drmParameters$pingParameters$urlParameters" } fun buildPreplayLiveUrl(ssaiDescription: UplynkSsaiDescription): String = with(ssaiDescription) { val prefix = prefix ?: DEFAULT_PREFIX - var url = "$prefix/preplay/$urlAssetType/$urlAssetId?v=2" - if (ssaiDescription.contentProtected) { - url += "&manifest=mpd" - url += "&rmt=wv" - } - - url += "&$pingParameters&$urlParameters" - - return url + return "$prefix/preplay/$urlAssetType/$urlAssetId?v=2$drmParameters$pingParameters$urlParameters" } - private val UplynkSsaiDescription.urlParameters - get() = preplayParameters.map { "${it.key}=${it.value}" }.joinToString("&") - - private val UplynkSsaiDescription.pingParameters: String - get() { - val feature = UplynkPingFeatures.from(this) - return if (feature == UplynkPingFeatures.NO_PING) { - "ad.pingc=0" - } else { - "ad.pingc=1&ad.pingf=${feature.pingfValue}" - } - } - - private val UplynkSsaiDescription.urlAssetType - get() = when (assetType) { - UplynkAssetType.ASSET -> "" - UplynkAssetType.CHANNEL -> "channel" - UplynkAssetType.EVENT -> "event" - } - - private val UplynkSsaiDescription.urlAssetId - get() = when { - assetIds.isEmpty() && externalIds.size == 1 -> "$userId/${externalIds.first()}.json" - assetIds.isEmpty() && externalIds.size > 1 -> "$userId/${externalIds.joinToString(",")}/multiple.json" - assetIds.size == 1 -> "${assetIds.first()}.json" - else -> assetIds.joinToString(separator = ",") + "/multiple.json" - } - fun buildAssetInfoUrls( ssaiDescription: UplynkSsaiDescription, sessionId: String, diff --git a/connectors/uplynk/src/main/java/com/theoplayer/android/connector/uplynk/internal/UplynkSsaiDescriptionUrlExtensions.kt b/connectors/uplynk/src/main/java/com/theoplayer/android/connector/uplynk/internal/UplynkSsaiDescriptionUrlExtensions.kt new file mode 100644 index 00000000..09fb44bb --- /dev/null +++ b/connectors/uplynk/src/main/java/com/theoplayer/android/connector/uplynk/internal/UplynkSsaiDescriptionUrlExtensions.kt @@ -0,0 +1,43 @@ +package com.theoplayer.android.connector.uplynk.internal + +import com.theoplayer.android.connector.uplynk.UplynkAssetType +import com.theoplayer.android.connector.uplynk.UplynkSsaiDescription + +internal val UplynkSsaiDescription.drmParameters: String + get() = if (contentProtected) { + "&manifest=mpd&rmt=wv" + } else { + "" + } + +internal val UplynkSsaiDescription.urlParameters + get() = if (preplayParameters.isNotEmpty()) { + preplayParameters.map { "${it.key}=${it.value}" }.joinToString("&", prefix = "&") + } else { + "" + } + +internal val UplynkSsaiDescription.pingParameters: String + get() { + val feature = UplynkPingFeatures.from(this) + return if (feature == UplynkPingFeatures.NO_PING) { + "&ad.pingc=0" + } else { + "&ad.pingc=1&ad.pingf=${feature.pingfValue}" + } + } + +internal val UplynkSsaiDescription.urlAssetType + get() = when (assetType) { + UplynkAssetType.ASSET -> "" + UplynkAssetType.CHANNEL -> "channel" + UplynkAssetType.EVENT -> "event" + } + +internal val UplynkSsaiDescription.urlAssetId + get() = when { + assetIds.isEmpty() && externalIds.size == 1 -> "$userId/${externalIds.first()}.json" + assetIds.isEmpty() && externalIds.size > 1 -> "$userId/${externalIds.joinToString(",")}/multiple.json" + assetIds.size == 1 -> "${assetIds.first()}.json" + else -> assetIds.joinToString(separator = ",") + "/multiple.json" + } diff --git a/connectors/uplynk/src/test/java/com/theoplayer/android/connector/uplynk/internal/AdHandlerTest.kt b/connectors/uplynk/src/test/java/com/theoplayer/android/connector/uplynk/internal/AdHandlerTest.kt new file mode 100644 index 00000000..2440d54a --- /dev/null +++ b/connectors/uplynk/src/test/java/com/theoplayer/android/connector/uplynk/internal/AdHandlerTest.kt @@ -0,0 +1,253 @@ +package com.theoplayer.android.connector.uplynk.internal + +import com.theoplayer.android.api.ads.Ad +import com.theoplayer.android.api.ads.AdBreak +import com.theoplayer.android.api.ads.AdBreakInit +import com.theoplayer.android.api.ads.AdInit +import com.theoplayer.android.api.ads.ServerSideAdIntegrationController +import com.theoplayer.android.connector.uplynk.network.UplynkAd +import com.theoplayer.android.connector.uplynk.network.UplynkAdBreak +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlin.time.toDuration + + +class AdHandlerTest { + + @Mock + private lateinit var mockAd: Ad + + @Mock + private lateinit var mockAdBreak: AdBreak + + @Mock + private lateinit var controller: ServerSideAdIntegrationController + private lateinit var adHandler: AdHandler + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + whenever(controller.createAdBreak(any())).thenReturn(mockAdBreak) + whenever(controller.createAd(any(), any())).thenReturn(mockAd) + adHandler = AdHandler(controller) + } + + @Test + fun createAdBreak_always_callsControllerCreateAdBreak() { + val adBreak = UplynkAdBreak( + listOf(), + "", + "", + 100.toDuration(DurationUnit.SECONDS), + 200.toDuration(DurationUnit.SECONDS) + ) + + adHandler.createAdBreak(adBreak) + + verify(controller).createAdBreak( + eq( + AdBreakInit( + timeOffset = 100, + maxDuration = 200, + customData = adBreak + ) + ) + ) + } + + @Test + fun createAdBreak_withEmptyAds_neverCallsControllerCreateAd() { + val adBreak = UplynkAdBreak( + listOf(), + "", + "", + 100.toDuration(DurationUnit.SECONDS), + 200.toDuration(DurationUnit.SECONDS) + ) + + adHandler.createAdBreak(adBreak) + + verify(controller, never()).createAd(any(), any()) + } + + @Test + fun createAdBreak_withAds_callsControllerCreateAdForEveryAd() { + val adBreak = UplynkAdBreak( + listOf( + UplynkAd( + null, + listOf(), + "", + "", + mapOf(), + 1f, + 2f, + 100.toDuration(DurationUnit.SECONDS) + ), + UplynkAd( + null, + listOf(), + "", + "", + mapOf(), + 1f, + 2f, + 200.toDuration(DurationUnit.SECONDS) + ), + UplynkAd( + null, + listOf(), + "", + "", + mapOf(), + 1f, + 2f, + 300.toDuration(DurationUnit.SECONDS) + ), + ), "", "", 400.toDuration(DurationUnit.SECONDS), 500.toDuration(DurationUnit.SECONDS) + ) + + adHandler.createAdBreak(adBreak) + + verify(controller, times(adBreak.ads.size)).createAd(any(), eq(mockAdBreak)) + verify(controller).createAd( + eq( + AdInit( + type = adBreak.type, + duration = 100, + customData = adBreak.ads[0] + ) + ), eq(mockAdBreak) + ) + verify(controller).createAd( + eq( + AdInit( + type = adBreak.type, + duration = 200, + customData = adBreak.ads[1] + ) + ), eq(mockAdBreak) + ) + verify(controller).createAd( + eq( + AdInit( + type = adBreak.type, + duration = 300, + customData = adBreak.ads[2] + ) + ), eq(mockAdBreak) + ) + } + + @Test + fun onAdBegin_forUnknownAd_throwsAnException() { + val ad = + UplynkAd(null, listOf(), "", "", mapOf(), 1f, 2f, 100.toDuration(DurationUnit.SECONDS)) + + assertThrows(java.lang.IllegalStateException::class.java) { + adHandler.onAdBegin(ad) + } + } + + @Test + fun onAdBegin_forCreatedAdBreak_callsBeginAd() { + val uplynkAd = + UplynkAd(null, listOf(), "", "", mapOf(), 1f, 2f, 100.toDuration(DurationUnit.SECONDS)) + val adBreak = UplynkAdBreak( + listOf(uplynkAd), + "", + "", + 400.toDuration(DurationUnit.SECONDS), + 500.toDuration(DurationUnit.SECONDS) + ) + adHandler.createAdBreak(adBreak) + + adHandler.onAdBegin(uplynkAd) + + verify(controller).beginAd(eq(mockAd)) + } + + @Test + fun onAdEnd_forUnknownAd_throwsAnException() { + val ad = + UplynkAd(null, listOf(), "", "", mapOf(), 1f, 2f, 100.toDuration(DurationUnit.SECONDS)) + + assertThrows(java.lang.IllegalStateException::class.java) { + adHandler.onAdEnd(ad) + } + } + + @Test + fun onAdEnd_forCreatedAdBreak_callsEndAd() { + val uplynkAd = + UplynkAd(null, listOf(), "", "", mapOf(), 1f, 2f, 100.toDuration(DurationUnit.SECONDS)) + val adBreak = UplynkAdBreak( + listOf(uplynkAd), + "", + "", + 400.toDuration(DurationUnit.SECONDS), + 500.toDuration(DurationUnit.SECONDS) + ) + adHandler.createAdBreak(adBreak) + + adHandler.onAdEnd(uplynkAd) + + verify(controller).endAd(eq(mockAd)) + } + + @Test + fun onAdProgressUpdate_forCreatedAdBreak_callsUpdateAdProgress() { + val uplynkAd = + UplynkAd(null, listOf(), "", "", mapOf(), 1f, 2f, 100.toDuration(DurationUnit.SECONDS)) + val adBreak = UplynkAdBreak( + listOf(uplynkAd), + "", + "", + 400.toDuration(DurationUnit.SECONDS), + 500.toDuration(DurationUnit.SECONDS) + ) + adHandler.createAdBreak(adBreak) + + adHandler.onAdProgressUpdate( + UplynkAdState(uplynkAd, AdState.STARTED), + adBreak, + 450.toDuration(DurationUnit.SECONDS) + ) + + verify(controller).updateAdProgress(eq(mockAd), eq(0.5)) + } + + @Test + fun onAdProgressUpdate_forUnknownAd_throwsAnException() { + val ad = + UplynkAd(null, listOf(), "", "", mapOf(), 1f, 2f, 100.toDuration(DurationUnit.SECONDS)) + val adBreak = UplynkAdBreak( + listOf(ad), + "", + "", + 400.toDuration(DurationUnit.SECONDS), + 500.toDuration(DurationUnit.SECONDS) + ) + + + assertThrows(java.lang.IllegalStateException::class.java) { + adHandler.onAdProgressUpdate( + UplynkAdState(ad, AdState.NOT_PLAYED), + adBreak, + Duration.ZERO + ) + } + } + +} \ No newline at end of file diff --git a/connectors/uplynk/src/test/java/com/theoplayer/android/connector/uplynk/internal/UplynkSsaiDescriptionConverterTest.kt b/connectors/uplynk/src/test/java/com/theoplayer/android/connector/uplynk/internal/UplynkSsaiDescriptionConverterTest.kt index 331032a3..aa88fb38 100644 --- a/connectors/uplynk/src/test/java/com/theoplayer/android/connector/uplynk/internal/UplynkSsaiDescriptionConverterTest.kt +++ b/connectors/uplynk/src/test/java/com/theoplayer/android/connector/uplynk/internal/UplynkSsaiDescriptionConverterTest.kt @@ -7,6 +7,8 @@ import org.junit.Before import org.junit.Test import org.mockito.MockitoAnnotations import kotlin.test.assertContains +import kotlin.time.DurationUnit +import kotlin.time.toDuration class UplynkSsaiDescriptionConverterTest { private lateinit var ssaiDescription: UplynkSsaiDescription @@ -25,14 +27,14 @@ class UplynkSsaiDescriptionConverterTest { } @Test - fun buildPreplayUrl_whenPrefixIsNotNull_startsUrlFromPrefix() { + fun buildPreplayVodUrl_whenPrefixIsNotNull_startsUrlFromPrefix() { val result = converter.buildPreplayVodUrl(ssaiDescription) assertTrue(result.startsWith("preplayprefix")) } @Test - fun buildPreplayUrl_whenPrefixIsNull_startsUrlFromPrefix() { + fun buildPreplayVodUrl_whenPrefixIsNull_startsUrlFromPrefix() { ssaiDescription = ssaiDescription.copy(prefix = null) val result = converter.buildPreplayVodUrl(ssaiDescription) @@ -41,14 +43,14 @@ class UplynkSsaiDescriptionConverterTest { } @Test - fun buildPreplayUrl_whenAssetIdHasMultipleValues_addsThemAsCommaSeparatedList() { + fun buildPreplayVodUrl_whenAssetIdHasMultipleValues_addsThemAsCommaSeparatedList() { val result = converter.buildPreplayVodUrl(ssaiDescription) assertTrue(result.contains("/asset1,asset2,asset3/")) } @Test - fun buildPreplayUrl_whenAssetIdHasSingleValue_usesItAsJsonFilename() { + fun buildPreplayVodUrl_whenAssetIdHasSingleValue_usesItAsJsonFilename() { ssaiDescription = ssaiDescription.copy(assetIds = listOf("singleasset")) val result = converter.buildPreplayVodUrl(ssaiDescription) @@ -57,7 +59,7 @@ class UplynkSsaiDescriptionConverterTest { } @Test - fun buildPreplayUrl_whenAssetIdsIsEmpty_addsUserIdAndExternalIds() { + fun buildPreplayVodUrl_whenAssetIdsIsEmpty_addsUserIdAndExternalIds() { ssaiDescription = UplynkSsaiDescription( assetIds = listOf(), externalIds = listOf("extId1", "extId2"), userId = "userId" ) @@ -69,7 +71,7 @@ class UplynkSsaiDescriptionConverterTest { } @Test - fun buildPreplayUrl_whenAssetIdsIsEmptyAndExternalIdIsSingle_addsUserIdAndExternalId() { + fun buildPreplayVodUrl_whenAssetIdsIsEmptyAndExternalIdIsSingle_addsUserIdAndExternalId() { ssaiDescription = UplynkSsaiDescription( assetIds = listOf(), externalIds = listOf("extId1"), userId = "userId" ) @@ -81,7 +83,7 @@ class UplynkSsaiDescriptionConverterTest { } @Test - fun buildPreplayUrl_always_followsTheTemplate() { + fun buildPreplayVodUrl_always_followsTheTemplate() { val result = converter.buildPreplayVodUrl(ssaiDescription) val items = result.split("/", "?") @@ -143,4 +145,43 @@ class UplynkSsaiDescriptionConverterTest { assertContains(result, "prefix/player/assetinfo/ext/userId/extId1.json") assertContains(result, "prefix/player/assetinfo/ext/userId/extId2.json") } + + @Test + fun buildStartPingUrl_always_hasStartParameter() { + val result = converter.buildStartPingUrl("prefix", "sessionId", 200.toDuration(DurationUnit.SECONDS)) + + assertContains(result, "ev=start") + } + + @Test + fun buildStartPingUrl_always_startsTheSameAsNormalPingRequest() { + val result = converter.buildStartPingUrl("prefix", "sessionId", 200.toDuration(DurationUnit.SECONDS)) + val pingUrl = converter.buildPingUrl("prefix", "sessionId", 200.toDuration(DurationUnit.SECONDS)) + + assertContains(result, pingUrl) + } + + @Test + fun buildSeekedPingUrl_always_hasSeekParameters() { + val result = converter.buildSeekedPingUrl("prefix", "sessionId", 200.toDuration(DurationUnit.SECONDS), 180.toDuration(DurationUnit.SECONDS)) + + assertContains(result, "ev=seek") + assertContains(result, "ft=180") + } + + @Test + fun buildSeekedPingUrl_always_startsTheSameAsNormalPingRequest() { + val result = converter.buildSeekedPingUrl("prefix", "sessionId", 200.toDuration(DurationUnit.SECONDS), 180.toDuration(DurationUnit.SECONDS)) + val pingUrl = converter.buildPingUrl("prefix", "sessionId", 200.toDuration(DurationUnit.SECONDS)) + + assertContains(result, pingUrl) + } + + @Test + fun buildPingUrl_always_followsThePingTemplate() { + val currentTime = 200 + val result = converter.buildPingUrl("prefix", "sessionId", currentTime.toDuration(DurationUnit.SECONDS)) + + assertEquals(result, "prefix/session/ping/sessionId.json?v=3&pt=200") + } } \ No newline at end of file